feat: add production capability foundations

This commit is contained in:
donghyeon-ka
2026-07-31 23:50:44 +09:00
parent b3add0162d
commit 567422f2e5
757 changed files with 132385 additions and 2146 deletions
+39 -25
View File
@@ -47,9 +47,24 @@ production configuration and compare the result with the committed snapshot.
### JwtToAuthenticatedPrincipalConverter
- `principal` 필드를 `transient` 로 두는 근거: principal 은 매 인증마다 converter 가 재구성하며
`ObjectOutputStream` 으로 round-trip 되지 않는다(이 템플릿엔 Java-직렬화 세션 저장소가 없음 — grep 확인).
`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`)가
선언한 세분화 코드를 방출한다.
@@ -234,22 +249,20 @@ production configuration and compare the result with the committed snapshot.
## ratelimit
### 알고리즘 seam (RateLimiter / RateLimiterFactory / RateLimitAlgorithm / FixedWindowRateLimiter)
- 알고리즘은 프로젝트마다 바뀔 수 있는 운영 선택이라 `RateLimiter` 인터페이스 뒤에 둔다.
- **OCP(개방-폐쇄)**: `RateLimitInterceptor``RateLimiter` 타입에만 의존하고, `RateLimiterFactory` 의 단일
`switch` 가 설정에서 구체 전략을 선택한다. 새 알고리즘 추가 = "새 `RateLimiter` 구현 + `RateLimitAlgorithm`
enum 값 + factory case" 이며 interceptor/web config 변경 불요. 향후 후보: `SLIDING_WINDOW`, `TOKEN_BUCKET`.
- **알고리즘 중립 출력 계약**: 구현마다 카운트 방식이 달라도(fixed-window end vs 연속 sliding vs token refill)
`X-RateLimit-*` 헤더 계약이 안정적이도록 모든 구현이 `RateLimitDecision` 을 아래 의미로 채운다.
- `limit` — 설정 quota
- `remaining` — 해당 키에 지금 아직 허용되는 요청 수, 0 으로 floor
- `resetAt` — 키가 최소 1개 요청 capacity 를 다시 얻는 시각(fixed-window=window 종료, token-bucket=다음
refill, sliding-window=가장 오래된 카운트 요청 만료 시점)
- `allowed` — quota 소진 시 false (→ 429)
- **FixedWindowRateLimiter 트레이드오프**: `X-RateLimit-Reset` 시각은 정확(window 종료)한 대신 window 경계를
가로지르는 burst 를 허용 — 스켈레톤 계약상 허용 가능. **D5**: 분산 limiter 는 core 범위 밖이라 per-instance
전용이며, 다중 인스턴스 배포 시 유효 한도는 설정값의 N배. key→window 맵은 evict 되지 않는다(single-node,
distinct active key 수로 bounded) — 키 cardinality 무제한 배포는 expiry/eviction 추가 필요.
### 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
@@ -270,11 +283,12 @@ production configuration and compare the result with the committed snapshot.
- servlet filter 가 아니라 interceptor 를 쓰는 이유: 비인증 키에 필요한 route template 이 interceptor 단계에서
resolve 되기 때문(RateLimitKeyResolver 참조).
- `@EnableConfigurationProperties` 근거: 앱 레벨 `@ConfigurationPropertiesScan` 을 돌리지 않는 `@WebMvcTest`
슬라이스에서도 `RateLimitSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에서도 `EdgeRateLimitTransportSettings` 를 쓰게 하려고. `Clock` 은 공유 application bean 이 있으면 가져오고
슬라이스에선 `Clock#systemUTC()` 로 fallback.
### RateLimitInterceptor
- fixed-window rate limit 매핑된 handler 실행 전에 적용. 모든 응답에 `X-RateLimit-*` 헤더 포함(generated_if_missing=true).
- provider가 선택한 rate-limit policy를 매핑된 handler 실행 전에 적용. quota 결과에는
`X-RateLimit-*` 헤더를 포함한다(generated_if_missing=true).
- 한도 초과 거부 응답의 세 보장(RATE_LIMIT category + retryable + `Retry-After`)이 클라이언트가 이를 retryable
의존성 장애로 오분류하는 것을 막는다.
@@ -295,12 +309,12 @@ production configuration and compare the result with the committed snapshot.
`Access-Control-Allow-Credentials: true` 와 함께 보낼 수 없다. Spring 런타임 검사에 의존하지 않고 기동
시점에 fail-fast 거부.
### RateLimitSettings
- `ca-skeleton.rate-limit.*` 에서 바인딩되고, composition root 의 `@ConfigurationPropertiesScan` 으로 자동 등록된다.
- `enabled``APP_RATE_LIMIT_ENABLED`(env-keys.yaml, restart-only, behavior-change)에 매핑.
- `limit`/`window`/`algorithm` 은 env key 없음 — 리미터 튜닝 파라미터(`프로젝트 선택`; 멀티 인스턴스
정확성은 범위 밖, D5)이며 fork 가 레지스트리 변경 없이 `application.yml` 에서 재정의하도록 in-code 기본값.
`algorithm` 기본값 `RateLimitAlgorithm.FIXED_WINDOW`.
### 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 가