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
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,17 @@ public ExtractionResponse extractLink(
ProductLink link = ProductLink.parse(request.url());
// model 은 호출자가 백오피스에서 지정한 값이라 원장에 남긴다 — 추출 품질이 흔들릴 때 "그때 어느 모델이었나"를
// 되짚는 유일한 근거다(자유 문자열이라 메트릭 라벨로는 못 쓴다).
// headlessAllowed 도 원장에 남긴다 — 허가 없는 대상이 브라우저로 갔는지(또는 허가가 왜 안 왔는지)를
// 사후에 되짚을 수 있는 유일한 근거다.
// authorized 도 원장에 남긴다 — 우회 수단이 열린 요청이었는지를 사후에 되짚을 수 있는 유일한 근거다.
log.info(
"extract request correlationId={} headlessFirst={} headlessAllowed={} model={} url={}",
"extract request correlationId={} authorized={} model={} url={}",
correlationId,
request.headlessFirst(),
request.headlessAllowed(),
request.authorized(),
request.model(),
link.safeLogString()
);
ProductSnapshot snapshot = productLinkExtractor.extract(
link,
request.headlessFirst(),
request.headlessAllowed(),
request.authorized(),
request.model()
);
return ExtractionResponse.from(snapshot);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
* <p>url 형식 검증을 Bean Validation 이 아니라 {@code ProductLink.parse} 에 맡긴다 — blank·형식·스킴
* 위반이 전부 계약 코드 INVALID_URL 하나로 떨어져야 하기 때문이다.
*
* <p>headlessFirst·headlessAllowed 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는
* <p>authorized 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는
* FAIL_ON_NULL_FOR_PRIMITIVES 가 기본 on 이라, 이 선택 필드를 안 보내는 구버전 호출자의 요청이 400 으로
* 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 같은 이름 파라미터와 같다.
* 깨진다(통합 테스트로 실측).
*
* <p>headlessAllowed 의 누락 정규화가 false 인 것은 fail-safe 다 — 이 필드를 모르는 구버전 호출자의 요청은
* "허가 없음"으로 떨어져 헤드리스를 타지 않는다. 반대로 두면 허가 계약이 배포 순서에 따라 조용히 뚫린다.
* <p>authorized 는 "이 대상이 플랫폼의 명시적 허락을 받았는가"다. 누락 정규화가 false 인 것은 fail-safe 다 —
* 이 필드를 모르는 구버전 호출자의 요청은 "허락 없음"으로 떨어져 우회 수단이 열리지 않는다. 판정의 원장은
* 호출자(core)에 있고 이 서비스는 요청 단위로 받아 렌더 서비스까지 전달만 한다.
*
* <p>model 은 호출자가 지정한 LLM 모델이며 선택 필드다. 안 보내면 null 이 되어 기본 모델을 쓴다(String 이라
* primitive 함정은 없다). 빈 문자열·공백 처리는 GeminiHttpClient 의 후보 계산이 흡수한다.
*/
public record LinkExtractionRequest(String url, Boolean headlessFirst, Boolean headlessAllowed, String model) {
public record LinkExtractionRequest(String url, Boolean authorized, String model) {

public LinkExtractionRequest {
headlessFirst = Boolean.TRUE.equals(headlessFirst);
headlessAllowed = Boolean.TRUE.equals(headlessAllowed);
authorized = Boolean.TRUE.equals(authorized);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
import org.springframework.stereotype.Component;

/**
* 정적 HTTP fetch 기반 추출 전략. authorized 는 쓰지 않는다 — 우리 IP 로 문서를 받아오는 것뿐이라
* 우회할 대상이 없다(그 개념은 렌더 서비스에만 있다).
*
* 정적 HTTP fetch 기반 추출 전략. fetch 는 1회뿐이고, 이후 파싱(구조화 우선 → 미달이면 같은 HTML 로 LLM
* fallback, 재fetch 없음)은 헤드리스 전략과 공유하는 {@link HtmlSnapshotPipeline} 이 맡는다.
*/
Expand All @@ -19,7 +22,7 @@ public class DefaultProductLinkExtractor implements LinkExtractionStrategy {
private final HtmlSnapshotPipeline htmlSnapshotPipeline;

@Override
public ProductSnapshot extract(ProductLink link, String model) {
public ProductSnapshot extract(ProductLink link, boolean authorized, String model) {
long fetchStart = System.nanoTime();
PageContent page = pageFetcher.fetch(link);
long fetchMs = (System.nanoTime() - fetchStart) / 1_000_000;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,12 @@
* <ul>
* <li>fetch 가 막힘 — escalatable 차단({@code PageFetchException.escalatable} 이 단일 진실).</li>
* <li>fetch 는 성공했지만 결과가 READY 필수 필드를 못 채움 — 부분 SSR SPA(이름·이미지 OG 만 SSR 에 싣고
* 가격은 JS 렌더 뒤에만 존재, 카카오 톡딜 실측)는 문서에 없는 값이라 LLM 도 못 뽑는다. 이 승격이 없으면
* 그런 host 는 HEADLESS_FIRST 정책이 유일한 성공 경로(정합성 조건)가 된다 — 정책은 느린-실패 낭비를 줄이는
* 최적화로만 남긴다는 설계를 이 승격이 지킨다.</li>
* 가격은 JS 렌더 뒤에만 존재, 카카오 톡딜 실측)는 문서에 없는 값이라 LLM 도 못 뽑는다.</li>
* </ul>
*
* <p>단 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)는 전부 요청의 headlessAllowed 허가 뒤에 있다 — 허가가
* 없으면 "비싸고 느려서" 가 아니라 "써서는 안 되므로" 열리지 않는다. 이 서비스는 허가 대상을 알지 못하고 판정도
* 하지 않는다(무상태): 허가의 단일 진실은 호출자 쪽에 있고 여기서는 요청 단위 플래그로만 받는다.
* <p>추출 경로는 요청의 authorized 와 무관하게 항상 같다 — 브라우저로 여는 것 자체는 우리 신원을 밝히고 하는
* 일이라 허락을 전제하지 않는다. authorized 가 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)뿐이고,
* 이 클래스는 그 값을 판단 없이 전략에 넘기기만 한다(무상태 — 원장은 호출자에 있다).
*
* <p>에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과
* 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서
Expand All @@ -36,11 +34,6 @@
public class FallbackProductLinkExtractor implements ProductLinkExtractor {

private static final String ESCALATION_METRIC = "product.extract.escalation";
/**
* 직행 경로의 추세 축. 라벨 키는 {@code outcome} 뿐 — plain 실패가 없어 category 축이 존재하지 않는다
* (메트릭 이름이 달라 escalation 의 {@code outcome, category} 키 집합과 충돌하지 않는다).
*/
private static final String HEADLESS_FIRST_METRIC = "product.extract.headless_first";
private static final String TAG_OUTCOME = "outcome";
private static final String TAG_CATEGORY = "category";
private static final String OUTCOME_SUCCESS = "success";
Expand All @@ -58,71 +51,31 @@ public class FallbackProductLinkExtractor implements ProductLinkExtractor {
private final HeadlessExtractionProperties headlessProperties;

@Override
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);
}

// 호출자(core)의 브라우저 직행 정책(DB, 백오피스에서 배포 없이 변경) — plain 이 항상 차단되는 host 의
// 느린-실패(fetch 타임아웃을 다 기다린 뒤 에스컬레이트) 낭비를 없앤다. 직행 실패는 plain 으로 되돌리지
// 않고 그대로 전파한다: 재시도는 호출자 outbox recover 축이, 정책 오지정은 백오피스 롤백이 진다.
if (headlessFirst) {
return extractHeadlessFirst(link, model);
public ProductSnapshot extract(ProductLink link, boolean authorized, String model) {
// 이 서비스의 운영 비상 차단. 꺼져 있으면 승격 자체가 없어 정적 fetch 결과가 그대로 나간다.
if (!headlessProperties.enabled()) {
return plain.extract(link, authorized, model);
}

ProductSnapshot plainSnapshot;
try {
plainSnapshot = plain.extract(link, model);
plainSnapshot = plain.extract(link, authorized, model);
} catch (RuntimeException e) {
if (!shouldEscalate(e)) {
throw e;
}
return escalateToHeadless(link, categoryOf(e), model);
return escalateToHeadless(link, categoryOf(e), authorized, model);
}
if (plainSnapshot.missingReadyField()) {
// 이대로 반환하면 응답 경계(ExtractionResponse.from)가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는다 —
// 확정 전에 브라우저 렌더 DOM 으로 한 번 더 시도한다. 승격은 plain 의 try 바깥이라 headless 실패가
// 위 catch 로 새어 재승격되는 일이 없고, 승격 결과가 여전히 불완전하면 그때 경계가 같은 확정 실패로
// 닫는다(재승격 없음).
return escalateToHeadless(link, CATEGORY_INCOMPLETE_SNAPSHOT, model);
return escalateToHeadless(link, CATEGORY_INCOMPLETE_SNAPSHOT, authorized, model);
}
return plainSnapshot;
}

/**
* 브라우저 직행(정책 힌트). outcome 을 별도 카운터로 집계한다 — escalation 카운터는 에스컬레이션 축만
* 커버해서, 직행 볼륨·성공률이 시계열에 없으면 호출자의 직행 정책 오지정(실제로는 plain 이 통하는 host)이
* 로그 grep 전까지 조용히 지속된다(메트릭=추세, 로그=원장).
*
* <p>outcome=success 는 "요청을 살렸다"(완전한 READY snapshot 확보)다 — 판정 규칙은
* {@link #outcomeOf(ProductSnapshot)} 참조.
*/
private ProductSnapshot extractHeadlessFirst(ProductLink link, String model) {
log.info("extract route=headless_first url={}", link.safeLogString());
try {
ProductSnapshot snapshot = headless.extract(link, model);
headlessFirstCounter(outcomeOf(snapshot)).increment();
return snapshot;
} catch (Throwable failure) {
// escalateToHeadless 와 같은 이유로 Throwable — Error 실패도 집계에서 빠지지 않게 하고 그대로 rethrow.
headlessFirstCounter(OUTCOME_FAILED).increment();
log.warn(
"extract route=headless_first outcome=failed cause={} url={}",
failure.getClass().getSimpleName(),
link.safeLogString()
);
throw failure;
}
}

private Counter headlessFirstCounter(String outcome) {
return meterRegistry.counter(HEADLESS_FIRST_METRIC, TAG_OUTCOME, outcome);
}

/**
* plain 으로 못 끝내(차단 또는 불완전 결과) headless 로 넘긴다. 결과를 outcome 으로 집계하되 "무엇이 escalate
* 됐나"를 category(fetch 실패 코드명 또는 INCOMPLETE_SNAPSHOT)로 쪼갠다 — 무조건 폴백이라 낭비(특히 일시
Expand All @@ -133,11 +86,11 @@ private Counter headlessFirstCounter(String outcome) {
* outcome=success 는 "요청을 살렸다"(완전한 READY snapshot 확보)다 — 판정 규칙은
* {@link #outcomeOf(ProductSnapshot)} 참조.
*/
private ProductSnapshot escalateToHeadless(ProductLink link, String category, String model) {
private ProductSnapshot escalateToHeadless(ProductLink link, String category, boolean authorized, String model) {
Objects.requireNonNull(category, "category");
log.info("extract escalate=headless plainCategory={} url={}", category, link.safeLogString());
try {
ProductSnapshot snapshot = headless.extract(link, model);
ProductSnapshot snapshot = headless.extract(link, authorized, model);
escalationCounter(outcomeOf(snapshot), category).increment();
return snapshot;
} catch (Throwable headlessFailure) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ public class HeadlessProductLinkExtractor implements LinkExtractionStrategy {
private final HtmlSnapshotPipeline htmlSnapshotPipeline;

@Override
public ProductSnapshot extract(ProductLink link, String model) {
public ProductSnapshot extract(ProductLink link, boolean authorized, String model) {
long renderStart = System.nanoTime();
PageContent page = headlessRenderer.render(link);
PageContent page = headlessRenderer.render(link, authorized);
long renderMs = (System.nanoTime() - renderStart) / 1_000_000;

return htmlSnapshotPipeline.extract(page, "render=" + renderMs + "ms", model);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public interface LinkExtractionStrategy {
String PLAIN = "plainLinkExtractionStrategy";
String HEADLESS = "headlessLinkExtractionStrategy";

/** @param model 호출자가 지정한 LLM 모델(없으면 null). 전략은 해석하지 않고 파이프라인까지 흘려보낸다. */
ProductSnapshot extract(ProductLink link, String model);
/**
* @param authorized 이 대상이 허락을 받았는가. 정적 fetch 전략에는 우회 개념이 없어 무시되고, 헤드리스
* 전략만 렌더 서비스로 전달한다. 두 전략이 같은 시그니처를 갖는 편이 호출부(Fallback)가 분기를
* 들고 있는 것보다 낫다 — 전략이 늘어도 호출부가 안 바뀐다.
* @param model 호출자가 지정한 LLM 모델(없으면 null). 전략은 해석하지 않고 파이프라인까지 흘려보낸다.
*/
ProductSnapshot extract(ProductLink link, boolean authorized, String model);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,14 @@
public interface ProductLinkExtractor {

/**
* @param headlessFirst 호출자(core)의 플랫폼 라우팅 정책 힌트. 정책의 단일 진실은 호출자 쪽 동적 설정(DB,
* 백오피스)에 있고, 무상태인 이 서비스는 요청 단위 힌트로만 받는다. true 면 plain(정적 fetch)을 건너뛰고
* 처음부터 헤드리스로 추출한다 — 단 이 서비스의 헤드리스 스위치가 꺼져 있으면 무시된다. 힌트일 뿐이라
* headlessAllowed 가 false 면 함께 무시된다(허가가 라우팅보다 앞선다).
* @param headlessAllowed 이 대상에 헤드리스를 써도 되는지에 대한 호출자의 허가. false 면 어떤 경로로도
* 헤드리스를 타지 않는다 — 직행(headlessFirst)·차단 승격·불완전 승격 셋 다 닫힌다. 허가 대상의 단일
* 진실은 호출자(core) 쪽에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는
* @param authorized 이 대상이 플랫폼의 명시적 허락을 받았는가. 추출 경로 자체는 이 값과 무관하다 —
* 정적 fetch 로 시작해 필요하면 브라우저로 승격하는 흐름은 항상 같다. 이 값이 여는 것은 렌더 서비스의
* 우회 수단(지문 보정·프록시)뿐이며, 여기서는 판단하지 않고 그대로 전달만 한다. 허락 대상의 단일
* 진실은 호출자(core)에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는
* fail-safe 다({@code LinkExtractionRequest}).
* @param model 호출자가 지정한 LLM 모델 힌트. headlessFirst 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽
* @param model 호출자가 지정한 LLM 모델 힌트. authorized 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽
* 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. null 이면 기본 모델을 쓰며,
* 지정 모델이 사라졌으면 기본 모델로 대체된다.
*/
ProductSnapshot extract(ProductLink link, boolean headlessFirst, boolean headlessAllowed, String model);
ProductSnapshot extract(ProductLink link, boolean authorized, String model);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@
* <p>include_html 을 켜는 이유: renderer 는 파싱하지 않으므로(HTML 렌더러) 파싱(구조화/LLM)은 우리가 렌더된
* HTML 로 직접 한다.
*
* <p>authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정·
* 프록시)을 연다. 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게
* 안전한 쪽으로만 어긋난다.
*
* <p>compress 를 모르는 구버전 renderer 는 이 필드를 무시하고(pydantic 기본) plain JSON 을 준다 — 그래서
* 해제 판별을 요청이 아니라 응답 헤더로 두면(HttpHeadlessRenderer 참조) 켠 채로도 배포 순서와 무관하게 안전하다.
*/
record HeadlessRenderRequest(
String url,
boolean authorized,
@JsonProperty("include_html") boolean includeHtml,
boolean compress
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@
*/
public interface HeadlessRenderer {

PageContent render(ProductLink link);
/**
* @param authorized 허락받은 대상인가. 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정·프록시)을
* 연다. 이 경계는 판정하지 않고 전달만 한다 — 원장은 호출자(core)에 있다.
*/
PageContent render(ProductLink link, boolean authorized);
}
Loading
Loading