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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ docker info > /dev/null 2>&1 || (open -a Docker && until docker info > /dev/null

- **도메인 → 응답 DTO**: 응답 DTO 의 `companion object` 에 `from(도메인)` 정적 팩토리. 예: `UserResponse.from(user)`, `WishItemResponse.WishView.from(wish)`.
- **요청 DTO → 도메인/커맨드**: 요청 DTO 의 `toXxx()` 인스턴스 메서드. 예: `CreateTournamentRequest.toCreateTournament()`.
- **외부 응답 → 도메인**: 외부 결과 객체의 `toXxx()`. 예: 원격 추출 응답 DTO 의 `toProductSnapshot(link)` (`RemoteExtractionContract.kt`).
- **외부 응답 → 도메인**: 외부 결과 객체의 `toXxx()`. 예: 생성 응답 클래스(`ExtractionResult`)의 확장 `toProductSnapshot(link)` (`RemoteExtractionContract.kt`).
- **스냅샷·도메인 → 엔티티**: 받는 엔티티의 `companion object` 정적 팩토리. 예: `ItemSnapshot.pending(...)`, `ItemSnapshot.manual(...)`.

매핑 분기·정규화는 단위 테스트로 검증한다 (`.claude/rules/testing-principles.md` 의 "분기 위치 결정 트리" (3) 매퍼 함수 분기).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import com.depromeet.piki.common.exception.ErrorCode
// 확정 실패(원격 422)를 **운영 액션 축**으로 나눈 분류(#936). "이 숫자가 늘면 누가 무엇을 하는가"가 기준이라,
// 실패의 기술적 원인이 아니라 대응이 같은 것끼리 묶인다.
// 계약 정본(shared-infra/contracts/extraction.proto)의 Bucket 과 1:1 이고, 파싱 메트릭의
// reason 라벨(ItemParsingMetrics)도 여기서 파생한다 — 셋(카탈로그·예외·메트릭)이 어긋나면
// reason 라벨(ItemParsingMetrics)도 여기서 파생한다 — 셋(계약·예외·메트릭)이 어긋나면
// ExtractionErrorCatalogTest 가 잡는다.
enum class ExtractionFailureBucket {
// 사용자가 상품 아닌 걸 넣었다. 정상 트래픽이라 할 일이 없다.
Expand All @@ -27,7 +27,7 @@ enum class ExtractionFailureBucket {
// 확정 실패 예외가 자기 bucket 을 스스로 밝히게 하는 ErrorCode. 예외 클래스가 늘어도 워커는 이 인터페이스만
// 보므로 reason 파생 경로가 한 줄로 유지되고, bucket 은 code 정의 옆(single source)에 박힌다.
//
// bucket 이 nullable 인 이유: 일시(transient) code 는 bucket 이 없다(카탈로그도 같은 모양). 일시 실패는
// bucket 이 nullable 인 이유: 일시(transient) code 는 bucket 이 없다(계약도 같은 모양). 일시 실패는
// 소유권 반납으로 되살아나 종결 집계(reason)에 닿지 않고, 상한을 소진하면 recover 가 retry_exhausted 로 센다.
interface ExtractionFailureCode : ErrorCode {
val bucket: ExtractionFailureBucket?
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.depromeet.piki.product.service.remote

import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode
import com.depromeet.piki.contracts.extraction.v1.ExtractionFailure
import com.depromeet.piki.contracts.extraction.v1.ModelProbeRequest
import org.slf4j.LoggerFactory
Expand All @@ -12,7 +13,7 @@ import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException

// 모델 유효성 프로브. 백오피스가 모델을 저장하기 전에 extractor 로 실호출해 "이 모델이 이 경로에서 실제로
// 동작하는가"를 확인한다 (계약: extractor repo docs/api-contract.md, POST /internal/models/probe).
// 동작하는가"를 확인한다 (계약: shared-infra/contracts/extraction.proto 의 rpc ModelProbe).
//
// 아는 모델 목록을 코드에 박는 대신 런타임 실측으로 거르는 자리다 — allowlist 를 박으면 새 모델이 나올 때마다
// extractor 배포가 필요해져 "배포 없이 바꾼다"는 목적이 절반 무너진다. 경고창만 띄우는 방식은 게이트가 되지
Expand All @@ -34,7 +35,7 @@ interface ExtractionModelProbe {

@Component
class HttpExtractionModelProbe(
// 추출 호출과 같은 클라이언트를 쓴다. read-timeout 이 55s 라 백오피스 저장이 최악의 경우 그만큼 멈추지만,
// 추출 호출과 같은 클라이언트를 쓴다. read-timeout 이 계약의 호출자 타임아웃이라 백오피스 저장이 최악의 경우 그만큼 멈추지만,
// 프로브는 최소 프롬프트 1회라 실측 수 초이고 없는 모델은 즉시 404 로 돌아온다 — 전용 빈을 늘릴 만큼의
// 이득이 없다. 느린 모델 때문에 화면이 오래 멈추기 시작하면 그때 프로브 전용 타임아웃으로 가른다.
@Qualifier("remoteExtractionRestClient") private val restClient: RestClient,
Expand Down Expand Up @@ -81,13 +82,13 @@ class HttpExtractionModelProbe(
return IllegalArgumentException(MESSAGE_UNAVAILABLE)
}
// 실패 body 는 추출 계약과 같은 모양이라 생성 클래스를 공유한다. 모르는 code 는 UNSPECIFIED 라 else 로 간다.
val code = runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code?.name }.getOrNull()
val code = runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code }.getOrNull()
// 거절은 계약상 정상 결과(운영자가 없는 모델을 입력)라 info.
log.info("모델 프로브 거절 code={} target={} model={}", code, target, model)
return IllegalArgumentException(
when (code) {
CODE_MODEL_NOT_FOUND -> MESSAGE_NOT_FOUND
CODE_MODEL_INCOMPATIBLE -> MESSAGE_INCOMPATIBLE
ExtractionErrorCode.MODEL_NOT_FOUND -> MESSAGE_NOT_FOUND
ExtractionErrorCode.MODEL_INCOMPATIBLE -> MESSAGE_INCOMPATIBLE
// 모르는 code — 이 바이너리보다 새 extractor 가 사유를 늘렸을 수 있다. 거절 사실만 전한다.
else -> MESSAGE_REJECTED
},
Expand All @@ -103,9 +104,6 @@ class HttpExtractionModelProbe(
// internal 인 이유: 이 사유 문구가 곧 백오피스 화면에 뜨는 계약이라 테스트가 같은 상수로 단언한다.
// 테스트에 문자열을 다시 적으면 한쪽만 바뀌었을 때 어긋난 채로 통과한다.
companion object {
internal const val CODE_MODEL_NOT_FOUND = "MODEL_NOT_FOUND"
internal const val CODE_MODEL_INCOMPATIBLE = "MODEL_INCOMPATIBLE"

internal const val MESSAGE_NOT_FOUND = "응답하지 않는 모델입니다. 모델명을 확인해 주세요."
internal const val MESSAGE_INCOMPATIBLE = "모델은 있으나 이 경로의 요청을 처리하지 못합니다. 다른 모델을 골라 주세요."
internal const val MESSAGE_REJECTED = "이 모델은 사용할 수 없습니다."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient

// 원격 추출 서비스(extractor)의 링크 추출 클라이언트. 계약 정본은 TeamPiKi/infra 의
// contracts/extraction-api.md 이고, 호출·3갈래 번역(2xx / 422+code / 그 외)·2xx 계약 위반 가드는 이미지 클라이언트
// contracts/extraction.proto(모양)·extraction-api.md(동작 규칙)이고, 호출·3갈래 번역(2xx / 422+code / 그 외)·2xx 계약 위반 가드는 이미지 클라이언트
// (HttpImageSnapshotExtractor)와 공유하므로 RemoteExtractionContract 한 곳에 있다 — 여기는 링크 고유의 요청(URL)만 진다.
//
// 차단(BLOCKED)은 여기서 끝난다 — 요청을 아예 내보내지 않으므로 extractor·renderer 는 그 도메인의 존재조차
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import com.depromeet.piki.product.service.ExtractionFailureCode
// 나머지는 비 RETRYABLE(즉시 FAILED).
//
// bucket 은 확정 실패를 메트릭에서 무엇으로 셀지의 정본이다(#936). 일시(TRANSIENT_FAILURE)는 종결 집계에
// 닿지 않으므로 bucket 이 없다(카탈로그의 transient code 와 같은 모양).
// 닿지 않으므로 bucket 이 없다(계약의 TRANSIENT code 와 같은 모양).
//
// status 교정: 종전엔 두 팩토리 모두 502 를 직접 들었으나, category 가 status 를 소유하게 되며
// PERMANENT_FAILURE(SERVER_ERROR)는 500 으로 파생된다(에픽 결정 2 의 OAuthException.misconfigured 502→500 과 동형).
Expand All @@ -32,7 +32,7 @@ enum class ProductExtractorErrorCode(
// 우리 방어가 발동했거나(호스트 차단·리다이렉트 이상) 이 바이너리가 모르는 code 로 422 가 온 경우.
// tolerant reader — 모르는 code 라도 422 면 확정 실패다(extractor 계약 §1). 재시도 무의미.
// 둘 다 "코드를 조사한다"가 대응이라 internal_error 로 센다: 전자는 우리 방어·버그이고, 후자는 매핑이
// 뒤처졌다는 신호(카탈로그·translate 갱신)라 결국 코드 작업으로 귀결된다.
// 뒤처졌다는 신호(계약·translate 갱신)라 결국 코드 작업으로 귀결된다.
PERMANENT_FAILURE(
"EXTRACTOR-002",
ErrorCategory.SERVER_ERROR,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.piki.product.service.remote

import com.depromeet.piki.common.exception.BaseException
import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode
import com.depromeet.piki.contracts.extraction.v1.ExtractionFailure
import com.depromeet.piki.contracts.extraction.v1.ExtractionMethod
import com.depromeet.piki.contracts.extraction.v1.ExtractionResult
Expand Down Expand Up @@ -37,31 +38,31 @@ internal object RemoteExtractionContract {
// 확정 실패(422) code 전수 → 우리 예외. 계약 정본(shared-infra/contracts/extraction.proto)의
// permanent code 를 빠짐없이 여기에 명시한다 — 표에 없는 code 는 아래 fallback 으로 떨어져 internal_error 로
// 세지므로, 매핑 누락이 "우리가 이름을 아는 실패"인 척 묻히지 않는다.
// 표를 when 대신 값으로 둔 이유: 카탈로그와의 전수 대조(ExtractionErrorCatalogTest)가 이 키 집합을 직접 읽는다.
// 표를 when 대신 값으로 둔 이유: 계약과의 전수 대조(ExtractionErrorCatalogTest)가 이 키 집합을 직접 읽는다.
// when 분기는 밖에서 열거할 수 없어 "누락이 else 로 조용히 흡수됐는지"를 기계가 가릴 수 없다.
//
// 어느 예외로 보내는지가 곧 메트릭 reason 이다 — 예외의 errorCode 가 bucket 을 들고 있고(ExtractionFailureCode),
// ItemParsingMetrics.reasonOf 가 그 bucket 을 라벨로 옮긴다. 문구는 여기서 갈리지 않는다(고정 사용자 문구).
internal val PERMANENT_TRANSLATIONS: Map<String, () -> BaseException> =
internal val PERMANENT_TRANSLATIONS: Map<ExtractionErrorCode, () -> BaseException> =
mapOf(
// 사용자가 상품 아닌 걸 넣었다 — 정상 트래픽.
"NOT_PRODUCT_PAGE" to { ProductSnapshotException.notProductPage() },
"INVALID_URL" to { ProductSnapshotException.notProductPage() },
ExtractionErrorCode.NOT_PRODUCT_PAGE to { ProductSnapshotException.notProductPage() },
ExtractionErrorCode.INVALID_URL to { ProductSnapshotException.notProductPage() },
// 우리 구성으로 못 읽었다 — 도메인 허가 후보 신호.
"EMPTY_SHELL" to { ProductSnapshotException.noExtractableContent() },
"NO_EXTRACTABLE_CONTENT" to { ProductSnapshotException.noExtractableContent() },
ExtractionErrorCode.EMPTY_SHELL to { ProductSnapshotException.noExtractableContent() },
ExtractionErrorCode.NO_EXTRACTABLE_CONTENT to { ProductSnapshotException.noExtractableContent() },
// 대상이 우리를 막았다 — BLOCKED 정책 후보.
"FETCH_CLIENT_ERROR" to { ProductExtractorException.blockedByTarget() },
"PERMANENT_UPSTREAM" to { ProductExtractorException.blockedByTarget() },
ExtractionErrorCode.FETCH_CLIENT_ERROR to { ProductExtractorException.blockedByTarget() },
ExtractionErrorCode.PERMANENT_UPSTREAM to { ProductExtractorException.blockedByTarget() },
// 추출은 됐는데 값을 믿을 수 없다 — 모델·프롬프트·검증 규칙 소관.
// IMAGE_UNSUPPORTED(이미지 경로 전용)도 "받은 결과를 상품 정보로 쓸 수 없다"는 같은 성격이다.
"UNTRUSTWORTHY_VALUE" to { ProductSnapshotException.untrustworthyValue() },
"LLM_INVALID_RESPONSE" to { ProductSnapshotException.untrustworthyValue() },
"IMAGE_UNSUPPORTED" to { ProductSnapshotException.untrustworthyValue() },
ExtractionErrorCode.UNTRUSTWORTHY_VALUE to { ProductSnapshotException.untrustworthyValue() },
ExtractionErrorCode.LLM_INVALID_RESPONSE to { ProductSnapshotException.untrustworthyValue() },
ExtractionErrorCode.IMAGE_UNSUPPORTED to { ProductSnapshotException.untrustworthyValue() },
// 우리 방어가 발동했다 — 정상 흐름이면 애초에 우리 경계(SSRF 가드·리다이렉트 제한)가 먼저 걸렀어야 한다.
"BLOCKED_HOST" to { ProductExtractorException.permanentFailure() },
"TOO_MANY_REDIRECTS" to { ProductExtractorException.permanentFailure() },
"MALFORMED_REDIRECT" to { ProductExtractorException.permanentFailure() },
ExtractionErrorCode.BLOCKED_HOST to { ProductExtractorException.permanentFailure() },
ExtractionErrorCode.TOO_MANY_REDIRECTS to { ProductExtractorException.permanentFailure() },
ExtractionErrorCode.MALFORMED_REDIRECT to { ProductExtractorException.permanentFailure() },
)

// 원격 추출 호출 한 건의 전부 — POST 부터 3갈래(2xx 매핑 / 422+code 확정 / 그 외 일시)가 이 함수 안에서 끝난다.
Expand Down Expand Up @@ -94,7 +95,7 @@ internal object RemoteExtractionContract {
}

// 2xx 응답 → ProductSnapshot. extractor 는 2xx 로 name·imageUrl·currentPrice 중 **하나 이상**을 보장한다
// (자기 쪽 ExtractionResponse.from 이 하나도 못 건졌을 때만 422 UNTRUSTWORTHY_VALUE 로 닫는다, extractor#37).
// (자기 쪽 응답 경계가 하나도 못 건졌을 때만 422 UNTRUSTWORTHY_VALUE 로 닫는다, extractor#37).
// 그래서 부분값 2xx 는 계약 위반이 아니라 정상 결과이고, 그것을 READY/INCOMPLETE/FAILED 로 가르는 판정은
// 경계가 아니라 도메인(ItemSnapshot.markExtracted, #944)이 쥔다 — 여기서 세 필드를 다 요구하면 부분값이
// INCOMPLETE 로 가는 길 자체가 닫혀 재시도 후 FAILED 로 끝난다(#950 이 고친 것이 정확히 그 상태다).
Expand Down Expand Up @@ -150,9 +151,9 @@ internal object RemoteExtractionContract {
log.warn("remote extract transient status={} {}", e.statusCode.value(), target)
return ProductExtractorException.transientFailure(e)
}
// 모르는 code 문자열은 파서가 무시해 UNSPECIFIED 로 읽힌다 — 그 이름은 표에 없으니 아래 fallback 으로 간다.
// 모르는 code 문자열은 파서가 무시해 UNSPECIFIED 로 읽힌다 — 표에 없으니 아래 fallback 으로 간다.
val code =
runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code?.name }
runCatching { e.getResponseBodyAs(ExtractionFailure::class.java)?.code }
.getOrNull()
// code 는 관측·디버깅용(계약 §1) — 응답엔 노출되지 않고 로그로만 남긴다. 확정 실패는 계약상 정상 결과라 info.
// 원문 code 는 이 줄에만 남는다: 메트릭은 bucket 단위라(카디널리티) 개별 code 추적은 로그가 진다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ class RemoteExtractionHttpClientConfig {
setReadTimeout(properties.readTimeoutMs)
},
).observationRegistry(observationRegistry)
// registerDefaults 를 빼면 Jackson 이 사라져 image·probe 요청이 "컨버터 없음"으로 일시 실패한다(실측).
.configureMessageConverters { it.registerDefaults().addCustomConverter(RemoteExtractionContract.messageConverter()) }
.build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ data class RemoteExtractionProperties(
// 기본값의 정본은 계약(extraction.proto 의 caller_read_timeout_seconds)이다. 전 경로가 같은 클라이언트·같은 값을
// 쓰고, extractor 는 자기 내부 예산이 이 값 미만인지 테스트로 대조한다.
// 한계: SimpleClientHttpRequestFactory 의 read timeout 은 per-read 소켓 타임아웃이라 총 소요시간의 상한은 아니다 —
// slow-drip 응답(read 마다 55s 미만 간격)은 이 가드를 지나 stale 을 넘길 수 있다. 그 경우에도 extractor 가
// slow-drip 응답(read 마다 타임아웃 미만 간격)은 이 가드를 지나 stale 을 넘길 수 있다. 그 경우에도 extractor 가
// 무상태라 중복 발주의 대가는 LLM 비용 1회로 바운드된다(상태 오염 없음).
val readTimeoutMs: Int = ExtractionEndpoints.CALLER_READ_TIMEOUT_MS,
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class ExtractionErrorCatalogTest {

@Test
fun `계약의 확정 실패 code 는 전수가 translate 의 명시 분기로 있다`() {
val expected = parsingPermanentCodes.map { it.name }.toSet()
val expected = parsingPermanentCodes.toSet()
val mapped = RemoteExtractionContract.PERMANENT_TRANSLATIONS.keys
val missing = expected - mapped
val unknown = mapped - expected
Expand All @@ -43,13 +43,13 @@ class ExtractionErrorCatalogTest {
if (missing.isNotEmpty()) {
appendLine(
"- 매핑 누락(모르는 code 용 fallback 으로 떨어져 internal_error 로 집계된다): " +
missing.sorted().joinToString(", "),
missing.map { it.name }.sorted().joinToString(", "),
)
}
if (unknown.isNotEmpty()) {
appendLine(
"- 계약에 없는 code 를 매핑하고 있다(오타이거나 계약에서 사라진 code): " +
unknown.sorted().joinToString(", "),
unknown.map { it.name }.sorted().joinToString(", "),
)
}
},
Expand All @@ -67,7 +67,7 @@ class ExtractionErrorCatalogTest {
if (bucket == Bucket.BUCKET_UNSPECIFIED) {
return@mapNotNull "${code.name}: 계약에 bucket 이 없다(확정 실패는 bucket 필수)"
}
val translate = RemoteExtractionContract.PERMANENT_TRANSLATIONS[code.name] ?: return@mapNotNull null
val translate = RemoteExtractionContract.PERMANENT_TRANSLATIONS[code] ?: return@mapNotNull null
val reason = ItemParsingMetrics.reasonOf(translate())
val expected = bucket.name.lowercase()
reason.takeIf { it != expected }?.let { "${code.name}: 계약 bucket=$expected 인데 메트릭 reason=$it 로 집계된다" }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.depromeet.piki.product.service.remote

import com.depromeet.piki.contracts.extraction.v1.ExtractionErrorCode
import org.junit.jupiter.api.Test
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
Expand Down Expand Up @@ -67,15 +68,15 @@ class HttpExtractionModelProbeTest {
fun `422 MODEL_NOT_FOUND 는 모델명을 확인하라는 사유로 거절한다`() {
assertEquals(
HttpExtractionModelProbe.MESSAGE_NOT_FOUND,
rejectionMessage("""{"code":"${HttpExtractionModelProbe.CODE_MODEL_NOT_FOUND}"}"""),
rejectionMessage("""{"code":"${ExtractionErrorCode.MODEL_NOT_FOUND.name}"}"""),
)
}

@Test
fun `422 MODEL_INCOMPATIBLE 은 다른 모델을 고르라는 사유로 거절한다`() {
assertEquals(
HttpExtractionModelProbe.MESSAGE_INCOMPATIBLE,
rejectionMessage("""{"code":"${HttpExtractionModelProbe.CODE_MODEL_INCOMPATIBLE}"}"""),
rejectionMessage("""{"code":"${ExtractionErrorCode.MODEL_INCOMPATIBLE.name}"}"""),
)
}

Expand Down
Loading