# shared-contract — 설계 결정 참조 스켈레톤 전역 운영 계약(operational contract) 모듈. 패키지 루트: `dev.caskeleton.shared`. 모듈 책임·허용/금지 의존(Java 표준 라이브러리 only)·테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다. 이 모듈은 한 가지 규칙을 끝까지 지킨다: **Java 표준 라이브러리만 쓴다.** Spring·Jackson·JPA· Micrometer·OTel 같은 프레임워크 타입을 import 하지 않는다. 그래서 모든 계층(application·web· persistence·outbound)이 프레임워크 충돌 없이 이 타입들을 공유할 수 있고, HTTP status 같은 전송 개념도 프레임워크 타입이 아니라 평범한 `int` 로 표현한다(매핑은 web 어댑터가 한다). `health/RedisHealthSnapshotProvider`도 같은 원칙을 따른다. Redis adapter는 native client나 예외를 노출하지 않고 bounded role/capability 상태만 제공하며, bootstrap이 이를 Actuator health로 변환한다. --- ## ratelimit — edge enforcement 계약 `EdgeRateLimitPort`는 inbound와 Redis adapter 사이의 provider-neutral edge enforcement 경계다. Business quota나 entitlement policy를 담는 application use case가 아니며, 이미 pseudonymized된 subject digest와 bounded policy ID/cost/deadline만 받는다. `RateLimitPolicy`는 fixed window, sliding-window counter, token bucket 중 하나의 exact parameter subtype을 고정하고 Lua의 `2^53-1` 안전 정수, 1일 window/refill/cleanup, 1시간 clock regression, `FAIL_CLOSED`만 허용한다. 결과는 evaluated, known pre-send unavailable, post-dispatch indeterminate, schema/program/reply incompatible로 분리하므로 adapter가 장애를 allow나 평범한 deny로 숨길 수 없다. 이 패키지는 Java 표준 라이브러리만 사용하며 Redis key, command, Lua, Spring/Lettuce 타입을 노출하지 않는다. --- ## messaging — generic envelope schema resource - `contracts/messaging/envelope/v1.schema.json` 은 비즈니스 필드를 모르는 공통 envelope v1의 저장소 소유 Draft 2020-12 리소스다. root와 aggregate metadata는 닫혀 있고, `payload`는 object 크기만 제한한다. 실제 payload 필드와 닫힘 정책은 feature owner의 별도 schema가 소유한다. - `.schema.sha256`은 schema 파일의 exact bytes SHA-256이다. 같은 version의 schema bytes를 바꾸면 digest도 의도적으로 갱신하고 compatibility 검토를 다시 해야 한다. - 이 단계의 테스트는 UTF-8, 정본 구조, digest, remote `$ref` 금지만 JDK로 검사한다. Task 6의 실제 Draft 2020-12 validator가 붙기 전까지 validator compatibility가 증명된 것은 아니다. - classpath resource가 존재한다는 사실은 runtime discovery나 자동 등록을 의미하지 않는다. contract catalog와 encoder가 명시적으로 조립되기 전에는 어떤 publisher도 이 리소스를 읽지 않는다. --- ## error — 에러 코드 계약 ### ApiErrorCode (인터페이스) - **클라이언트에 노출되는, 안정적이고 기계가 읽을 수 있는 에러 코드 계약.** 스켈레톤 공통 코드는 `OperationalError` 에 있고, 포크한 프로젝트는 이 인터페이스를 구현해(보통 enum) 자기 도메인 코드를 더한다. - **`httpStatus()` 가 프레임워크 타입이 아니라 평범한 `int` 인 이유:** 이 모듈을 프레임워크 중립으로 유지하기 위해서다(stdlib-only 규칙). 실제 전송 status 타입으로의 매핑은 web 어댑터가 한다. - **`retryable` 의 의미:** `true` 면 같은 입력이 나중에 성공할 수 있다는 뜻(일시적 인프라 장애 / rate limit). `false` 면 입력 자체를 바꿔야 한다. ### Category (enum) - **운영 에러 분류 10-value SSOT enum.** 응답에 `error.category` 로 노출되어, 클라이언트가 모든 코드를 일일이 열거하지 않고도 굵직하게 분기할 수 있게 해 준다. - **분류(identity)만 담는다.** 코드별 HTTP status·`retryable` 은 `ApiErrorCode` 구현과 `error-codes.yaml` 에 있지 여기 있지 않다 — `retryable` 은 **코드별** 값이며 category 에서 계산하지 않는다. ### OperationalError (enum) 스켈레톤 공통 운영/전송/보안 에러 코드의 집합이다. `USER_NOT_FOUND` 같은 **도메인 전용 코드는 여기 두지 않고** 그 코드를 쓰는 모듈에 둔다. - **출처/SSOT:** 모든 코드의 status/category/`retryable` 값은 레지스트리 `docs/registries/error-codes.yaml` 를 그대로 미러링한다. 코드 추가/변경은 레지스트리가 먼저다. **전송 형태(transport-shape) 코드의 분류 — 프로젝트 선택** - `METHOD_NOT_ALLOWED`(405), `UNSUPPORTED_MEDIA_TYPE`(415), `NOT_ACCEPTABLE`(406), `PAYLOAD_TOO_LARGE`(413), `URI_TOO_LONG`(414) 는 "요청 형태가 잘못됐다" 류의 전송 계층 에러다. 10-value enum 에는 이들을 위한 전용 category 가 없어, 가장 가까운 "클라이언트 요청 형태" 버킷인 `VALIDATION` 에 넣었다. 이를 강제하는 외부 표준은 없다(프로젝트 결정). - `PRECONDITION_FAILED`(412) 는 `If-Match` 검증 실패, 즉 낙관적 동시성 충돌의 HTTP 표현이다. 그래서 persistence 계층의 `DB_SERIALIZATION_FAILURE`/`DB_DEADLOCK` 와 같은 식구인 `CONFLICT` 로 분류한다. **보안 코드 — 굵은(coarse) 폴백 vs 세분화(fine-grained)** - `UNAUTHENTICATED`/`INVALID_TOKEN`/`FORBIDDEN` 은 굵은 폴백 코드다. 시큐리티 필터를 거치지 않는 경로(예: 컨트롤러에서 직접 던진 `AccessDeniedException`)를 위해 남겨 둔다. - `AUTH_*` 세분화 코드는 보안 운영 베이스라인의 AuthN/AuthZ 결정 매트릭스 구현이다. 굵은 3-way 매핑 대신 레지스트리가 선언한 세분화 코드를 쓰도록 정리한 것으로, 런타임에 리소스 서버의 AuthenticationEntryPoint / AccessDeniedHandler 가 방출한다. - `AUTH_KID_UNKNOWN` 은 `retryable=true`: 키 회전(key rotation) 중 알 수 없는 JWKS `kid` 는 키 세트가 새로고침되면 저절로 풀린다(레지스트리에서 `false→true` 로 바뀐 이력 있음, Retry-After 5s). - `AUTH_JWKS_UNAVAILABLE` 은 JWKS 엔드포인트 장애 = 일시적 의존성 실패 → 503, retryable. - `INTERNAL_AUTH_MISCONFIGURATION` 은 `retryable=false`: 보호돼야 할 엔드포인트가 public 으로 새어 나가는 것은 **배포 시점 설정 버그**이지 일시적 장애가 아니다. 같은 요청을 다시 보내도 (재배포 전까지) 절대 풀리지 않으므로, "INTERNAL 은 retryable" 이라는 일반 휴리스틱에서 의도적으로 벗어나 false 로 둔다. **rate-limit / idempotency** - `RATE_LIMIT_EXCEEDED` 는 `retryable=true` 이며 Retry-After 헤더와 짝을 이룬다. - `IDEMPOTENT_IN_FLIGHT`/`IDEMPOTENT_REQUEST_MISMATCH` 는 결정적인 클라이언트 결과(다시 poll 하거나 본문을 고쳐야 함)라 `retryable=false`. **ADAPTER_DISABLED — 어댑터 런타임 fail-fast** - 비활성(`app...enabled=false`, 기본값) 상태인 선택적 어댑터(Kafka/Redis/Slack/ Google Email)의 use-case 경로가 호출됐을 때 던지는 런타임 fail-fast 코드다(`AdapterDisabledException` 매핑 결과). - 기동 시점의 `REQUIRED_ADAPTER_DISABLED`(owner: 마이그레이션/기동 계약, exit code 72)와 **의도적으로 다른 코드**다. 런타임 호출과 기동 검증은 서로 다른 lifecycle 이라, 코드를 재사용하면 두 의미가 뭉개진다. `INTERNAL_AUTH_MISCONFIGURATION` 과 마찬가지로 결정적 설정/프로그래밍 버그이지 일시적 장애가 아니므로 `retryable=false` — 여전히 비활성인 어댑터를 다시 호출해도 풀리지 않는다. **persistence — SQLState → 코드 분류** - `DB_*` 코드는 adapter-persistence 의 `PersistenceExceptionTranslator` 가 raw Spring `DataAccessException`/SQLState 를 벗겨 낸 뒤 방출하는 프레임워크 중립 코드다. JPA 예외 자체는 web 계층까지 절대 도달하지 않는다. - **`PERSISTENCE` 라는 category 는 없다.** 10-value Category 가 SSOT 이므로 DB 실패도 기존 분류에 녹여 넣는다: 연결 끊김/타임아웃 → `TRANSIENT_DEPENDENCY`, 직렬화/데드락/유니크 → `CONFLICT`, null/FK/check → `DATA_INTEGRITY`. | 코드 | SQLState | category | HTTP | retryable | 메모 | |---|---|---|---|---|---| | `DB_UNAVAILABLE` | 08* | TRANSIENT_DEPENDENCY | 503 | ✅ | 연결 실패 | | `DB_SERIALIZATION_FAILURE` | 40001 | CONFLICT | 409 | ✅ | | | `DB_DEADLOCK` | 40P01 | CONFLICT | 409 | ✅ | backoff 후 재시도 | | `DB_NULL_VIOLATION` | 23502 | DATA_INTEGRITY | 409 | ❌ | | | `DB_FK_VIOLATION` | 23503 | DATA_INTEGRITY | 409 | ❌ | | | `DB_UNIQUE_VIOLATION` | 23505 | CONFLICT | 409 | ❌ | 비즈니스 매핑 | | `DB_CHECK_VIOLATION` | 23514 | DATA_INTEGRITY | 409 | ❌ | | | `DB_IDLE_IN_TX_TIMEOUT` | 25P03 | TRANSIENT_DEPENDENCY | 503 | ✅ | | | `DB_QUERY_CANCELED` | 57014 | TRANSIENT_DEPENDENCY | 503 | ❌ | | **outbound HTTP — upstream 실패 분류** - `DEPENDENCY_*` 코드는 adapter-outbound 의 `OutboundHttpErrorMapper` 가 upstream HTTP/네트워크 실패를 분류한 뒤 방출한다. raw upstream 응답은 web 계층까지 도달하지 않는다. - **알려진 미해결 엣지:** 408(Request Timeout)·429(Too Many Requests)는 의미상 재시도 가능하지만, 레지스트리 SSOT 는 **모든 upstream 4xx 를 `PERMANENT_DEPENDENCY`(`retryable=false`)로 분류**한다. 이는 의도적 결정이며, 바꾸려면 레지스트리 갱신과 어댑터 변경을 함께 해야 한다. | 코드 | category | HTTP | retryable | 트리거 | |---|---|---|---|---| | `DEPENDENCY_TIMEOUT` | TRANSIENT_DEPENDENCY | 504 | ✅ | upstream read/global-call timeout | | `DEPENDENCY_CONNECT_FAILED` | TRANSIENT_DEPENDENCY | 503 | ✅ | TCP connect 거부/타임아웃 | | `DEPENDENCY_DNS_FAILED` | TRANSIENT_DEPENDENCY | 503 | ✅ | 이름 해석 실패 | | `DEPENDENCY_4XX_CLIENT` | PERMANENT_DEPENDENCY | 502 | ❌ | upstream 이 요청 거부(4xx) | | `DEPENDENCY_5XX_SERVER` | TRANSIENT_DEPENDENCY | 502 | ✅ | upstream 서버 에러(5xx) | | `DEPENDENCY_CIRCUIT_OPEN` | TRANSIENT_DEPENDENCY | 503 | ✅ | 서킷 브레이커 open / shutdown 거부 fail-fast | **transactional outbox** - outbox 상태 머신: `PENDING → IN_FLIGHT → PUBLISHED | FAILED | DEAD`. - `OUTBOX_PUBLISH_FAILED`: 일시적 발행 실패 → row 가 FAILED 로 가고 backoff 재시도. 메시지 브로커/relay 가 일시적 의존성이라 `TRANSIENT_DEPENDENCY`, `retryable=true`(retry_after 30s). - `OUTBOX_DEAD_LETTER`: 최대 재시도 소진 → row 가 DEAD(DLQ)로. 수동 개입이 필요하고 같은 발행을 다시 해도 풀리지 않으므로 `retryable=false`(`INTERNAL_AUTH_MISCONFIGURATION`/`ADAPTER_DISABLED` 와 같은 논리: 결정적 종료 상태이지 일시적 장애가 아님). **background job / async executor** - 이 계약이 소유하는 재시도/DLQ 어휘이며 outbox/outbound 계약이 가져다 쓴다. - `JOB_EXECUTOR_REJECTED`: bounded executor 포화(AbortPolicy 거부). 스레드 풀+큐가 다 차서 생긴 일시적 용량 부족으로, 부하가 빠지면 풀린다 → `TRANSIENT_DEPENDENCY`/503, retryable(Retry-After 5s). - `JOB_TIMEOUT`: 실행 중 job 이 예산(19s graceful-shutdown interrupt 포함)을 초과 → 일시적, 다음 cycle 에 재시도 → `TRANSIENT_DEPENDENCY`/500, retryable. - `JOB_DEAD_LETTER`: 재시도 소진 → DLQ 종료 상태. 수동 개입 필요, 같은 job 을 다시 해도 풀리지 않음 → `INTERNAL`/500, `retryable=false`(`OUTBOX_DEAD_LETTER` 와 같은 논리). **distributed lock** - `LOCK_ACQUISITION_TIMEOUT`: 분산 락 provider(`JdbcLockRegistry`/in-process `LockRegistry`)가 제한된 `waitTime` 안에 락을 얻지 못했을 때 방출(무한 블로킹 없이 try-lock + 유한 waitTime, D5). - 일반 500 이 아니라 `CONFLICT`+`retryable=true` 인 이유: 락 경합은 일시적이다 — 보유자가 임계 구역을 떠나거나 lease TTL 이 만료되면 같은 요청이 락을 얻는다. `DB_DEADLOCK`/ `DB_SERIALIZATION_FAILURE` 와 같은 재시도 가능 `CONFLICT`(409) 식구다. 이건 효율용 락 타임아웃 (D6)이며, 정합성 자체는 이 코드가 아니라 DB 제약이 지킨다. **기타 운영 코드** - `JVM_OOM`: JVM OutOfMemoryError 분류. JVM 이 죽는 종료성 장애(`ExitOnOutOfMemoryError`, exit 137)라 재시도해도 안 풀림 → `retryable=false` (`INTERNAL`/500). 로그의 `error.code=JVM_OOM` 유무로 kubelet OOMKill 과 구별한다. - `ACTUATOR_FORBIDDEN`: 운영 환경에서 위험한 actuator 엔드포인트(env/configprops/heapdump/threaddump/shutdown) 접근 → 403(`AUTHZ`/403/false, 로그 WARN). ### MappingException - **경계 mapper 의 sentinel.** request→command/query 변환, response shaping, outbound ACL 등 어떤 경계 mapper 에서든 payload 가 구조적으로는 멀쩡한데 의미상 매핑이 불가능할 때 던진다. - web 어댑터의 기본 `GlobalExceptionHandler` 가 이 타입을 잡아 `MAPPING_FAILED`(400)로 보낸다 — `INTERNAL_ERROR` 로 새어 나가지 않게 하려는 것. - **`shared.error` 에 사는 이유:** 어떤 모듈(web/persistence/outbound ACL mapper)이든 cross-adapter 의존 없이 던질 수 있게 하기 위해서다. ### AdapterDisabledException - **integration-adapter-templates Layer 3 의 런타임 fail-fast sentinel.** 비활성 상태인 선택적 어댑터(Kafka/Redis/Slack/Google Email)의 use-case 경로가 호출되면 던진다. - **정상 경로에선 도달할 수 없다:** Layer 1(`@ConditionalOnProperty` bean-gating)이 비활성일 때 실제 어댑터 bean 을 아예 등록하지 않으므로 호출될 수 없다. 이 예외는 Layer 1/Layer 2 를 우회한 호출에 대한 **최후의 방어선**이다 — 조용한 no-op 이나 타임아웃 대기 없이 즉시 실패해서, 비활성 의존성이 상태를 오염시키거나 멈추게 두지 않고 바로 표면화한다. - web 어댑터가 이 타입을 `ADAPTER_DISABLED`(500, retryable=false)로 매핑한다 — 기동 시점의 `REQUIRED_ADAPTER_DISABLED` 와는 절대 섞지 않는 별개 런타임 코드. `shared.error` 거주 이유는 `MappingException` 과 동일하다. ### DependencyFailureException - **분류된 outbound HTTP(의존성) 실패의 프레임워크 중립 운반체**. adapter-outbound 의 `OutboundHttpErrorMapper` 가 raw 네트워크/HTTP 예외를 잡아 upstream 실패 매트릭스로 분류한 뒤, 안정적 `ApiErrorCode`(`DEPENDENCY_*`)와 upstream 의존성 이름을 담아 이 타입으로 감싸 다시 던진다. - **`diagnosticMessage` 는 서버 로그 전용이며 upstream raw 응답 본문을 절대 담아선 안 된다.** 테스트 계약: "upstream raw error body 가 response/log 에 노출되면 실패". web 어댑터는 `errorCode()` + 고정된 client-safe 메시지로 매핑하므로 upstream status/body/header/raw 예외 클래스가 API 클라이언트에 닿지 않는다. - **stdlib-only 유지:** `ApiErrorCode` 는 프레임워크 중립이고 cause 는 평범한 `Throwable` 이라 no-Spring 규칙을 지킨다. `adapter-outbound` 가 던지고 `adapter-web` 이 잡되 금지된 cross-adapter 의존을 만들지 않도록 `shared.error` 에 둔다. ### PersistenceFailureException - **분류된 persistence 실패의 프레임워크 중립 운반체**. adapter-persistence 의 `PersistenceExceptionTranslator` 가 raw Spring `DataAccessException` 을 잡아 SQLState 를 9행 매트릭스로 분류한 뒤, 안정적 `ApiErrorCode`(`DB_*`)만 담아 이 타입으로 감싸 다시 던진다. - raw `DataAccessException` 은 **서버 로그용 cause 로만** 보존한다. web 어댑터가 `errorCode()` + 고정 client-safe 메시지로 매핑하므로 SQLState·제약/인덱스 이름·SQL 조각·JPA/Spring 예외 클래스가 클라이언트에 닿지 않는다(D1, business-rule-validation C7/D9). - `shared.error` 거주·stdlib-only 이유는 `DependencyFailureException` 과 동일하다. --- ## response — 응답 envelope 계약 ### Envelope - **스켈레톤 공통 단일 아이템 응답 envelope.** 성공과 실패가 한 모양을 공유한다: 최상위 `success` 플래그, `data`(성공) 또는 `error`(실패) 필드, 그리고 request/trace/correlation id 를 담는 `meta`. `data`/`error` 중 정확히 하나만 non-null 이다. - **RFC 7807 ProblemDetail 을 대체한다**(boundary D5/D6). `meta` 객체는 예전의 평평한 `traceId` 필드를 대체하며, success/error 대칭은 유지하면서 요청 진단 정보를 더 풍부하게 담는다. ### ApiError - **`success=false` 일 때 Envelope 안에 들어가는 에러 payload.** - D5 가 RFC 7807 ProblemDetail 을 거부하고, D10 이 `category`(10-value Category enum)를 **1급 필드**로 추가했다 — 클라이언트가 모든 코드를 열거하지 않고도 굵게 분기할 수 있게. - 필드 의미: `code`(기계가독 식별자, 클라이언트는 `message` 가 아니라 이걸로 분기), `category`(굵은 운영 버킷), `message`(client-facing 사유 — stack trace·내부 ID 금지), `retryable`(운영 메타를 1급으로 끌어올림), `details`(코드별 polymorphic — VALIDATION 이면 field error, BATCH_PARTIAL_FAILURE 면 per-item 결과, 아니면 null). ### ResponseMeta - **envelope `meta` 객체로 노출되는 per-response correlation 메타**(D19/G2). camelCase JSON 이 wire form 이고, 로그/MDC form 은 snake_case(`request_id`/`trace_id`/`correlation_id`)이며, 그 변환(projection)은 adapter-web 에서 한다(이 모듈은 프레임워크 중립). - **D7: 실제 응답에서 `traceId` 는 절대 null 이 아니다** — tracing 이 꺼져 있으면 어댑터가 생성한 opaque id 를 채운다. `span_id` 는 로그 전용이라 여기 의도적으로 노출하지 않는다 (`mdc-keys.yaml` 에서 `envelope_field: null`). - `page`는 collection 응답에 pagination 메타를 싣고 단일 아이템 응답에선 null 이다. 3-arg 생성자는 단일 아이템 호출부를 그대로 두고, list 컨트롤러는 `withPage()` 로 pagination 을 붙인다. ### PageMeta - **envelope `meta.page` 로 노출되는 pagination 메타**(D7/D18). collection 응답에만 있고 단일 아이템 엔 null. - 필드: `number`(0-indexed 페이지 번호, Spring `Pageable` 과 동일, `0`=첫 페이지), `size`(1..100 cap 적용 후 실제 page size), `total`(전체 element 수 — 빈 collection 은 `0`, 이때 `data` 는 `[]` 이지 null 이 아님), `sort`(Spring native 형식 `"field,direction"`, 미정렬이면 null). - 프레임워크 중립: JSON 필드명은 camelCase(envelope wire SSOT), Jackson import 없음. ### BulkEnvelope - **bulk 엔드포인트 응답 envelope**(B8). `ApiError`/`ResponseMeta` 를 재사용하되 `data`(단일 값)를 `results`(리스트)로 바꾼다. - `success=true` 는 **모든** 아이템이 성공했을 때만이다. 하나라도 실패하면 → `success=false` + `error.code=BATCH_PARTIAL_FAILURE` + per-item `details`. `results` 는 전부 성공일 때만 non-null. ### BulkItemResult - **bulk 엔드포인트의 `error.details[]` 안에 들어가는 per-item 결과.** - 성공: `status="ok"` + `id`. 실패: `status="error"` + `code` + `message`. - 디버그 payload 를 두지 않아 envelope 를 로그/전송해도 안전하다(B8 + D3 마스킹). --- ## operation — 장기 실행 작업(LRO) 계약 ### Operation - **장기 실행 작업(long-running-operation) 폴링 본문**. `GET /v1/operations/{id}` 가 `Envelope` 의 `data` 로 반환한다. - 필드: `operationId`(opaque server id, 202 응답 `Location` 헤더의 마지막 segment 이기도 함, never null), `status`(`OperationStatus`, terminal 까지 클라이언트가 polling), `statusUrl`(self link), `result`(`SUCCEEDED` 일 때만, AIP151-C3), `error`(`FAILED` 일 때만, AIP151-C5, 스켈레톤 `ApiError` 모양 재사용). - 프레임워크 중립. id 발급과 202/Location 배선은 web 어댑터가 한다. ### OperationStatus - **LRO lifecycle status**(D17). - **프로젝트 선택:** 이 5-value 어휘는 Google AIP-151 의 `done`/`response`/`error` 이진 모델(AIP151-C4/C3/C5)을 프로젝트 내부용으로 투영한 것이다 — AIP-151 자체에는 enum 이 없다. 매핑: `PENDING`=접수됐으나 미시작, `RUNNING`=`done=false` 진행 중, `SUCCEEDED`=`done=true`+ `response`, `FAILED`=`done=true`+`error`, `CANCELLED`=`done=true`+취소. --- ## request — 요청 값 계약 ### Patch - **PATCH 커맨드 필드용 스켈레톤 공통 3-state 값**(B2). 한 필드에 대한 호출자의 의도를 표현한다: `absent()`=필드 생략(변경 없음), `ofNull()`=명시적 `null`(값 비우기), `of(value)`=값으로 교체. - web 어댑터가 JSON `JsonNullable`(Jackson 을 아는, openapi-generator 산출물)를 이 Jackson-free 타입으로 매핑한 뒤 use case 를 호출한다 → `application-core` 는 wire 표현을 절대 보지 않는다. --- ## security — 권한 계약 ### Permission - **`resource:action` 으로 이름 붙인 권한 집행 단위**. - **문법:** 2-segment, 소문자, colon 으로 구분(예: `worklog:close`). AWS IAM 의 `service:Action` 관례(IAM-NAMING-C1)와 Curity 의 `resource:action` 산업 관행(CURITY-SCOPE-C2)을 따른다. - **점(dot) 형태 `service.resource.verb`(Google IAM)를 거부하는 이유:** Java 패키지명과 시각적으로 헷갈리고, 이 단일 서비스 스켈레톤엔 필요 없는 service prefix 를 중복시키기 때문이다. 멀티 서비스용 `service:resource:action` 문법은 의도적인 향후 확장이라, 3-segment 값을 오늘 조용히 받아들이지 않고 명시적으로 거부한다. - `AuthorizationPort` 계약이 소비하는 as-built 값 객체다. `shared-contract`(Java-only, 프레임워크 자유)에 두어, application 계층은 Spring Security 타입 없이 계약을 표현하고 web 어댑터는 같은 타입으로 role→permission 을 해석한다. --- ## metrics — 메트릭/알림 계약 > 이 패키지의 구체 수치(cardinality 상한, P1/P2/P3 임계치 등)는 대부분 > **프로젝트 선택** — 외부 표준에서 유도한 게 아니라 이 스켈레톤이 합의한 운영 가정이며, > 트래픽 패턴/SLO 데이터에 따라 개정 대상인 "문서화된 기본값"이다. 모든 클래스는 stdlib-only(Spring/ > Jackson/Micrometer 의존 없음). ### MetricNaming - **Micrometer dot.case 메트릭 이름의 작명/단위 접미사 규칙**(D2/D3 + §3). - **이름 규칙(D2):** 소문자 영문+숫자만, 단일 점으로 segment 구분, 각 segment 는 영문자로 시작 (예: `http.server.requests`). 정확한 규칙은 `VALID_NAME_PATTERN` 정규식에 있다. - **단위 접미사(D2):** 이름의 마지막 segment 로 `seconds`/`bytes`/`total` 중 하나를 붙인다 (timer=seconds, byte 게이지=bytes, counter=total). 이 집합 밖의 접미사는 금지. - **Prometheus base-name 변환(§3):** dot.case 이름의 `.` 을 `_` 로 바꾼다 (`http.server.requests` → `http_server_requests`). - **INFERENCE / 운영 메모:** Spring Boot 3 Prometheus exporter 는 런타임에 추가 접미사 (`_seconds_bucket`/`_count`/`_sum` 등)를 더 붙인다 — 그건 exporter 의 소관이고 이 base-name 변환의 범위 밖이다. 실제 방출되는 이름은 `/actuator/prometheus` 로 검증할 것. ### CardinalityBounds - **스켈레톤에 등록되는 메트릭의 tag 별 cardinality 상한**(§Cardinality Bounds 표의 Java mirror). `limitFor(key)` 로 magic number 하드코딩 없이 상한을 조회한다(MeterFilter 설정, cardinality 계약 테스트). - 상수값(프로젝트 선택): `STATUS_CODE`=7(1xx~5xx + ok/other), `URI_TEMPLATE`=200 (라우트는 `/users/{id}` 처럼 템플릿 정규화 필수, raw path 는 `ForbiddenMetricTags` 로 금지), `DEPENDENCY_NAME`=50, `ERROR_CODE`=100, `TENANT_ID`=1000, `RESILIENCE4J_OUTCOME`=5 (SUCCESS/FAILURE/CIRCUIT_OPEN/TIMEOUT/REJECTED). - **`ERROR_CODE`=100 은 `error-codes.yaml` 의 행 수 상한과 동기화한다.** 레지스트리가 100행을 넘으면 이 상수와 레지스트리 메모를 함께 갱신해야 한다. - **`TENANT_ID`=1000:** 메트릭 라벨은 bounded mapping-table ID 나 cohort bucket 을 써야 한다 — raw UUID tenant 식별자는 라벨로 금지. 1001번째 tenant 부터는 bucket folding 이 자동 적용된다. - **tag-key 매핑 quirk 2가지**(`limitFor` 에서 명시 처리): (1) HTTP 메트릭 레지스트리 행의 tag 이름은 `status_code` 가 아니라 `status` 다 — 혼동을 막으려 `status`/`status_code` 둘 다 `STATUS_CODE`(7)로 매핑. (2) Resilience4j bound 는 `outcome` tag(5값)에 적용 — 상수명은 명료성을 위해 `RESILIENCE4J_OUTCOME` 이지만 lookup 키는 레지스트리 tag 이름인 `outcome` 다. 다른 메트릭의 `outcome` tag 는 실제 cardinality 가 다를 수 있으나(db 3, outbox 4), lookup 은 보수적 출발점으로 Resilience4j bound 를 돌려준다. ### ForbiddenMetricTags - **스켈레톤 전역에서 금지된 high-cardinality 메트릭 tag 키**(D8): `user_id`, `request_id`, `raw_url`, `raw_query`, `raw_header_value`, `ip_address`. - **왜 금지하나(D8):** label key-value 조합 하나마다 Prometheus 에 새 time series 가 생긴다. user 식별자·raw URL·IP 처럼 **무한히 늘어나는(unbounded)** 값을 tag 로 쓰면 time series 가 수백만 개로 폭증해 애플리케이션과 Prometheus 서버 메모리를 잡아먹는다. 근거: Prometheus 공식 best practice (PROM-CARD-C1/C2 — "모든 unique label 조합 = 새 time series"; user ID/email/unbounded set 을 명시), Micrometer `HighCardinalityTagsDetector`(MM-HCARD-C1/C2 — `userId`/`requestId`/`traceId` 가 대표 예시). - **`request_id` 가 여기선 금지인데 baggage 엔 허용인 이유(의도적 비대칭):** `request_id` 는 `BaggageAllowlist.ALLOWED` 에 있다 — 분산 추적 correlation 을 위한 정당한 W3C baggage 키이기 때문이다. 하지만 **메트릭 라벨**로 쓰면 요청당 time series 1개씩, 수백만 개가 된다. 요청별 correlation 은 메트릭 tag 가 아니라 분산 추적(trace ID/exemplar)으로 해야 한다. 나중에 읽는 사람이 이걸 "고친다"며 forbidden 목록에서 `request_id` 를 빼지 말 것 — 비대칭은 의도적이다. - **프로젝트 선택:** 명백한 user/request ID 예시(PROM-CARD-C2, MM-HCARD-C2) 외의 멤버(`raw_url`/`raw_query`/`raw_header_value`/`ip_address`)는 "unbounded set" 원칙을 HTTP 특유의 출처에 적용한 운영 가정이다. 이를 명시적으로 열거하는 외부 표준은 없다. ### AlertSeverity - **메트릭 계약의 알림 심각도 분류**(D7 / §P1/P2/P3). `key()` 가 돌려주는 소문자 형태(`p1`/`p2`/`p3`)가 레지스트리 `alert_severity_thresholds` 키와 일치하며, `fromKey()` 의 정규 입력이다(대소문자 무시). - **임계치 수치는 provisional, 프로젝트 선택** — 이 스켈레톤이 합의한 운영 가정이지 외부 표준이 아니다. 정식 SLO 채택 시 재검토할 것(D5 burn-rate migration path). - **P1**(릴리스 차단): error rate >5% 5분 OR >10% 1분; p99 latency >5s 5분; 필수 의존성 unavailable >2분. - **P2**(on-call 즉시 대응): error rate >1% 10분; p99 latency >1s 10분; 선택 의존성 degraded >5분. - **P3**(업무 시간 대응): error rate >0.1% 1시간; p99 latency >500ms 30분; 평소의 10× spike. --- ## tracing — 분산 추적 계약 > tracing 패키지는 **OTel/Micrometer 런타임이 포크에서 활성화되는 seam**이라는 전제로 설계됐다 >. 그래서 계약 타입 자체는 stdlib-only(Spring/OTel/ > Micrometer 의존 없음)로 두어, adapter-web·adapter-outbound 가 tracer 라이브러리에 컴파일타임으로 > 묶이지 않고도 헤더 모양/기록 seam 에 의존할 수 있게 한다. ### TraceParent - **W3C `traceparent` 헤더의 불변(immutable) 값 타입**(D5/D7). 형식: `00-<32자리 소문자 hex>-<16자리 소문자 hex>-<2자리 소문자 hex>`. - **strict W3C 검증**(`parse`/`of` 가 적용): version=`00`, 정확히 4개의 dash 구분 필드, trace-id 는 32자리 소문자 hex 이며 all-zero 금지, parent-id(span-id)는 16자리 소문자 hex 이며 all-zero 금지, trace-flags 는 2자리 소문자 hex(값 자유, `sampled` = 최하위 비트). - **실패 처리 정책:** `parse` 는 위반 시 `Optional.empty()` 를 돌려줄 뿐 예외를 던지지 않는다. `of` 는 invalid 입력이면 예외를 던진다. silent normalization 은 하지 않으므로 호출자가 소문자로 넣어야 한다. ### BaggageAllowlist - **스켈레톤의 W3C/OTel baggage allowlist**(D2/D8). - **D8:** W3C `baggage` 헤더에는 `tenant_id` 와 `request_id` 만 허용한다. 그 밖의 모든 키는 downstream 으로 전파하기 전에 제거한다 — W3C Baggage spec §4.1 의 trust-boundary 규칙과 OTel Baggage API 의 "untrusted process" 제거 의무(D2)에 따른 것. ### SpanErrorRecorder - **tracer 라이브러리에 결합하지 않고 span error 를 기록하기 위한 D12 seam.** adapter-web (`GlobalExceptionHandler`)·adapter-outbound 가 컴파일타임 tracer 의존 없이 span error 를 일관되게 기록할 수 있게 한다. - **`NOOP` 기본값:** tracer 가 classpath 에 없는 스켈레톤 기본 상태를 위한 no-op 구현. Micrometer Tracing 을 활성화하는 포크는 `app-bootstrap` composition root 에서 이 bean 을 교체한다. - **D12 구현 계약 (실제 tracer 를 배선하는 포크가 지켜야 할 것):** 1. `Observation.error(throwable)` 또는 동등한 OTel `span.recordException(throwable)` 을 호출해 error lifecycle event 를 내보내고 예외를 현재 span 에 붙인다. 2. span status 를 별도 호출 `span.setStatus(StatusCode.ERROR)` 로 ERROR 로 만든다 — OTel Trace API 명세(OTEL-TAPI-C4)상 `recordException` 은 AddEvent 만 하지 status 를 바꾸지 않는다. 3. `error.code` span attribute 를 전달받은 `errorCode` 문자열로 붙인다. 주의: `error.code` 는 ca-tmpl 레지스트리 attribute 이름이지, OTel semantic-convention 의 `exception.type`/ `exception.message`/`exception.stacktrace` 가 아니다. 4. 전체 stack trace 는 현재 span 이 sampled 일 때만 붙인다. unsampled span 에는 `error.code` attribute 만 붙이고 stack trace 부착은 금지 — cardinality/데이터 볼륨 오버헤드를 피하기 위한 ca-tmpl 운영 결정(D12 프로젝트 선택, 외부 spec 근거 없음). --- ## concurrency — 런타임 컨텍스트 전파 계약 도메인/비즈니스 컨텍스트(예: 비즈니스 식별자)를 virtual-thread 와 `StructuredTaskScope.fork()` 경계 너머로 전파하는 계약이다. ### 설계 골격 — "Default + 교체 가능한 추상화" (rate-limit 패턴) 동작하는 기본 구현을 제공하되 strategy seam 뒤에 두어, 호출부를 건드리지 않고 교체/대체할 수 있게 한다. `RateLimiter` / `RateLimitAlgorithm` / `RateLimiterFactory` 와 정확히 같은 구조다: - **`DomainContextPropagator`** — port(인터페이스). - **`ThreadLocalDomainContextPropagator`** — 기본 구현(plain `ThreadLocal`, 명시적 capture/restore, virtual-thread 안전, `InheritableThreadLocal` 미사용). - **`DomainContextStrategy`** — 선택 가능한 strategy enum(기본 `THREAD_LOCAL`; `MICROMETER`/ `SCOPED_VALUE` 는 주석으로만 남긴 향후 strategy). - **`DomainContextPropagatorFactory`** — 유일한 확장점(단일 `switch`). - **`DomainContextKey`** — 도메인이 공급하는 per-value 확장점(키), `RateLimitKeyResolver` 와 유사. Spring 배선(`DomainContextSettings` + `DomainContextConfig`, `ca-skeleton.domain-context.strategy` 바인딩)은 `app-bootstrap` 에 있고, 설정이 없으면 strategy 는 `THREAD_LOCAL` 로 기본 동작한다. ### 무엇을 일부러 ship 하지 않았나 (그리고 왜) - **도메인 키:** 스켈레톤엔 도메인이 없으므로 `DomainContextKey` 상수를 ship 하지 않고, 기본적으로 흐르는 값도 없다. 도메인은 필요해질 때 자기 키를 선언한다(그 전까지 seam 은 관측 가능한 동작이 없다 — route 가 없는 `RateLimitKeyResolver` 와 같다). - **`ScopedValue`/`StructuredTaskScope` strategy:** Java 21 preview API 이고 빌드에 `--enable-preview` 가 없어(빌드 사실 C1) 운영에서 컴파일되지 않는다. `domain_context_propagation_primitives_stay_unshipped` ArchUnit 규칙(app-bootstrap `CleanArchitectureTest`)이, strategy seam 을 통해 활성화되기 전까지 이를 운영 코드에서 배제한다. ### 경계 → 메커니즘 위임 맵 (S1, 참고용) 각 경계의 전파는 형제 계약이 소유한다. 이 계약은 **도메인 컨텍스트** 핸드오프와 통합 view 만 소유한다: | 경계 | 메커니즘 | 소유자 | |---|---|---| | inbound HTTP 필터 | `request_id`/`correlation_id`/`trace_id` MDC(SLF4J 2.0+) | B6(boundary-validation) | | outbound HTTP/메시지 | W3C `traceparent`/`tracestate` + baggage | distributed-tracing D5/D7/D8 | | `@Async` `ThreadPoolTaskExecutor` | `TaskDecorator` 의 MDC 4-key 복사(planned) | background-job D5/D6 | | virtual-thread carrier | 진단용 MDC, `InheritableThreadLocal` 금지 | B6 | | `StructuredTaskScope.fork()`/스레드 핸드오프 | 이 패키지 propagator 통한 **도메인 컨텍스트** | 여기서 소유(S2/S3) | > `@Async` 경계는 background-job 이 executor 배선을 소유하되, 그 `TaskDecorator` 가 > `DomainContextPropagator.wrap(Runnable)` 도 호출해 MDC 복사와 함께 도메인 컨텍스트도 운반해야 한다. > 이 계약은 seam 만 제공한다. ### DomainContextPropagator (port) - 도메인/비즈니스 컨텍스트를 스레드·`fork()` 경계 너머 전파하는 **strategy seam**(S2/S3). 애플리케이션 코드는 이 인터페이스만 의존하고, Factory 가 설정에서 구체 strategy 를 고른다. strategy 추가 = "새 impl + enum 값 1개 + factory case 1개", 호출부 변경 없음. - **진단(diagnostic) 컨텍스트는 여기 두지 않는다.** `request_id`/`trace_id`/`correlation_id` 는 MDC 에 살고 inbound 필터가 virtual thread 에서 전파한다(B6 + D11). 이 propagator 는 **도메인 채널** — 별개의 관심사다. - **명시적 핸드오프(S3):** 구현은 스레드 간 암묵적 상속에 의존해선 안 된다(`InheritableThreadLocal` 은 `no_inheritable_thread_local` ArchUnit 규칙으로 금지). 스레드/`fork()` 경계를 넘는 코드는 `wrap()` 또는 `capture()`+`restore()` 로 컨텍스트를 명시적으로 재확립한다. ### DomainContextStrategy (enum) - `THREAD_LOCAL` 이 기본이자 현재 ship 된 유일한 값이다. 값을 추가하려면 enum 값 + `DomainContextPropagator` 구현 + Factory case 를 함께 더한다(`RateLimitAlgorithm` 패턴). - **`THREAD_LOCAL`:** plain `ThreadLocal` + 명시적 capture/restore. virtual-thread 안전(VT 마다 자기 copy), `no_inheritable_thread_local` 금지 준수(`InheritableThreadLocal` 아님), 추가 의존성 0. - **향후 strategy(주석으로만 존재):** - `MICROMETER` — `io.micrometer:context-propagation` 의 `ContextSnapshot`/`ContextRegistry`. 안정적 API 이고 MDC/tracing 과 같은 채널에 통합된다. 다중 키나 Reactor bridge 가 필요할 때 고른다 (C7: 활성화 전에 virtual-thread 동작을 확인할 것). - `SCOPED_VALUE` — Java 21 `ScopedValue`. 불변이며 `StructuredTaskScope` 안에서 자동 상속된다. PREVIEW: `--enable-preview` 가 필요한데 현재 빌드는 켜지 않는다(C1) → `domain_context_propagation_primitives_stay_unshipped` ArchUnit 규칙으로 guard 된다. ### DomainContextPropagatorFactory - 설정된 strategy 를 만들어 주는 곳(S2). 내부의 단일 `switch` 가 유일한 확장점이다 — strategy 추가 = enum 값 + 구현 + case, 호출부는 그대로. `RateLimiterFactory` 와 같은 모양. ### ThreadLocalDomainContextPropagator (기본 구현) - plain `ThreadLocal` 이 현재 스레드의 도메인 컨텍스트 맵을 들고, 스레드 경계는 명시적 capture/restore 로 넘긴다(D4/D6). - **왜 plain `ThreadLocal` 인가:** `InheritableThreadLocal` 이 아니라서 pooled carrier 스레드 너머로 조용히 새지 않고 `no_inheritable_thread_local` ArchUnit 규칙(B6)을 지킨다. virtual thread 는 각자 자기 copy 를 갖는다(Oracle Java 21 docs, TL-VT-C1) → 같은 스레드 read 는 동작하지만 fork 시 상속은 없다 — 그래서 핸드오프가 `wrap()`/`capture()` 로 **명시적**이어야 하는 것이다. - multi-key 고빈도 변경이나 Reactor bridging 용이 아니다 — 그건 `MICROMETER` strategy 의 일이다. 이 기본 구현은 흔한 경우(작은 비즈니스 식별자 집합을 명시적 async 핸드오프 너머 운반)를 노린다. ### DomainContextKey - 단일 도메인/비즈니스 컨텍스트 값에 대한 typed·named 키(S2). **확장점**이다: 비즈니스 식별자를 async/fork 경계 너머 운반해야 하는 도메인이 `DomainContextKey` 상수 하나를 선언하고 `Propagator` 를 통해 read/write 한다 — 프로젝트가 `RateLimitKeyResolver` 를 공급하는 것과 비슷하다. 스켈레톤은 메커니즘을 ship 하고, 도메인은 키를 공급한다. - identity 는 `name` 뿐이다 → 같은 이름의 두 키는 같은 slot 을 가리킨다(`type` 은 read 시 구분용). 키는 `static final` 상수로 두라는 의도이며, 요청마다 새로 만들지 않는다. ### DomainContextSnapshot - 스레드/`fork()` 경계 너머 명시적 핸드오프를 위한, 한 스레드 도메인 컨텍스트의 불변 capture(S3). Micrometer Context Propagation 의 `ContextSnapshot` capture/restore 모양을 본떴다. - `restore()` 는 반드시 try-with-resources 와 함께 써서 worker 스레드가 오염된 채 남지 않게 한다.