Files
clean-architecture-backend-…/src/shared-contract/README.md
T
donghyeon-ka bbccccc195 merge: integrate messaging R2 polling producer
# Conflicts:
#	docs/superpowers/specs/2026-07-28-messaging-production-capability-design.md
#	src/app-bootstrap/gradle.lockfile
#	src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/ArchitectureViolationFixtureTest.java
#	src/build.gradle
2026-08-01 00:04:02 +09:00

523 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.<domain>.<adapter>.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<T>`(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 스레드가 오염된 채 남지 않게 한다.