32 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 헤더 정책을 소유한다(
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 SecurityAuthentication토큰 필드의 관례적 해결책이 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/JwtExceptioncause) →AUTH_TOKEN_INVALID_SIGNATURE/AUTH_TOKEN_MALFORMED/AUTH_KID_UNKNOWN - JWKS endpoint 장애 →
AUTH_JWKS_UNAVAILABLE(503, transient)
- missing token →
- 텍스트 휴리스틱은 의도적으로 좁고 순서가 있다. 매핑되지 않은 실패는 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_PERMISSION403). AUTHZ_TENANT_MISMATCH는 application-layer 의 cross-tenant 결정이라 일반 SpringAccessDeniedException으로는 추론 불가 — 여기서 방출하지 않는다.
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_INFRASTRUCTUREstatic@Bean으로 등록 — 일반 싱글톤보다 먼저 인스턴스화되어 애플리케이션 빈을 조기 초기화로 끌어들이지 않는다.
RequiresPermissionAuthorizationManager
RequiresPermission의 Spring-aware 강제 메커니즘(커스텀AuthorizationManager<MethodInvocation>).- 가로챈 메서드(또는 선언 타입)에서 애너테이션을 읽고, 현재
Authentication을 프레임워크 독립적AuthorizationPrincipal로 매핑해 결정을 applicationAuthorizationPort에 위임한다. 따라서 application/domain 은 어떤 Spring Security 타입도 갖지 않으며, 이 어댑터가 두 세계가 만나는 유일한 지점이다. - 포트가 거부 시 application
AuthorizationDeniedException을 던지고, 이 매니저가 그것을 거부된AuthorizationDecision으로 변환한다. method-security 인터셉터가 이를AccessDeniedException→AUTHZ_INSUFFICIENT_PERMISSION403 으로 만든다(§4).null반환은 기권(abstain)이라 애너테이션 없는 메서드는 영향받지 않는다. - 매핑은 fail-closed: 미인증 요청이거나 우리
AuthenticatedPrincipal이 아닌 principal 은 0개 role 로 해석되어 거부된다.
AuthorizationAdapter
- application
AuthorizationPort의 web-adapter 구현체. - 결정은 fail-closed: principal 의 유효 권한 집합에 요구 권한이 없으면
AuthorizationDeniedException으로 거부 →RequiresPermissionAuthorizationManager가 변환 → 최종AUTHZ_INSUFFICIENT_PERMISSION403.
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_접두사는 SpringGrantedAuthority에만 있고 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()→ SpringHttpStatus매핑,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→MDCtrace_id,spanId→span_id. 부재/공백/무효면 fresh ROOT traceparent 생성(32-hex traceId, 16-hex spanId, sampled=false)하여 MDCtrace_id가 항상 의미 있는 W3C id 이고 절대 null 이 아니게 한다(D4: 추적 비활성 상태에서도meta.traceIdnon-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로 가명화. rawidpUserId()는 절대 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 를 MDCtrace_id에 쓴다. 어느 값이 최종 반영될지는 필터/observation 의 ORDER 와 scope 에 달려 있다 — 이 필터가 이기면 클라이언트의meta.traceId가 실제 export 된 span 의 trace-id 와 불일치해, "응답 id 로 trace 조회"라는 D4 의 핵심 목적이 조용히 깨진다. 실제 tracer 를 연결하는 fork 는 tracer 가 MDCtrace_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계층 검증 전략.
- 단순 제약(범위/필수/정규식)은 JSR-303 +
@Validated로 선언해 잘못된 값이BindValidationException으로 기동 실패(maxAgeSeconds). - JSR-303 로 표현 불가한 조건부/교차필드 규칙은 compact constructor 의 fail-fast
throw로 강제(관대한 기본값 폴백 금지). - 정상 기본값(CORS disabled 시 빈 origins, 미설정 method/header)은 invalid 가 아니라 합리적 기본값으로 채움.
- 단순 제약(범위/필수/정규식)은 JSR-303 +
- 교차필드 불변식: 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가 무해화하고 MDCcorrelation_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
ResponseMetawire 객체로 projection 하는 D19 단일 변환 지점. adapter-web 에 위치하는 이유: shared-contract 는 프레임워크 중립이라 MDC 를 읽으면 안 된다.
RetryAfterAdvisor
Retry-After노출 지점. 구체 헤더 값과 429/503 세부는 이 영역의 책임이고, per-coderetry_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 페이지네이션 파라미터.
page0-indexed: SpringPageableparity(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-Matchvalidator 가 현재 리소스 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_FAILED400 으로 라우팅.