chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
# 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 스레드가 오염된 채 남지 않게 한다.
|
||||
Reference in New Issue
Block a user