Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
- 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갈래 (전이 규약)

Expand All @@ -28,11 +29,12 @@
요청:

```json
{ "url": "https://www.musinsa.com/products/12345", "headlessFirst": false, "model": "gemini-3.1-flash-lite" }
{ "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` 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선).
- `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 상관용이며 동작에 영향 없다.
Expand Down Expand Up @@ -69,7 +71,7 @@
| `TOO_MANY_REDIRECTS` | `PageFetchException.tooManyRedirects` |
| `MALFORMED_REDIRECT` | `PageFetchException.malformedRedirect` |
| `PERMANENT_UPSTREAM` | `PageFetchException.permanentUpstreamError` — 대상 500/501 (봇 차단 추정) |
| `EMPTY_SHELL` | `PageFetchException.emptyShell` — fetch 는 2xx 지만 본문이 데이터 없는 CSR 셸(파싱 no-data 를 재분류). 헤드리스 에스컬레이션 대상이라, 헤드리스가 켜진 구성에선 헤드리스 결과가 대신 응답된다 |
| `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 형식·스킴 위반. 정상 흐름에선 호출자가 동기 검증해 도달하지 않는다(방어) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,22 @@ public ExtractionResponse extractLink(
ProductLink link = ProductLink.parse(request.url());
// model 은 호출자가 백오피스에서 지정한 값이라 원장에 남긴다 — 추출 품질이 흔들릴 때 "그때 어느 모델이었나"를
// 되짚는 유일한 근거다(자유 문자열이라 메트릭 라벨로는 못 쓴다).
// headlessAllowed 도 원장에 남긴다 — 허가 없는 대상이 브라우저로 갔는지(또는 허가가 왜 안 왔는지)를
// 사후에 되짚을 수 있는 유일한 근거다.
log.info(
"extract request correlationId={} headlessFirst={} model={} url={}",
"extract request correlationId={} headlessFirst={} headlessAllowed={} model={} url={}",
correlationId,
request.headlessFirst(),
request.headlessAllowed(),
request.model(),
link.safeLogString()
);
ProductSnapshot snapshot = productLinkExtractor.extract(link, request.headlessFirst(), request.model());
ProductSnapshot snapshot = productLinkExtractor.extract(
link,
request.headlessFirst(),
request.headlessAllowed(),
request.model()
);
return ExtractionResponse.from(snapshot);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@
* <p>url 형식 검증을 Bean Validation 이 아니라 {@code ProductLink.parse} 에 맡긴다 — blank·형식·스킴
* 위반이 전부 계약 코드 INVALID_URL 하나로 떨어져야 하기 때문이다.
*
* <p>headlessFirst 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는
* <p>headlessFirst·headlessAllowed 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는
* FAIL_ON_NULL_FOR_PRIMITIVES 가 기본 on 이라, 이 선택 필드를 안 보내는 구버전 호출자의 요청이 400 으로
* 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 headlessFirst 와 같다.
* 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 같은 이름 파라미터와 같다.
*
* <p>headlessAllowed 의 누락 정규화가 false 인 것은 fail-safe 다 — 이 필드를 모르는 구버전 호출자의 요청은
* "허가 없음"으로 떨어져 헤드리스를 타지 않는다. 반대로 두면 허가 계약이 배포 순서에 따라 조용히 뚫린다.
*
* <p>model 은 호출자가 지정한 LLM 모델이며 선택 필드다. 안 보내면 null 이 되어 기본 모델을 쓴다(String 이라
* primitive 함정은 없다). 빈 문자열·공백 처리는 GeminiHttpClient 의 후보 계산이 흡수한다.
*/
public record LinkExtractionRequest(String url, Boolean headlessFirst, String model) {
public record LinkExtractionRequest(String url, Boolean headlessFirst, Boolean headlessAllowed, String model) {

public LinkExtractionRequest {
headlessFirst = Boolean.TRUE.equals(headlessFirst);
headlessAllowed = Boolean.TRUE.equals(headlessAllowed);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
* 최적화로만 남긴다는 설계를 이 승격이 지킨다.</li>
* </ul>
*
* <p>단 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)는 전부 요청의 headlessAllowed 허가 뒤에 있다 — 허가가
* 없으면 "비싸고 느려서" 가 아니라 "써서는 안 되므로" 열리지 않는다. 이 서비스는 허가 대상을 알지 못하고 판정도
* 하지 않는다(무상태): 허가의 단일 진실은 호출자 쪽에 있고 여기서는 요청 단위 플래그로만 받는다.
*
* <p>에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과
* 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서
* 여기서 별도로 판정한다.
Expand Down Expand Up @@ -54,9 +58,12 @@ public class FallbackProductLinkExtractor implements ProductLinkExtractor {
private final HeadlessExtractionProperties headlessProperties;

@Override
public ProductSnapshot extract(ProductLink link, boolean headlessFirst, String model) {
// 호출자 정책이 이 서비스의 스위치를 앞설 수는 없다 — 스위치가 꺼져 있으면 headlessFirst 힌트도 무시한다.
if (!headlessProperties.enabled()) {
public ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model) {
// 두 조건이 모두 서 있어야 헤드리스가 열린다. enabled 는 이 서비스의 운영 비상 차단(호출자 정책이 앞설 수
// 없다), headlessAllowed 는 이 대상에 브라우저를 써도 되는지에 대한 호출자의 허가다. 둘 중 하나라도
// 없으면 plain 만 타고 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)가 한꺼번에 닫힌다 — 아래 분기가
// 전부 이 가드 뒤에 있는 것이 그 보장이다. headlessFirst 는 허가가 선 뒤에만 의미를 갖는 라우팅 힌트다.
if (!headlessProperties.enabled() || !headlessAllowed) {
return plain.extract(link, model);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ public interface ProductLinkExtractor {
/**
* @param headlessFirst 호출자(core)의 플랫폼 라우팅 정책 힌트. 정책의 단일 진실은 호출자 쪽 동적 설정(DB,
* 백오피스)에 있고, 무상태인 이 서비스는 요청 단위 힌트로만 받는다. true 면 plain(정적 fetch)을 건너뛰고
* 처음부터 헤드리스로 추출한다 — 단 이 서비스의 헤드리스 스위치가 꺼져 있으면 무시된다.
*/
/**
* 처음부터 헤드리스로 추출한다 — 단 이 서비스의 헤드리스 스위치가 꺼져 있으면 무시된다. 힌트일 뿐이라
* headlessAllowed 가 false 면 함께 무시된다(허가가 라우팅보다 앞선다).
* @param headlessAllowed 이 대상에 헤드리스를 써도 되는지에 대한 호출자의 허가. false 면 어떤 경로로도
* 헤드리스를 타지 않는다 — 직행(headlessFirst)·차단 승격·불완전 승격 셋 다 닫힌다. 허가 대상의 단일
* 진실은 호출자(core) 쪽에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는
* fail-safe 다({@code LinkExtractionRequest}).
* @param model 호출자가 지정한 LLM 모델 힌트. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽
* 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. null 이면 기본 모델을 쓰며,
* 지정 모델이 사라졌으면 기본 모델로 대체된다.
*/
ProductSnapshot extract(ProductLink link, boolean headlessFirst, String model);
ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model);
}
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,21 @@ void headlessFirstHintIgnoredWhenHeadlessDisabled() throws Exception {
.andExpect(jsonPath("$.name").value("직접파싱 상품"));
}

@Test
@DisplayName("headlessAllowed 를 실어 보내도 요청이 깨지지 않고 plain 경로로 정상 추출한다")
void headlessAllowedFieldIsAcceptedOnTheWire() throws Exception {
// 이 필드는 primitive 로 받으면 안 보낸 요청이 400 으로 깨지는 함정이 있어(headlessFirst 와 같은 이유)
// wire 수용 자체가 계약이다. 필드를 안 보내는 구버전 호출자 쪽은 이 클래스의 나머지 케이스가 상시 검증한다.
stubGeminiClient.reset();
stubPageFetcher.build = link -> PageContent.of(link, STRUCTURED_HTML);

mockMvc().perform(post("/internal/extractions/link")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"url\": \"https://shop.example.com/p/11\", \"headlessAllowed\": true}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("직접파싱 상품"));
}

@Test
@DisplayName("LLM 이 상품 페이지가 아니라고 판정하면 422 NOT_PRODUCT_PAGE 를 반환한다")
void notProductPage() throws Exception {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.depromeet.piki.extractor.api;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

/**
* 요청 계약의 선택 플래그 정규화. 특히 headlessAllowed 의 기본값은 안전 기본값(fail-safe)이라 계약의 일부다 —
* 이 필드를 모르는 구버전 호출자·필드를 빠뜨린 요청이 "허가받은 것"으로 흘러가면 허가 게이트가 배포 순서에 따라
* 조용히 뚫린다.
*/
class LinkExtractionRequestTest {

@Test
@DisplayName("headlessAllowed 를 안 보내면 허가 없음(false)으로 정규화된다")
void missingHeadlessAllowedDefaultsToDenied() {
LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null, null);

assertEquals(Boolean.FALSE, request.headlessAllowed());
}

@Test
@DisplayName("headlessAllowed=true 를 보내면 허가로 그대로 전달된다")
void explicitHeadlessAllowedIsPreserved() {
LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, true, null);

assertEquals(Boolean.TRUE, request.headlessAllowed());
}

@Test
@DisplayName("headlessFirst 를 안 보내면 false 로 정규화된다")
void missingHeadlessFirstDefaultsToFalse() {
LinkExtractionRequest request = new LinkExtractionRequest("https://shop.example.com/p", null, null, null);

assertEquals(Boolean.FALSE, request.headlessFirst());
}
}
Loading
Loading