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

34 KiB

adapter-web — 설계 결정 참조

인바운드 HTTP / 보안 어댑터 모듈. 패키지 루트: dev.caskeleton.adapter.web.

허용/금지 의존, 경계 계약(B1B8), 스키마/직렬화 계약(S1S5), 비즈니스 규칙 검증 계약(C1~C8), 테스트 명령 같은 모듈 규칙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 헤더 정책을 소유한다(CacheControlFilterCache-Control: no-store + Vary 방출). 헤더의 단일·결정적 소유자를 보장하기 위해 Spring Security 자체의 기본 writer 를 끈다.
  • AuthN/AuthZ 분류기를 exceptionHandlingoauth2ResourceServer 양쪽에 설정. 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를 깨므로 사용하지 않는다. PrimitiveSessionSecurityContextRepositoryAuthenticatedPrincipal의 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 → InsufficientAuthenticationExceptionAUTH_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 인터셉터가 이를 AccessDeniedExceptionAUTHZ_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 있으면 RetryAfterAdvisorRetry-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-ControlSecurityConfig 에서 비활성화 → 실행 앱에서 이 필터가 헤더 단일 소유자. 독립 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, spanIdspan_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-contractEdgeRateLimitPort만 호출한다. 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.ofIdempotencyScopeMissingException(→ 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 으로 라우팅.