38 KiB
shared-contract — 설계 결정 참조
스켈레톤 전역 운영 계약(operational contract) 모듈. 패키지 루트: dev.caskeleton.shared.
모듈 책임·허용/금지 의존(Java 표준 라이브러리 only)·테스트 명령 같은 모듈 규칙은 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) 중 알 수 없는 JWKSkid는 키 세트가 새로고침되면 저절로 풀린다(레지스트리에서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 SpringDataAccessException/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-processLockRegistry)가 제한된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(
@ConditionalOnPropertybean-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 SpringDataAccessException을 잡아 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 페이지 번호, SpringPageable과 동일,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-itemdetails.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 는outcometag(5값)에 적용 — 상수명은 명료성을 위해RESILIENCE4J_OUTCOME이지만 lookup 키는 레지스트리 tag 이름인outcome다. 다른 메트릭의outcometag 는 실제 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-bootstrapcomposition root 에서 이 bean 을 교체한다.- D12 구현 계약 (실제 tracer 를 배선하는 포크가 지켜야 할 것):
Observation.error(throwable)또는 동등한 OTelspan.recordException(throwable)을 호출해 error lifecycle event 를 내보내고 예외를 현재 span 에 붙인다.- span status 를 별도 호출
span.setStatus(StatusCode.ERROR)로 ERROR 로 만든다 — OTel Trace API 명세(OTEL-TAPI-C4)상recordException은 AddEvent 만 하지 status 를 바꾸지 않는다. error.codespan attribute 를 전달받은errorCode문자열로 붙인다. 주의:error.code는 ca-tmpl 레지스트리 attribute 이름이지, OTel semantic-convention 의exception.type/exception.message/exception.stacktrace가 아니다.- 전체 stack trace 는 현재 span 이 sampled 일 때만 붙인다. unsampled span 에는
error.codeattribute 만 붙이고 stack trace 부착은 금지 — cardinality/데이터 볼륨 오버헤드를 피하기 위한 ca-tmpl 운영 결정(D12 프로젝트 선택, 외부 spec 근거 없음).
concurrency — 런타임 컨텍스트 전파 계약
도메인/비즈니스 컨텍스트(예: 비즈니스 식별자)를
virtual-thread 와 StructuredTaskScope.fork() 경계 너머로 전파하는 계약이다.
설계 골격 — "Default + 교체 가능한 추상화" (rate-limit 패턴)
동작하는 기본 구현을 제공하되 strategy seam 뒤에 두어, 호출부를 건드리지 않고 교체/대체할 수 있게 한다.
RateLimiter / RateLimitAlgorithm / RateLimiterFactory 와 정확히 같은 구조다:
DomainContextPropagator— port(인터페이스).ThreadLocalDomainContextPropagator— 기본 구현(plainThreadLocal, 명시적 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/StructuredTaskScopestrategy: Java 21 preview API 이고 빌드에--enable-preview가 없어(빌드 사실 C1) 운영에서 컴파일되지 않는다.domain_context_propagation_primitives_stay_unshippedArchUnit 규칙(app-bootstrapCleanArchitectureTest)이, 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_localArchUnit 규칙으로 금지). 스레드/fork()경계를 넘는 코드는wrap()또는capture()+restore()로 컨텍스트를 명시적으로 재확립한다.
DomainContextStrategy (enum)
THREAD_LOCAL이 기본이자 현재 ship 된 유일한 값이다. 값을 추가하려면 enum 값 +DomainContextPropagator구현 + Factory case 를 함께 더한다(RateLimitAlgorithm패턴).THREAD_LOCAL: plainThreadLocal+ 명시적 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 21ScopedValue. 불변이며StructuredTaskScope안에서 자동 상속된다. PREVIEW:--enable-preview가 필요한데 현재 빌드는 켜지 않는다(C1) →domain_context_propagation_primitives_stay_unshippedArchUnit 규칙으로 guard 된다.
DomainContextPropagatorFactory
- 설정된 strategy 를 만들어 주는 곳(S2). 내부의 단일
switch가 유일한 확장점이다 — strategy 추가 = enum 값 + 구현 + case, 호출부는 그대로.RateLimiterFactory와 같은 모양.
ThreadLocalDomainContextPropagator (기본 구현)
- plain
ThreadLocal이 현재 스레드의 도메인 컨텍스트 맵을 들고, 스레드 경계는 명시적 capture/restore 로 넘긴다(D4/D6). - 왜 plain
ThreadLocal인가:InheritableThreadLocal이 아니라서 pooled carrier 스레드 너머로 조용히 새지 않고no_inheritable_thread_localArchUnit 규칙(B6)을 지킨다. virtual thread 는 각자 자기 copy 를 갖는다(Oracle Java 21 docs, TL-VT-C1) → 같은 스레드 read 는 동작하지만 fork 시 상속은 없다 — 그래서 핸드오프가wrap()/capture()로 명시적이어야 하는 것이다. - multi-key 고빈도 변경이나 Reactor bridging 용이 아니다 — 그건
MICROMETERstrategy 의 일이다. 이 기본 구현은 흔한 경우(작은 비즈니스 식별자 집합을 명시적 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 의ContextSnapshotcapture/restore 모양을 본떴다. restore()는 반드시 try-with-resources 와 함께 써서 worker 스레드가 오염된 채 남지 않게 한다.