Skip to content
Merged
18 changes: 9 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

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

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

## 언어: Java 25

- 모던 idiom 을 기본으로: `record`(값 객체·DTO), `sealed`(닫힌 분기), pattern matching `switch`. 로컬 변수 `var` 는 우변에서 타입이 자명할 때만.
- **이 repo 는 사람이 직접 읽고 이해하는 것을 우선한다.** 영리한 축약보다 평이하고 읽히는 코드. 포팅 시 Kotlin 원본의 의도를 보존하되 Java 다운 표현으로 옮긴다.
- **이 repo 는 사람이 직접 읽고 이해하는 것을 우선한다.** 영리한 축약보다 평이하고 읽히는 코드.

### Lombok

Expand All @@ -33,7 +33,7 @@ core(코틀린)에서 분리된 **상품 추출 서비스**다. 상품 URL(또
- **선언부**(클래스·인터페이스·record·enum·상수·필드·메서드·생성자) 주석은 Javadoc(`/** */`). 메서드 본문 안에서만 `//`.
- **지운다**: 코드가 이미 말하는 "무엇", 시그니처 재진술 `@param`(`@param region S3 리전`), 클래스 Javadoc 과 같은 말의 반복, 흐름 나레이션(`// 1) fetch 한다`), 자명한 분기 설명, 테스트에서 `@DisplayName`·단언이 이미 말하는 라벨.
- **남긴다**: 설계 근거(왜 이 대안을 버렸나), 코드로 안 보이는 외부 제약·함정, 계약·보안 판단의 이유, 도달 불가 분기의 불변식, 외부 명세 링크. 이 "왜" 주석이 이 repo 의 자산이다.
- **SSOT 위반 주석 금지.** 정본이 딴 곳에 있는 수치·목록·동작 — 다른 repo(renderer·PIKI-Server)의 구현 상세, `docs/api-contract.md` 의 계약 서술·타임아웃 예산, **코드의 기본값·상수값**, 다른 클래스가 정본인 분류 — 을 복제하지 않는다(조용히 낡는다). 정본을 가리키는 참조 한 줄로 대신한다.
- **SSOT 위반 주석 금지.** 정본이 딴 곳에 있는 수치·목록·동작 — 다른 repo(renderer·core)의 구현 상세, `docs/api-contract.md` 의 계약 서술·타임아웃 예산, **코드의 기본값·상수값**, 다른 클래스가 정본인 분류 — 을 복제하지 않는다(조용히 낡는다). 정본을 가리키는 참조 한 줄로 대신한다.
- **Javadoc 문법 게이트: `./gradlew javadoc` 이 error 0 이어야 한다.** raw `<...>` 는 `{@code <script>}` 로 감싸고, 본문 줄머리에 `@` 로 시작하는 애노테이션 이름을 두지 않는다(`{@code @DefaultValue}`) — 둘 다 error 를 낸다. `>`·`&` 단독은 `&gt;`·`&amp;`, 목록은 `<ul><li>`, 문단은 `<p>`.

### Null 처리
Expand All @@ -60,15 +60,15 @@ core 의 Elvis 규칙에 대응하는 Java 규칙:
## 로깅

- 클래스에 `@Slf4j` 를 붙여 쓴다(필드명 `log`). 명시적 `LoggerFactory.getLogger` 선언은 쓰지 않는다.
- URL 은 반드시 마스킹(`safeLogString` 포팅본: host+path만, 쿼리스트링 제외). 토큰·원문 HTML·LLM 응답 원문을 로그에 남기지 않는다.
- URL 은 마스킹해서 찍는다(`safeLogString`: host+path만, 쿼리스트링 제외). 쿼리스트링에 토큰이 실릴 수 있어서다. 토큰·원문 HTML·LLM 응답 원문도 로그에 남기지 않는다.
- 레벨: info=정상 흐름·지표·호출자 계약 위반 / warn=외부(몰·Gemini·S3) 실패·에스컬레이션 실패·SSRF 차단 / error=서버 버그(스택 포함).
- SLF4J `{}` placeholder 사용, 문자열 연결 금지.

## 포팅 규율 (이관 기간 한정)
## 설정값

- **동작 등가(파리티)가 목표다.** Kotlin 원본의 분기·상수·메시지를 그대로 옮기고, 개선·리팩터링은 이관 완료 후 별도 작업으로 뺀다.
- 포팅한 클래스의 Javadoc 에 원본 경로를 남긴다: `core: product/service/http/HttpPageFetcher.kt 포팅`.
- 하드코딩이던 상수(fetch 3MB cap·UA·타임아웃·LLM 200K char cap)는 `@ConfigurationProperties` 로 외부화하되 **기본값은 원본과 동일**하게 둔다.
- 운영 튜닝 손잡이(UA·타임아웃·리다이렉트 횟수)는 하드코딩하지 않고 `@ConfigurationProperties` 로 외부화한다(`FetchProperties`·`GeminiProperties`·`HeadlessExtractionProperties` 등).
- 크기 안전 상한은 손잡이가 아니라 안전장치라 클래스 상수로 둔다(`HttpPageFetcher.MAX_FETCH_BYTES`·`PruningHtmlParser.MAX_RETAINED_CHARS`·`GeminiHtmlExtractor.MAX_LLM_CHARS`). 설정으로 열어 둔 동안 아무도 지정하지 않았고 키 이름만 드리프트할 자리가 생겨 상수로 되돌린 이력이 `HttpPageFetcher`·`PruningHtmlParser` 의 상수 주석에 있다.
- 기본값은 코드가 정본이라 문서에 숫자를 박지 않는다.

## 테스트

Expand All @@ -80,7 +80,7 @@ core 의 Elvis 규칙에 대응하는 Java 규칙:
- **단언**: JUnit 5 `Assertions` 기본. 컬렉션·객체 그래프 비교만 AssertJ.
- **DB 가 없다** — Testcontainers·Docker 불필요. `./gradlew test` 가 그냥 돈다. 저장소 격리·트랜잭션 롤백 관련 원칙은 이 repo 에 해당 사항이 없다.
- **좌표**: 통합 베이스는 `support/IntegrationTestSupport`(`@SpringBootTest` 유일 선언), 외부 경계 stub(GeminiClient·PageFetcher·S3)은 `support/IntegrationStubs` 에 `@Primary` 로 등록한다.
- **메타 테스트**: `TestConventionTest`(금지 import·컨텍스트 규칙 기계 강제)는 파싱 포팅(3단계)과 함께 이식한다. 그 전까지 원칙 준수는 사람 리뷰가 책임진다.
- **메타 테스트**: `support/TestConventionTest`(금지 import·컨텍스트 규칙 기계 강제)가 `./gradlew test` 에 포함된다. 규칙을 바꿀 땐 산문을 먼저 고치고 메타 테스트를 따라 고친다.

## 의존성

Expand Down
17 changes: 8 additions & 9 deletions docs/style-decisions.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
# 스타일 결정 기록 — 우아한테크코스 스타일 검토 (2026-07-20)
# 스타일 결정 기록 (2026-07-20)

참고 기준: [woowacourse-teams/2025-estime `be/dev`](https://github.com/woowacourse-teams/2025-estime/tree/be/dev) 백엔드.
목표: 우테코식 객체지향 스타일을 참고하되, 이 서비스의 정체(무상태·단일 소비자·포팅 파리티 — CLAUDE.md 참조)에 맞는 형태로 취사선택한다. 아래 결정이 이 repo 스타일의 기준선이며, CLAUDE.md 와 충돌하면 CLAUDE.md 가 우선한다.
목표: 외부 백엔드 코드베이스의 객체지향 관례를 검토하되, 이 서비스의 정체(무상태·단일 소비자 — CLAUDE.md 참조. 작성 당시엔 포팅 파리티 기간이기도 했다)에 맞는 형태로 취사선택한다. 아래 결정이 이 repo 스타일의 기준선이며, CLAUDE.md 와 충돌하면 CLAUDE.md 가 우선한다.

## 1. estime 대비 채택 / 기각 결정
## 1. 검토한 관례의 채택 / 기각 결정

| # | 관례 (estime) | 결정 | 근거 |
| # | 검토한 관례 | 결정 | 근거 |
|---|---|---|---|
| 1 | 4모듈 헥사고날(core/application/infrastructure/api) | **기각** | 무상태·DB 없음·소비자 1개 서비스에 모듈 분리는 과설계. 대신 포트-어댑터의 핵심(인터페이스 경계: `PageFetcher`·`HeadlessRenderer`·`GeminiClient`·`ImageStorage`)은 이미 단일 모듈 안에서 성립해 있다 — 이 경계를 유지·강화하는 것으로 같은 효과를 얻는다. |
| 2 | rich domain + 정적 팩토리(`withoutId`/`from`/`of`) + 생성 시점 검증, public 생성자 금지 | **이미 부합 — 유지** | `ProductLink.parse`·`ProductImage.of`·`ProductSnapshot.fromExtracted` 가 같은 철학. 팩토리 명명은 현행(`parse`=문자열 해석, `of`=성분 조립, `from~`=다른 표현 변환)을 유지한다. |
| 3 | 값 객체를 Lombok 클래스로 | **기각 — record 유지** | estime 의 클래스 VO 는 JPA 제약(no-arg 생성자) 대응이 크다. JPA 없는 이 repo 에선 `record` 가 더 정확한 도구다. |
| 3 | 값 객체를 Lombok 클래스로 | **기각 — record 유지** | 검토한 코드의 클래스 VO 는 JPA 제약(no-arg 생성자) 대응이 크다. JPA 없는 이 repo 에선 `record` 가 더 정확한 도구다. |
| 4 | Lombok 전면(@Getter·@RequiredArgsConstructor·@Slf4j…) | **보일러플레이트 표적 채택** (2026-07-23 재결정) | 사용자 피드백("생성자 직접 작성을 극도로 꺼림", "로거도 전부 애노테이션으로")으로 전면 기각 → 두 애노테이션만 채택. **`@RequiredArgsConstructor`**: 순수 필드-대입 생성자 12개 클래스 전환(@Qualifier 는 필드 + lombok.config copyableAnnotations), 조립 로직 생성자 4개는 손 유지(HttpPageFetcher·HttpHeadlessRenderer·GeminiHttpClient — 파생 필드/클라이언트 빌드, RequestScopedDnsResolver — 편의 생성자 2개). **`@Slf4j`**: 명시적 Logger 선언 11개 전환 — 생성 결과가 `private static final Logger log = LoggerFactory.getLogger(자기클래스.class)` 와 바이트코드 동일(javap 로 확인)이라 로거 이름·로그 출력 변화 없음. 값 객체는 여전히 record, @Builder·@Setter·@Data·@Value·@Getter 는 미채택. 애노테이션 순서는 Lombok → Spring. |
| 5 | jakarta.validation 미사용, 검증은 100% 도메인 책임 | **이미 부합 — 유지** | url 형식 검증을 Bean Validation 이 아니라 `ProductLink.parse` 가 맡는 현행 구조가 정확히 같은 철학. |
| 6 | 사유별 예외 클래스 + 이중 메시지(log 영어/user 한국어) + HTTP 200 고정 `CustomApiResponse` 봉투 | **기각** | 소비자가 사람이 아니라 PIKI-Server 워커다. HTTP status 가 계약의 전이 신호(2xx/422/기타)라 200 고정 봉투는 계약 파괴. userMessage 도 무의미. 단 estime 의 "사유별 예외" 의도는 우리 "사유 하나 = 정적 팩토리 하나" 규칙이 이미 담고 있다. |
| 6 | 사유별 예외 클래스 + 이중 메시지(log 영어/user 한국어) + HTTP 200 고정 `CustomApiResponse` 봉투 | **기각** | 소비자가 사람이 아니라 core 워커다. HTTP status 가 계약의 전이 신호(2xx/422/기타)라 200 고정 봉투는 계약 파괴. userMessage 도 무의미. 단 "사유별 예외" 의도는 우리 "사유 하나 = 정적 팩토리 하나" 규칙이 이미 담고 있다. |
| 7 | 초박형 컨트롤러 + Swagger 스펙 인터페이스 분리 | **절반 부합** | 초박형 컨트롤러는 이미 부합. 스펙 인터페이스 분리는 기각 — 계약 SSOT 가 `docs/api-contract.md` 고 소비자가 하나라 Swagger 문서화 계층이 필요 없다. |
| 8 | 일급 컬렉션(`Participants`·`Votes`) | **보류(사례 발생 시 채택)** | 현재 컬렉션 불변식을 가진 도메인 개념이 없다. 생기면 이 패턴을 쓴다. |
| 9 | `TimeProvider` 포트로 시간 주입, 도메인은 `now` 파라미터 수령 | **보류(사례 발생 시 채택)** | 현재 시간 의존 도메인 로직이 없다. 생기면 `Instant.now()` 직접 호출 대신 이 패턴을 쓴다. |
| 10 | 파라미터·로컬변수 `final` 전면 | **기각** | 시그니처 노이즈 대비 이득이 작고 repo 관례가 아니다. 불변은 record·불변 컬렉션으로 표현한다. |
| 11 | 주석 최소주의(151개 파일 중 Javadoc 6개) | **기각 — "왜" Javadoc 자산 유지** | 이 repo 의 "왜" 주석은 CLAUDE.md 가 자산으로 선언한 차별점이다(포팅 근거·계약 경계·보안 근거). estime 방향으로 줄이지 않는다. 단 시그니처 재진술 `@param` 같은 "무엇" 주석은 노이즈로 제거한다(§2). |
| 12 | 테스트: @DisplayName 한국어 · AssertJ 전면 · Mockito 계층 · @BeforeEach 적극 | **부분 채택(이미 부합) / 나머지 기각** | @DisplayName 한국어 한 문장은 이미 규약. Mockito 는 기각(stub 우선 규율이 estime 보다 강하고 유지 가치가 있다). @BeforeEach 금지 유지(각 테스트 자기완결). AssertJ 는 현행대로 컬렉션·객체 그래프 비교에만. given-when-then 주석은 강제하지 않되 긴 테스트에서 허용. |
| 11 | 주석 최소주의(151개 파일 중 Javadoc 6개) | **기각 — "왜" Javadoc 자산 유지** | 이 repo 의 "왜" 주석은 CLAUDE.md 가 자산으로 선언한 차별점이다(포팅 근거·계약 경계·보안 근거). 방향으로 줄이지 않는다. 단 시그니처 재진술 `@param` 같은 "무엇" 주석은 노이즈로 제거한다(§2). |
| 12 | 테스트: @DisplayName 한국어 · AssertJ 전면 · Mockito 계층 · @BeforeEach 적극 | **부분 채택(이미 부합) / 나머지 기각** | @DisplayName 한국어 한 문장은 이미 규약. Mockito 는 기각(stub 우선 규율이 검토한 관례보다 강하고 유지 가치가 있다). @BeforeEach 금지 유지(각 테스트 자기완결). AssertJ 는 현행대로 컬렉션·객체 그래프 비교에만. given-when-then 주석은 강제하지 않되 긴 테스트에서 허용. |
| 13 | 메서드 명명: `validate~`(생성 시)·`ensure~`(사용 시)·`obtain~`(찾고 없으면 throw)·`markAs~`(상태전이) | **채택(새 코드 기준)** | 검증 계열 명명의 일관성은 가져갈 가치가 있다. 단 기존 코드 일괄 개명은 하지 않는다 — 포팅 파리티 기간엔 새로 쓰는 코드부터 적용. |

## 2. 이번 /code-review 결과의 결정
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import org.springframework.web.bind.annotation.RestController;

/**
* 내부 추출 API (docs/api-contract.md). 소비자는 core outbox 워커 하나뿐이고 보안그룹으로
* 내부 추출 API (docs/api-contract.md). 소비자는 core 의 파싱 작업 큐 워커 하나뿐이고 보안그룹으로
* 격리되므로 인증·응답 래퍼 없이 계약 그대로 노출한다.
*/
@Slf4j
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.depromeet.piki.extractor.common.storage;

/**
* 이미지(OCR) 경로가 쓰는 두 연산만 둔다 — download 는 outbox 재실행 시점에 등록 워커가 적재한 raw 원본을 다시 읽는 용도다.
* 이미지(OCR) 경로가 쓰는 두 연산만 둔다 — download 는 파싱 작업 큐가 재실행할 때 등록 워커가 적재한 raw 원본을 다시 읽는 용도다.
* <p>presign·exists·delete·deleteByPrefix 는 등록 수명주기(발급·확인·회수·탈퇴 파기)라 core 소관이다.
*/
public interface ImageStorage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
* 일이라 허락을 전제하지 않는다. authorized 가 여는 것은 렌더 서비스의 우회 수단(지문 보정·프록시)뿐이고,
* 이 클래스는 그 값을 판단 없이 전략에 넘기기만 한다(무상태 — 원장은 호출자에 있다).
*
* <p>에스컬레이션 축(plain 확정 → headless)은 호출자 outbox 의 재시도 축(일시 오류 → 같은 plain 재시도)과
* <p>에스컬레이션 축(plain 확정 → headless)은 호출자 파싱 작업 큐의 재시도 축(일시 오류 → 같은 plain 재시도)과
* 직교한다. 차단·불완전 결과는 재시도 축에서 이미 확정 실패(422)라 그 슬롯(attemptCount)에 얹을 수 없다. 그래서
* 여기서 별도로 판정한다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public String toString() {
* <p>{@code @DefaultValue} 는 부분 바인딩(예: max-attempts 만 지정)에서도 나머지 필드의 기본값을 유지시킨다
* — 아래 no-arg 생성자와 값이 어긋나면 바인딩 경로와 직접 생성 경로의 기본값이 갈린다.
*
* @param maxAttempts 총 시도 횟수(초기 호출 + 재시도). URL 파싱 재시도를 호출자(PIKI-Server) outbox recover 로
* @param maxAttempts 총 시도 횟수(초기 호출 + 재시도). URL 파싱 재시도를 호출자(core)의 파싱 작업 큐 회수로
* 일원화했으므로 기본값은 Gemini 내부 재시도를 끄는 쪽이다.
*/
public record Retry(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public final class PageFetchException extends ExtractionException {

/**
* 정적 fetch 실패를 실제 브라우저(헤드리스)로 재시도(escalate)할지 표시한다. FallbackProductLinkExtractor 가
* 이 값으로 정한다(에스컬레이션 축은 호출자의 outbox 재시도 축과 직교). 정책은 "무조건 폴백": 예외는 둘뿐이고
* 이 값으로 정한다(에스컬레이션 축은 호출자의 파싱 작업 큐 재시도 축과 직교). 정책은 "무조건 폴백": 예외는 둘뿐이고
* 나머지 fetch 실패는 전부 escalatable 이다 — 봇 방어가 어떤 status 로도 위장해 status·body 로 차단/genuine 을
* 못 가른다.
*
Expand Down
14 changes: 7 additions & 7 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
server:
# 8080=PIKI-Server, 8000=piki-hb(헤드리스) 와 겹치지 않는 포트. dev 는 본 서버 박스에 컨테이너로 동거한다.
# 8080=core, 8000=piki-hb(헤드리스) 와 겹치지 않는 포트. dev 는 본 서버 박스에 컨테이너로 동거한다.
port: 8090

spring:
Expand All @@ -21,28 +21,28 @@ gemini:
# 기본 모델은 GeminiProperties.DEFAULT_MODEL 한 곳에서만 정의한다 (drift 방지). 여기엔 리터럴을 두지 않는다.
# 운영에서 모델을 바꾸려면 GEMINI_MODEL 환경변수로 override (relaxed binding: GEMINI_MODEL -> gemini.model).
retry:
# max-attempts 는 총 시도 횟수(초기 호출 + 재시도). 기본 1 = 재시도 없음 — URL 파싱 재시도는 호출자(PIKI-Server)의
# outbox recover 한 곳으로 모여 있다. 여기서 내부 재시도를 켜면 재시도가 이중으로 겹친다.
# max-attempts 는 총 시도 횟수(초기 호출 + 재시도). 기본 1 = 재시도 없음 — URL 파싱 재시도는 호출자(core)의
# 파싱 작업 큐 회수 한 곳으로 모여 있다. 여기서 내부 재시도를 켜면 재시도가 이중으로 겹친다.
max-attempts: ${GEMINI_RETRY_MAX_ATTEMPTS:1}

management:
endpoints:
web:
exposure:
# PIKI-Server 와 동일한 2층 구조 전제: 외부 노출 없음(보안그룹) + 박스 내부 Alloy 만 scrape.
# core 와 동일한 2층 구조 전제: 외부 노출 없음(보안그룹) + 박스 내부 Alloy 만 scrape.
include: health, prometheus
metrics:
tags:
# Grafana Cloud 에서 서비스 단위 구분용 공통 라벨. PIKI-Server(application=PIKI)와 별개 시계열.
# Grafana Cloud 에서 서비스 단위 구분용 공통 라벨. core(application=piki-core)와 별개 시계열.
application: ${spring.application.name}
tracing:
sampling:
# PIKI-Server 와 동일하게 전수 추적. 볼륨이 부담되면 그때 하향.
# core 와 동일하게 전수 추적. 볼륨이 부담되면 그때 하향.
probability: 1.0
export:
otlp:
# 로컬엔 수집기(Alloy)가 없어 기본 off. 운영 컨테이너에만 TRACING_OTLP_ENABLED=true 를 준다.
# off 여도 trace 생성·전파(traceparent 수용, PIKI-Server item.parse span 아래 연결)는 그대로다.
# off 여도 trace 생성·전파(traceparent 수용, core 의 item.parse span 아래 연결)는 그대로다.
enabled: ${TRACING_OTLP_ENABLED:false}
opentelemetry:
tracing:
Expand Down
Loading
Loading