# 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 동작). - **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만 남는 것을 검증한다. ### 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`). - 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재 `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)` 규칙 참조). - `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/message violation 리스트를 details 로 | | `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/rejectedValue/message 리스트를 details 로 | | `HttpMessageNotReadableException` | `VALIDATION_FAILED` | 코드 상태 | cause 클래스명을 details 로 | | `NoHandlerFoundException` | `ROUTE_NOT_FOUND` | 코드 상태 | | | `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}` 로 보장한다. - 위치: sample 모듈이 아니라 adapter-web. 실행 앱은 adapter-web 에 의존하지만 sample-portfolio 에는 의존하지 않으므로, 응답 래핑이 실제로 동작하려면 여기 있어야 한다. ### 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:`(api_key_id override), 인증 `user:`, 미인증 `ip::`(정규화). - 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`(B2)를 Jackson 이 역직렬화하지 못해 absent / explicit-null 구분이 조용히 붕괴된다. - 스켈레톤 전역 web 관심사(공유 `Patch` 타입과 짝)라 도메인 샘플 모듈이 아니라 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/""` 는 스켈레톤의 예시 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 으로 라우팅.