왜
extractor 가 422 로 돌려주는 확정 실패 code 13종 중 core 가 의미를 아는 건 2종뿐이다. 나머지는 전부 else 폴백으로 permanent_error 한 바구니에 들어간다.
// RemoteExtractionContract.kt:99
return when (code) {
CODE_NOT_PRODUCT_PAGE -> ProductSnapshotException.notProductPage()
CODE_UNTRUSTWORTHY_VALUE -> ProductSnapshotException.untrustworthyValue()
else -> ProductExtractorException.permanentFailure()
}
30일 실측이 이 바구니가 이름대로 쓰이지 않고 있음을 보여준다 (Loki):
| code |
건수 |
현재 reason |
NOT_PRODUCT_PAGE |
41 |
not_product |
UNTRUSTWORTHY_VALUE |
39 |
not_product |
NO_EXTRACTABLE_CONTENT |
3 |
permanent_error |
EMPTY_SHELL |
1 |
permanent_error |
| 나머지 9종 |
0 |
- |
미매핑으로 permanent_error 에 떨어진 4건이 전부 "우리가 이 페이지를 못 읽었다" 계열이고, 이름이 뜻하는 진짜 외부·내부 오류 code 9종은 30일간 한 건도 없었다. permanent_error 는 자기 이름대로 쓰이는 게 아니라 매핑 누락분을 받는 통이 되어 있다.
게다가 EMPTY_SHELL 은 성격이 바뀐 code 다. extractor 에서 permanent=true, escalatable=true("정적 fetch 로는 같은 셸이지만 실제 브라우저면 뚫린다")인데, 정직함 전환(2026-08-12)으로 헤드리스가 기본 차단되면서 화이트리스트 밖 도메인은 이 code 가 그대로 422 로 표면화된다. 의미는 "상품 아님"도 "외부 오류"도 아닌 "이 도메인은 지금 우리 구성으로 못 읽는다"는 커버리지 신호이며 앞으로 늘어날 code 인데, 지금은 permanent_error 에 묻혀 보이지 않는다.
무엇을
선행: infra 의 contracts/extraction-error-codes.yaml 신설 이슈가 머지된 뒤 착수한다.
전이 판정은 손대지 않는다. 422 를 확정 실패로 보고 즉시 FAILED 하는 것은 모든 code 에 대해 옳다. 고칠 대상은 "그 실패를 무엇이라 부르고 어떻게 세는가" 한 층뿐이다.
ProductSnapshotException.noExtractableContent() 팩토리 추가 + ProductSnapshotErrorCode 엔트리 (번호 append-only). 계약 문서가 이미 지목한 동등물이다.
RemoteExtractionContract.translate 를 전수 매핑으로 확장 — 카탈로그의 모든 permanent code 를 명시 분기로 처리하고, else 는 "모르는 code" 방어로만 남긴다.
ItemParsingMetrics reason 재편 — reason 을 "이 숫자가 늘면 누가 무엇을 하는가"로 나눈다.
| reason |
뜻 |
늘면 할 일 |
code |
not_product |
사용자가 상품 아닌 걸 넣음 |
없음 (정상) |
NOT_PRODUCT_PAGE, INVALID_URL |
unreadable |
우리 구성으로 못 읽음 |
도메인 허가 후보 |
EMPTY_SHELL, NO_EXTRACTABLE_CONTENT |
blocked |
대상이 우리를 막음 |
UNSUPPORTED 정책 후보 |
FETCH_CLIENT_ERROR, PERMANENT_UPSTREAM |
extract_quality |
추출은 됐는데 값을 못 믿음 |
모델·프롬프트·검증 규칙 |
UNTRUSTWORTHY_VALUE, LLM_INVALID_RESPONSE, IMAGE_UNSUPPORTED |
internal_error |
우리 버그·방어 발동 |
코드 조사 |
BLOCKED_HOST, TOO_MANY_REDIRECTS, MALFORMED_REDIRECT |
permanent_error 는 제거한다. recover 계열(retry_exhausted·no_source·deadline)과 ready_rejected·none 은 그대로 둔다 — 파이프라인 내부 사정이라 축이 다르고 이미 액션과 잘 대응한다.
ci.yml 에 infra 체크아웃 스텝 + 메타 테스트 — 카탈로그의 모든 permanent code 가 translate 에 나타나고, 각 code 가 카탈로그 bucket 과 같은 reason 으로 귀결되는지 검사한다.
- 기존 단위 테스트 갱신 —
translate 분기 망라.
결정 근거
UNTRUSTWORTHY_VALUE 를 not_product 에서 뗀다. 39건으로 두 번째로 많은데 NOT_PRODUCT_PAGE(41건)와 한 통에 있어 "상품 아님" 지표를 사실상 두 배로 부풀린다. 성격도 다르다. 대신 대시보드 히스토리 연속성이 그 지점에서 끊긴다.
카디널리티는 감당된다. Prometheus counter 는 increment 될 때만 시계열을 만드는데, blocked·internal_error 계열 code 는 30일간 0건이라 시계열이 생성되지 않는다. 실질 증가는 두 종이고 permanent_error 가 사라지므로 환경 축 2개를 곱해도 순증 약 +2. Metrics 프리티어 한도 상황에서도 안전하다.
범위 밖
- 사용자 문구 분화 — CLAUDE.md 규약이 "detail 은 전부 고정 사용자 문구, 구분은 로그·메트릭"이므로 현재의 단일 문구가 규약대로다.
extraction_platform_policies(DB)와의 통합 — 축이 다르다. DB 정책은 요청 전 도메인 라우팅이고 운영자가 백오피스로 바꾼다. 카탈로그는 응답 후 해석이고 배포로 양쪽이 함께 움직인다. unreadable 추이가 정책 추가의 근거가 되는 입력·출력 관계다.
후속
대시보드·알림의 reason 축 갱신(permanent_error 참조 제거, unreadable 추이 패널 신설)은 이 이슈 배포 이후 별건으로 한다.
왜
extractor 가 422 로 돌려주는 확정 실패 code 13종 중 core 가 의미를 아는 건 2종뿐이다. 나머지는 전부
else폴백으로permanent_error한 바구니에 들어간다.30일 실측이 이 바구니가 이름대로 쓰이지 않고 있음을 보여준다 (Loki):
NOT_PRODUCT_PAGEnot_productUNTRUSTWORTHY_VALUEnot_productNO_EXTRACTABLE_CONTENTpermanent_errorEMPTY_SHELLpermanent_error미매핑으로
permanent_error에 떨어진 4건이 전부 "우리가 이 페이지를 못 읽었다" 계열이고, 이름이 뜻하는 진짜 외부·내부 오류 code 9종은 30일간 한 건도 없었다.permanent_error는 자기 이름대로 쓰이는 게 아니라 매핑 누락분을 받는 통이 되어 있다.게다가
EMPTY_SHELL은 성격이 바뀐 code 다. extractor 에서permanent=true, escalatable=true("정적 fetch 로는 같은 셸이지만 실제 브라우저면 뚫린다")인데, 정직함 전환(2026-08-12)으로 헤드리스가 기본 차단되면서 화이트리스트 밖 도메인은 이 code 가 그대로 422 로 표면화된다. 의미는 "상품 아님"도 "외부 오류"도 아닌 "이 도메인은 지금 우리 구성으로 못 읽는다"는 커버리지 신호이며 앞으로 늘어날 code 인데, 지금은permanent_error에 묻혀 보이지 않는다.무엇을
선행: infra 의
contracts/extraction-error-codes.yaml신설 이슈가 머지된 뒤 착수한다.전이 판정은 손대지 않는다. 422 를 확정 실패로 보고 즉시 FAILED 하는 것은 모든 code 에 대해 옳다. 고칠 대상은 "그 실패를 무엇이라 부르고 어떻게 세는가" 한 층뿐이다.
ProductSnapshotException.noExtractableContent()팩토리 추가 +ProductSnapshotErrorCode엔트리 (번호 append-only). 계약 문서가 이미 지목한 동등물이다.RemoteExtractionContract.translate를 전수 매핑으로 확장 — 카탈로그의 모든 permanent code 를 명시 분기로 처리하고,else는 "모르는 code" 방어로만 남긴다.ItemParsingMetricsreason 재편 — reason 을 "이 숫자가 늘면 누가 무엇을 하는가"로 나눈다.not_productNOT_PRODUCT_PAGE,INVALID_URLunreadableEMPTY_SHELL,NO_EXTRACTABLE_CONTENTblockedFETCH_CLIENT_ERROR,PERMANENT_UPSTREAMextract_qualityUNTRUSTWORTHY_VALUE,LLM_INVALID_RESPONSE,IMAGE_UNSUPPORTEDinternal_errorBLOCKED_HOST,TOO_MANY_REDIRECTS,MALFORMED_REDIRECTpermanent_error는 제거한다. recover 계열(retry_exhausted·no_source·deadline)과ready_rejected·none은 그대로 둔다 — 파이프라인 내부 사정이라 축이 다르고 이미 액션과 잘 대응한다.ci.yml에 infra 체크아웃 스텝 + 메타 테스트 — 카탈로그의 모든 permanent code 가translate에 나타나고, 각 code 가 카탈로그bucket과 같은 reason 으로 귀결되는지 검사한다.translate분기 망라.결정 근거
UNTRUSTWORTHY_VALUE를not_product에서 뗀다. 39건으로 두 번째로 많은데NOT_PRODUCT_PAGE(41건)와 한 통에 있어 "상품 아님" 지표를 사실상 두 배로 부풀린다. 성격도 다르다. 대신 대시보드 히스토리 연속성이 그 지점에서 끊긴다.카디널리티는 감당된다. Prometheus counter 는 increment 될 때만 시계열을 만드는데,
blocked·internal_error계열 code 는 30일간 0건이라 시계열이 생성되지 않는다. 실질 증가는 두 종이고permanent_error가 사라지므로 환경 축 2개를 곱해도 순증 약 +2. Metrics 프리티어 한도 상황에서도 안전하다.범위 밖
extraction_platform_policies(DB)와의 통합 — 축이 다르다. DB 정책은 요청 전 도메인 라우팅이고 운영자가 백오피스로 바꾼다. 카탈로그는 응답 후 해석이고 배포로 양쪽이 함께 움직인다.unreadable추이가 정책 추가의 근거가 되는 입력·출력 관계다.후속
대시보드·알림의 reason 축 갱신(
permanent_error참조 제거,unreadable추이 패널 신설)은 이 이슈 배포 이후 별건으로 한다.