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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# PIKI-Server 의 ci.yml 과 같은 구조를 따르되, 이 서비스는 DB 가 없어 Testcontainers/Docker 없이 test 까지 그냥 돈다.
# core 의 ci.yml 과 같은 구조를 따르되, 이 서비스는 DB 가 없어 Testcontainers/Docker 없이 test 까지 그냥 돈다.
name: CI

on:
Expand Down
20 changes: 10 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
# PIKI-Extractor 프로젝트 컨벤션
# extractor 프로젝트 컨벤션

## 이 서비스의 정체

PIKI-Server(코틀린)에서 분리된 **상품 추출 서비스**다. 상품 URL(또는 S3 이미지)을 받아 fetch → 구조화 파싱(JSON-LD/OG) → LLM(Gemini) fallback → 정규화를 거쳐 추출 결과를 돌려준다.
core(코틀린)에서 분리된 **상품 추출 서비스**다. 상품 URL(또는 S3 이미지)을 받아 fetch → 구조화 파싱(JSON-LD/OG) → LLM(Gemini) fallback → 정규화를 거쳐 추출 결과를 돌려준다.

- **무상태.** DB 없음, 호출 간 상태 없음. 상태를 넣고 싶어지면 설계 경고 신호다. 재시도·내구성·상태 전이는 전부 호출자(PIKI-Server outbox)의 몫이다.
- **소비자는 PIKI-Server 워커 하나뿐.** 공개 API 가 아니다. 보안그룹 내부망 전용, 인증 없음.
- **무상태.** DB 없음, 호출 간 상태 없음. 상태를 넣고 싶어지면 설계 경고 신호다. 재시도·내구성·상태 전이는 전부 호출자(core outbox)의 몫이다.
- **소비자는 core 워커 하나뿐.** 공개 API 가 아니다. 보안그룹 내부망 전용, 인증 없음.
- **계약의 single source 는 `docs/api-contract.md`.** 응답은 3갈래뿐이다: 2xx(성공) / 422+code(확정 실패) / 그 외 전부(일시 실패). 진화는 additive-only, 배포는 Extractor 먼저.
- 차단 우회 **방법론**(스텔스·프록시·IP 전략)은 private repo(PIKI-HeadlessBrowser)에만 둔다. 이 repo(public)에는 "이 호스트는 헤드리스로 라우팅" 수준까지만 담는다.
- 차단 우회 **방법론**(스텔스·프록시·IP 전략)은 private repo(renderer)에만 둔다. 이 repo(public)에는 "이 호스트는 헤드리스로 라우팅" 수준까지만 담는다.

## 언어: Java 25

Expand All @@ -16,7 +16,7 @@ PIKI-Server(코틀린)에서 분리된 **상품 추출 서비스**다. 상품 UR

### Null 처리

PIKI-Server 의 Elvis 규칙에 대응하는 Java 규칙:
core 의 Elvis 규칙에 대응하는 Java 규칙:

- 파라미터·필드의 non-null 강제는 `Objects.requireNonNull(x, "메시지")`.
- "없을 수 있음"을 반환할 땐 `Optional<T>` 또는 도메인 결과 타입(sealed). **null 반환 금지.**
Expand All @@ -28,7 +28,7 @@ PIKI-Server 의 Elvis 규칙에 대응하는 Java 규칙:

## 예외 정책

판단 기준 한 줄은 PIKI-Server 와 같다: **"정상 호출로 여기 닿을 수 있나?"** 단, 이 서비스의 클라이언트는 PIKI-Server 워커다.
판단 기준 한 줄은 core 와 같다: **"정상 호출로 여기 닿을 수 있나?"** 단, 이 서비스의 클라이언트는 core 워커다.

- 닿는다 → **계약** → `ExtractionException`(커스텀, code + 일시/확정 구분 내장) → 핸들러가 422 또는 502 로 매핑.
- 못 닿는다 → **불변식** → `IllegalStateException`/`IllegalArgumentException` → 일반 500. 호출자는 이를 "일시 실패"로 보고 bounded 재시도하므로 안전하다.
Expand All @@ -45,12 +45,12 @@ PIKI-Server 의 Elvis 규칙에 대응하는 Java 규칙:
## 포팅 규율 (이관 기간 한정)

- **동작 등가(파리티)가 목표다.** Kotlin 원본의 분기·상수·메시지를 그대로 옮기고, 개선·리팩터링은 이관 완료 후 별도 작업으로 뺀다.
- 포팅한 클래스 상단 주석에 원본 경로를 남긴다: `// PIKI-Server: product/service/http/HttpPageFetcher.kt 포팅`.
- 포팅한 클래스 상단 주석에 원본 경로를 남긴다: `// core: product/service/http/HttpPageFetcher.kt 포팅`.
- 하드코딩이던 상수(fetch 3MB cap·UA·타임아웃·LLM 200K char cap)는 `@ConfigurationProperties` 로 외부화하되 **기본값은 원본과 동일**하게 둔다.

