Skip to content

Latest commit

 

History

History
178 lines (130 loc) · 17.1 KB

File metadata and controls

178 lines (130 loc) · 17.1 KB

extractor API 계약

이 문서가 계약의 single source 다. 구현(springdoc /v3/api-docs)과 어긋나면 이 문서를 기준으로 구현을 고친다.

소비자는 core 의 outbox 워커 하나뿐이다. 공개 API 가 아니며, 보안그룹으로 내부망에서만 접근한다(별도 인증 없음).

0. 설계 불변식

  • Extractor 는 무상태다. DB 없음, 호출 간 상태 없음. 같은 요청이 중복 도착해도 상태 오염이 없다(중복의 대가는 LLM 비용 한 번뿐). Extractor 에 상태를 넣고 싶어지면 설계 경고 신호다.
  • 재시도·내구성·상태 전이는 전부 호출자(core 의 item_snapshots outbox)의 책임이다. Extractor 는 "단건 시도 1회"에만 답한다.
  • 에스컬레이션(plain fetch → headless 브라우저)은 Extractor 내부 관심사다. 응답 계약에 드러나지 않는다 — 호출자는 어떤 fetch 전략이 쓰였는지 모른다. 단 "어느 플랫폼을 처음부터 헤드리스로 보낼지"의 정책은 호출자(DB·백오피스)가 주인이라, 요청 필드 headlessFirst 로 힌트만 받는다(§2) — 무상태 불변식을 지키는 선에서의 유일한 정책 수용 지점이다.
  • 헤드리스는 허가받은 대상에만 쓴다. 허가 여부의 주인도 호출자라, 요청 필드 headlessAllowed 로 받는다(§2). Extractor 는 허가 대상을 알지 못하고 판정도 하지 않으며(무상태), 허가가 없으면 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)를 전부 닫고 plain 만 돈다. 이것은 성능 최적화가 아니라 계약이다.

1. 응답 3갈래 (전이 규약)

Extractor 응답 의미 core 전이
2xx + 추출 결과 성공 markReady
422 + {code} 확정 실패 (재시도 무의미) 즉시 markFailed
그 외 전부 (5xx·타임아웃·연결 실패·미지의 상태) 일시 실패 PROCESSING 유지 → recover 재시도 (attempt 상한 2)
  • 전이 판정은 HTTP status 만 사용한다. 422 body 의 code 는 관측·디버깅용이며, 호출자가 모르는 code 여도 422 면 확정 실패로 처리한다(tolerant reader).
  • fail-safe 원칙: 분류할 수 없는 실패는 전부 "일시"로 떨어진다. 확정 실패 신호는 422 하나뿐이다. 호출자의 attempt 상한이 재시도 비용을 바운드한다.

2. 엔드포인트

POST /internal/extractions/link — URL 상품 추출 (이관 1차)

요청:

