Files
tech-log-backend/src/adapter/inbound/web/README.md
T

463 lines
34 KiB
Markdown

# adapter-web — 설계 결정 참조
인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: `dev.caskeleton.adapter.web`.
허용/금지 의존, 경계 계약(B1~B8), 스키마/직렬화 계약(S1~S5), 비즈니스 규칙 검증 계약(C1~C8),
테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서
덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할
때 본다. 아래 설명은 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로
풀어 둔다.
---
## OpenAPI contract stabilization
Springdoc 3 represents an untyped Java `Object` as an unconstrained OAS 3.1 schema (`{}`).
`OpenApiContractConfig` owns the transport-specific correction for the shared `ApiError.details`
field and publishes it as `type: object`. This preserves the committed HTTP contract without adding
Swagger annotations or dependencies to `shared-contract`. Real-server OpenAPI tests import this
production configuration and compare the result with the committed snapshot.
---
## auth — 인증 (OIDC resource server)
### SecurityConfig
- **Spring Security 기본 Cache-Control writer 비활성화.** 이 모듈이
HTTP cache 헤더 정책을 소유한다(`CacheControlFilter``Cache-Control: no-store` + `Vary` 방출).
헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다.
- **AuthN/AuthZ 분류기를 `exceptionHandling``oauth2ResourceServer` 양쪽에 설정.** entry point 는
missing-token(authorization-layer)과 invalid-token(bearer-filter-layer) 실패를, access-denied
handler 는 403 을 담당한다. 두 곳 모두에 설정해야 bearer filter 와 authorization filter 가 동일한
Envelope writer 로 귀결된다.
### JwtDecoderConfig
- Spring Boot auto-config decoder 를 대체해 validator chain 을 기본값 의존이 아닌 **명시적 구성**으로 만든다.
- **D2 — clock skew 60s 명시 고정**(`JwtTimestampValidator`). framework 기본값에 의존하면 Spring
업그레이드로 기본값이 바뀔 때 silent-drift 위험이 있어 여기서 못박는다.
- **D4 — issuer 검증**(`SecuritySettings.issuerUri()`). **D3 — audience 검증**(`SecuritySettings.audience()`),
단 blank audience 면 검사 건너뜀(기존 settings 계약과 일치).
- **JWKS lazy discovery** (`SupplierJwtDecoder`): 기동 시 IdP 가 reachable 일 필요가 없고, 첫 decode
시점에 issuer-uri/`.well-known` 네트워크 호출이 일어난다(Spring Boot auto-config 와 동일한 lazy 동작).
lazy 초기화 중 외부 discovery/JWKS I/O 실패는 필터 밖 runtime exception으로 탈출시키지 않고
`AUTH_JWKS_UNAVAILABLE`로 분류하며, 비-I/O 초기화 실패는 `INTERNAL_AUTH_MISCONFIGURATION`으로
fail-closed 한다. 두 carrier 모두 고정 진단만 가지며 원격 응답/URL은 공개 응답에 넣지 않는다.
- **Minimal 결정**: JWKS cache TTL 과 unknown-kid rate-limit 은 override 하지 않는다. 정확한 수치는
IdP-side token TTL 에 달린 NEEDS_CONTEXT 라 Nimbus/Spring 기본값을 쓰고 문서로만 남긴다.
- `jwtValidator` 가 package-private + static 인 이유: 네트워크/IdP 의존 없이 단위 테스트 가능하게 하려고.
- audience validator 의 오류 description(`"The aud claim is not valid"`)은 `SecurityErrorClassifier`
"aud claim" 휴리스틱과 매칭되어 `AUTH_AUDIENCE_MISMATCH` 로 분류되도록 **의도적으로 맞춘 문자열 계약**이다.
### JwtToAuthenticatedPrincipalConverter
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
`ObjectOutputStream` 으로 round-trip 되지 않는다. Redis session mode에서도 아래 primitive snapshot
repository가 `Authentication` 객체 그래프를 저장하지 않는다.
Serializable 이 아닌 Spring Security `Authentication` 토큰 필드의 관례적 해결책이 transient 표시다.
### JWT / Redis session 상호배타 모드
`ca-skeleton.security.auth-mode=jwt|redis-session`은 하나만 선택한다. JWT mode는 stateless이고
CSRF/session repository를 만들지 않는다. Redis session mode는 `Secure`, `HttpOnly`, host-only
session cookie, `SameSite=Lax`, cookie/header CSRF와 `migrateSession` fixation 방어를 함께 켠다.
기본 `HttpSessionSecurityContextRepository`는 Spring Security 객체 전체를 session attribute에 넣어
outbound session codec의 primitive allowlist를 깨므로 사용하지 않는다.
`PrimitiveSessionSecurityContextRepository``AuthenticatedPrincipal`의 bounded
principal/email/roles/authorities만 versioned `byte[]` snapshot으로 저장한다. credential, bearer/JWT,
arbitrary principal graph와 `SPRING_SECURITY_CONTEXT` 객체는 저장하지 않는다. foreign principal이나
손상·초과 snapshot은 인증 없음으로 fail closed한다. 실제 security filter save/restore 테스트가 다음
요청에서 principal과 authorities가 복원되고 session에는 primitive snapshot만 남는 것을 검증한다.
응답 본문 flush/redirect/error가 새 session보다 먼저 commit되지 않도록 repository가 Spring Security의
commit-aware response wrapper 계약을 구현한다. 또한 이 모듈은 HTML 로그인 복귀용 request cache를
사용하지 않는 API 경계이므로 request cache를 명시적으로 비활성화한다. 따라서 미인증 요청이
`DefaultSavedRequest` 같은 framework object를 session에 넣지 않는다. app-bootstrap의
`redisSessionHttpIntegrationTest`가 TLS/ACL Redis와 서로 다른 세 개의 web context를 사용해 생성,
복구, logout tombstone, stale save 거부, 장애 시 controller 이전 fail-closed를 검증한다.
### SecurityErrorClassifier
- AuthN/AuthZ decision matrix 구현. 실행 앱이 coarse 한 3-way 매핑 대신 registry(`docs/registries/error-codes.yaml`)가
선언한 세분화 코드를 방출한다.
- **메커니즘 & 트레이드오프**: Spring Security 는 JWT 실패에 단일 typed reason 을 노출하지 않으므로,
classifier 가 예외 그래프와 validator/Nimbus 메시지 텍스트를 검사한다. 매핑:
- missing token → `InsufficientAuthenticationException``AUTH_TOKEN_MISSING`
- claim validators(`JwtValidationException`) → description 에 따라 `AUTH_TOKEN_EXPIRED` /
`AUTH_ISSUER_MISMATCH` / `AUTH_AUDIENCE_MISMATCH`
- decode/signature/unknown-kid(`BadJwtException`/`JwtException` cause) →
`AUTH_TOKEN_INVALID_SIGNATURE` / `AUTH_TOKEN_MALFORMED` / `AUTH_KID_UNKNOWN`
- JWKS endpoint 장애 → `AUTH_JWKS_UNAVAILABLE` (503, transient)
- 텍스트 휴리스틱은 의도적으로 **좁고 순서가 있다**. 매핑되지 않은 실패는 500 이 아니라 안전한
`AUTH_TOKEN_MALFORMED`(401)로 폴백한다.
- `AUTHZ_TENANT_MISMATCH` 는 여기서 추론 불가 — application-layer 의 cross-tenant 결정이며, 일반 `AccessDeniedException` 에는 `AUTHZ_INSUFFICIENT_PERMISSION`
만 방출한다.
### AuthErrorResponseWriter
- **토큰/PII 리댁션.** 응답 본문엔 해당
코드의 일반 `client_safe_message` 만 담고, 원시 예외 텍스트·`Authorization` 헤더·issuer·audience 는
절대 포함하지 않는다. 로그 라인엔 code/category/요청 path 만 기록하고 bearer token 은 절대 로깅하지
않는다(leak 테스트가 강제하는 계약). 전체 로그 마스킹 필터는 별도 log-management 영역에서 다룬다.
- **WWW-Authenticate(RFC 9110 §15.5.2).** 401 응답은 반드시 WWW-Authenticate 헤더를 갖되, `error_description`
으로 issuer/token 세부가 새지 않도록 최소한으로 유지한다.
### EnvelopeAuthenticationEntryPoint
- AuthN matrix 구현(인증 실패를 세분화 `OperationalError` 로 분류).
- resource-server 인증 실패는 filter layer(`BearerTokenAuthenticationFilter` / `ExceptionTranslationFilter`)에서
처리되어 `@RestControllerAdvice` 에 도달하지 않는다. 따라서 세분화 분류는 `GlobalExceptionHandler`
아니라 반드시 이 entry point 에 위치해야 한다.
### EnvelopeAccessDeniedHandler
- AuthN/AuthZ decision matrix 의 AuthZ 분기(유효 토큰 + 권한 부족 →
`AUTHZ_INSUFFICIENT_PERMISSION` 403).
- `AUTHZ_TENANT_MISMATCH` 는 application-layer 의 cross-tenant 결정이라
일반 Spring `AccessDeniedException` 으로는 추론 불가 — 여기서 방출하지 않는다.
---
## authz — 인가 (`@RequiresPermission` 강제)
### MethodSecurityConfig
- `RequiresPermission` 강제 지점을 Spring method security 에 배선한다.
- `@EnableMethodSecurity(prePostEnabled = false)` — method-security 인프라는 켜되 `@PreAuthorize`/
`@PostAuthorize` 인터셉터는 등록하지 않는다(의도적). 컨텍스트 내 유일한 authorization advice 가 아래
커스텀 advisor 가 되게 하기 위함.
- 이 선택이 **애플리케이션 계층을 Spring Security 애너테이션으로부터 자유롭게 유지(D1)**: 유스케이스는
프레임워크 독립적 plain 애너테이션 `RequiresPermission` 만 선언하고 Spring-aware 강제는 이 어댑터가 공급.
- advisor 는 `ROLE_INFRASTRUCTURE` static `@Bean` 으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어
애플리케이션 빈을 조기 초기화로 끌어들이지 않는다.
### RequiresPermissionAuthorizationManager
- `RequiresPermission` 의 Spring-aware 강제 메커니즘(커스텀 `AuthorizationManager<MethodInvocation>`).
- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `Authentication` 을 프레임워크 독립적
`AuthorizationPrincipal` 로 매핑해 결정을 application `AuthorizationPort` 에 위임한다. 따라서
application/domain 은 어떤 Spring Security 타입도 갖지 않으며, **이 어댑터가 두 세계가 만나는 유일한 지점**이다.
- 포트가 거부 시 application `AuthorizationDeniedException` 을 던지고, 이 매니저가 그것을 거부된
`AuthorizationDecision` 으로 변환한다. method-security 인터셉터가 이를 `AccessDeniedException`
`AUTHZ_INSUFFICIENT_PERMISSION` 403 으로 만든다(§4). `null` 반환은 기권(abstain)이라 애너테이션 없는
메서드는 영향받지 않는다.
- 매핑은 **fail-closed**: 미인증 요청이거나 우리 `AuthenticatedPrincipal` 이 아닌 principal 은 0개 role 로
해석되어 거부된다.
### AuthorizationAdapter
- application `AuthorizationPort` 의 web-adapter 구현체.
- 결정은 **fail-closed**: principal 의 유효 권한 집합에 요구 권한이 없으면 `AuthorizationDeniedException` 으로
거부 → `RequiresPermissionAuthorizationManager` 가 변환 → 최종 `AUTHZ_INSUFFICIENT_PERMISSION` 403.
### RolePermissionRegistry
- 호출자의 raw role 들을 유효 `Permission` 집합으로 해석한다.
- role 키를 **소문자로 normalize**: Keycloak 이 role 대소문자를 보장하지 않으므로 조회를 대소문자 무관으로.
- 권한은 role 별로 **명시적으로 열거**한 집합이며 와일드카드(예: `worklog:*`)는 의도적으로 미지원 — 미래의
`worklog:delete` 가 암묵적으로 부여되지 않도록(least-privilege, OWASP-AUTHZ-C4; §3 default B).
- 해석은 fail-closed: 알 수 없는 role / 빈 role 집합 / 빈 registry 모두 0개 권한.
### RolePermissionPolicy
- app-side role→permission 매핑 소스.
- 키가 raw IdP role 이름인 이유: `ROLE_` 접두사는 Spring `GrantedAuthority` 에만 있고 principal 의 raw role
집합엔 없으므로 붙이지 않는다.
- startup-bound static config 라 staleness 가 없다.
- **app-side config 를 기본값으로 택한 근거**: resource server 를 IdP 의 permission-claim 발급으로부터
디커플링한다. IdP-authoritative 소스(Keycloak Authorization Services / permission claims)는 본 contract 에서
의도적으로 out-of-scope 인 대안이다.
---
## error — 에러 → Envelope 변환
### GlobalExceptionHandler
스켈레톤 공통 기반 에러 → `Envelope` 변환기.
- **D5: RFC 7807 `ProblemDetail` 표현은 거부**하고 자체 `Envelope` 형식을 쓴다.
- **운영/전송/보안 예외만** 처리한다. 도메인 예외는 소비 모듈의 별도 `@RestControllerAdvice` 가 처리하고
Spring 이 두 advice 를 합성(compose)한다(CLAUDE.md 의 `@Order(HIGHEST_PRECEDENCE)` 규칙 참조).
- **클라이언트 메시지는 allowlist다.** 예외 메시지, validation interpolated message, rejected
request value, raw request URL은
`error.message`/`details`에 넣지 않는다. `ClientSafeErrorMessages`의 코드별 고정 문구와
정규화된 server-owned field, allowlisted reason code/fixed message, expectedType,
supported-method 같은 bounded 구조 메타데이터만 공개한다. collection/map index와 key는 field
path에서 제거한다.
- 코드별 문구가 명시되지 않은 operational code는 category 기반 고정 문구로 fail-closed 한다. 이
fallback은 새 코드를 실수로 진단 문자열에 연결하는 대신 transient/conflict/data-integrity 또는
`Internal server error`만 공개한다.
- `adapter-web` 에 위치하는 이유: 실행 앱이 어떤 sample 모듈에도 의존하지 않고 envelope 형식 에러 응답을
제공하도록.
- **`spanErrorRecorder`.** 프로덕션 코드를 특정 트레이서
라이브러리에 결합하지 않고 span 에러를 기록하기 위한 이음새. 기본값 `SpanErrorRecorder.NOOP`. `@Autowired`
생성자가 `ObjectProvider` 로 self-default 하므로 전체 컨텍스트 / `@WebMvcTest` 슬라이스 / 순수 단위 테스트
모두 seam 빈 등록을 강제하지 않고 와이어링된다. Micrometer Tracing fork 는 자체 `SpanErrorRecorder` 빈만
등록하면 no-op 을 오버라이드한다.
**예외 → 에러코드 → HTTP 상태 매핑 계약** (매핑 자체는 코드가 SSOT; 아래는 근거):
| 예외 | 코드 | 상태 | 근거 |
|---|---|---|---|
| `MappingException` | `MAPPING_FAILED` | 400 | B3: 매퍼 내부 실패는 `MAPPING_FAILED` 로, `BAD_PARAMETER`/`INTERNAL_ERROR` 로 보내지 않음 |
| `AdapterDisabledException` | `ADAPTER_DISABLED` | 500 (retryable=false) | Layer 3 런타임 fail-fast(integration-adapter-templates §4/D4). 시작-수명주기용 `REQUIRED_ADAPTER_DISABLED` 가 아님(§Audit A2). 예외 메시지의 어댑터 이름은 서버 로그용, 클라이언트는 `client_safe_message` 만 |
| `IllegalArgumentException` | `BAD_PARAMETER` | 400 | B3: 매퍼가 아닌 호출자의 일반 예외 |
| `ConstraintViolationException` | `VALIDATION_FAILED` | 400 | 정규화된 field + allowlisted reason code/fixed message 리스트를 details 로. interpolated message와 iterable key/index는 미노출 |
| `MethodArgumentTypeMismatchException` | `BAD_PARAMETER` | 400 | expectedType 을 details 로 |
| `InvalidBearerTokenException` | `INVALID_TOKEN` | 코드 상태 | |
| `AuthenticationException` | `UNAUTHENTICATED` | 코드 상태 | |
| `AccessDeniedException` | `SecurityErrorClassifier` 결정(예: `AUTHZ_INSUFFICIENT_PERMISSION`) | 분류기 결정 | 메서드-시큐리티 거부가 컨트롤러를 빠져나오면 여기 도달. 필터 계층 `EnvelopeAccessDeniedHandler` 와 **동일한 세분화 코드**를 내도록 무상태 classifier 에 위임 |
| `PreconditionFailedException` | `PRECONDITION_FAILED` | 412 | D15: `If-Match` 불일치 쓰기 = 낙관적 동시성 충돌 → 412 (raw 409/500 금지) |
| `PageValidationException` | `VALIDATION_FAILED` | 400 | D18/D20/D21: 계약 범위 밖 페이지/정렬/필터 파라미터. field + reasonCode 를 details 로 |
| `CursorException` | `VALIDATION_FAILED` | 400 | D22: 변조/만료/손상 커서. 조언 "첫 페이지 재요청", details field="cursor" code="CURSOR_INVALID" |
| `IdempotencyInFlightException` | `IDEMPOTENT_IN_FLIGHT` | 409 (retryable=false) | 대기 후에도 원본 처리 중. 진단정보(scope/principal)는 클라이언트 미도달 |
| `IdempotencyRequestMismatchException` | `IDEMPOTENT_REQUEST_MISMATCH` | 422 | D8: `Idempotency-Key` 를 다른 본문으로 재사용. fingerprint/scope 노출 금지 |
| `IdempotencyScopeMissingException` | `VALIDATION_FAILED` | 400 | §실패모드: 해석 가능한 scope 없는 키(예: 미인증 호출자)는 전역 충돌 대신 400 거부 |
| `PersistenceFailureException` | `ex.errorCode()` (사전분류 `DB_*`) | 코드 결정 | adapter-persistence translator 가 SQLState→`DB_*` 로 이미 분류. 클라이언트 메시지는 category-derived 안전 문자열, **절대 `ex.getMessage()` 아님**(SQLState/제약명 담음, 서버 로그 전용) |
| `DependencyFailureException` | `ex.errorCode()` (사전분류 `DEPENDENCY_*`) | 코드 결정 | adapter-outbound `OutboundHttpErrorMapper` 가 upstream 실패를 분류. 클라이언트 메시지는 per-code 고정 문자열(error-codes.yaml), **절대 `ex.getMessage()` 아님**. retryable + `retry_after_seconds` 있으면 `RetryAfterAdvisor``Retry-After` 부착 |
| `HttpRequestMethodNotSupportedException` | `METHOD_NOT_ALLOWED` | 405 | D12: 405 는 지원 메서드를 나열한 `Allow` 헤더 필수 |
| `HttpMediaTypeNotSupportedException` | `UNSUPPORTED_MEDIA_TYPE` | 415 | D9: 요청 본문 형식 미지원 — 406 과 구별 |
| `MaxUploadSizeExceededException` | `PAYLOAD_TOO_LARGE` | 413 | D8: 과대 본문은 envelope 내 413, raw 500 금지. 멀티파트 전용 413(`UPLOAD_SIZE_EXCEEDED`)은 이 영역의 책임 — 병합 후 정제 |
| `HttpMediaTypeNotAcceptableException` | `NOT_ACCEPTABLE` | 406 | D9: Accept 에 맞는 표현 없음 — 415 와 구별(합치면 RFC 9110 의미론 상실) |
| `MethodArgumentNotValidException` | `VALIDATION_FAILED` | 코드 상태 | 정규화된 field + allowlisted reason code/fixed message 리스트를 details 로. rejectedValue/defaultMessage/iterable key/index는 secret/PII 가능성이 있어 미노출 |
| `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 |
| `NoHandlerFoundException`, `NoResourceFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | controller/static-resource 어느 404 경로도 같은 Envelope를 사용하고 raw request URL을 echo하지 않음 |
| `Exception` (catch-all) | `INTERNAL_ERROR` | 500 | span 에러 기록 + "Internal server error" 고정 메시지 |
### ErrorResponseFactory
- 기반 운영 핸들러와 모든 도메인 핸들러가 **공유**하여 envelope 형식이 정확히 한 곳에서만 만들어지게
하는 단일-소스 컴포넌트(`httpStatus()` → Spring `HttpStatus` 매핑, `error.category` 운반, MDC 에서 `meta` 추출).
---
## envelope / filter
### EnvelopeBodyAdvice
- 컨트롤러는 도메인/DTO 타입을 반환하고, 이 advice 가 와이어 형태를 항상
`{success, data | error, traceId}` 로 보장한다.
- 위치: adapter-web. 실행 앱이 이 모듈을 의존하므로 응답 래핑이 실제 runtime에 적용된다.
### CacheControlFilter
- 스켈레톤 기본 HTTP 캐시 정책.
- `Cache-Control: no-store` 는 인증된 API 의 안전한 기본값. `Vary: Accept, Accept-Encoding, Authorization`
공유 프록시/CDN 이 협상이나 주체를 가로질러 콘텐츠를 오염(poison)시키지 못하게 한다.
- 기본값을 체인 **이전**에 설정: 캐시 가능한 엔드포인트가 반환값 처리에서 `Cache-Control`(예:
`private, max-age=60`)을 가진 `ResponseEntity` 를 반환해 기본값을 덮어쓰는 opt-in 이 가능하도록.
- 책임 경계: 이 모듈은 캐시 *헤더 정책*을 소유하고, 캐시 *레이어*(Redis/CDN)는 별도 인프라가 소유한다.
- 단일 소유권: Spring Security 기본 `Cache-Control``SecurityConfig` 에서 비활성화 → 실행 앱에서 이 필터가
헤더 단일 소유자. 독립 MockMvc(보안 체인 없음)에서도 이 필터가 유일 writer.
### RequestLoggingFilter
- **MDC 키 정책(D11/D19).** `MdcKeys` 의 snake_case 키 사용.
- **인바운드 id 헤더(D14/D15).** `X-Request-Id` / `X-Correlation-Id` 는 사용 전 sanitize(CR/LF + control 제거)
및 길이 제한. 부재/공백은 서버 생성.
- **W3C `traceparent`(D5/D7/D4).** 유효한 인바운드 traceparent 가 있으면 채택해 그 `traceId`→MDC `trace_id`,
`spanId``span_id`. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId,
sampled=false)하여 MDC `trace_id`**항상** 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성
상태에서도 `meta.traceId` non-null 보장). 해석된 traceparent 는 응답 헤더에 설정.
- `sampled=false` 근거: tracer seam 이 실제 sampling 결정을 소유하며 스켈레톤엔 exporter 가 없다.
- `freshHex16` 근거: 16-char span id 는 fresh UUID 의 least-significant bits 에서 파생·zero-pad — 64비트
전체가 entropy 를 갖도록(UUIDv4 version nibble 은 most-significant bits 라 제외). variant bits 가 값을
non-zero 로 유지해 W3C non-all-zero 규칙 충족.
- **사용자 주체 가명화.** `user_principal` 은 MDC 에 놓이기 전
`UserPrincipalPseudonymizerPort` 로 가명화. raw `idpUserId()` 는 절대 MDC/로그에 기록되지 않는다.
- **route template 해석.** 저-cardinality 매칭 라우트 템플릿 반환. `BEST_MATCHING_PATTERN_ATTRIBUTE`
는 handler mapping 이후 DispatcherServlet 이 설정하므로 `finally` 블록에서 항상 사용 가능.
- **주의 — 생성된 `trace_id` 는 실제 span 의 trace-id 가 아니다.** 무-tracer
스켈레톤에선 이 필터가(인바운드 traceparent 부재 시) `trace_id` 를 발급(MINT)하고 `ResponseMetaFactory`
이를 `meta.traceId` 로 투영한다. fork 가 Micrometer Tracing 을 켜면 OTel SDK 도 같은 요청에 trace-id 를
발급하고 SLF4J-Micrometer 브리지가 *자신의* id 를 MDC `trace_id` 에 쓴다. 어느 값이 최종 반영될지는
필터/observation 의 **ORDER 와 scope** 에 달려 있다 — 이 필터가 이기면 클라이언트의 `meta.traceId`
실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다.
실제 tracer 를 연결하는 fork 는 tracer 가 MDC `trace_id` 의 유일 소유자가 되게 해야 한다(이 필터를 tracing
observation *이후*로 정렬하거나, 생성 대신 `Span.current()` 채택). 현 green 테스트 스위트는 이를 잡지
못한다 — 무-tracer 메커니즘만 검증한다.
---
## ratelimit
### provider-neutral edge contract
- inbound web은 `shared-contract``EdgeRateLimitPort`만 호출한다. Redis key, Lua, local counter와
provider 설정을 알지 못한다.
- outbound provider activation SSOT는
`ca-skeleton.capabilities.rate-limit.provider=disabled|redis`이고, HTTP enforcement의 별도 축은
`app.rate-limit.enabled`다. transport가 enabled인데 exact provider가 없거나 중복이면 startup을
실패시킨다.
- fixed window, sliding counter, token bucket 선택과 policy revision은 Redis provider가 소유한다.
과거 process-local unbounded fixed-window map/factory/settings는 제거되었다. local emergency가
필요하면 bounded cardinality/TTL/in-flight와 명시적 degraded-provider 계약을 먼저 추가해야 하며,
silent primary fallback은 허용하지 않는다.
- `EdgeRateLimitTransportBridge`는 provider의 typed allow/deny/unavailable/incompatible outcome을
HTTP 2xx/429/503과 `Retry-After`로만 투영한다. timeout은 quota가 소비되지 않았다는 증거가 아니다.
### RateLimitKeyResolver
- 키 형태: service-to-service
`apikey:<id>`(api_key_id override), 인증 `user:<id>`, 미인증 `ip:<source-ip>:<METHOD route-template>`(정규화).
- principal 은 로그 `user_principal`(`AuthenticatedPrincipal#idpUserId`)과 동일 표현 재사용. pseudonymization 은
이 영역의 책임 seam — 이 브랜치는 표현을 재사용만 하고 변환하지 않는다.
- tenant prefixing 은 아직 구현하지 않은 확장 지점이다.
- **하드 룰:** 키는 raw 토큰이나 요청 본문에서 절대 도출하지 않는다.
- `HandlerInterceptor` 입력으로 resolve 하는 이유: route template(`/v1/worklogs/{id}`)을 쓰기 위함. servlet
filter 는 handler mapping 이전에 실행돼 구체 경로만 보므로 모든 id 가 서로 다른 키가 되어버린다.
### RateLimitClientIpMode
- 비인증 rate-limit 키의 클라이언트 IP 소스 선택 enum.
- `REMOTE_ADDR_ONLY` — 직접 노출 배포의 안전한 기본값(spoofing 가능한 forwarded 헤더 무시).
- `FORWARDED_HEADERS_TRUSTED` — 신뢰할 수 있는 ingress/LB 가 forwarded 헤더를 덮어쓰는 경우에만 사용.
### RateLimitWebConfig
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
resolve 되기 때문(RateLimitKeyResolver 참조).
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor
- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
`X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true).
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
의존성 장애로 오분류하는 것을 막는다.
---
## settings / config / http
### CorsSettings
- **3계층 검증 전략.**
1. 단순 제약(범위/필수/정규식)은 JSR-303 + `@Validated` 로 선언해 잘못된 값이 `BindValidationException` 으로
기동 실패(`maxAgeSeconds`).
2. JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast `throw` 로 강제(관대한 기본값
폴백 금지).
3. 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움.
- **교차필드 불변식**: CORS enabled 시 최소 하나의 allowed origin 필수(JSR-303 표현 불가 → fail-fast). 빈 목록
관대한 폴백은 모든 브라우저 호출자를 조용히 거부하게 된다.
- **D9 (WHATWG Fetch §3.3, FETCH-CORS-C3)**: wildcard origin + credentials 금지 — `Access-Control-Allow-Origin: *`
`Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
시점에 fail-fast 거부.
### EdgeRateLimitTransportSettings
- `app.rate-limit.*`은 HTTP enforcement, default policy ID, pseudonymization key version,
caller deadline, trusted client-IP mode만 소유한다.
- algorithm/quota/state TTL/HMAC secret는 outbound Redis capability 설정이 소유하며 web settings로
복제하지 않는다.
### SecuritySettings
- OIDC resource-server 설정. `issuerUri` 는 인증이 연결될 때 필수 — 없으면 Spring Boot oauth2 auto-config 가
기동 시 실패하므로 여기서 명확한 에러를 먼저 표면화한다. 나머지 knob 은 warn 후 폴백.
### PresentationSettings
- 검증 정책 "warn-and-default": 부재/잘못된 prefix 값은 앱을 멈추는 대신 빈 prefix 로 폴백 — 모든 엔드포인트가
(/api 없이) 계속 접근 가능하게 유지.
### JacksonNullableConfig
- `JsonNullableModule` 을 Spring 관리 `ObjectMapper` 에 등록. 없으면 PATCH 요청 DTO 의 `JsonNullable<T>`(B2)를
Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다.
- 스켈레톤 전역 web 관심사(공유 `Patch<T>` 타입과 짝)라 도메인 샘플 모듈이 아니라 adapter-web 에 위치.
### ApiHeaders
- 인바운드 web 어댑터 전역의 HTTP 헤더명 상수 — `docs/registries/headers.yaml`
코드 미러. 리터럴을 중앙집중해 controller/filter/advice 가 casing 으로 drift 하지 않게 하고, 레지스트리
일관성 테스트가 단일 출처를 참조하게 한다.
- 소유권: `X-Api-Version`(D2)·`Idempotency-Key`(D3 — 이름만; key shape/scope/replay 정책은
application-core 소유)는 여기서 생산. conditional-request(D15)·cache(D16)·method/negotiation/
LRO(D12/D17)·pagination(D18)·rate-limit signaling(generated_if_missing=true; Limit/Remaining 은 numeric, Reset 은
rfc3339 = fixed-window end)·deep-offset deprecation marker(D18)·always-emitted(D24)는 표준 RFC 9110/9111 이름 참조.
---
## observability
### MdcKeys
- snake_case MDC 키 이름은 로그/진단 레지스트리(`mdc-keys.yaml`)를 따른다.
같은 논리 ID 의 envelope 형태(camelCase)와 HTTP 헤더 형태(kebab-case)는 D19 projection 이며,
변환 단일 지점은 `ResponseMetaFactory`.
### MdcCorrelationIdPortAdapter
- `RequestLoggingFilter`가 무해화하고 MDC `correlation_id`에 넣은 값을 application-core의
`CorrelationIdPort`로 투영한다.
- absent/blank는 `Optional.empty()`로 반환한다. application/sample 계층은 SLF4J/MDC를 직접
참조하지 않고 event-id fallback 정책만 소유한다.
### HeaderSanitizer
- 인바운드 헤더 값을 MDC/로그 도달 전에 무해화(D14, OWASP-LOG-C3/C5, CWE-117). 스켈레톤은 구조화 JSON 로깅을
가정하므로 위협은 CR/LF/제어문자를 통한 로그 라인 위조 — 값은 보존하되 `\r`/`\n`/ASCII 제어문자(`< 0x20`)를
제거 후 길이 제한.
- `프로젝트 선택`: 구체 문자셋 정책(strip vs encode)과 최대 길이는 ca-tmpl 트레이드오프. OWASP 는
원칙만 규정하고 정규식/한계는 규정하지 않는다.
### ResponseMetaFactory
- snake_case MDC 진단 키를 camelCase `ResponseMeta` wire 객체로 projection 하는 D19 단일 변환 지점. adapter-web
에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다.
### RetryAfterAdvisor
- **`Retry-After` 노출 지점.** 구체 헤더 값과 429/503 세부는 이 영역의 책임이고,
per-code `retry_after_seconds` 는 error-codes.yaml 에 존재한다.
이 helper 는 "재시도 가능한 코드가 `Retry-After` 헤더를 받을 자격이 있는가?"만 답해, 호출부가
재시도 가능 여부를 재도출하지 않고 헤더를 붙이게 한다.
- **Tracing wiring:** 운영 5xx 는 서버 span 에 `exception` 이벤트 + span status ERROR 를 기록해야 하나,
Micrometer-Tracing/OTel 가 classpath 에 없어 wiring 은 이 영역의 책임 — 의도적
미구현.
- 필드 `RETRY_AFTER_SECONDS` 는 error-codes.yaml 의 `retry_after_seconds` 컬럼 미러.
이 advisor 가 유일한 Retry-After 노출 지점이라 여기 중앙화한다.
`DEPENDENCY_4XX_CLIENT` 는 비재시도(retryable=false)라 `shouldAdvise` 가드로 empty 반환.
---
## pagination
### PageParams
- 검증된 offset 페이지네이션 파라미터. `page` 0-indexed: Spring `Pageable`
parity(SPRING-PAGE-C1). `size` 기본 20 / min 1 / max 100: 프로젝트 DoS 캡(Spring 자체 `DEFAULT_MAX_PAGE_SIZE`
는 2000, SPRING-PAGE-C4).
- `프로젝트 선택`: 정확한 size 캡(100)/min(1)/deep-offset 임계값(10000)은 프로젝트 내부
트레이드오프 — 표준은 원칙만 고정하고 숫자는 고정하지 않는다.
### SortParam
- Spring `Pageable` 네이티브 문법 `field,direction` 의 단일 정렬 term(D20). 비-네이티브 문법 거부 근거:
JSON:API prefix(`-foo`)·colon form(`foo:desc`)·AIP-132 space form(`"foo desc"`)은 모두 Spring 자동 바인딩을
깨뜨리므로 금지.
### PageValidationException
- 페이지네이션/정렬 요청 파라미터가 스켈레톤의 요청 경계를 위반할 때 발생.
---
## cursor
### CursorCodec
- 불투명·서명·시간 제한 페이지네이션 커서 코덱(D22, AIP158-C5).
- **SEAM(producer-only)**: HMAC 키와 회전 정책은 이 영역의 책임. 해당 브랜치가 이
저장소에 없어 프로덕션 키 wiring 은 `planned`. 코덱은 주입된 키를 받고 테스트/로컬용 `withDevKey()` 팩토리
제공(프로덕션 금지). encode/decode 메커니즘·opacity·무결성 검사·TTL 은 여기 구현.
- `DEFAULT_TTL`: D22 의 24h TTL 은 프로젝트 내부 숫자(AIP-158 은 opacity 만 고정, TTL 미고정).
### CursorException
- 불투명 페이지네이션 커서 검증 실패 시 발생(D22).
---
## conditional
### ETags
- HTTP 계층 낙관적 동시성/캐시 검증용 weak-ETag 도출 및 조건부 요청 매칭(D15, RFC9110-C13..C17).
`weakFromVersion` 산출물 `W/"<version>"` 는 스켈레톤의 예시 wire 형태다.
- `프로젝트 선택`: RFC 9110 은 `If-Match` 에 strong 비교를 의무화하나, 이 스켈레톤은 불투명 값을
leniently 비교(`W/` weak 마커와 둘러싼 따옴표 무시)해 문서화된 weak-ETag 형태로도 낙관적 잠금을 구동한다.
strong ETag 를 발행하는 프로덕션 fork 도 동일 호출 지점을 유지 가능.
### PreconditionFailedException
- 쓰기 요청의 `If-Match` validator 가 현재 리소스 ETag 와 불일치할 때 발생(D15). 412 로 매핑해 raw 409/500 과
구분 — persistence 계층이 serialization failure 로 surface 할 동일한 낙관적 동시성 충돌의 HTTP 계층 표현.
---
## idempotency
### IdempotencyKeySupport
- HTTP 요청으로부터 application `IdempotencyExecutor` 입력을 조립하는 web 측 helper.
- principal 은 인증된 `AuthenticatedPrincipal#idpUserId()` — rate-limit 키 및 로그 `user_principal` 과 동일
표현. 미인증 호출자는 principal 이 없어 `IdempotencyScope.of`
`IdempotencyScopeMissingException`(→ 400)으로 거부 → scope 없는 키의 전역 충돌 방지.
- `프로젝트 선택`: fingerprint 는 raw 전송 바이트가 아니라 직렬화된 command payload 기준으로
계산 → JSON 키 순서/공백 차이로 인한 false mismatch 방지. 단, 바이트 동일 body 를 두 번 POST 한 클라이언트는
여전히 매칭. 완전한 요청 canonicalization 은 실제 요청 패턴으로 추가 검증이 필요하다.
- tenant 는 null(단일 테넌트); tenant scoping 은 아직 구현하지 않은 확장 지점이다.
### JsonIdempotentResponseCodec
- Jackson 기반 `IdempotentResponseCodec`(§B): web 어댑터가 application executor 의 저장/replay JSON wire 포맷을
소유. (역)직렬화 실패는 `MappingException` 으로 surface 되어 base handler 가 raw 500 이 아닌 `MAPPING_FAILED`
400 으로 라우팅.