## 테스트

PIKI-Server 테스트 컨벤션의 Java 번역판. 원문 규약이 더 자세하다 (PIKI-Server `.claude/rules/testing-convention.md`).
core 테스트 컨벤션의 Java 번역판. 원문 규약이 더 자세하다 (core `.claude/rules/testing-convention.md`).

- **분류**: 단위(Spring 없이 분기 망라) / 통합(HTTP 진입 계약 검증, 외부는 stub) / E2E(실제 몰·Gemini 호출, env 격리). **DB 가 없으므로 Testcontainers·Docker 불필요** — `./gradlew test` 가 그냥 돈다.
- **모킹 금지, stub 우선.** Mockito/`@MockBean` 금지. 외부 경계(GeminiClient·PageFetcher·S3)는 `IntegrationStubs` 한 곳에 `@Primary` stub 빈으로 등록, default 람다는 throw.
Expand All @@ -64,7 +64,7 @@ PIKI-Server 테스트 컨벤션의 Java 번역판. 원문 규약이 더 자세

- 버전의 single source 는 `build.gradle.kts`. 문서에 버전 숫자를 박지 않는다.
- 새 의존성은 Maven Central 최신 안정 버전(pre-release 제외), Spring Boot BOM 관리 대상은 버전 명시 금지.
- PIKI-Server 와 공유하는 라인(jsoup·guava·AWS SDK·springdoc)은 그쪽과 버전을 맞춘다.
- core 와 공유하는 라인(jsoup·guava·AWS SDK·springdoc)은 그쪽과 버전을 맞춘다.

## 브랜치·PR

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# PIKI-Extractor
# extractor

PIKI 의 상품 추출 서비스. 상품 URL(또는 S3 이미지)을 받아 fetch → 구조화 파싱(JSON-LD/OpenGraph) → LLM(Gemini) fallback → 정규화를 거쳐 추출 결과를 반환한다. PIKI-Server 의 파싱 파이프라인을 분리한 것으로, 소비자는 PIKI-Server 의 outbox 워커 하나뿐인 내부 서비스다.
PIKI 의 상품 추출 서비스. 상품 URL(또는 S3 이미지)을 받아 fetch → 구조화 파싱(JSON-LD/OpenGraph) → LLM(Gemini) fallback → 정규화를 거쳐 추출 결과를 반환한다. core 의 파싱 파이프라인을 분리한 것으로, 소비자는 core 의 outbox 워커 하나뿐인 내부 서비스다.

