From ec70f01f2788701edcd5944f7f92d7c8eac70883 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, 9 Aug 2026 11:01:53 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20plain=20=EA=B2=B0=EA=B3=BC=EA=B0=80?= =?UTF-8?q?=20READY=20=ED=95=84=EC=88=98=20=ED=95=84=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=BB=20=EC=B1=84=EC=9A=B0=EB=A9=B4=20=ED=97=A4=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=EB=A1=9C=20=EC=97=90=EC=8A=A4=EC=BB=AC?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부분 SSR SPA(카카오 톡딜 실측: 이름·이미지 OG 만 SSR 에 있고 가격은 JS 렌더 뒤에만 존재)는 fetch 가 성공해도 문서에 가격이 없어 LLM 도 못 뽑는다. 기존 승격 축(PageFetchException.escalatable)은 fetch 실패만 봐서 이 경우 브라우저를 안 써 보고 UNTRUSTWORTHY_VALUE 확정 실패로 닫혔다 - READY 판정(name·imageUrl·currentPrice)을 ProductSnapshot.missingReadyField 로 올려 응답 경계(ExtractionResponse.from)와 승격 판정이 한 조건을 공유하게 했다 - 불완전 승격은 plain 의 try 바깥에서 호출해 headless 실패가 catch 로 새어 재승격되는 축을 차단하고, 기존 escalation 카운터에 category=INCOMPLETE_SNAPSHOT 으로 집계한다 - 이 승격이 없으면 해당 host 는 HEADLESS_FIRST 정책이 유일한 성공 경로(정합성 조건)가 된다. 정책을 느린-실패 낭비 제거(최적화)로만 남긴다는 설계를 지키는 변경 --- .../extractor/api/ExtractionResponse.java | 4 +- .../extractor/domain/ProductSnapshot.java | 9 ++ .../FallbackProductLinkExtractor.java | 43 ++++++--- .../extractor/domain/ProductSnapshotTest.java | 13 +++ .../FallbackProductLinkExtractorTest.java | 92 ++++++++++++++++++- 5 files changed, 143 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java index fd2f731..9b3001f 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResponse.java @@ -30,9 +30,7 @@ public static ExtractionResponse from(ProductSnapshot snapshot) { if (snapshot.method() == null) { log.warn("extraction response without method - origin marking missed"); } - if (snapshot.name() == null || snapshot.name().isBlank() - || snapshot.imageUrl() == null - || snapshot.currentPrice() == null) { + if (snapshot.missingReadyField()) { throw ProductSnapshotException.untrustworthyValue(); } return new ExtractionResponse( diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java index 1a91c73..6750901 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ProductSnapshot.java @@ -35,6 +35,15 @@ public ProductSnapshot withOrigin(ProductLink finalUrl, ExtractionMethod method) return new ProductSnapshot(link, name, imageUrl, currentPrice, currency, finalUrl, method); } + /** + * 호출자(core)의 READY 불변식(name·imageUrl·currentPrice)을 채우지 못했는가 — currency 는 READY 필수가 아니다. + * 응답 경계(ExtractionResponse)의 성공 게이트와 헤드리스 에스컬레이션 판정(FallbackProductLinkExtractor)이 + * 이 판정 하나를 공유한다 — 두 곳의 조건이 어긋나면 "승격 없이 확정 실패" 또는 "무의미한 승격"이 생긴다. + */ + public boolean missingReadyField() { + return name == null || name.isBlank() || imageUrl == null || currentPrice == null; + } + /** 컬럼 길이 제약은 호출자(core items 테이블)의 계약이다. 값이 바뀌면 양쪽을 함께 갱신한다. */ private static final int NAME_MAX_LENGTH = 512; private static final int IMAGE_URL_MAX_LENGTH = 2048; 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 a3eb865..e1726d3 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java @@ -11,12 +11,19 @@ import org.springframework.stereotype.Component; /** - * 상품 URL 추출의 공개 진입점. 두 전략을 "plain 먼저, 차단되면 headless" 로 엮는다 — headless 는 비싸고 느려 - * plain 이 차단으로 막힌 경우에만 탄다. + * 상품 URL 추출의 공개 진입점. 두 전략을 "plain 먼저, plain 으로 못 끝내면 headless" 로 엮는다 — headless 는 + * 비싸고 느려 plain 으로 끝나지 않는 경우에만 탄다. "못 끝냄"은 두 축이다: + *
에스컬레이션 축(plain 차단 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과 - * 직교한다. 차단은 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서 여기서 - * 별도로 판정한다. + *
에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과 + * 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서 + * 여기서 별도로 판정한다. */ @Slf4j @RequiredArgsConstructor @@ -34,6 +41,8 @@ public class FallbackProductLinkExtractor implements ProductLinkExtractor { private static final String OUTCOME_SUCCESS = "success"; private static final String OUTCOME_FAILED = "failed"; private static final String CATEGORY_UNKNOWN = "unknown"; + /** fetch 실패 코드가 아닌 유일한 category — plain 은 성공했지만 READY 필수 필드를 못 채워 승격된 경우. */ + private static final String CATEGORY_INCOMPLETE_SNAPSHOT = "INCOMPLETE_SNAPSHOT"; /** 필드의 {@code @Qualifier} 는 lombok.config 의 copyableAnnotations 가 생성자 파라미터로 복사한다. */ @Qualifier(LinkExtractionStrategy.PLAIN) @@ -57,14 +66,23 @@ public ProductSnapshot extract(ProductLink link, boolean headlessFirst, String m return extractHeadlessFirst(link, model); } + ProductSnapshot plainSnapshot; try { - return plain.extract(link, model); + plainSnapshot = plain.extract(link, model); } catch (RuntimeException e) { if (!shouldEscalate(e)) { throw e; } - return escalateToHeadless(link, e, model); + return escalateToHeadless(link, categoryOf(e), model); + } + if (plainSnapshot.missingReadyField()) { + // 이대로 반환하면 응답 경계(ExtractionResponse.from)가 확정 실패(UNTRUSTWORTHY_VALUE)로 닫는다 — + // 확정 전에 브라우저 렌더 DOM 으로 한 번 더 시도한다. 승격은 plain 의 try 바깥이라 headless 실패가 + // 위 catch 로 새어 재승격되는 일이 없고, 승격 결과가 여전히 불완전하면 그때 경계가 같은 확정 실패로 + // 닫는다(재승격 없음). + return escalateToHeadless(link, CATEGORY_INCOMPLETE_SNAPSHOT, model); } + return plainSnapshot; } /** @@ -95,15 +113,14 @@ private Counter headlessFirstCounter(String outcome) { } /** - * plain 이 막혀 headless 로 넘긴다. 결과를 outcome 으로 집계하되 "어떤 실패가 escalate 됐나"를 category 로 - * 쪼갠다 — 무조건 폴백이라 낭비(특히 일시 오류를 헤드리스로 보냈는데 실패)가 생기므로, 그 비율을 category - * 별로 봐서 후속 per-host 튜닝의 근거로 삼는다(메트릭=추세, host 로그=원장). headless 예외는 그대로 상위로 - * 전파해 API 계층의 계약 매핑에 맡긴다. + * plain 으로 못 끝내(차단 또는 불완전 결과) headless 로 넘긴다. 결과를 outcome 으로 집계하되 "무엇이 escalate + * 됐나"를 category(fetch 실패 코드명 또는 INCOMPLETE_SNAPSHOT)로 쪼갠다 — 무조건 폴백이라 낭비(특히 일시 + * 오류를 헤드리스로 보냈는데 실패)가 생기므로, 그 비율을 category 별로 봐서 후속 per-host 튜닝의 근거로 + * 삼는다(메트릭=추세, host 로그=원장). headless 예외는 그대로 상위로 전파해 API 계층의 계약 매핑에 맡긴다. * *
라벨 키 집합 {@code outcome, category} 는 이 카운터의 모든 발행 경로에서 동일해야 한다. */ - private ProductSnapshot escalateToHeadless(ProductLink link, RuntimeException plainFailure, String model) { - String category = categoryOf(plainFailure); + private ProductSnapshot escalateToHeadless(ProductLink link, String category, String model) { log.info("extract escalate=headless plainCategory={} url={}", category, link.safeLogString()); try { ProductSnapshot snapshot = headless.extract(link, model); diff --git a/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java b/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java index 5c3dbe0..254f126 100644 --- a/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java +++ b/src/test/java/com/depromeet/piki/extractor/domain/ProductSnapshotTest.java @@ -1,8 +1,10 @@ package com.depromeet.piki.extractor.domain; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -103,4 +105,15 @@ void zeroPricePasses() { void positivePricePasses() { assertEquals(1_000, ProductSnapshot.fromExtracted(link, "상품", null, 1_000, "KRW").currentPrice()); } + + @Test + @DisplayName("READY 필수 필드(name·imageUrl·currentPrice)가 하나라도 비면 missingReadyField 다") + void missingReadyFieldBranches() { + String image = "https://cdn.example.com/a.jpg"; + assertTrue(new ProductSnapshot(link, null, image, 1_000, "KRW").missingReadyField()); + assertTrue(new ProductSnapshot(link, " ", image, 1_000, "KRW").missingReadyField()); + assertTrue(new ProductSnapshot(link, "상품", null, 1_000, "KRW").missingReadyField()); + assertTrue(new ProductSnapshot(link, "상품", image, null, "KRW").missingReadyField()); + assertFalse(new ProductSnapshot(link, "상품", image, 1_000, null).missingReadyField()); + } } diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java index f540749..b8ebf51 100644 --- a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java +++ b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java @@ -5,6 +5,7 @@ import com.depromeet.piki.extractor.domain.ProductLink; import com.depromeet.piki.extractor.domain.ProductSnapshot; +import com.depromeet.piki.extractor.domain.ProductSnapshotException; import com.depromeet.piki.extractor.extraction.http.PageFetchException; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; @@ -13,7 +14,8 @@ import org.junit.jupiter.api.Test; /** - * Fallback(진입점)이 "plain 먼저, 막히면 headless" 를 flag·escalatable 규칙대로 엮는지 Spring 없이 검증한다. + * Fallback(진입점)이 "plain 먼저, 못 끝내면(차단·불완전 결과) headless" 를 flag·escalatable·READY 필수 필드 + * 규칙대로 엮는지 Spring 없이 검증한다. * *
통합 테스트는 외부 경계(PageFetcher·GeminiClient)만 stub 하고 이 라우팅을 실제로 타므로, 분기 망라는
* 여기 단위에서 한다. 두 전략은 실제 빈이 네트워크/브라우저를 요구해 단위로 세울 수 없어,
@@ -22,7 +24,10 @@
class FallbackProductLinkExtractorTest {
private final ProductLink link = ProductLink.parse("https://shop.example.com/p");
- private final ProductSnapshot snapshot = new ProductSnapshot(null, "나이키", null, 99_000, null);
+ // plain "성공" 픽스처는 READY 필수 필드(name·imageUrl·currentPrice)를 다 채운다 — 비면 불완전 승격 분기로 빠진다.
+ private final ProductSnapshot snapshot =
+ new ProductSnapshot(null, "나이키", "https://cdn.example.com/nike.png", 99_000, null);
+ private final ProductSnapshot incomplete = new ProductSnapshot(null, "톡딜 상품", "https://cdn.example.com/p.png", null, null);
private static class FakeStrategy implements LinkExtractionStrategy {
private final Function 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(OUTCOME_SUCCESS).increment();
+ headlessFirstCounter(outcomeOf(snapshot)).increment();
return snapshot;
} catch (Throwable failure) {
// escalateToHeadless 와 같은 이유로 Throwable — Error 실패도 집계에서 빠지지 않게 하고 그대로 rethrow.
@@ -119,12 +122,14 @@ private Counter headlessFirstCounter(String outcome) {
* 삼는다(메트릭=추세, host 로그=원장). headless 예외는 그대로 상위로 전파해 API 계층의 계약 매핑에 맡긴다.
*
* 라벨 키 집합 {@code outcome, category} 는 이 카운터의 모든 발행 경로에서 동일해야 한다.
+ * outcome=success 는 "요청을 살렸다"(완전한 READY snapshot 확보)다 — 판정 규칙은
+ * {@link #outcomeOf(ProductSnapshot)} 참조.
*/
private ProductSnapshot escalateToHeadless(ProductLink link, String category, String model) {
log.info("extract escalate=headless plainCategory={} url={}", category, link.safeLogString());
try {
ProductSnapshot snapshot = headless.extract(link, model);
- escalationCounter(OUTCOME_SUCCESS, category).increment();
+ escalationCounter(outcomeOf(snapshot), category).increment();
return snapshot;
} catch (Throwable headlessFailure) {
// Exception 이 아니라 Throwable 을 잡는다: headless 구현이 미구현 오류나 OOM 등 Error 로 실패해도
@@ -144,6 +149,15 @@ private Counter escalationCounter(String outcome, String category) {
return meterRegistry.counter(ESCALATION_METRIC, TAG_OUTCOME, outcome, TAG_CATEGORY, category);
}
+ /**
+ * headless 결과의 성공 판정. 예외 없이 반환됐어도 READY 필수 필드가 비면 응답 경계에서 확정 실패로 닫히는
+ * 결과다 — 그걸 success 로 세면 구제 성공률(escalation)·직행 성공률(headless_first)이 실제보다 부푼다.
+ * outcome 은 예외 여부가 아니라 "요청을 살렸는가"를 센다.
+ */
+ private String outcomeOf(ProductSnapshot snapshot) {
+ return snapshot.missingReadyField() ? OUTCOME_FAILED : OUTCOME_SUCCESS;
+ }
+
/**
* plain 실패를 headless 로 넘길지 판정한다. 어떤 실패가 넘길 수 있는지는 {@code PageFetchException.escalatable}
* 이 단일 진실이다. 우리가 SSRF 로 막은 host 를 헤드리스로 다시 겨누는 것은 recall 문제가 아니라 SSRF
diff --git a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java
index b8ebf51..ab80eea 100644
--- a/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java
+++ b/src/test/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractorTest.java
@@ -95,7 +95,8 @@ void escalatesToHeadlessOnEscalatableFailure() {
FakeStrategy plain = new FakeStrategy(l -> {
throw PageFetchException.clientError(new RuntimeException("403"));
});
- ProductSnapshot headlessSnapshot = new ProductSnapshot(null, "헤드리스 결과", null, 50_000, null);
+ ProductSnapshot headlessSnapshot =
+ new ProductSnapshot(null, "헤드리스 결과", "https://cdn.example.com/h.png", 50_000, null);
FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
@@ -174,7 +175,8 @@ void headlessFirstSkipsPlain() {
FakeStrategy plain = new FakeStrategy(l -> {
throw new IllegalStateException("plain 은 호출되면 안 됨");
});
- ProductSnapshot headlessSnapshot = new ProductSnapshot(null, "헤드리스 결과", null, 50_000, null);
+ ProductSnapshot headlessSnapshot =
+ new ProductSnapshot(null, "헤드리스 결과", "https://cdn.example.com/h.png", 50_000, null);
FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot);
SimpleMeterRegistry registry = new SimpleMeterRegistry();
@@ -256,16 +258,38 @@ void escalatesToHeadlessOnIncompleteSnapshot() {
}
@Test
- @DisplayName("불완전 승격의 headless 결과가 여전히 불완전해도 재승격 없이 그대로 반환한다")
+ @DisplayName("불완전 승격의 headless 결과가 여전히 불완전하면 재승격 없이 그대로 반환하고 failed 로 집계한다")
void incompleteEscalationReturnsHeadlessResultAsIs() {
- // 확정 실패 판정은 응답 경계 한 곳이 진다 — 여기서 또 승격하면 루프 축이 생긴다.
+ // 확정 실패 판정은 응답 경계 한 곳이 진다 — 여기서 또 승격하면 루프 축이 생긴다. 다만 outcome 은
+ // "요청을 살렸는가"라, 예외 없이 반환됐어도 불완전(경계에서 확정 실패 예정)이면 success 로 세지 않는다.
FakeStrategy plain = new FakeStrategy(l -> incomplete);
FakeStrategy headless = new FakeStrategy(l -> incomplete);
+ SimpleMeterRegistry registry = new SimpleMeterRegistry();
- ProductSnapshot result = fallback(true, plain, headless).extract(link, false, null);
+ ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null);
assertEquals(incomplete, result);
assertEquals(1, headless.calls);
+ assertEquals(
+ 1.0,
+ registry.counter("product.extract.escalation", "outcome", "failed", "category", "INCOMPLETE_SNAPSHOT").count()
+ );
+ }
+
+ @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, null);
+
+ assertEquals(incomplete, result);
+ assertEquals(0, plain.calls);
+ assertEquals(1.0, registry.counter("product.extract.headless_first", "outcome", "failed").count());
}
@Test
From 00e7498fa881440d15eca264179beeb7d16bf779 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: Mon, 10 Aug 2026 05:19:16 +0900
Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20escalateToHeadless=20=EC=9D=98?=
=?UTF-8?q?=20category=20non-null=20=EA=B3=84=EC=95=BD=EC=9D=84=20?=
=?UTF-8?q?=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EA=B0=95=EC=A0=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- category 는 Micrometer 태그 값이라 null 이면 계약 위반이 카운터 생성 시점에야 드러난다. 진입 시 Objects.requireNonNull 로 실패 지점을 고정 (CodeRabbit 리뷰 반영)
---
.../piki/extractor/extraction/FallbackProductLinkExtractor.java | 2 ++
1 file changed, 2 insertions(+)
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 7551cd7..ae3b87c 100644
--- a/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java
+++ b/src/main/java/com/depromeet/piki/extractor/extraction/FallbackProductLinkExtractor.java
@@ -5,6 +5,7 @@
import com.depromeet.piki.extractor.extraction.http.PageFetchException;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
+import java.util.Objects;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -126,6 +127,7 @@ private Counter headlessFirstCounter(String outcome) {
* {@link #outcomeOf(ProductSnapshot)} 참조.
*/
private ProductSnapshot escalateToHeadless(ProductLink link, String category, String model) {
+ Objects.requireNonNull(category, "category");
log.info("extract escalate=headless plainCategory={} url={}", category, link.safeLogString());
try {
ProductSnapshot snapshot = headless.extract(link, model);