pii-masker
요약. pii-masker: 자율 개선 회차 25회, 릴리즈 16건. 최근 릴리즈 v1.0.20 (자산 1개). 건강 A
- 25회차
- 1프로젝트
- 16배포 준비 완료
- 0릴리즈 진행 중
- 2병합 완료
- 1검토 대기
- 0검증 실패
- 6변경 없음
- 0실행 오류
- $29.27비용
- 1시간 22분에이전트 시간
현황
- 저장소
- https://github.com/hkjang/pii-masker
- 마지막 회차
- 2026-09-10 14:39 KST — 🚀 릴리즈 merged PR #17, released v1.0.20
- 최근 릴리즈
- v1.0.20 — released · 자산 1개 (이전 v1.0.19: 1개) 전체 릴리즈 →
회차 이력
| 일시 | 프로젝트 | 결과 |
|---|---|---|
| 2026-09-10 14:39 | pii-masker | 배포 준비 완료 merged PR #17, released v1.0.20 |
| 2026-09-10 00:39 | pii-masker | 배포 준비 완료 merged PR #16, released v1.0.19 |
| 2026-09-09 12:11 | pii-masker | 배포 준비 완료 merged PR #15, released v1.0.18 |
| 2026-09-09 10:48 | pii-masker | 배포 준비 완료 merged PR #14, released v1.0.17 |
| 2026-09-09 02:39 | pii-masker | 배포 준비 완료 merged PR #13, released v1.0.16 |
| 2026-09-08 21:58 | pii-masker | 배포 준비 완료 merged PR #12, released v1.0.15 |
| 2026-09-07 03:27 | pii-masker | 배포 준비 완료 merged PR #11, released v1.0.14 |
| 2026-09-06 18:11 | pii-masker | 배포 준비 완료 merged PR #10, released v1.0.13 |
| 2026-09-05 16:57 | pii-masker | 병합 완료 resumed: merged PR #9 |
| 2026-09-05 16:47 | pii-masker | 배포 준비 완료 release-only, released v1.0.12 |
| 2026-09-05 16:24 | pii-masker | 검토 대기 CI no-ci, PR open PR #9 |
| 2026-09-05 06:02 | pii-masker | 배포 준비 완료 merged PR #8, released v1.0.11 +1 assets |
| 2026-09-04 13:53 | pii-masker | 배포 준비 완료 merged PR #7, released v1.0.10 +1 assets |
| 2026-09-04 11:05 | pii-masker | 변경 없음 assets-only v1.0.4, assets for v1.0.4 +1 assets |
| 2026-09-04 11:03 | pii-masker | 변경 없음 assets-only v1.0.5, assets for v1.0.5 +1 assets |
| 2026-09-04 11:02 | pii-masker | 변경 없음 assets-only v1.0.6, assets for v1.0.6 +1 assets |
| 2026-09-04 11:00 | pii-masker | 변경 없음 assets-only v1.0.7, assets for v1.0.7 +1 assets |
| 2026-09-04 10:58 | pii-masker | 변경 없음 assets-only v1.0.8, assets for v1.0.8 +1 assets |
| 2026-09-04 06:58 | pii-masker | 변경 없음 assets-only, assets for v1.0.9 +1 assets |
| 2026-09-04 00:11 | pii-masker | 배포 준비 완료 merged PR #6, released v1.0.9 |
| 2026-09-03 16:38 | pii-masker | 배포 준비 완료 merged PR #5, released v1.0.8 |
| 2026-09-03 09:46 | pii-masker | 배포 준비 완료 merged PR #4, released v1.0.7 |
| 2026-09-03 04:16 | pii-masker | 배포 준비 완료 merged PR #3, released v1.0.6 |
| 2026-09-02 21:55 | pii-masker | 배포 준비 완료 merged PR #2, released v1.0.5 |
| 2026-09-02 15:26 | pii-masker | 병합 완료 merged PR #1, release released |
비용·사용량
| 시각 | 프로젝트 | 단계 | 시간 | 턴 | 비용 | 토큰 입력/출력 | 종료 |
|---|---|---|---|---|---|---|---|
| 14:39 | pii-masker | 릴리즈 | 2분 | 22 | $0.63 | 507K / 6K | success |
| 14:36 | pii-masker | review | 2분 | 16 | $0.68 | 489K / 8K | success |
| 14:34 | pii-masker | 개선 | 4분 | 31 | $1.56 | 1.3M / 15K | success |
| 00:39 | pii-masker | 릴리즈 | 1분 | 15 | $0.44 | 237K / 5K | success |
| 00:37 | pii-masker | review | 2분 | 12 | $0.60 | 283K / 8K | success |
| 00:35 | pii-masker | 개선 | 4분 | 29 | $1.78 | 1.4M / 16K | success |
| 12:11 | pii-masker | 릴리즈 | 1분 | 16 | $0.49 | 316K / 5K | success |
| 12:09 | pii-masker | review | 4분 | 16 | $1.30 | 668K / 18K | success |
| 12:04 | pii-masker | 개선 | 13분 | 60 | $4.34 | 4.3M / 40K | success |
| 10:48 | pii-masker | 릴리즈 | 1분 | 16 | $0.45 | 261K / 5K | success |
| 10:46 | pii-masker | review | 1분 | 9 | $0.41 | 253K / 4K | success |
| 02:38 | pii-masker | 릴리즈 | 2분 | 17 | $0.58 | 376K / 6K | success |
| 02:36 | pii-masker | review | 2분 | 11 | $0.49 | 212K / 7K | success |
| 02:34 | pii-masker | 개선 | 4분 | 24 | $1.48 | 1.0M / 16K | success |
| 21:58 | pii-masker | 릴리즈 | 2분 | 16 | $0.56 | 407K / 5K | success |
| 21:55 | pii-masker | review | 1분 | 13 | $0.50 | 273K / 5K | success |
| 21:54 | pii-masker | 개선 | 3분 | 27 | $1.65 | 1.4M / 13K | success |
| 03:27 | pii-masker | 릴리즈 | 2분 | 19 | $0.61 | 421K / 5K | success |
| 03:25 | pii-masker | review | 2분 | 13 | $0.55 | 272K / 7K | success |
| 03:23 | pii-masker | 개선 | 3분 | 21 | $1.18 | 840K / 11K | success |
| 18:10 | pii-masker | 릴리즈 | 2분 | 17 | $0.55 | 397K / 5K | success |
| 18:08 | pii-masker | review | 2분 | 12 | $0.66 | 397K / 8K | success |
| 18:06 | pii-masker | 개선 | 6분 | 38 | $2.12 | 1.6M / 26K | success |
| 16:46 | pii-masker | 릴리즈 | 1분 | 19 | $0.59 | 389K / 6K | success |
| 16:19 | pii-masker | review | 4분 | 15 | $0.81 | 431K / 10K | success |
| 16:16 | pii-masker | 개선 | 10분 | 58 | $4.25 | 4.6M / 37K | success |
| 16:02 | pii-masker | 개선 | 0분 | 0 | $0.00 | 0 / 0 | unknown |
아이디어 백로그 — 대기 7 / 전체 8
| 아이디어 | 가치/위험/크기 | 상태 | 메모 | 갱신 |
|---|---|---|---|---|
| 동기 슬롯 대기열의 메모리 상한 | 3/3/M | 대기 | 슬롯을 기다리는 요청이 이미 읽은 업로드 바이트를 들고 있어 대기열 메모리는 여전히 무제한. 본문을 읽기 전에 슬롯을 잡거나 대기 요청 수 자체를 제한해야 하는데, 본문 읽기 중 슬롯을 점유하면 느린 클라이언트가 슬롯을 묶으므로 읽기 데드라인과 함께 설계 필요. | 2026-09-10 |
| 종료 시 interrupted로 표시된 job의 재개 | 3/3/M | 대기 | 입력 파일이 디스크에 남아 있으므로 기동 시 해당 job을 queued로 되돌려 다시 큐에 넣는 것이 가능하나, 무한 재시도 방지를 위한 시도 횟수 기록이 필요. 중단 상태가 디스크에 영속화되어 있으므로 시도 횟수 필드를 같은 레코드에 얹기 쉬움. | 2026-09-10 |
| internal/config 나머지 순수 함수 단위 테스트 | 2/1/S | 대기 | 타임아웃 관련 3개는 있으나 normalizeAllowHosts, normalizeEndpointURL, normalizePIILang/Schema, envInt/envNonNegativeInt/envBool은 여전히 테스트 없음. t.Setenv로 Load()까지 검증 가능. | 2026-09-10 |
| /v1/jobs/{id}/result에 HEAD 메서드 허용 | 2/1/S | 대기 | server.go:85의 라우트가 GET만 등록되어 있어 다운로드 크기를 미리 확인하는 HEAD 프로브가 405를 받음. http.ServeContent는 이미 HEAD를 올바르게 처리하므로 Methods에 http.MethodHead 추가로 충분. 다른 GET 라우트도 함께 검토할 여지. | 2026-09-10 |
| jobs.Store.load()가 디렉터리 이름과 job.json의 ID 불일치를 검증하지 않음 | 2/1/S | 대기 | 모든 쓰기 경로(writeFile/persistLocked)가 job.ID로 디렉터리를 정하는데 load()는 entry.Name() 디렉터리에서 읽은 job.ID를 그대로 키로 쓴다. 손상되거나 손으로 만든 job.json이 다른 ID를 담고 있으면 DeleteExpired가 엉뚱한 디렉터리를 지우고 원래 디렉터리는 영원히 남는다. 불일치 디렉터리를 건너뛰는 가드로 충분. | 2026-09-10 |
| 업로드 파일명 유니코드 정규화(NFC) | 2/2/S | 대기 | sanitizeUploadFilename이 정규화를 하지 않아 macOS 클라이언트가 보내는 NFD(자모 분리) 한글 이름이 그대로 디스크와 메타데이터에 기록됨. 시각적으로 같은 이름이 바이트로 달라 비교·재조회가 어긋나고, filename*로 그대로 되돌려 보내므로 왕복도 NFD로 고정됨. vendor에 이미 있는 golang.org/x/text/unicode/norm으로 NFC 적용 가능. | 2026-09-10 |
| TestConnection 응답 본문 32KB 절단이 성공 메시지로 그대로 노출됨 | 1/1/S | 대기 | client.go:224가 32KB로 자른 본문을 성공 시 ConnectionStatus.Message에 통째로 넣어, 프로브 응답이 큰 엔드포인트에서는 UI에 수십KB짜리 원문이 그대로 뿌려진다. 요약/길이 제한(summarizeResponseBody 재사용)으로 다듬을 여지. | 2026-09-10 |
| 업스트림 응답 8MB 절단 시 오해를 부르는 오류 | 3/1/S | 완료 | client.go의 응답 읽기를 maxUpstreamResponseBytes+1로 바꿔 초과를 판별하고, 초과 시 decode_failed 대신 response_too_large(재시도 불가, 문서 분할/verbose 해제 힌트)를 반환. 4xx/5xx는 기존 상태 코드 분류를 우선. 단위 테스트 2개 추가. | 2026-09-10 |
원장 (에이전트가 남긴 기록)
2026-09-02
- 선택: 비동기
/v1/jobs업로드 사전 검증 (가치 4 / 위험 1 / 작업량 S) - 결과: 성공
- 요약:
POST /v1/jobs가 동기 경로(/v1/mask)와 달리 크기·MIME·페이지 수 검증을 건너뛰어, 초과/미지원 파일도 디스크에 저장하고 202를 반환한 뒤 백그라운드에서야 실패하던 문제를 고쳤습니다.service.CreateJob이 저장 전에validateAttachment/countPages를 실행하고 새service.InvalidInputError로 감싸 핸들러가 400invalid_request를 돌려주도록 했으며, 검증 실패 시 job 디렉터리가 생기지 않는지 확인하는 통합 테스트 3개(미지원 타입/크기 초과/PDF 페이지 한도)를 추가했습니다. 부수적으로-race에서 드러난 pdfcpuConfigPath전역 변수 경합을 요청마다 쓰지 않고 각 패키지init()에서 한 번만 설정하도록 분리했습니다. 검증은go vet ./...,go test ./...,go test -race ./...전부 통과, 그리고 수정 코드를 임시로 되돌려 새 테스트 3개가 실제로 실패하는 것까지 확인했습니다(커밋 2개). - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) / job 파일 보존·정리 정책 부재로 저장소 무한 증가 /CreateJob의go runJob동시 실행 개수 제한 없음 /internal/jobs,internal/config패키지 단위 테스트 전무 / 마스킹 정책 엣지케이스(빈 로컬파트 이메일, 구분자 없는 계좌번호 등) 테스트 보강 - 릴리즈: v1.0.4 (2026-09-02, 태그 사후 푸시)
2026-09-02 (2회차)
- 선택: 마스킹 값의 룬 정렬 보장으로 잘못된 영역 검게 칠하는 문제 수정 (가치 5 / 위험 2 / 작업량 S)
- 결과: 성공
- 요약:
MaskValue가TrimSpace한 값을 마스킹해 반환하고maskAddress가strings.Fields+Join(" ")로 공백을 뭉개는 바람에, 마스킹 결과가 원본과 룬 수·위치가 어긋나ComputeMaskedRuneSpans가 엉뚱한 구간을 돌려주고 문서에 잘못된 위치가 검게 칠해지던 실제 PII 노출 버그를 고쳤습니다(예:" 800901-1234567"→ 스팬[7,14), 마지막 자리7이 그대로 노출 / 이중 공백 주소는 번지 대신 도로명 일부를 가림). 마스킹은 트림된 값에 적용하되 앞뒤 공백을 복원하는restoreSurroundingSpace를 추가하고, 주소는 토큰 시작 오프셋을 보존해 원본 구분자를 그대로 재조립하도록 바꿨으며, 안전망으로ComputeMaskedRuneSpans가 룬 길이 불일치 시nil을 반환해 호출부가 필드 전체를 덮도록 했습니다. 모든 규칙이 룬 수·공백 위치를 보존하는지 검사하는 테이블 테스트 15케이스와 회귀 케이스 4개를 추가했고,gofmt -l(무출력),go vet ./...,go test -count=1 ./...,go test -race -count=1 ./...통과 및 수정 전 코드로 되돌려 새 테스트가 실제로 실패하는 것까지 확인했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) / job 파일 보존·정리 정책 부재로 저장소 무한 증가 /CreateJob의go runJob동시 실행 개수 제한 없음 /internal/jobs,internal/config패키지 단위 테스트 전무 /maskAllVisible등이isWhitespaceRune(ASCII 한정)만 보므로 U+00A0 같은 유니코드 공백 처리 불일치 - 릴리즈: v1.0.5 (2026-09-02)
2026-09-03
- 선택: 업로드 요청 본문 크기 상한 적용 (가치 4 / 위험 2 / 작업량 S)
- 결과: 성공
- 요약:
POST /v1/mask,POST /v1/jobs가r.Body에 아무 상한 없이ParseMultipartForm(MaxFileSizeBytes + 1MB)를 호출해, 클라이언트가 임의 크기(수 GB) 본문을 흘려보내면 서버가 51MB를 메모리에 버퍼링하고 나머지는 temp 파일로 흘린 뒤에야validateAttachment에서 거절하던 메모리·디스크 DoS 경로를 막았습니다.http.MaxBytesReader로 본문을MaxFileSizeBytes + 64KB(멀티파트 프레이밍 여유분)에서 자르고, 잘린 요청은 새payloadTooLargeError→413 payload_too_large로 응답하며,maxMemory를 8MB 고정으로 낮춰 큰 파트가 RAM에 이중으로 남지 않고 temp 파일로 넘어가게 했습니다. 상한 초과 시 413을 받는 통합 테스트 2개(/v1/mask,/v1/jobs+ job 미생성 확인)와 한도에 딱 맞는 업로드가 여전히 200으로 처리되는 회귀 테스트 1개를 추가했고,gofmt -l(무출력)·go vet ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-count=5로 새 테스트 플래키 여부 확인, 수정 전 코드로 되돌려 새 테스트 2개가 실제로 실패(본문 512KB를 끝까지 읽고 400 반환)하는 것까지 확인했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) / job 파일 보존·정리 정책 부재로 저장소 무한 증가 /CreateJob의go runJob동시 실행 개수 제한 없음 /internal/jobs,internal/config패키지 단위 테스트 전무 / 마스킹 결과가 원본과 동일할 때(예: 숫자 없는 주민등록번호 필드) 스팬이 비어 필드 전체를 덮는 과잉 마스킹 - 릴리즈: v1.0.6 (2026-09-03)
2026-09-03 (2회차)
- 선택: 이미지 압축 폭탄(선언 해상도) 디코딩 상한 적용 (가치 4 / 위험 2 / 작업량 S)
- 결과: 성공
- 요약: 업로드 크기 상한은 있었지만 디코딩 후 픽셀 수에는 아무 제한이 없어, 헤더에 40000x40000을 선언한 수백 KB짜리 PNG 한 장으로
image.Decode가 6GB 이상을 할당하게 만드는 메모리 고갈 경로가 남아 있었습니다(masking.MaskImageFile,upstage.prepareUpstreamAttachment두 곳). 새document.ValidateImageDimensions/DecodeImage가image.DecodeConfig로 헤더만 먼저 읽어 5천만 픽셀(600dpi A4 스캔 이상) 초과를 거절하고,service.validateAttachment에서 이미지 MIME일 때 이를 호출해 동기·비동기 경로 모두 픽셀 버퍼 할당이나 job 저장 전에 400으로 실패하게 했으며, 두 디코딩 지점도 같은 가드를 거치도록 방어를 이중화했습니다. IHDR을 조작한 초소형 폭탄 PNG로 단위 테스트 4개와 통합 테스트 2개(/v1/mask오류 메시지 확인,/v1/jobs400 + job 미생성)를 추가했고,gofmt -l(무출력)·go vet ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과, 가드를 임시로 제거해 새 테스트 3개가 실제로 실패(각각 미거절 / 202 반환 / “not enough pixel data” 디코드 오류)하는 것까지 확인했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) / job 파일 보존·정리 정책 부재로 저장소 무한 증가 /CreateJob의go runJob동시 실행 개수 제한 없음(고루틴마다 원본 바이트를 메모리에 유지) /internal/jobs,internal/config패키지 단위 테스트 전무 / 마스킹 결과가 원본과 동일할 때 스팬이 비어 필드 전체를 덮는 과잉 마스킹 - 릴리즈: v1.0.7 (2026-09-03)
2026-09-03 (3회차)
- 선택: 업로드 파일명 제어문자 정화로 멀티파트 헤더 인젝션 차단 (가치 4 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
sanitizeUploadFilename이 공백 트림과filepath.Base만 하고 있어서, 클라이언트가 RFC 2231(filename*=utf-8''sample%0D%0A...)로 인코딩해 보낸 파일명이 Go에서 진짜 CR/LF를 품은 문자열로 디코딩된 뒤 그대로 두 곳의 헤더에 박히던 인젝션 경로를 막았습니다./v1/mask가 돌려주는multipart/mixed파트의Content-Disposition(멀티파트 파트 헤더는net/http의 응답 헤더 정화를 거치지 않음)과 추론 서버로 나가는 요청의 document 파트 헤더가 대상이며, 실제로 수정 전 코드에서는 조작된 파일명이 업스트림 멀티파트 본문을 깨뜨려 mock이 “document part is required”로 400을 반환했습니다. 업로드 수용 시점에 제어문자·따옴표·역슬래시·경로 구분자를 제거하고 남는 게 없으면document로 대체하며 120바이트(확장자 유지, 룬 경계 보존)로 자르고, 한글 등 비ASCII 이름은 그대로 둡니다. 헤더를 만드는 두 지점(httpapi.attachmentDisposition,upstage.escapeMultipartValue)에도 같은 가드를 넣어 방어를 이중화했습니다.internal/document단위 테스트 16케이스(테이블 13 + 절단/NewAttachment/MaskedFilename)와 통합 테스트 2개(/v1/mask응답 본문에\r\nX-Injected:없음,/v1/jobs완료 후 job 디렉터리 파일명·다운로드Content-Disposition정상)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-count=5로 플래키 여부 확인, 수정 코드를 임시로 되돌려 새 통합 테스트 2개가 실제로 실패(각각 502 업스트림 오류 / 파일명에 CRLF 잔존)하는 것까지 확인했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) / job 파일 보존·정리 정책 부재로 원본 PII 파일이 무기한 잔존 /CreateJob의go runJob동시 실행 개수 제한 없음(고루틴마다 원본 바이트를 메모리에 유지, 디스크에 이미 저장된 입력을 재사용하면 해소 가능) /internal/jobs,internal/config패키지 단위 테스트 전무 /handleGetJobResult가 결과 파일 전체를 메모리에 올린 뒤 응답(스트리밍 전환 여지) - 릴리즈: v1.0.8 (2026-09-03)
2026-09-04
- 선택: 비동기 job 동시 실행 개수 제한 + 입력 바이트 디스크 재읽기 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
CreateJob이 요청마다go s.runJob(jobID, input)을 무제한으로 띄우면서 클로저가 업로드 원본(최대 50MB)을 통째로 붙들고 있어,/v1/jobs를 연달아 호출하면 동시에 실행되는 고루틴 수만큼 메모리가 선형으로 늘어나 OOM에 이르는 경로를 막았습니다.Service에jobSlots세마포어(PII_MASKER_MAX_CONCURRENT_JOBS, 기본 4, 0 이하이면 4로 폴백)를 두어 슬롯을 얻은 러너만 실행되게 하고, 러너에 넘기는 값을upstage.ParseOptions만으로 줄인 뒤 새loadJobInput이 슬롯 확보 후에야job.InputPath(지금까지 기록만 하고 아무도 읽지 않던 필드)에서 원본을 다시 읽도록 바꿨습니다. 덕분에 대기 중인 작업은 고루틴 하나 값만 쓰고queued상태를 유지하며, 저장된 입력을 읽지 못하면storage_read_failed로 실패 처리합니다. 검증은 업스트림 핸들러를 잡아두고 동시 요청 수를 세는 통합 테스트(제한 2, 작업 5개 → 최대 동시 2 유지, 해제 후 5개 모두 completed)와loadJobInput단위 테스트 2개를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-count=5로 플래키 여부 확인, 세마포어 획득 두 줄을 임시로 제거해 새 통합 테스트가 실제로 실패(“expected at most 2 concurrent jobs, got 5”)하는 것까지 확인했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) / job 파일 보존·정리 정책 부재로 원본 PII 파일이 무기한 잔존(TTL 스위퍼 필요) /handleGetJobResult가 결과 파일 전체를 메모리에 올린 뒤 응답(http.ServeContent스트리밍 전환 여지) /internal/jobs,internal/config패키지 단위 테스트 전무 / 대기 중인 job 개수 자체는 여전히 무제한이라 디스크는 계속 증가 - 릴리즈: v1.0.9 (2026-09-04)
2026-09-04 (2회차)
- 선택: job 보존 기간(TTL) 정리 스위퍼 (가치 5 / 위험 3 / 작업량 M)
- 결과: 성공
- 요약: 비동기 job이 저장한 업로드 원본(=마스킹해 달라고 받은 개인정보 원문)과 마스킹 결과가 디스크에 무기한 남고 job 레코드도 메모리 맵에 영구 누적되던 문제를 고쳤습니다. 5회 연속 보류돼 온 최대 잔여 리스크입니다.
jobs.Store.DeleteExpired(cutoff)가Metadata.UpdatedAt이 cutoff 이전인 job의 디렉터리를 통째로 지우고 맵에서도 제거하되,queued/running상태는 나이와 무관하게 보존합니다(러너가 슬롯을 얻으면InputPath를 다시 읽어야 하므로).Service에PurgeExpiredJobs/StartRetentionSweeper를 추가해 기동 직후 1회 +retention/4(1분~1시간 클램프) 주기로 정리하고,app.New가 컨텍스트로 스위퍼를 띄우고 새App.Close()가 멈춥니다. 보존 기간은PII_MASKER_JOB_RETENTION_HOURS(기본 24시간,0이면 정리 비활성)이며, 0을 허용해야 해서envNonNegativeInt를 새로 뒀습니다(기존envInt는 0 이하를 기본값으로 되돌림). 검증은internal/jobs단위 테스트 3개(만료 삭제/미완료 job 보존/재기동 후 로드된 job에도 적용 — 이 패키지 첫 테스트),internal/service테스트 4개(퍼지, 보존 0이면 미삭제, 스위퍼가 컨텍스트 취소까지 반복, 주기 계산), 통합 테스트 1개(이전 실행이 남긴 만료 job 디렉터리가 기동 직후 사라지고GET /v1/jobs/{id}가 404, 최근 job은 유지)를 추가했고gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=5로 플래키 여부 확인, 그리고 (1)DeleteExpired무력화 (2)app.New의 스위퍼 기동 제거 (3) queued/running 가드 제거 세 가지로 되돌려 각각 대응 테스트가 실제로 실패하는 것까지 확인했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) /handleGetJobResult가 결과 파일 전체를 메모리에 올린 뒤 응답(http.ServeContent스트리밍 전환 여지) /internal/config패키지 단위 테스트 전무(normalizeAllowHosts,envNonNegativeInt등) / 대기 중인 job 개수(큐 길이) 자체는 여전히 무제한이라 폭주 시 디스크가 보존 기간 동안 계속 증가 /cmd/pii-masker에 graceful shutdown 없음(App.Close가 생겼으므로 시그널 처리와 묶을 여지) - 릴리즈: v1.0.10 (2026-09-04)
2026-09-05
- 선택:
PII_MASKER_ALLOW_HOSTS실제 적용(리다이렉트 포함) (가치 5 / 위험 2 / 작업량 M) - 결과: 성공
- 요약: README에 문서화되어 있고
config.Load가normalizeAllowHosts로 파싱까지 하던PII_MASKER_ALLOW_HOSTS가 코드 어디에서도 검사되지 않는 완전한 무동작 상태였습니다(UpstageConfig.AllowHosts참조처가 테스트 설정뿐). 게다가httpClient()가 리다이렉트를 기본 정책대로 무제한 추종해서, 추론 엔드포인트가 307/308을 돌려주면 Go가GetBody로 본문을 재전송하며 업로드한 개인정보 원문이 임의 호스트로 흘러가고, 302 계열에서도x-api-key인증 토큰은 Go의 교차 도메인 헤더 제거 대상이 아니라 그대로 따라갔습니다.Client.checkHostAllowed/checkURLAllowed/hostAllowed를 추가해 (1)performParseRequest에서 멀티파트 본문을 만들기 전에, (2)TestConnection에서 요청 전에, (3)CheckRedirect로 매 리다이렉트 홉마다 대상 호스트를 검사하고 최대 5홉으로 제한했습니다. 허용 목록이 비면 기본 URL의 호스트로 폴백해 무제한이 되는 일이 없고, 항목은api.upstage.ai(호스트만) /127.0.0.1:8080(포트 포함) 둘 다 매칭하며 대소문자·공백을 무시합니다. 차단은 새blockedHostError→classifyRequestError에서upstream_host_not_allowedCallError로 매핑되어/v1/mask가 502 + 해당 코드를,/v1/test-connection이ok:false를 돌려줍니다.internal/upstage테스트 5개(호스트 매칭 테이블 9케이스, 빈 목록의 기본 URL 폴백, 차단 호스트로 요청 미도달, 비허용 호스트로의 리다이렉트 차단, 허용 호스트 간 리다이렉트는 정상 전달)와 통합 테스트 1개(/v1/mask502 + 업스트림 미도달)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=5로 플래키 여부 확인,checkURLAllowed를 항상 nil 반환으로 되돌려 새 테스트 4개가 실제로 실패하는 것까지 확인했습니다. README에도 이 변수가 무엇을 강제하는지 설명을 추가했습니다. - 보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) /handleGetJobResult가 결과 파일 전체를 메모리에 올린 뒤 응답(http.ServeContent스트리밍 전환 여지, 삭제된 파일에 500 대신 404도 함께 해결) /cmd/pii-masker에 graceful shutdown 없음(App.Close가 있으므로 시그널 처리와 묶을 여지) /internal/config패키지 단위 테스트 전무(normalizeAllowHosts,envNonNegativeInt등) / 동기/v1/mask경로에는 동시 실행 제한이 없어 비동기 job에만 적용된 메모리 상한이 우회됨 - 릴리즈: v1.0.11 (2026-09-05)
2026-09-05
- 선택: 동기
/v1/mask동시 처리 개수 제한 (가치 4 / 위험 2 / 작업량 M) - 결과: 성공
- 요약: 비동기 job에는
PII_MASKER_MAX_CONCURRENT_JOBS세마포어가 있었지만 동기 경로에는 아무 제한이 없어서,/v1/mask를 동시에 호출하는 것만으로 그 상한을 우회해 요청 수만큼 문서를 동시에 디코딩·재렌더링할 수 있었습니다(페이지 렌더링은 업로드 크기의 수십 배 메모리를 씁니다).Service에syncSlots세마포어(PII_MASKER_MAX_CONCURRENT_SYNC, 기본 4)를 두고ProcessSync가 슬롯을 얻은 뒤에만 처리하도록 했으며, 슬롯이 없으면PII_MASKER_SYNC_QUEUE_WAIT_SECONDS(기본 10초,0이면 즉시 거절)만큼만 대기했다가 새service.ServerBusyError→503+Retry-After+server_busy메타데이터로 흘려보냅니다(대기자도 업로드 바이트를 들고 있으므로 대기 시간은 짧게 잡았습니다). 요청 컨텍스트가 취소되면 대기를 즉시 그만둡니다. 검증은 통합 테스트 2개(업스트림을 붙잡고 5개 동시 요청 → 최대 동시 2 유지 후 5개 모두 200 / 슬롯이 찬 상태에서 두 번째 요청이 업스트림에 닿지 않고 503+server_busy)와internal/service단위 테스트 4개(즉시 거절·해제 후 재사용, 대기 후 획득, 컨텍스트 취소, 거절 메타데이터)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=5로 플래키 여부 확인,ProcessSync의 슬롯 획득을 임시로 제거해 새 테스트 3개가 실제로 실패(동시 5 / 502 / 크기 검증 오류)하는 것까지 확인했습니다. README에도 제한과 두 환경 변수를 문서화했습니다. -
보류 아이디어:
/v1/history의limit상한 없음(과도한 값 요청 시 전체 목록 직렬화) /handleGetJobResult가 결과 파일 전체를 메모리에 올린 뒤 응답(http.ServeContent스트리밍 전환 + 삭제된 파일에 500 대신 404) /cmd/pii-masker에 graceful shutdown과http.Server타임아웃(ReadHeaderTimeout/IdleTimeout) 없음 — 유휴 커넥션이 무한정 유지됨 /internal/config패키지 단위 테스트 전무(normalizeAllowHosts,envNonNegativeInt등) / 동기 슬롯을 기다리는 요청은 이미 읽은 업로드 바이트를 메모리에 들고 있어, 대기열 자체의 메모리는 여전히 무제한 - 릴리즈: v1.0.12 (2026-09-05, run 2026-09-05-164521-pii-masker-release)
2026-09-06
- 선택: graceful shutdown과 정체 커넥션 타임아웃 (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
cmd/pii-masker가 시그널을 전혀 처리하지 않아 배포·재시작 때 처리 중이던 동기 마스킹 응답이 중간에 잘리고App.Close()(보존 기간 스위퍼 정지)가 호출되지 않았으며,http.Server에ReadHeaderTimeout/IdleTimeout이 없어 헤더를 찔끔찔끔 보내는 연결이나 유휴 keep-alive 연결이 고루틴을 무한정 붙들 수 있었습니다(Slowloris).App.Run(ctx)/App.Serve(ctx, ln)을 두어 컨텍스트가 취소되면 수신을 멈추고Shutdown으로 최대PII_MASKER_SHUTDOWN_TIMEOUT_SECONDS초(기본 45초, 업스트림 타임아웃 30초보다 길게) 동안 in-flight 요청을 배출한 뒤 배경 워커를 정지하도록 하고,main은signal.NotifyContext(SIGINT/SIGTERM)로 이를 연결했습니다. 타임아웃은PII_MASKER_READ_HEADER_TIMEOUT_SECONDS(기본 15초)·PII_MASKER_IDLE_TIMEOUT_SECONDS(기본 60초)로 설정하되, 50MB 업로드와 느린 추론 응답을 끊지 않도록ReadTimeout/WriteTimeout은 의도적으로 두지 않았고 값이 0 이하이면 가드가 꺼지지 않게 기본값으로 되돌립니다. 검증은internal/app첫 테스트 3개(취소 후 리스너 종료,io.Pipe로 본문을 절반만 보낸 in-flight 요청이 취소 후에도 정상 응답을 받는 배출 테스트, 헤더를 끝내지 않는 raw 연결이 250ms 안에 끊기는 테스트)와internal/config테스트 3개(기본값/환경변수 override/0·음수·비정수 폴백)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=5로 플래키 여부 확인,ReadHeaderTimeout설정과Shutdown(→Close)을 임시로 되돌려 새 테스트 2개가 실제로 실패하는 것까지 확인했습니다. README에도 종료 동작과 세 환경 변수를 문서화했습니다. -
보류 아이디어:
/v1/history의limit상한 없음(?limit=100000한 번으로 전체 job 직렬화, 기본 20/최대 100 클램프 필요) /handleGetJobResult가 결과 파일(최대 50MB)을os.ReadFile로 통째로 올린 뒤 응답(os.Open+http.ServeContent로 스트리밍 + 삭제된 파일에 500 대신 404) / 동기 슬롯 대기열의 메모리 상한 — 대기 중인 요청이 이미 읽은 업로드 바이트를 들고 있어 대기열 메모리는 여전히 무제한 /internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang등) 단위 테스트 / 비동기runJob고루틴은 graceful shutdown 대상이 아니라 종료 시running상태로 남음(종료 시queued로 되돌리거나 완료 대기 필요) - 릴리즈: v1.0.13 (2026-09-06, run 2026-09-06-180047-pii-masker-improve)
2026-09-07
- 선택: job 결과 다운로드를 디스크 스트리밍으로 전환 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
handleGetJobResult가os.ReadFile로 마스킹 결과 파일(최대 50MB)을 통째로 메모리에 올린 뒤 응답해서, 동기 마스킹(PII_MASKER_MAX_CONCURRENT_SYNC)·비동기 job(PII_MASKER_MAX_CONCURRENT_JOBS)에 걸어 둔 메모리 상한과 달리 다운로드 경로에는 아무 제한이 없어 동시 다운로드 수만큼 메모리가 선형으로 늘었습니다.os.Open+file.Stat()+http.ServeContent로 바꿔 파일을 버퍼링 없이 흘려보내고, 덤으로Accept-Ranges/Range(재개 가능한 다운로드)와 조건부 요청을 지원하며, 보존 기간 스위퍼나 외부 요인으로 결과 파일이 사라진 경우500 result_read_failed대신404 job_result_not_found를 반환하도록 고쳤습니다(errors.Is(err, fs.ErrNotExist)).Content-Type은 메타데이터 값이 있을 때만 설정해 비어 있으면ServeContent의 확장자 추론에 맡깁니다. 검증은 통합 테스트 2개(완료된 PDF job에 대해Accept-Ranges: bytes확인 +Range: bytes=8-23→ 206·Content-Range·본문이 전체 응답의 해당 슬라이스와 일치, 결과 파일을 디스크에서 지운 뒤 404 +job_result_not_found)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=5로 플래키 여부 확인, 수정 전os.ReadFile구현으로 되돌려 새 테스트 2개가 실제로 실패(각각Accept-Ranges빈 값 / 500result_read_failed)하는 것까지 확인했습니다. -
보류 아이디어:
/v1/history의limit상한 없음(?limit=100000한 번으로 전체 job 직렬화, 기본 20/최대 100 클램프 필요) / 동기 슬롯 대기열의 메모리 상한 — 대기 중인 요청이 이미 읽은 업로드 바이트를 들고 있어 대기열 메모리는 여전히 무제한 /internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang/Schema,envInt/envNonNegativeInt/envBool) 단위 테스트 / 종료 시 실행 중인 비동기 job이running으로 남고 재기동 시failed로 표시될 뿐 재개되지 않음(종료 전 완료 대기 또는queued되돌림) //v1/jobs/{id}/result가GET만 라우팅되어HEAD다운로드 프로브가 405를 받음(ServeContent는 이미 HEAD를 처리 가능) - 릴리즈: v1.0.14 (2026-09-07, run 2026-09-07-032049-pii-masker-improve)
2026-09-08
- 선택:
/v1/history의limit상한 적용 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공
- 요약:
handleHistory가?limit=을parsed > 0만 확인하고 그대로ListJobs에 넘겨서,?limit=100000요청 하나로 보존 기간(기본 24시간) 안의 모든 job 레코드를 복제·정렬·JSON 직렬화하게 만들 수 있었습니다(다른 경로에는 이미 동시 실행·본문 크기 상한이 걸려 있어 이곳만 무제한). 새historyLimit헬퍼가 기본 20 / 최대 100으로 클램프하고, 값이 없거나 파싱 불가·0·음수·int 범위 초과이면 기본값으로 돌아가며, UI가 쓰는?limit=8은 그대로 동작합니다.internal/httpapi첫 내부 패키지 단위 테스트(테이블 10케이스)와 job 150개를 디스크에 미리 심어 두고 5가지 쿼리(기본/5/100000/-1/all)의 반환 개수와 최신순 정렬을 확인하는 통합 테스트를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=5로 플래키 여부 확인, 상한 코드를 임시로 제거해 새 테스트 2개가 실제로 실패(historyLimit("100000") = 100000/ 150건 반환)하는 것까지 확인했습니다. README의 엔드포인트 목록에도 기본값·상한을 적었습니다. -
보류 아이디어: 동기 슬롯 대기열의 메모리 상한(대기 중인 요청이 이미 읽은 업로드 바이트를 보유) / 종료 시 실행 중인 비동기 job이
running으로 남고 재기동 시failed처리될 뿐 재개되지 않음 /internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang/Schema,envInt/envNonNegativeInt/envBool) 단위 테스트 //v1/jobs/{id}/result가GET만 라우팅되어HEAD프로브가 405를 받음(ServeContent는 이미 HEAD 처리) / 다운로드Content-Disposition이 한글 파일명을 quoted-string에 원시 UTF-8로 넣어 RFC 6266의filename*=UTF-8''…가 없음 - 릴리즈: v1.0.15 (2026-09-08, run 2026-09-08-215056-pii-masker-improve)
2026-09-09
- 선택: 다운로드 파일명의 RFC 6266
filename*인코딩 (가치 3 / 위험 1 / 작업량 S) - 결과: 성공
- 요약:
attachmentDisposition이 한글 파일명을Content-Disposition의 quoted-string에 원시 UTF-8 바이트로 넣고 있어서, 헤더 값을 latin-1로 읽는 클라이언트가masked_계약서.pdf같은 mojibake로 저장하던 문제를 고쳤습니다. 이제 비ASCII 룬을_로 바꾼 ASCII 폴백filename="..."과 RFC 5987 attr-char 기준으로 퍼센트 인코딩한filename*=UTF-8''...를 함께 내보내고(ASCII 전용 이름은 종전과 동일한 한 줄 유지, 남는 ASCII가 없으면 폴백은document), 헤더 값 전체가 ASCII로만 구성됩니다. 함수 하나만 고쳐 동기/v1/mask멀티파트 파트 헤더와 job 결과 다운로드 두 호출 지점에 동시에 적용됐습니다. 검증은internal/httpapi단위 테스트 2개(테이블 6케이스 +mime.ParseMediaType왕복 4케이스로 원본 이름 바이트 단위 복원 확인)와 통합 테스트 1개(한글 이름 업로드 후/v1/mask파일 파트 헤더와/v1/jobs/{id}/result응답 헤더 양쪽에서filename*존재·헤더 전체 ASCII·파싱된filename이masked_계약서.pdf인지 확인)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=1 ./...전부 통과,-race -count=3으로 플래키 여부 확인, 이전 구현으로 임시 되돌려 새 검사 4개가 실제로 실패(단위 3케이스 + 통합의 “expected an encoded filename* parameter”)하는 것까지 확인했습니다. -
보류 아이디어: 동기 슬롯 대기열의 메모리 상한(대기 요청이 이미 읽은 업로드 바이트를 보유) / 종료 시 실행 중인 비동기 job이
running으로 남고 재기동 시failed처리될 뿐 재개되지 않음 /internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang/Schema,envInt/envNonNegativeInt/envBool) 단위 테스트 //v1/jobs/{id}/result가GET만 라우팅되어HEAD프로브가 405를 받음(ServeContent는 이미 HEAD 처리) / 업로드 파일명 유니코드 정규화 부재(NFD 자모 분리 한글이 그대로 디스크에 기록되어 재조회·비교가 어긋남) - 릴리즈: v1.0.16 (2026-09-09, run 2026-09-09-023058-pii-masker-improve)
- 릴리즈: v1.0.17 (2026-09-09, run 2026-09-09-104105-pii-masker-improve)
2026-09-09
- 선택: 종료 시 실행 중인 비동기 job 배출(graceful drain) (가치 4 / 위험 2 / 작업량 M)
- 결과: 성공
- 요약:
App.Serve가http.Server.Shutdown으로 in-flight HTTP 요청만 배출하고go s.runJob고루틴은 그대로 버려서, 업스트림 응답을 거의 다 받아 마스킹까지 끝낸 작업도 배포·재시작 때 통째로 사라지고 재기동 시job_interruptedfailed로만 표시되던 문제를 고쳤습니다.Service에draining채널 +drainMu(RWMutex) +runnersWaitGroup을 두고,CreateJob이 새startJobRunner로 러너를 등록한 뒤에만 고루틴을 띄우도록 했으며, 새Service.Shutdown(ctx)가draining을 닫아 신규 러너를 막고 이미 실행 중인 러너를 ctx 기한까지 기다립니다. 슬롯을 아직 못 잡은 러너는acquireJobSlot이draining을 슬롯보다 먼저 확인해 즉시 포기하고 새markJobInterrupted로job_interrupted(재시도 가능)를 기록하므로, 종료가 대기 중인 작업 때문에 늘어지지 않고 폴링 중인 클라이언트도 바로 답을 받습니다.App.Serve는server.Shutdown과 같은PII_MASKER_SHUTDOWN_TIMEOUT_SECONDS예산을 공유해svc.Shutdown을 호출하고, 기한을 넘긴 경우는 서버 오류가 아니라 로그로만 남깁니다(파일은 남고 다음 기동에서 interrupted 처리). 검증은internal/app통합 테스트 2개(업스트림을 붙잡은 채 job 생성 → 취소 후에도Serve가 반환하지 않고, 게이트를 열면 디스크의 job.json이completed/ 슬롯 1개에서 두 번째 job이 취소 직후job_interruptedfailed로 기록되고 실행 중이던 첫 job은completed)와internal/service단위 테스트 5개(러너 없을 때 즉시 반환, 실행 중 러너가 있으면DeadlineExceeded, draining 후startJobRunner거부, 슬롯 대기 중 draining 시 포기, draining 중CreateJob이job_interrupted로 저장)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=3 ./...전부 통과,svc.Shutdown호출을 임시로 제거해 새 통합 테스트 2개가 실제로 실패(“serve returnedwhile a job was still masking a document" / queued job이 failed가 되지 않아 타임아웃)하는 것까지 확인했습니다. README의 종료 동작 문단에도 비동기 작업 배출 규칙을 적었습니다. -
보류 아이디어: 동기 슬롯 대기열의 메모리 상한(대기 요청이 이미 읽은 업로드 바이트를 보유) /
internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang/Schema,envInt/envNonNegativeInt/envBool) 단위 테스트 //v1/jobs/{id}/result가GET만 라우팅되어HEAD프로브가 405를 받음(ServeContent는 이미 HEAD 처리) / 업로드 파일명 유니코드 정규화(NFC) 부재로 macOS의 NFD 한글 이름이 그대로 디스크·메타데이터에 기록됨 / 종료 시 interrupted로 표시된 job의 재개(현재는 실패로만 남고 재시도는 클라이언트 몫) - 릴리즈: v1.0.18 (2026-09-09, run 2026-09-09-115103-pii-masker-improve)
2026-09-10
- 선택: 재기동이 job 파일 보존 기한을 밀어내던 문제 수정 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
jobs.Store.load()가 하드 킬로queued/running상태로 남은 job을job_interruptedfailed로 바꾸면서UpdatedAt을 현재 시각으로 올리고 그 변경을 디스크에 쓰지 않아서, 다음 기동 때 다시 디스크의 원래 상태를 읽고 또 시각을 올리는 일이 반복됐습니다. 보존 기간(기본 24시간)이UpdatedAt기준이므로, 보존 기간보다 자주 재시작하는 서버에서는 중단된 job의 업로드 원본(=마스킹해 달라고 받은 개인정보 원문)이 영구히 지워지지 않았습니다. 이제 중단 표시는 job이 마지막으로 바뀐 시각을 그대로 유지하고, 그 전환을persistLocked로 한 번 디스크에 반영해(실패해도 무해) 다음 기동에서 되풀이되지 않게 했습니다.load()가 맵을 직접 채우므로s.mu를 명시적으로 잡도록 바꿨습니다. 검증은internal/jobs테스트 2개(48시간 전runningjob이 재로드 후 시각을 유지하고DeleteExpired(now-24h)로 실제 삭제됨 / 재로드 후 디스크의job.json이failed+job_interrupted이고 시각도 그대로)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=3 ./...전부 통과, 이전 동작(시각 갱신 + 미영속)으로 임시 되돌려 새 테스트 2개가 실제로 실패하는 것까지 확인했습니다. README의 종료 동작 문단에도 보존 기한이 밀리지 않는다는 점을 적었습니다. -
보류 아이디어: 동기 슬롯 대기열의 메모리 상한(대기 요청이 이미 읽은 업로드 바이트를 보유) / 업스트림 응답 8MB 절단이
decode_failed로 잘못 보고됨(별도 오류 코드 필요) /internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang/Schema,envInt/envNonNegativeInt/envBool) 단위 테스트 //v1/jobs/{id}/result가GET만 라우팅되어HEAD프로브가 405를 받음 / 업로드 파일명 유니코드 정규화(NFC) 부재로 macOS의 NFD 한글 이름이 그대로 디스크·메타데이터에 기록됨 - 릴리즈: v1.0.19 (2026-09-10, run 2026-09-10-003120-pii-masker-improve)
2026-09-10
- 선택: 업스트림 응답 8MB 절단을 별도 오류로 보고 (가치 3 / 위험 1 / 작업량 S)
- 결과: 성공
- 요약:
performParseRequest가 추론 응답을io.LimitReader(body, 8MB)로 읽어서, 8MB를 넘는 응답(페이지가 많은 문서나verbose옵션을 켠 요청에서 충분히 발생 가능)이 조용히 잘린 채 JSON 디코더로 넘어가고decode_failed(“엔드포인트 URL과 응답 형식을 확인하세요”)로 보고됐습니다. 정상 엔드포인트를 의심하게 만드는 막다른 진단이라, 이제maxUpstreamResponseBytes+1만큼 읽어 초과 여부를 판별하고 초과 시response_too_large(재시도 불가, 한도 크기를 적은 detail + 문서 분할·verbose 해제 힌트)를 반환합니다. 8MB 상한은 이유를 적은 이름 있는 상수로 뺐고, 4xx/5xx는 크기보다 상태 코드가 더 많은 것을 말해 주므로 기존classifyHTTPError분류를 그대로 우선합니다. 검증은internal/upstage테스트 2개(8MB를 넘는 200 응답 →response_too_large·Retryable=false·상태 200 보고 / 같은 크기의 500 응답은 여전히server_error)를 추가했고,gofmt -l(무출력)·go vet ./...·go build ./...·go test -count=1 ./...·go test -race -count=3 ./...전부 통과, 이전 구현(limit 그대로 + 초과 판별 없음)으로 임시 되돌려 새 테스트가 실제로decode_failed로 실패하는 것까지 확인했습니다. -
보류 아이디어: 동기 슬롯 대기열의 메모리 상한(대기 요청이 이미 읽은 업로드 바이트를 보유) /
internal/config의 나머지 순수 함수(normalizeAllowHosts,normalizeEndpointURL,normalizePIILang/Schema,envInt/envNonNegativeInt/envBool) 단위 테스트 //v1/jobs/{id}/result가GET만 라우팅되어HEAD프로브가 405를 받음(ServeContent는 이미 HEAD 처리) / 업로드 파일명 유니코드 정규화(NFC) 부재로 macOS의 NFD 한글 이름이 그대로 디스크·메타데이터에 기록됨 /jobs.Store.load()가 디렉터리 이름과job.json의 ID 불일치를 검증하지 않아DeleteExpired가 엉뚱한 디렉터리를 지울 수 있음 - 릴리즈: v1.0.20 (2026-09-10, run 2026-09-10-143115-pii-masker-improve)