- **API 계약**: [docs/api-contract.md](docs/api-contract.md) — 응답 3갈래(2xx / 422 / 그 외=재시도), additive-only 진화
- **스택**: Java 25 · Spring Boot 4 · virtual threads · 무상태(DB 없음)
- **관련 repo**: [PIKI-Server](https://github.com/depromeet/PIKI-Server) (호출자), PIKI-HeadlessBrowser (차단 우회 렌더링, private)
- **관련 repo**: [core](https://github.com/TeamPiKi/core) (호출자), renderer (차단 우회 렌더링, private)

## 실행

Expand Down
10 changes: 5 additions & 5 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ repositories {
mavenCentral()
}

// 이미지(OCR) 경로의 S3 raw 읽기·크롭 업로드용. 버전 라인은 PIKI-Server 와 동일하게 유지한다.
// 이미지(OCR) 경로의 S3 raw 읽기·크롭 업로드용. 버전 라인은 core 와 동일하게 유지한다.
val awsSdkVersion = "2.44.11"

dependencyManagement {
Expand All @@ -31,15 +31,15 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-validation")

// OpenAPI 문서 노출(/v3/api-docs, UI 없음). 계약의 원문은 docs/api-contract.md.
// Spring Boot 4 / Jackson 3 호환 라인 — PIKI-Server 와 동일.
// Spring Boot 4 / Jackson 3 호환 라인 — core 와 동일.
implementation("org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.3")

// 상품 페이지 HTML 에서 구조화 데이터(JSON-LD <script>·OpenGraph <meta>)를 파싱한다.
// PIKI-Server product 파이프라인 포팅 대상(그쪽 #425). Spring Boot BOM 미관리라 버전 명시.
// core product 파이프라인 포팅 대상(그쪽 #425). Spring Boot BOM 미관리라 버전 명시.
implementation("org.jsoup:jsoup:1.22.2")

// redirect 추적 시 "같은 회사 도메인(eTLD+1)" 판정에 guava InternetDomainName(Public Suffix List)을 쓴다.
// 점 개수 어림은 a.co.kr↔b.co.kr 을 같은 회사로 오판해 SSRF 구멍 (PIKI-Server #440 과 동일 근거).
// 점 개수 어림은 a.co.kr↔b.co.kr 을 같은 회사로 오판해 SSRF 구멍 (core #440 과 동일 근거).
implementation("com.google.guava:guava:33.6.0-jre")

// SSRF IP-pin: 가드가 검증한 IP 로만 연결하도록 DnsResolver 를 끼울 수 있는 HTTP 클라이언트.
Expand All @@ -50,7 +50,7 @@ dependencies {
implementation("software.amazon.awssdk:s3")

// 관측: /actuator/health(상태)·/actuator/prometheus(Alloy scrape) + OTLP tracing.
// 구성 근거(왜 OTel 3종인지)는 PIKI-Server build.gradle.kts 의 동일 블록 주석 참고.
// 구성 근거(왜 OTel 3종인지)는 core build.gradle.kts 의 동일 블록 주석 참고.
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
implementation("org.springframework.boot:spring-boot-micrometer-tracing-opentelemetry")
Expand Down
26 changes: 13 additions & 13 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
# PIKI-Extractor API 계약
# extractor API 계약

이 문서가 계약의 single source 다. 구현(springdoc `/v3/api-docs`)과 어긋나면 이 문서를 기준으로 구현을 고친다.

소비자는 PIKI-Server 의 outbox 워커 하나뿐이다. 공개 API 가 아니며, 보안그룹으로 내부망에서만 접근한다(별도 인증 없음).
소비자는 core 의 outbox 워커 하나뿐이다. 공개 API 가 아니며, 보안그룹으로 내부망에서만 접근한다(별도 인증 없음).

## 0. 설계 불변식

- Extractor 는 **무상태**다. DB 없음, 호출 간 상태 없음. 같은 요청이 중복 도착해도 상태 오염이 없다(중복의 대가는 LLM 비용 한 번뿐). Extractor 에 상태를 넣고 싶어지면 설계 경고 신호다.
- 재시도·내구성·상태 전이는 전부 호출자(PIKI-Server 의 item_snapshots outbox)의 책임이다. Extractor 는 "단건 시도 1회"에만 답한다.
- 재시도·내구성·상태 전이는 전부 호출자(core 의 item_snapshots outbox)의 책임이다. Extractor 는 "단건 시도 1회"에만 답한다.
- 에스컬레이션(plain fetch → headless 브라우저)은 Extractor 내부 관심사다. 응답 계약에 드러나지 않는다 — 호출자는 어떤 fetch 전략이 쓰였는지 모른다. 단 "어느 플랫폼을 처음부터 헤드리스로 보낼지"의 **정책**은 호출자(DB·백오피스)가 주인이라, 요청 필드 `headlessFirst` 로 힌트만 받는다(§2) — 무상태 불변식을 지키는 선에서의 유일한 정책 수용 지점이다.

## 1. 응답 3갈래 (전이 규약)

| Extractor 응답 | 의미 | PIKI-Server 전이 |
| Extractor 응답 | 의미 | core 전이 |
|---|---|---|
| 2xx + 추출 결과 | 성공 | `markReady` |
| 422 + `{code}` | 확정 실패 (재시도 무의미) | 즉시 `markFailed` |
Expand All @@ -31,7 +31,7 @@
{ "url": "https://www.musinsa.com/products/12345", "headlessFirst": false }
```

- `url` (필수): https 스킴의 상품 페이지 URL. 형식·스킴·미지원 플랫폼의 동기 검증은 호출자(PIKI-Server 등록 경계)가 이미 끝냈다는 전제이나, Extractor 도 자기 경계에서 방어 검증한다(다층 방어).
- `url` (필수): https 스킴의 상품 페이지 URL. 형식·스킴·미지원 플랫폼의 동기 검증은 호출자(core 등록 경계)가 이미 끝냈다는 전제이나, Extractor 도 자기 경계에서 방어 검증한다(다층 방어).
- `headlessFirst` (선택, 기본 false — additive, 이관 7단계): 호출자의 플랫폼 라우팅 정책(`HEADLESS_FIRST`, DB·백오피스 동적 설정) 힌트. true 면 plain(정적 fetch)을 건너뛰고 처음부터 헤드리스 브라우저로 추출한다. 정책의 단일 진실은 호출자 DB 에 있고 무상태인 Extractor 는 요청 단위로만 받는다. Extractor 의 `product.extract.headless.enabled` 가 꺼져 있으면 무시된다(스위치가 힌트보다 우선).
- 헤더 `X-Correlation-Id` (선택): 호출자의 item_snapshot id. 로그·trace 상관용이며 동작에 영향 없다.

Expand All @@ -41,15 +41,15 @@
{ "name": "나이키 에어포스", "imageUrl": "https://...", "currentPrice": 99000, "currency": "KRW" }
```

- **`name`(non-blank)·`imageUrl`·`currentPrice` 의 non-null 을 Extractor 가 보장한다** — PIKI-Server 의 READY 불변식(`requireReadyInvariant`: name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과 동일 조건이다. 보장할 수 없으면 성공이 아니라 422(`UNTRUSTWORTHY_VALUE`)다. `currency` 는 READY 필수가 아니라 **nullable** 이다. 호출자의 엔티티 불변식은 최후 보루로 유지된다.
- **`name`(non-blank)·`imageUrl`·`currentPrice` 의 non-null 을 Extractor 가 보장한다** — core 의 READY 불변식(`requireReadyInvariant`: name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과 동일 조건이다. 보장할 수 없으면 성공이 아니라 422(`UNTRUSTWORTHY_VALUE`)다. `currency` 는 READY 필수가 아니라 **nullable** 이다. 호출자의 엔티티 불변식은 최후 보루로 유지된다.

확정 실패 422:

```json
{ "code": "NOT_PRODUCT_PAGE" }
```

| code | PIKI-Server 기준 동등물 |
| code | core 기준 동등물 |
|---|---|
| `NOT_PRODUCT_PAGE` | `ProductSnapshotException.notProductPage` — 상품 페이지가 아님 |
| `UNTRUSTWORTHY_VALUE` | `ProductSnapshotException.untrustworthyValue` — 추출값이 범위·상식 위반 |
Expand Down Expand Up @@ -96,25 +96,25 @@

| 층 | 값 | 근거 |
|---|---|---|
| PIKI-Server stale 판정 | 60s | `ItemParsingScheduler.STALE_TIMEOUT` (기존 값) |
| PIKI-Server → Extractor HTTP read | 55s (connect 2s) | stale 미만 — recover 의 유령 중복 발주 방지. link·image 공용 |
| core stale 판정 | 60s | `ItemParsingScheduler.STALE_TIMEOUT` (기존 값) |
| core → Extractor HTTP read | 55s (connect 2s) | stale 미만 — recover 의 유령 중복 발주 방지. link·image 공용 |
| Extractor 내부 합계 (link) | 약 50s | 아래 합 + 여유 |
| 대상 몰 fetch (link) | connect 5s / read 15s | 현행 유지 |
| 헤드리스 render (link, 7단계) | connect 2s / read 20s | 실측 전형 1.6~5.5s(kream 프록시 포함) 대비 약 4배 여유. headless-first 최악(connect 2 + render 20 + LLM 30 = 약 52s)이 호출자 read 55s 안에 들도록 상한 |
| Gemini | read 30s | 현행 유지 (link LLM fallback·image OCR 동일) |
| Extractor 내부 합계 (image) | 약 40s | S3 download + Gemini OCR 30s + crop + 결과 upload. S3 는 동일 리전이라 수 초 |

**안쪽 예산은 항상 바깥보다 작아야 한다.** link 와 image 가 호출자 read 55s 를 공유하므로, 어느 경로든 Extractor 내부 값을 늘릴 땐 이 표를 갱신하고 PIKI-Server 쪽 read 타임아웃과 함께 재검증한다.
**안쪽 예산은 항상 바깥보다 작아야 한다.** link 와 image 가 호출자 read 55s 를 공유하므로, 어느 경로든 Extractor 내부 값을 늘릴 땐 이 표를 갱신하고 core 쪽 read 타임아웃과 함께 재검증한다.

예외적으로 **에스컬레이션 경로(plain 실패 → headless)의 최악 스택**은 호출자 read 55s 를 넘을 수 있다. plain fetch 는 수동 redirect 추적(hop 상한 3 = 요청 최대 4회)마다 connect/read 타임아웃이 **새로 적용**되므로 fetch 단독의 이론 최악이 이미 약 88s 다(이 특성은 headless 이전부터 존재). 여기에 render 22s + LLM 30s 가 얹히면 이론 최악 약 140s — 단, 각 단이 전부 타임아웃까지 끄는 경우는 실측상 없다시피 하고(차단은 대개 즉시 4xx/5xx 로 떨어져 fetch 가 빨리 실패한다), 넘치면 호출자는 read 타임아웃 → 일시 실패로 처리해 recover 가 재시도한다. 그 사이 Extractor 가 계속 돌아 중복 발주가 겹쳐도 Extractor 는 무상태라 안전하고(§0 — 중복의 대가는 LLM 비용 한 번), attempt 상한 2 가 총비용을 바운드한다. 이 스택을 55s 안에 구겨 넣으려면 render 예산이 실측 대비 무의미하게 얇아져(5s 이하) recall 을 잃는다 — 의도된 트레이드오프다.

## 4. 진화 규칙

- **additive-only**: 응답 필드 추가·422 code 추가는 자유. 필드 제거·의미 변경·타입 변경은 금지 — 필요하면 새 경로로 분리한다.
- **배포 순서: Extractor 먼저, 소비자(PIKI-Server) 나중.** 본 서버 staging 이 extractor-prod 를 기본으로 바라보는 구성이 이 순서 위반을 릴리스 전에 잡는다.
- **배포 순서: Extractor 먼저, 소비자(core) 나중.** 본 서버 staging 이 extractor-prod 를 기본으로 바라보는 구성이 이 순서 위반을 릴리스 전에 잡는다.
- 호출자는 tolerant reader — 모르는 응답 필드·code 를 무시한다.

## 5. 관측

- W3C `traceparent` 헤더를 수용해 PIKI-Server 의 `item.parse` span 아래로 연결된다(micrometer tracing 기본 동작).
- 메트릭 `product.extract{via,reason}`·`product.extract.escalation{outcome,category}` 은 PIKI-Server 에서 이 서비스로 이동한다. `application=piki-extractor` 라벨로 본 서버 시계열과 구분된다.
- W3C `traceparent` 헤더를 수용해 core 의 `item.parse` span 아래로 연결된다(micrometer tracing 기본 동작).
- 메트릭 `product.extract{via,reason}`·`product.extract.escalation{outcome,category}` 은 core 에서 이 서비스로 이동한다. `application=piki-extractor` 라벨로 본 서버 시계열과 구분된다.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

// 내부 추출 API (docs/api-contract.md). 소비자는 PIKI-Server outbox 워커 하나뿐이고 보안그룹으로 격리되므로
// 내부 추출 API (docs/api-contract.md). 소비자는 core outbox 워커 하나뿐이고 보안그룹으로 격리되므로
// 인증·응답 래퍼 없이 계약 그대로 노출한다. 두 경로(link·image)가 같은 응답 모양(ExtractionResponse)을 공유한다.
@RestController
@RequestMapping("/internal/extractions")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

// 성공(200) 응답. link·image 두 엔드포인트가 같은 모양을 공유한다(둘 다 ProductSnapshot 을 내려보낸다).
// 계약(docs/api-contract.md): name(non-blank)·currentPrice·imageUrl 의 non-null 을 이 서비스가 보장한다 —
// 호출자(PIKI-Server)의 READY 불변식(name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과
// 호출자(core)의 READY 불변식(name·price·imageUrl·extractedAt, extractedAt 은 호출자가 전이 시점에 채움)과
// 동일 조건이며, 보장 못 하면 성공이 아니라 422(UNTRUSTWORTHY_VALUE)다. currency 는 READY 필수가 아니라 nullable.
public record ExtractionResponse(
String name,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.depromeet.piki.extractor.common.exception;

// 422 응답 body 의 code. docs/api-contract.md 의 code 표와 1:1 이어야 한다 — 추가는 자유(additive), 제거·의미 변경 금지.
// 호출자(PIKI-Server)는 이 값을 전이 판정에 쓰지 않고(status 만 사용) 관측·디버깅에만 쓴다.
// 호출자(core)는 이 값을 전이 판정에 쓰지 않고(status 만 사용) 관측·디버깅에만 쓴다.
public enum ExtractionErrorCode {
NOT_PRODUCT_PAGE,
UNTRUSTWORTHY_VALUE,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// 이미지(OCR) 경로가 쓰는 두 연산:
// upload — 크롭 결과를 저장하고 공개 URL 을 돌려준다.
// download — outbox 재실행 시점에 등록 워커가 적재한 raw 원본을 다시 읽는다.
// presign·exists·delete·deleteByPrefix 는 등록 수명주기(발급·확인·회수·탈퇴 파기)라 PIKI-Server 소관이다.
// presign·exists·delete·deleteByPrefix 는 등록 수명주기(발급·확인·회수·탈퇴 파기)라 core 소관이다.
public interface ImageStorage {

// 이미지 바이트를 저장소(S3)에 올리고 공개 접근 URL 을 돌려준다.
Expand Down
Loading
Loading