From 57853cd58640aef3a4ab68fec847e59d5b0eed00 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, 19 Oct 2026 06:33:24 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B3=84=EC=95=BD=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EB=92=A4=20=EB=82=A8=EC=9D=80=20=EC=84=9C=EC=88=A0?= =?UTF-8?q?=C2=B7=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=95=EB=A6=AC=EC=99=80?= =?UTF-8?q?=20=EB=8C=80=EC=A1=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spring-boot-starter-validation 의존성 삭제 - 원인: 요청 record 가 생성 클래스로 바뀌어 Bean Validation 사용처 0건 - 낡은 주석 정정 - compress 를 모르는 구버전 renderer 호환 서술 삭제 - 원인: 그 구버전은 internal_address_hit 검사로 이미 거부됨 - model 의 "없으면 null" 을 빈 문자열로 정정 - 원인: proto3 생략값은 빈 문자열이고 컨트롤러가 그대로 넘김 - 계약 §2·docs/api-contract.md·"전환 전 호출자" 참조 교체 - 대조 테스트 2개 추가 - 도메인 ExtractionMethod 와 계약 enum 의 이름 집합 - 계약 probe_only 와 모델 프로브 예외의 양방향 일치 --- build.gradle.kts | 3 +- .../extractor/api/ExtractionResultMapper.java | 2 +- .../extractor/domain/ExtractionMethod.java | 2 +- .../extraction/GeminiHtmlExtractor.java | 2 +- .../HeadlessExtractionProperties.java | 5 ++-- .../extraction/HtmlSnapshotPipeline.java | 2 +- .../extraction/LinkExtractionStrategy.java | 2 +- .../extraction/ProductLinkExtractor.java | 2 +- .../headless/HttpHeadlessRenderer.java | 7 ++--- .../image/ImageExtractionService.java | 2 +- .../piki/extractor/api/ContractWireTest.java | 2 +- .../api/ExtractionImageIntegrationTest.java | 6 ++-- .../ExtractionErrorCodeCatalogTest.java | 22 ++++++++++++++ .../domain/ExtractionMethodContractTest.java | 29 +++++++++++++++++++ 14 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 src/test/java/com/depromeet/piki/extractor/domain/ExtractionMethodContractTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 2143451..e088715 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,9 +37,8 @@ dependencyManagement { dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") - implementation("org.springframework.boot:spring-boot-starter-validation") - // OpenAPI 문서 노출(/v3/api-docs, UI 없음). 계약의 원문은 docs/api-contract.md. + // OpenAPI 문서 노출(/v3/api-docs, UI 없음). 계약 정본은 infra 의 contracts/. // Spring Boot 4 / Jackson 3 호환 라인 — core 와 동일. implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3") diff --git a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResultMapper.java b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResultMapper.java index 1e27a8a..49bac89 100644 --- a/src/main/java/com/depromeet/piki/extractor/api/ExtractionResultMapper.java +++ b/src/main/java/com/depromeet/piki/extractor/api/ExtractionResultMapper.java @@ -45,7 +45,7 @@ public static ExtractionResult from(ProductSnapshot snapshot) { result.setFinalUrl(snapshot.finalUrl().value().toString()); } if (snapshot.method() != null) { - // 이름이 어긋나면 여기서 터진다 - 계약 대조 테스트가 먼저 잡는다. + // 이름이 어긋나면 여기서 터진다 - ExtractionMethodContractTest 가 먼저 잡는다. result.setMethod(ExtractionMethod.valueOf(snapshot.method().name())); } return result.build(); diff --git a/src/main/java/com/depromeet/piki/extractor/domain/ExtractionMethod.java b/src/main/java/com/depromeet/piki/extractor/domain/ExtractionMethod.java index 119b9e0..720be98 100644 --- a/src/main/java/com/depromeet/piki/extractor/domain/ExtractionMethod.java +++ b/src/main/java/com/depromeet/piki/extractor/domain/ExtractionMethod.java @@ -2,7 +2,7 @@ // 값을 만들어낸 추출 경로. 호출자(core)가 snapshot 출처를 구분 저장하는 근거다(core#825 결정 4) — // LLM 경로는 같은 페이지를 재추출해도 값이 달라질 수 있어(비결정성 실측), 이후 "LLM 추출분은 가격 변동 -// 알림 제외" 같은 신뢰 정책이 이 구분 위에서 갈린다. 응답에는 enum 이름 문자열로 나간다(계약 §2). +// 알림 제외" 같은 신뢰 정책이 이 구분 위에서 갈린다. 계약 enum(extraction.proto 의 ExtractionMethod)과 이름이 1:1 이어야 한다. public enum ExtractionMethod { // 구조화 데이터(JSON-LD·OpenGraph) 직접 파싱 — 결정론적. STRUCTURED, diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java b/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java index cd73941..3b8bcba 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/GeminiHtmlExtractor.java @@ -26,7 +26,7 @@ public class GeminiHtmlExtractor { private final GeminiClient geminiClient; - /** @param model 호출자가 지정한 모델(없으면 null). 판정·대체는 클라이언트가 지므로 여기서는 그대로 넘긴다. */ + /** @param model 호출자가 지정한 모델(없으면 빈 문자열). 판정·대체는 클라이언트가 지므로 여기서는 그대로 넘긴다. */ public ProductSnapshot extract(Document document, ProductLink link, String model) { String html = sanitize(document); GeminiExtractionRequest request = GeminiExtractionRequest.forHtmlExtraction(link.value(), html); diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java index 7c1f2eb..6f91b88 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HeadlessExtractionProperties.java @@ -15,9 +15,8 @@ * @param readTimeout 렌더 서비스 내부 최악(goto + 렌더 정착 대기)을 다 기다리지는 않는다 — 호출자 read 예산 안에 * LLM fallback 몫을 남겨야 하기 때문이다. 층별 예산의 정본은 TeamPiKi/infra 의 contracts/extraction-api.md * "타임아웃 예산" 절. 상한을 넘긴 렌더는 일시 실패로 떨어져 호출자 재시도가 진다. - * @param compress 응답 zstd 압축 전송 요청(서버간 전송량 절감). 해제는 응답 헤더({@code X-Encoding}) 기준이라 - * compress 필드를 모르는 구버전 renderer(무시하고 plain JSON 으로 답한다)와도 호환된다 — 켜 둔 채로 배포 - * 순서와 무관하게 안전하고, 이 스위치는 압축 경로에 문제가 생겼을 때 끄는 kill-switch 다. + * @param compress 응답 zstd 압축 전송 요청(서버간 전송량 절감). 해제는 응답 헤더({@code X-Encoding}) 기준이고, + * 이 스위치는 압축 경로에 문제가 생겼을 때 끄는 kill-switch 다. * @param zstdDictDir zstd 학습 사전 디렉토리(파일명 = 사전ID, renderer {@code compress.py} 의 DICT_ID 규약). * 빈값이면 사전 없음. 롤아웃 순서: 사전 파일을 여기 먼저 배포한 뒤 renderer 쪽 사전 경로를 켠다 — 순서가 * 뒤집히면 미보유 사전ID 를 받아 해제 불가(일시 실패)가 된다. diff --git a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java index ee3ea5a..dff35a6 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/HtmlSnapshotPipeline.java @@ -43,7 +43,7 @@ public class HtmlSnapshotPipeline { * @param timing 호출 전략이 채우는 로그 조각({@code "fetch=123ms"} / {@code "render=5534ms"}) — HTML 획득 비용을 * extract 원장 로그 한 줄에 함께 남긴다. 전략 무관 파이프라인이 유일하게 전략을 아는 지점이라 해석하지 않는 * 문자열로만 받는다. - * @param model 호출자가 지정한 LLM 모델(없으면 null). 구조화 파싱으로 끝나면 쓰이지 않고, LLM fallback 으로 + * @param model 호출자가 지정한 LLM 모델(없으면 빈 문자열). 구조화 파싱으로 끝나면 쓰이지 않고, LLM fallback 으로 * 내려갈 때만 소비된다. */ public ProductSnapshot extract(PageContent page, String timing, String 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 d3d5218..d4a3b0c 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/LinkExtractionStrategy.java @@ -22,7 +22,7 @@ public interface LinkExtractionStrategy { * @param authorized 이 대상이 허락을 받았는가. 정적 fetch 전략에는 우회 개념이 없어 무시되고, 헤드리스 * 전략만 렌더 서비스로 전달한다. 두 전략이 같은 시그니처를 갖는 편이 호출부(Fallback)가 분기를 * 들고 있는 것보다 낫다 — 전략이 늘어도 호출부가 안 바뀐다. - * @param model 호출자가 지정한 LLM 모델(없으면 null). 전략은 해석하지 않고 파이프라인까지 흘려보낸다. + * @param model 호출자가 지정한 LLM 모델(없으면 빈 문자열). 전략은 해석하지 않고 파이프라인까지 흘려보낸다. */ 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 c31065f..bec229c 100644 --- a/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java +++ b/src/main/java/com/depromeet/piki/extractor/extraction/ProductLinkExtractor.java @@ -13,7 +13,7 @@ public interface ProductLinkExtractor { * 진실은 호출자(core)에 있고, 무상태인 이 서비스는 요청 단위로만 받는다. 누락은 false 로 정규화되는 * fail-safe 다(proto3 의 기본값 규칙). * @param model 호출자가 지정한 LLM 모델 힌트. authorized 와 같은 성질이다 — 정책의 단일 진실은 호출자 쪽 - * 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. null 이면 기본 모델을 쓰며, + * 동적 설정(DB, 백오피스)이고 무상태인 이 서비스는 요청 단위로만 받는다. 빈 문자열(null 도 같다)이면 기본 모델을 쓰며, * 지정 모델이 사라졌으면 기본 모델로 대체된다. */ ProductSnapshot extract(ProductLink link, boolean authorized, String model); 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 a0ed143..e41ad18 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 @@ -34,10 +34,9 @@ *

내부 주소 판정은 renderer 가 브라우저의 실제 연결 IP 로 하고 {@code internal_address_hit} 로 알린다. 여기서 홉 URL 을 * 다시 이름 해석하면 렌더와 다른 시점의 답이라 브라우저가 실제로 간 IP 와 어긋날 수 있어 하지 않는다. * - *

전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 해제 분기는 요청이 아니라 응답 헤더 - * ({@code X-Encoding: zstd}) 기준 — compress 를 모르는 구버전 renderer 는 plain JSON 을 주므로, 헤더 분기여야 - * renderer 와 어느 쪽이 먼저 배포되든 안전하다. 사전({@code X-Zstd-Dict})은 {@code ZstdDictionaries} 의 - * 롤아웃 규약 참조. + *

전송: 요청 compress=true 면 응답이 zstd raw 바이트로 온다. 압축 여부의 판별은 응답 헤더 + * ({@code X-Encoding: zstd}) 기준(render.proto 의 compress 주석). 사전({@code X-Zstd-Dict})은 + * {@code ZstdDictionaries} 의 롤아웃 규약 참조. */ @Slf4j @RequiredArgsConstructor diff --git a/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java b/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java index c77a922..27e992e 100644 --- a/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java +++ b/src/main/java/com/depromeet/piki/extractor/image/ImageExtractionService.java @@ -25,7 +25,7 @@ public class ImageExtractionService { private final ImageCropper imageCropper; private final ImageStorage imageStorage; - /** @param model 호출자가 지정한 LLM 모델(없으면 null). 해석·대체는 Gemini 클라이언트가 지므로 그대로 넘긴다. */ + /** @param model 호출자가 지정한 LLM 모델(없으면 빈 문자열). 해석·대체는 Gemini 클라이언트가 지므로 그대로 넘긴다. */ public ProductSnapshot extract(String bucket, String key, String model) { StoredImage stored = imageStorage.download(bucket, key); // download 가 S3 content-type 메타를 못 주면(메타 유실 등) 등록 때 key 에 박은 확장자로 mimeType 을 복원한다 — diff --git a/src/test/java/com/depromeet/piki/extractor/api/ContractWireTest.java b/src/test/java/com/depromeet/piki/extractor/api/ContractWireTest.java index 2d86d18..b990467 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/ContractWireTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/ContractWireTest.java @@ -73,7 +73,7 @@ void unsetFieldsAreOmitted() throws Exception { } @Test - @DisplayName("image 요청의 model null 은 생략과 같게 읽힌다 - 전환 전 호출자는 미지정을 null 로 보낸다") + @DisplayName("image 요청의 model null 은 생략과 같게 읽힌다 - 호출자가 null 을 보내도 깨지지 않는다") void nullModelReadsAsUnset() throws Exception { ImageExtractionRequest.Builder builder = ImageExtractionRequest.newBuilder(); PARSER.merge("{\"bucket\": \"b\", \"key\": \"items/raw/a.png\", \"model\": null}", builder); diff --git a/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java b/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java index e471361..a0c191e 100644 --- a/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java +++ b/src/test/java/com/depromeet/piki/extractor/api/ExtractionImageIntegrationTest.java @@ -27,7 +27,7 @@ import org.springframework.web.context.WebApplicationContext; /** - * {@code POST /internal/extractions/image} 의 HTTP 계약(docs/api-contract.md §2)을 실제 파이프라인 + * {@code POST /internal/extractions/image} 의 HTTP 계약(infra extraction-api.md §2)을 실제 파이프라인 * (download→OCR→crop→upload)으로 검증한다. 외부 경계인 S3({@code ImageStorage})·GeminiClient 만 stub 이다. */ class ExtractionImageIntegrationTest extends IntegrationTestSupport { @@ -68,7 +68,7 @@ void imageSuccess() throws Exception { .andExpect(jsonPath("$.currentPrice").value(89000)) .andExpect(jsonPath("$.currency").value("KRW")) .andExpect(jsonPath("$.imageUrl").value(StubImageStorage.UPLOADED_URL)) - // additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 이 명시적 null 이고, 추출은 Gemini 라 method=LLM. + // additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 은 생략되고, 추출은 Gemini 라 method=LLM. .andExpect(jsonPath("$.finalUrl").doesNotExist()) .andExpect(jsonPath("$.method").value("LLM")); } @@ -109,7 +109,7 @@ void boundingBoxCropIsWiredToUpload() throws Exception { .content(body("items/raw/crop.png"))) .andExpect(status().isOk()) .andExpect(jsonPath("$.imageUrl").value(StubImageStorage.UPLOADED_URL)) - // additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 이 명시적 null 이고, 추출은 Gemini 라 method=LLM. + // additive 계약(core#825): 이미지 경로는 원본 URL 이 없어 finalUrl 은 생략되고, 추출은 Gemini 라 method=LLM. .andExpect(jsonPath("$.finalUrl").doesNotExist()) .andExpect(jsonPath("$.method").value("LLM")); diff --git a/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java index 0eb6486..d66af7a 100644 --- a/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java +++ b/src/test/java/com/depromeet/piki/extractor/common/exception/ExtractionErrorCodeCatalogTest.java @@ -135,6 +135,28 @@ void escalatableMatchesFactories() { } } + @Test + @DisplayName("계약의 probe_only 는 모델 프로브 예외가 만드는 code 에만 붙는다") + void probeOnlyMatchesFactories() { + // 프로브 code 가 추출 응답으로 새면 호출자가 bucket 없는 확정 실패를 받는다 - 축이 섞이지 않게 양방향으로 묶는다. + List mismatches = new ArrayList<>(); + for (FactoryCase factoryCase : factoryCases()) { + ExtractionException exception = factoryCase.exception(); + boolean declared = com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode + .valueOf(exception.code().name()) + .getValueDescriptor() + .getOptions() + .getExtension(ExtractionProto.probeOnly); + boolean actual = exception instanceof ModelProbeException; + if (declared != actual) { + mismatches.add(exception.code() + " probe_only: 계약=" + declared + " / " + factoryCase.name() + "=" + actual); + } + } + if (!mismatches.isEmpty()) { + fail("계약의 probe_only 와 예외 팩토리가 어긋난다.\n " + String.join("\n ", mismatches)); + } + } + /** 이름이 계약에 없으면 여기서 터진다 - 목록 어긋남은 enumMatchesContract 가 먼저 지목한다. */ private static Disposition declaredDisposition(String code) { return com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode.valueOf(code) diff --git a/src/test/java/com/depromeet/piki/extractor/domain/ExtractionMethodContractTest.java b/src/test/java/com/depromeet/piki/extractor/domain/ExtractionMethodContractTest.java new file mode 100644 index 0000000..7081dbe --- /dev/null +++ b/src/test/java/com/depromeet/piki/extractor/domain/ExtractionMethodContractTest.java @@ -0,0 +1,29 @@ +package com.depromeet.piki.extractor.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Arrays; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** 응답 매퍼가 도메인 이름으로 계약 enum 을 찾으므로, 목록이 어긋나면 그 경로의 성공 응답이 500 으로 샌다. */ +class ExtractionMethodContractTest { + + @Test + @DisplayName("ExtractionMethod 상수 집합은 계약 enum 의 값 집합과 정확히 일치한다") + void enumMatchesContract() { + Set contract = Arrays.stream(com.depromeet.piki.contracts.extraction.v1.ExtractionMethod.values()) + .filter(value -> value != com.depromeet.piki.contracts.extraction.v1.ExtractionMethod.UNRECOGNIZED) + .filter(value -> value.getNumber() != 0) + .map(Enum::name) + .collect(Collectors.toCollection(TreeSet::new)); + Set domain = Arrays.stream(ExtractionMethod.values()) + .map(Enum::name) + .collect(Collectors.toCollection(TreeSet::new)); + + assertEquals(contract, domain); + } +}