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..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,18 +5,26 @@ 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; 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 +42,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,26 +67,38 @@ 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; } /** * 브라우저 직행(정책 힌트). 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(OUTCOME_SUCCESS).increment(); + headlessFirstCounter(outcomeOf(snapshot)).increment(); return snapshot; } catch (Throwable failure) { // escalateToHeadless 와 같은 이유로 Throwable — Error 실패도 집계에서 빠지지 않게 하고 그대로 rethrow. @@ -95,19 +117,21 @@ 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} 는 이 카운터의 모든 발행 경로에서 동일해야 한다. + * outcome=success 는 "요청을 살렸다"(완전한 READY snapshot 확보)다 — 판정 규칙은 + * {@link #outcomeOf(ProductSnapshot)} 참조. */ - private ProductSnapshot escalateToHeadless(ProductLink link, RuntimeException plainFailure, String model) { - String category = categoryOf(plainFailure); + 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); - escalationCounter(OUTCOME_SUCCESS, category).increment(); + escalationCounter(outcomeOf(snapshot), category).increment(); return snapshot; } catch (Throwable headlessFailure) { // Exception 이 아니라 Throwable 을 잡는다: headless 구현이 미구현 오류나 OOM 등 Error 로 실패해도 @@ -127,6 +151,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/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..ab80eea 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 fn; @@ -90,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(); @@ -169,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(); @@ -226,4 +233,109 @@ void nonEscalatableFailureIsNeverEscalated() { assertThrows(PageFetchException.class, () -> fallback(true, plain, headless).extract(link, false, null)); assertEquals(0, headless.calls); } + + @Test + @DisplayName("plain 이 성공해도 READY 필수 필드가 비면 headless 로 에스컬레이트하고 INCOMPLETE_SNAPSHOT 으로 집계한다") + void escalatesToHeadlessOnIncompleteSnapshot() { + // 부분 SSR SPA(카카오 톡딜): fetch 는 성공하고 이름·이미지는 있지만 가격이 문서에 없다. 이대로 끝내면 + // 응답 경계에서 확정 실패가 되므로, 확정 전에 브라우저 DOM 으로 한 번 더 시도해야 HEADLESS_FIRST 정책이 + // 정합성 조건이 아니라 최적화로 남는다. + FakeStrategy plain = new FakeStrategy(l -> incomplete); + ProductSnapshot headlessSnapshot = + new ProductSnapshot(null, "톡딜 상품", "https://cdn.example.com/p.png", 23_900, null); + FakeStrategy headless = new FakeStrategy(l -> headlessSnapshot); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + ProductSnapshot result = fallback(true, plain, headless, registry).extract(link, false, null); + + assertEquals(headlessSnapshot, result); + assertEquals(1, plain.calls); + assertEquals(1, headless.calls); + assertEquals( + 1.0, + registry.counter("product.extract.escalation", "outcome", "success", "category", "INCOMPLETE_SNAPSHOT").count() + ); + } + + @Test + @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, 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 + @DisplayName("불완전 승격에서 headless 가 escalatable 실패를 던져도 재진입 없이 전파하고 failed 로 집계한다") + void incompleteEscalationFailurePropagatesWithoutReentry() { + // headless 실패가 plain 의 catch 로 새면 headless 를 두 번 때린다 — 승격은 시도 1회로 바운드된다. + FakeStrategy plain = new FakeStrategy(l -> incomplete); + FakeStrategy headless = new FakeStrategy(l -> { + throw PageFetchException.clientError(new RuntimeException("렌더 차단")); + }); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + + assertThrows(PageFetchException.class, () -> fallback(true, plain, headless, registry).extract(link, false, null)); + assertEquals(1, headless.calls); + assertEquals( + 1.0, + registry.counter("product.extract.escalation", "outcome", "failed", "category", "INCOMPLETE_SNAPSHOT").count() + ); + } + + @Test + @DisplayName("headless 가 꺼져 있으면 불완전 plain 결과도 그대로 반환한다 (응답 경계가 확정 실패로 닫는 기존 계약)") + void headlessOffReturnsIncompleteAsIs() { + FakeStrategy plain = new FakeStrategy(l -> incomplete); + FakeStrategy headless = new FakeStrategy(l -> { + throw new IllegalStateException("headless 는 호출되면 안 됨"); + }); + + ProductSnapshot result = fallback(false, plain, headless).extract(link, false, null); + + assertEquals(incomplete, result); + assertEquals(0, headless.calls); + } + + @Test + @DisplayName("LLM 의 상품 아님 확정(ProductSnapshotException)은 에스컬레이트하지 않고 그대로 전파한다") + void productSnapshotFailureIsNotEscalated() { + // 상품이 아니라는 판정은 fetch 축 실패가 아니다 — CSR 셸의 no-data 는 plain 전략(DefaultProductLinkExtractor)이 + // 이미 escalatable 로 재분류해 던지므로, 여기까지 온 ProductSnapshotException 은 브라우저로 다시 봐도 상품이 + // 되지 않는다. + FakeStrategy plain = new FakeStrategy(l -> { + throw ProductSnapshotException.notProductPage(); + }); + FakeStrategy headless = new FakeStrategy(l -> snapshot); + + assertThrows(ProductSnapshotException.class, () -> fallback(true, plain, headless).extract(link, false, null)); + assertEquals(0, headless.calls); + } }