From 38a1b6f7ce56f66e6298af4882502e06094e0c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:22:48 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9A=94=EC=B2=AD=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EC=9D=84=20authorized=20=ED=95=98=EB=82=98=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=98=EA=B3=A0=20=ED=97=A4=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EA=B1=B7=EC=96=B4=EB=82=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - headlessFirst(직행 최적화)를 제거한다. 불완전 승격이 들어온 뒤로는 정책 없이도 같은 결과에 도달해, 이 힌트는 도메인당 fetch 1회와 LLM 1회를 아끼는 값이 됐고 넣을 유인이 사라졌다. product.extract.headless_first 메트릭도 함께 제거 - headlessAllowed 게이트를 제거한다. 브라우저로 여는 것 자체는 신원을 밝히고 하는 일이라 허락을 전제하지 않는다 - 승격 3경로가 허가 뒤에 있던 구조를 풀어, 기본 상태에서도 CSR 페이지가 뚫린다 - 대신 authorized 를 받아 렌더 경계까지 전달만 한다. 이 값이 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)이고 판정 원장은 호출자에 있다(무상태) - 전략 계약(LinkExtractionStrategy)에도 authorized 를 실었다. plain 은 우회 개념이 없어 무시하지만, 두 전략이 같은 시그니처를 갖는 편이 호출부가 분기를 들고 있는 것보다 낫다 - stub·fake 에 전달된 값을 기록해 렌더 경계까지 도달하는지 케이스로 고정한다 - 이 전달이 끊기면 허락받은 대상이 조용히 정직 모드로 가거나 그 반대가 되는데 반환값으로는 안 드러난다 --- .../extractor/api/ExtractionController.java | 11 +- .../extractor/api/LinkExtractionRequest.java | 14 +- .../DefaultProductLinkExtractor.java | 5 +- .../FallbackProductLinkExtractor.java | 73 ++------ .../HeadlessProductLinkExtractor.java | 4 +- .../extraction/LinkExtractionStrategy.java | 9 +- .../extraction/ProductLinkExtractor.java | 15 +- .../headless/HeadlessRenderRequest.java | 5 + .../extraction/headless/HeadlessRenderer.java | 6 +- .../headless/HttpHeadlessRenderer.java | 12 +- .../api/LinkExtractionRequestTest.java | 30 ++- .../DefaultProductLinkExtractorTest.java | 6 +- .../FallbackProductLinkExtractorTest.java | 173 +++++------------- .../HeadlessProductLinkExtractorTest.java | 10 +- .../extraction/ProductLinkExtractE2ETest.java | 2 +- .../headless/HttpHeadlessRendererTest.java | 28 +-- .../support/StubHeadlessRenderer.java | 8 +- 17 files changed, 144 insertions(+), 267 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java index 18040bc..dbfcdfc 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionController.java @@ -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); diff --git a/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java b/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java index e79d63c..337e01c 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java +++ b/src/main/java/com/depromeet/piki/extractor/api/LinkExtractionRequest.java @@ -6,20 +6,20 @@ *
url 형식 검증을 Bean Validation 이 아니라 {@code ProductLink.parse} 에 맡긴다 — blank·형식·스킴 * 위반이 전부 계약 코드 INVALID_URL 하나로 떨어져야 하기 때문이다. * - *
headlessFirst·headlessAllowed 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는 + *
authorized 를 primitive boolean 이 아니라 Boolean 으로 받는 이유: Jackson 3 는 * FAIL_ON_NULL_FOR_PRIMITIVES 가 기본 on 이라, 이 선택 필드를 안 보내는 구버전 호출자의 요청이 400 으로 - * 깨진다(통합 테스트로 실측). 의미는 {@code ProductLinkExtractor.extract} 의 같은 이름 파라미터와 같다. + * 깨진다(통합 테스트로 실측). * - *
headlessAllowed 의 누락 정규화가 false 인 것은 fail-safe 다 — 이 필드를 모르는 구버전 호출자의 요청은 - * "허가 없음"으로 떨어져 헤드리스를 타지 않는다. 반대로 두면 허가 계약이 배포 순서에 따라 조용히 뚫린다. + *
authorized 는 "이 대상이 플랫폼의 명시적 허락을 받았는가"다. 누락 정규화가 false 인 것은 fail-safe 다 — + * 이 필드를 모르는 구버전 호출자의 요청은 "허락 없음"으로 떨어져 우회 수단이 열리지 않는다. 판정의 원장은 + * 호출자(core)에 있고 이 서비스는 요청 단위로 받아 렌더 서비스까지 전달만 한다. * *
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); } } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java index 69d53b2..6008802 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/DefaultProductLinkExtractor.java @@ -8,6 +8,9 @@ import org.springframework.stereotype.Component; /** + * 정적 HTTP fetch 기반 추출 전략. authorized 는 쓰지 않는다 — 우리 IP 로 문서를 받아오는 것뿐이라 + * 우회할 대상이 없다(그 개념은 렌더 서비스에만 있다). + * * 정적 HTTP fetch 기반 추출 전략. fetch 는 1회뿐이고, 이후 파싱(구조화 우선 → 미달이면 같은 HTML 로 LLM * fallback, 재fetch 없음)은 헤드리스 전략과 공유하는 {@link HtmlSnapshotPipeline} 이 맡는다. */ @@ -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; diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java index 9d47dee..4a035da 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java @@ -17,14 +17,12 @@ *
단 헤드리스 진입 3경로(직행·차단 승격·불완전 승격)는 전부 요청의 headlessAllowed 허가 뒤에 있다 — 허가가 - * 없으면 "비싸고 느려서" 가 아니라 "써서는 안 되므로" 열리지 않는다. 이 서비스는 허가 대상을 알지 못하고 판정도 - * 하지 않는다(무상태): 허가의 단일 진실은 호출자 쪽에 있고 여기서는 요청 단위 플래그로만 받는다. + *
추출 경로는 요청의 authorized 와 무관하게 항상 같다 — 브라우저로 여는 것 자체는 우리 신원을 밝히고 하는 + * 일이라 허락을 전제하지 않는다. authorized 가 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)뿐이고, + * 이 클래스는 그 값을 판단 없이 전략에 넘기기만 한다(무상태 — 원장은 호출자에 있다). * *
에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과 * 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서 @@ -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"; @@ -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 전까지 조용히 지속된다(메트릭=추세, 로그=원장). - * - *
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)로 쪼갠다 — 무조건 폴백이라 낭비(특히 일시 @@ -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) { diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java index 26a6bdd..b628b91 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractor.java @@ -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); diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java b/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java index 49b55e5..d3d5218 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java @@ -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); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java index fa78e46..20f9f3b 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java @@ -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); } diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java index 92bd1ec..a8f7888 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderRequest.java @@ -8,11 +8,16 @@ *
include_html 을 켜는 이유: renderer 는 파싱하지 않으므로(HTML 렌더러) 파싱(구조화/LLM)은 우리가 렌더된 * HTML 로 직접 한다. * + *
authorized 는 "이 대상이 허락을 받았는가" 다 — 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정· + * 프록시)을 연다. 이 필드를 모르는 구버전 renderer 는 무시하고 정직 모드로 돌므로, 배포 순서와 무관하게 + * 안전한 쪽으로만 어긋난다. + * *
compress 를 모르는 구버전 renderer 는 이 필드를 무시하고(pydantic 기본) plain JSON 을 준다 — 그래서
* 해제 판별을 요청이 아니라 응답 헤더로 두면(HttpHeadlessRenderer 참조) 켠 채로도 배포 순서와 무관하게 안전하다.
*/
record HeadlessRenderRequest(
String url,
+ boolean authorized,
@JsonProperty("include_html") boolean includeHtml,
boolean compress
) {
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java
index 1fb1da3..b0baaf8 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HeadlessRenderer.java
@@ -11,5 +11,9 @@
*/
public interface HeadlessRenderer {
- PageContent render(ProductLink link);
+ /**
+ * @param authorized 허락받은 대상인가. 렌더 서비스는 이 값이 true 일 때만 우회 수단(지문 보정·프록시)을
+ * 연다. 이 경계는 판정하지 않고 전달만 한다 — 원장은 호출자(core)에 있다.
+ */
+ PageContent render(ProductLink link, boolean authorized);
}
diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java
index d772318..41a1075 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/headless/HttpHeadlessRenderer.java
@@ -84,18 +84,18 @@ public HttpHeadlessRenderer(
}
@Override
- public PageContent render(ProductLink link) {
+ public PageContent render(ProductLink link, boolean authorized) {
try {
// 여기서 걸리면 PageFetchException.blockedHost 로 — plain 경로와 같은 계약 코드로 떨어진다.
internalHostGuard.verify(link);
- return renderVerified(link);
+ return renderVerified(link, authorized);
} finally {
dnsResolver.clear();
}
}
- private PageContent renderVerified(ProductLink link) {
- HeadlessRenderResponse response = requestRender(link);
+ private PageContent renderVerified(ProductLink link, boolean authorized) {
+ HeadlessRenderResponse response = requestRender(link, authorized);
String verdict = response.verdict();
if (VERDICT_BLOCK.equals(verdict)) {
@@ -127,13 +127,13 @@ private PageContent renderVerified(ProductLink link) {
return new PageContent(link, capHtml(html), resolveFinalUrl(response.finalUrl(), link));
}
- private HeadlessRenderResponse requestRender(ProductLink link) {
+ private HeadlessRenderResponse requestRender(ProductLink link, boolean authorized) {
ResponseEntity 헤드리스가 도는 케이스는 전부 headlessAllowed=true 를 넘긴다 — 허가가 헤드리스 진입의 전제 조건이기
- * 때문이다. 허가가 없을 때 3경로가 모두 닫히는지는 아래 headlessAllowed* 케이스가 따로 못박는다.
+ * authorized 는 헤드리스 진입 조건이 아니다 — 승격 경로는 그 값과 무관하게 같고, 이 플래그는 렌더 경계로
+ * 전달만 된다. 전달이 끊기지 않는지는 아래 authorized* 케이스가 못박는다.
*/
class FallbackProductLinkExtractorTest {
@@ -40,9 +40,12 @@ private static class FakeStrategy implements LinkExtractionStrategy {
this.fn = fn;
}
+ Boolean lastAuthorized;
+
@Override
- public ProductSnapshot extract(ProductLink link, String model) {
+ public ProductSnapshot extract(ProductLink link, boolean authorized, String model) {
calls++;
+ lastAuthorized = authorized;
return fn.apply(link);
}
}
@@ -73,7 +76,7 @@ void headlessOffDelegatesToPlain() {
throw new IllegalStateException("headless 는 호출되면 안 됨");
});
- ProductSnapshot result = fallback(false, plain, headless).extract(link, false, true, null);
+ ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null);
assertEquals(snapshot, result);
assertEquals(1, plain.calls);
@@ -88,7 +91,7 @@ void headlessOffPropagatesEscalatableFailure() {
});
FakeStrategy headless = new FakeStrategy(l -> snapshot);
- assertThrows(PageFetchException.class, () -> fallback(false, plain, headless).extract(link, false, true, null));
+ assertThrows(PageFetchException.class, () -> fallback(false, plain, headless).extract(link, false, null));
assertEquals(0, headless.calls);
}
@@ -103,7 +106,7 @@ void escalatesToHeadlessOnEscalatableFailure() {
FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
- ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null);
+ ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null);
assertEquals(headlessSnapshot, result);
assertEquals(1, plain.calls);
@@ -123,7 +126,7 @@ void headlessOnButPlainSucceeds() {
throw new IllegalStateException("headless 는 호출되면 안 됨");
});
- ProductSnapshot result = fallback(true, plain, headless).extract(link, false, true, null);
+ ProductSnapshot result = fallback(true, plain, headless).extract(link, false, null);
assertEquals(snapshot, result);
assertEquals(1, plain.calls);
@@ -143,7 +146,7 @@ void headlessErrorIsCountedAndPropagated() {
});
SimpleMeterRegistry registry = new SimpleMeterRegistry();
- assertThrows(Error.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null));
+ assertThrows(Error.class, () -> fallback(true, plain, headless, registry).extract(link, false, null));
assertEquals(
1.0,
registry.counter("product.extract.escalation", "outcome", "failed", "category", "FETCH_CLIENT_ERROR").count()
@@ -163,7 +166,7 @@ void headlessFailureIsCountedAndPropagated() {
});
SimpleMeterRegistry registry = new SimpleMeterRegistry();
- assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null));
+ assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null));
assertEquals(1, plain.calls);
assertEquals(1, headless.calls);
assertEquals(
@@ -172,57 +175,8 @@ void headlessFailureIsCountedAndPropagated() {
);
}
- @Test
- @DisplayName("headlessFirst 힌트가 오면 plain 을 건너뛰고 headless 로 직행하며 success 로 집계한다")
- void headlessFirstSkipsPlain() {
- FakeStrategy plain = new FakeStrategy(l -> {
- throw new IllegalStateException("plain 은 호출되면 안 됨");
- });
- ProductSnapshot headlessSnapshot =
- new ProductSnapshot(null, "헤드리스 결과", "https://cdn.example.com/h.png", 50_000, null);
- FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot);
- SimpleMeterRegistry registry = new SimpleMeterRegistry();
-
- ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, true, null);
-
- assertEquals(headlessSnapshot, result);
- assertEquals(0, plain.calls);
- assertEquals(1, headless.calls);
- assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "success").count());
- }
-
- @Test
- @DisplayName("headlessFirst 직행이 실패하면 plain 으로 되돌리지 않고 failed 로 집계한 뒤 그대로 전파한다")
- void headlessFirstFailurePropagatesWithoutPlainFallback() {
- // 직행 정책은 "plain 이 항상 차단되는 host" 지정이라 실패해도 plain 재시도는 낭비다 — 재시도는 호출자
- // outbox recover 축이, 정책 오지정은 백오피스 롤백이 진다. outcome=failed 시계열이 그 오지정의 추세 신호다.
- FakeStrategy plain = new FakeStrategy(l -> snapshot);
- FakeStrategy headless = new FakeStrategy(l -> {
- throw new RuntimeException("headless 실패");
- });
- SimpleMeterRegistry registry = new SimpleMeterRegistry();
-
- assertThrows(RuntimeException.class, () -> fallback(true, plain, headless, registry).extract(link, true, true, null));
- assertEquals(0, plain.calls);
- assertEquals(1, headless.calls);
- assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "failed").count());
- }
-
- @Test
- @DisplayName("headless 가 꺼져 있으면 headlessFirst 힌트도 무시하고 plain 에 위임한다")
- void headlessFirstIgnoredWhenDisabled() {
- // 호출자(core)의 라우팅 정책이 이 서비스의 스위치보다 앞설 수 없다 — 스위치가 꺼진 동안은 zero-diff.
- FakeStrategy plain = new FakeStrategy(l -> snapshot);
- FakeStrategy headless = new FakeStrategy(l -> {
- throw new IllegalStateException("headless 는 호출되면 안 됨");
- });
- ProductSnapshot result = fallback(false, plain, headless).extract(link, true, true, null);
- assertEquals(snapshot, result);
- assertEquals(1, plain.calls);
- assertEquals(0, headless.calls);
- }
@Test
@DisplayName("headless 가 켜져 있어도 escalatable 이 아닌 실패는 전파하고 headless 를 호출하지 않는다")
@@ -233,7 +187,7 @@ void nonEscalatableFailureIsNeverEscalated() {
});
FakeStrategy headless = new FakeStrategy(l -> snapshot);
- assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, true, null));
+ assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, null));
assertEquals(0, headless.calls);
}
@@ -249,7 +203,7 @@ void escalatesToHeadlessOnIncompleteSnapshot() {
FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
- ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null);
+ ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null);
assertEquals(headlessSnapshot, result);
assertEquals(1, plain.calls);
@@ -269,7 +223,7 @@ void incompleteEscalationReturnsHeadlessResultAsIs() {
FakeStrategy headless = new FakeStrategy(l -> incomplete);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
- ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, true, null);
+ ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null);
assertEquals(incomplete, result);
assertEquals(1, headless.calls);
@@ -279,21 +233,6 @@ void incompleteEscalationReturnsHeadlessResultAsIs() {
);
}
- @Test
- @DisplayName("headlessFirst 직행 결과가 불완전하면 그대로 반환하되 failed 로 집계한다")
- void headlessFirstIncompleteResultIsCountedFailed() {
- // 직행 성공률은 정책 오지정(직행해도 못 살리는 host)의 추세 신호다 — 경계에서 확정 실패로 닫힐 결과를
- // success 로 세면 그 신호가 실제보다 부푼다.
- FakeStrategy plain = new FakeStrategy(l -> snapshot);
- FakeStrategy headless = new FakeStrategy(l -> incomplete);
- SimpleMeterRegistry registry = new SimpleMeterRegistry();
-
- ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, true, null);
-
- assertEquals(incomplete, result);
- assertEquals(0, plain.calls);
- assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "failed").count());
- }
@Test
@DisplayName("불완전 승격에서 headless 가 escalatable 실패를 던져도 재진입 없이 전파하고 failed 로 집계한다")
@@ -305,7 +244,7 @@ void incompleteEscalationFailurePropagatesWithoutReentry() {
});
SimpleMeterRegistry registry = new SimpleMeterRegistry();
- assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, true, null));
+ assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null));
assertEquals(1, headless.calls);
assertEquals(
1.0,
@@ -321,7 +260,7 @@ void headlessOffReturnsIncompleteAsIs() {
throw new IllegalStateException("headless 는 호출되면 안 됨");
});
- ProductSnapshot result = fallback(false, plain, headless).extract(link, false, true, null);
+ ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null);
assertEquals(incomplete, result);
assertEquals(0, headless.calls);
@@ -338,79 +277,55 @@ void productSnapshotFailureIsNotEscalated() {
});
FakeStrategy headless = new FakeStrategy(l -> snapshot);
- assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, true, null));
+ assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, null));
assertEquals(0, headless.calls);
}
+
+
+
@Test
- @DisplayName("허가가 없으면 plain 이 escalatable 차단으로 막혀도 headless 로 승격하지 않고 예외를 전파한다")
- void headlessNotAllowedBlocksEscalation() {
- // 허가는 "비싸니 아낀다"가 아니라 "써서는 안 된다"는 계약이라, 차단당했다는 사정이 허가를 대신하지 못한다.
+ @DisplayName("허락 없는 요청도 escalatable 이 아닌 실패는 그대로 전파한다 (SSRF 차단은 승격 대상이 아니다)")
+ void unauthorizedPropagatesNonEscalatableFailure() {
FakeStrategy plain = new FakeStrategy(l -> {
- throw PageFetchException.clientError(new RuntimeException("403"));
+ throw PageFetchException.blockedHost();
});
FakeStrategy headless = new FakeStrategy(l -> {
throw new IllegalStateException("headless 는 호출되면 안 됨");
});
- SimpleMeterRegistry registry = new SimpleMeterRegistry();
- assertThrows(
- PageFetchException.class,
- () -> fallback(true, plain, headless, registry).extract(link, false, false, null));
- assertEquals(1, plain.calls);
+ assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, null));
assertEquals(0, headless.calls);
- assertEquals(
- 0.0,
- registry.counter("product.extract.escalation", "outcome", "success", "category", "FETCH_CLIENT_ERROR").count()
- );
}
@Test
- @DisplayName("허가가 없으면 plain 결과가 불완전해도 headless 로 승격하지 않고 그대로 반환한다")
- void headlessNotAllowedBlocksIncompleteEscalation() {
- // 불완전 승격은 세 진입 경로 중 유일하게 plain 이 예외 없이 끝나는 경로다 — 여기로 허가가 새면
- // 게이트가 반쪽이 된다. 반환값은 응답 경계가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는 기존 계약 그대로다.
+ @DisplayName("허락이 없어도 불완전 결과는 headless 로 승격한다 (허락은 승격 조건이 아니다)")
+ void unauthorizedStillEscalates() {
+ // 브라우저로 여는 것 자체는 신원을 밝히고 하는 일이라 허락을 전제하지 않는다. 허락이 여는 것은
+ // 렌더 서비스의 우회 수단뿐이고, 그 판단은 이 클래스가 아니라 렌더 경계 너머에 있다.
FakeStrategy plain = new FakeStrategy(l -> incomplete);
- FakeStrategy headless = new FakeStrategy(l -> {
- throw new IllegalStateException("headless 는 호출되면 안 됨");
- });
-
- ProductSnapshot result = fallback(true, plain, headless).extract(link, false, false, null);
-
- assertEquals(incomplete, result);
- assertEquals(1, plain.calls);
- assertEquals(0, headless.calls);
- }
-
- @Test
- @DisplayName("허가가 없으면 headlessFirst 직행 힌트가 와도 무시하고 plain 만 탄다")
- void headlessNotAllowedBeatsHeadlessFirstHint() {
- // 라우팅 힌트(어디로 보낼까)와 허가(써도 되나)는 다른 질문이다 — 힌트가 허가를 만들어내지 못한다.
- FakeStrategy plain = new FakeStrategy(l -> snapshot);
- FakeStrategy headless = new FakeStrategy(l -> {
- throw new IllegalStateException("headless 는 호출되면 안 됨");
- });
- SimpleMeterRegistry registry = new SimpleMeterRegistry();
+ ProductSnapshot rescued =
+ new ProductSnapshot(null, "톡딜 상품", "https://cdn.example.com/p.png", 23_900, null);
+ FakeStrategy headless = new FakeStrategy(l -> rescued);
- ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, true, false, null);
+ ProductSnapshot result = fallback(true, plain, headless).extract(link, false, null);
- assertEquals(snapshot, result);
- assertEquals(1, plain.calls);
- assertEquals(0, headless.calls);
- assertEquals(0.0, registry.counter("product.extract.headless_first", "outcome", "success").count());
+ assertEquals(rescued, result);
+ assertEquals(1, headless.calls);
+ assertEquals(Boolean.FALSE, headless.lastAuthorized);
}
@Test
- @DisplayName("허가가 없으면 escalatable 이 아닌 실패도 그대로 전파한다 (plain 단독 경로의 기존 계약)")
- void headlessNotAllowedPropagatesNonEscalatableFailure() {
+ @DisplayName("허락 플래그는 headless 전략까지 그대로 전달된다")
+ void authorizationReachesHeadlessStrategy() {
+ // 이 전달이 끊기면 허락받은 대상이 조용히 정직 모드로 가거나 그 반대가 된다 — 반환값으로는 안 드러난다.
FakeStrategy plain = new FakeStrategy(l -> {
- throw PageFetchException.blockedHost();
- });
- FakeStrategy headless = new FakeStrategy(l -> {
- throw new IllegalStateException("headless 는 호출되면 안 됨");
+ throw PageFetchException.clientError(new RuntimeException("403"));
});
+ FakeStrategy headless = new FakeStrategy(l -> snapshot);
- assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, false, null));
- assertEquals(0, headless.calls);
+ fallback(true, plain, headless).extract(link, true, null);
+
+ assertEquals(Boolean.TRUE, headless.lastAuthorized);
}
}
diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java
index 3ec96ad..2aa9ae5 100644
--- a/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/extraction/HeadlessProductLinkExtractorTest.java
@@ -31,7 +31,7 @@ class HeadlessProductLinkExtractorTest {
private final StubGeminiClient stubGemini = new StubGeminiClient();
private HeadlessProductLinkExtractor extractorWith(Function