{ "url": "https://www.musinsa.com/products/12345", "headlessFirst": false, "headlessAllowed": false, "model": "gemini-3.1-flash-lite" }
  • url (필수): https 스킴의 상품 페이지 URL. 형식·스킴·미지원 플랫폼의 동기 검증은 호출자(core 등록 경계)가 이미 끝냈다는 전제이나, Extractor 도 자기 경계에서 방어 검증한다(다층 방어).
  • headlessFirst (선택, 기본 false — additive, 이관 7단계): 호출자의 플랫폼 라우팅 정책(HEADLESS_FIRST, DB·백오피스 동적 설정) 힌트. true 면 plain(정적 fetch)을 건너뛰고 처음부터 헤드리스 브라우저로 추출한다. 정책의 단일 진실은 호출자 DB 에 있고 무상태인 Extractor 는 요청 단위로만 받는다. Extractor 의 product.extract.headless.enabled 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선). headlessAllowed 가 false 여도 함께 무시된다 — 허가가 라우팅보다 앞선다.
  • headlessAllowed (선택, 기본 false — additive): 이 대상에 헤드리스 브라우저를 써도 되는지에 대한 호출자의 허가. false 면 Extractor 는 어떤 경로로도 헤드리스를 타지 않는다 — 직행(headlessFirst)·차단 승격·불완전 승격 셋 다 닫히고 plain 만 돈다. 누락·null 은 false(허가 없음)로 정규화되는 fail-safe 라, 이 필드를 모르는 구버전 호출자의 요청은 헤드리스를 열지 않는다(배포 순서 무관, 안전한 쪽으로만 어긋난다). 허가 대상의 단일 진실은 호출자 쪽에 있고 Extractor 는 판정하지 않는다(무상태). product.extract.headless.enabled 는 그 위에 남는 Extractor 쪽 운영 비상 차단이다 — 둘 다 서야 헤드리스가 열린다.
  • model (선택, additive — core#875): 이 요청의 LLM 추출에 쓸 모델. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 호출자 DB(백오피스)에 있고 Extractor 는 요청 단위로만 받는다. 요청 단위로 받는 이유: Extractor 박스 한 대를 여러 환경이 공유하므로, 모델을 Extractor 환경변수로 잡으면 dev 에서 바꾼 것이 prod 파싱까지 덮는다. 생략·null·빈 문자열이면 Extractor 의 기본 모델(GeminiProperties.DEFAULT_MODEL)을 쓴다 — 구버전 호출자의 요청이 그대로 동작하므로 배포 순서 무관.
  • 지정 모델이 404 면 기본 모델로 대체하고 추출을 이어간다. 등록 당시 유효했던 모델이 폐기돼 사라지는 경우가 있고, 그때 파싱 전체가 죽는 것보다 기본 모델로 이어가는 편이 낫다(가용성 우선). 대체가 일어나도 응답 모양은 같으며, 발생 사실은 Extractor 의 warn 로그와 gemini.model.fallback 카운터에만 남는다. 400·5xx·timeout 은 대체하지 않는다 — 400 은 요청 body 쪽 결함일 수 있어 대체로 덮으면 버그가 묻히고, 나머지는 모델을 바꾼다고 풀리는 실패가 아니다.
  • 헤더 X-Correlation-Id (선택): 호출자의 item_snapshot id. 로그·trace 상관용이며 동작에 영향 없다.

성공 200:

{
  "name": "나이키 에어포스",
  "imageUrl": "https://...",
  "currentPrice": 99000,
  "currency": "KRW",
  "finalUrl": "https://www.musinsa.com/products/6760200",
  "method": "STRUCTURED"
}
  • finalUrl (additive, core#825): 리다이렉트를 따라간 최종 페이지 URL. 호출자가 상품 정체성(canonical) 정규화의 입력으로 쓴다 — 단축링크(onelink 등)는 경로가 불투명 코드라 이 값 없이 같은 상품을 알아볼 수 없다. link 경로는 항상 채워지고 image 경로는 null. 호출자는 이 값이 없으면(구버전 Extractor) canonical 확정을 건너뛴다 — 배포 순서 무관.
  • method (additive, core#825): 값을 만든 추출 경로. STRUCTURED(구조화 파싱, 결정론적) | LLM(Gemini — URL fallback·image 경로). 호출자가 snapshot 출처(SERVER/SERVER_LLM)를 구분 저장하는 근거다. tolerant reader 라 모르는 값이 와도 무시하고 출처 미기록으로 둔다.
  • name(non-blank)·imageUrl·currentPrice 의 non-null 을 Extractor 가 보장한다 — core 의 READY 불변식(requireReadyInvariant: name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과 동일 조건이다. 보장할 수 없으면 성공이 아니라 422(UNTRUSTWORTHY_VALUE)다. currency 는 READY 필수가 아니라 nullable 이다. 호출자의 엔티티 불변식은 최후 보루로 유지된다.

확정 실패 422:

{ "code": "NOT_PRODUCT_PAGE" }
code core 기준 동등물
NOT_PRODUCT_PAGE ProductSnapshotException.notProductPage — 상품 페이지가 아님
UNTRUSTWORTHY_VALUE ProductSnapshotException.untrustworthyValue — 추출값이 범위·상식 위반
FETCH_CLIENT_ERROR PageFetchException.clientError — 대상 4xx (403 차단·404·429 등)
BLOCKED_HOST PageFetchException.blockedHost — SSRF 차단 (headless 에스컬레이션 절대 금지 대상)
TOO_MANY_REDIRECTS PageFetchException.tooManyRedirects
MALFORMED_REDIRECT PageFetchException.malformedRedirect
PERMANENT_UPSTREAM PageFetchException.permanentUpstreamError — 대상 500/501 (봇 차단 추정)
EMPTY_SHELL PageFetchException.emptyShell — fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜지고 headlessAllowed 허가가 실린 요청에선 헤드리스 결과가 대신 응답된다
NO_EXTRACTABLE_CONTENT ProductSnapshotException.noExtractableContent — 본문에 가시 텍스트도 데이터 script 도 없어 LLM 을 부르지 않고 확정(빈 셸 환각 차단). plain 경로는 EMPTY_SHELL 재분류가 선행하므로 사실상 헤드리스 렌더 결과까지 셸일 때 나온다
LLM_INVALID_RESPONSE GeminiApiException clientError/parseError/noTextPart — 재시도 무의미한 LLM 실패
INVALID_URL url 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어)

일시 실패 (Extractor 는 502 를 쓴다, 호출자는 status 구분 없이 "2xx/422 외 전부"로 처리):

  • 대상 몰 502/503/504·연결 실패·빈 body (UPSTREAM_ERROR)
  • Gemini 5xx/429/408/transport 오류 (LLM_UPSTREAM)
  • 헤드리스 렌더 차단 (HEADLESS_BLOCKED, 이관 7단계 추가) — 렌더 서비스의 BLOCK 판정(HTTP 401/403/405/429/490 + 챌린지 title)은 429·일시 챌린지가 섞여 영구/일시를 못 가르므로 fail-safe 로 일시. 결정론적 차단의 재시도 낭비는 attempt 상한이 바운드
  • 헤드리스 렌더 서비스 연결 실패·타임아웃·빈 렌더(verdict=EMPTY)·브라우저 오류(verdict=ERROR)·압축(zstd) 응답 해제 실패 (HEADLESS_UPSTREAM, 이관 7단계 추가). 렌더 서비스(renderer)는 파싱하지 않으므로(verdict=OK|BLOCK|EMPTY|ERROR) HTML 이 있으면 verdict 와 무관하게 Extractor 파이프라인(구조화·LLM)이 추출을 이어간다
  • body 에 code 를 실을 수 있으나 호출자는 읽지 않는다(관측용).

POST /internal/extractions/image — S3 이미지 OCR 추출 + 크롭 (이관 6단계, 계약만 선확정)

요청:

{ "bucket": "dev-piki-images-996918499382", "key": "items/raw/0f3a....png", "model": "gemini-3.1-flash-lite" }
  • bucket 을 요청이 준다 — Extractor 는 dev/staging/prod 세 환경 트래픽을 받고 각 환경의 이미지 버킷이 다르다(dev-piki-images-* · staging-piki-images-* · piki-images-*). 버킷을 고정 config 로 두지 않고 요청별로 받아 버킷 무관하게 동작한다. IAM 은 *piki-images-996918499382/items/* 와일드카드로 세 버킷을 덮는다.
  • key: raw 원본 object key(등록 시 본 서버가 items/raw/{uuid}.{ext} 로 durable 적재한 것).
  • model (선택, additive — core#875): link 와 같은 규약이되 축이 갈린다 — 이미지 경로에는 이미지용 지정만 온다. 링크는 텍스트와 JSON 스키마를 다루고 이미지는 보는 능력이 필요해, 한쪽에 맞는 모델이 다른 쪽에 맞지 않을 수 있기 때문이다. 대체 규칙(404 만 기본 모델로)도 link 와 같다.

성공 200 (link 경로와 동일한 필드 모양):

{
  "name": "...",
  "imageUrl": "https://dev-piki-images-996918499382.s3.ap-northeast-2.amazonaws.com/items/0f3a....png",
  "currentPrice": 12000,
  "currency": "KRW",
  "finalUrl": null,
  "method": "LLM"
}
  • Extractor 가 download(bucket,key) → OCR 추출 → bbox 크롭(불가 시 원본) → upload(bucket, items/{uuid}.png) 를 다 하고, 업로드한 결과 이미지의 public URL 을 imageUrl 로 돌려준다. imageUrl 은 항상 non-null(크롭 실패해도 원본을 올린다 — 본 서버 워커의 bbox?.crop ?: 원본 동작과 동일).
  • non-null 규약은 link 와 같다: name(non-blank)·imageUrl·currentPrice 를 Extractor 가 보장, 못 채우면 422(UNTRUSTWORTHY_VALUE).

422 code 추가분: IMAGE_UNSUPPORTED(빈 이미지·미지원 MIME). 이미지에서 상품 식별 실패는 link 와 같은 UNTRUSTWORTHY_VALUE 를 재사용한다. 일시 실패 추가분: STORAGE_ERROR (S3 read/write 실패).

POST /internal/models/probe — 모델 유효성 프로브 (core#875)

호출자가 백오피스에서 모델을 저장하기 전에 "이 모델이 이 경로에서 실제로 동작하는가"를 묻는다. 저장 게이트가 이 응답으로 갈린다.

요청:

{ "model": "gemini-3.1-flash-lite", "target": "LINK" }
  • model (필수): 확인할 모델 이름. 아는 모델 목록을 Extractor 코드에 박지 않는 것이 이 엔드포인트의 존재 이유다 — allowlist 를 박으면 새 모델이 나올 때마다 Extractor 배포가 필요해져 "배포 없이 바꾼다"는 목적이 무너진다. 유효성은 런타임 실측이 판정한다.
  • target (필수): LINK 또는 IMAGE. 두 경로는 요청 wire 가 달라(link 는 responseJsonSchema 에 소문자 type, image 는 responseSchema 에 대문자 enum type 과 thinkingConfig) 한쪽에서 통과한 모델이 다른 쪽에서 400 일 수 있다.

판정은 메타 조회가 아니라 그 경로의 실제 generateContent 호출이다. 모델 존재만 확인하면 요청 스키마 비호환(400)을 못 거르는데, 400 은 추출 경로에서 대체 대상이 아니라 곧 파싱 전건 실패다. 게이트가 정작 막아야 할 실패를 놓치게 된다. 최소 입력을 쓰되 wire 모양은 운영과 같다.

대체 없이 지정 모델만 친다. 추출 경로처럼 대체하면 없는 모델을 넣어도 기본 모델이 대신 성공해 프로브가 통과하고, 저장 게이트가 무력화된다.

응답:

status 의미 호출자의 처리
200 (body 없음) 이 경로에서 동작하는 모델 저장 허용
422 + code 확정 거절 저장 거부 + 사유 표시
400 필수 필드 누락·모르는 target 호출자 구현 버그. 재시도해도 같다
그 외(502) 외부 사정으로 확인 불가 저장 거부 + 재시도 안내

422 code:

code 의미
MODEL_NOT_FOUND 그런 모델이 없다(Gemini 404). 오타이거나 폐기돼 사라진 모델
MODEL_INCOMPATIBLE 모델은 있으나 그 경로의 요청을 처리하지 못한다. 요청 스키마 비호환(400)·결제 티어 제한, 그리고 200 을 주면서 응답 스키마를 못 맞춘 경우까지 포함

일시 실패를 거절로 바꾸지 않는다. 5xx·429·타임아웃을 422 로 내보내면 외부가 잠깐 흔들린 사이에 멀쩡한 모델이 "쓸 수 없는 모델"로 판정돼 저장이 막힌다.

3. 타임아웃 예산

근거
core stale 판정 60s ItemParsingScheduler.STALE_TIMEOUT (기존 값)
core → Extractor HTTP read 55s (connect 2s) stale 미만 — recover 의 유령 중복 발주 방지. link·image 공용
Extractor 내부 합계 (link) 약 50s 아래 합 + 여유
대상 몰 fetch (link) connect 5s / read 15s 현행 유지
헤드리스 render (link, 7단계) connect 2s / read 20s 실측 전형 1.6~5.5s(kream 프록시 포함) 대비 약 4배 여유. headless-first 최악(connect 2 + render 20 + LLM 30 = 약 52s)이 호출자 read 55s 안에 들도록 상한
Gemini read 30s 현행 유지 (link LLM fallback·image OCR 동일)
Extractor 내부 합계 (image) 약 40s S3 download + Gemini OCR 30s + crop + 결과 upload. S3 는 동일 리전이라 수 초

안쪽 예산은 항상 바깥보다 작아야 한다. link 와 image 가 호출자 read 55s 를 공유하므로, 어느 경로든 Extractor 내부 값을 늘릴 땐 이 표를 갱신하고 core 쪽 read 타임아웃과 함께 재검증한다.

예외적으로 에스컬레이션 경로(plain 실패 → headless)의 최악 스택은 호출자 read 55s 를 넘을 수 있다. plain fetch 는 수동 redirect 추적(hop 상한 3 = 요청 최대 4회)마다 connect/read 타임아웃이 새로 적용되므로 fetch 단독의 이론 최악이 이미 약 88s 다(이 특성은 headless 이전부터 존재). 여기에 render 22s + LLM 30s 가 얹히면 이론 최악 약 140s — 단, 각 단이 전부 타임아웃까지 끄는 경우는 실측상 없다시피 하고(차단은 대개 즉시 4xx/5xx 로 떨어져 fetch 가 빨리 실패한다), 넘치면 호출자는 read 타임아웃 → 일시 실패로 처리해 recover 가 재시도한다. 그 사이 Extractor 가 계속 돌아 중복 발주가 겹쳐도 Extractor 는 무상태라 안전하고(§0 — 중복의 대가는 LLM 비용 한 번), attempt 상한 2 가 총비용을 바운드한다. 이 스택을 55s 안에 구겨 넣으려면 render 예산이 실측 대비 무의미하게 얇아져(5s 이하) recall 을 잃는다 — 의도된 트레이드오프다.

4. 진화 규칙

  • additive-only: 응답 필드 추가·422 code 추가는 자유. 필드 제거·의미 변경·타입 변경은 금지 — 필요하면 새 경로로 분리한다.
  • 배포 순서: Extractor 먼저, 소비자(core) 나중. 본 서버 staging 이 extractor-prod 를 기본으로 바라보는 구성이 이 순서 위반을 릴리스 전에 잡는다.
  • 호출자는 tolerant reader — 모르는 응답 필드·code 를 무시한다.

5. 관측

  • W3C traceparent 헤더를 수용해 core 의 item.parse span 아래로 연결된다(micrometer tracing 기본 동작).
  • 메트릭 product.extract{via,reason}·product.extract.escalation{outcome,category} 은 core 에서 이 서비스로 이동한다. application=piki-extractor 라벨로 본 서버 시계열과 구분된다.