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

38 KiB
Raw Blame History

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·retryableApiErrorCode 구현과 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_UNKNOWNretryable=true: 키 회전(key rotation) 중 알 수 없는 JWKS kid 는 키 세트가 새로고침되면 저절로 풀린다(레지스트리에서 false→true 로 바뀐 이력 있음, Retry-After 5s).
  • AUTH_JWKS_UNAVAILABLE 은 JWKS 엔드포인트 장애 = 일시적 의존성 실패 → 503, retryable.
  • INTERNAL_AUTH_MISCONFIGURATIONretryable=false: 보호돼야 할 엔드포인트가 public 으로 새어 나가는 것은 배포 시점 설정 버그이지 일시적 장애가 아니다. 같은 요청을 다시 보내도 (재배포 전까지) 절대 풀리지 않으므로, "INTERNAL 은 retryable" 이라는 일반 휴리스틱에서 의도적으로 벗어나 false 로 둔다.

rate-limit / idempotency

  • RATE_LIMIT_EXCEEDEDretryable=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}Envelopedata 로 반환한다.
  • 필드: 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.requestshttp_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_idBaggageAllowlist.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_idrequest_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 배선을 소유하되, 그 TaskDecoratorDomainContextPropagator.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): 구현은 스레드 간 암묵적 상속에 의존해선 안 된다(InheritableThreadLocalno_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(주석으로만 존재):
    • MICROMETERio.micrometer:context-propagationContextSnapshot/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 스레드가 오염된 채 남지 않게 한다.