chore: initialize from backend template 0a6dd0e
This commit is contained in:
@@ -0,0 +1,883 @@
|
||||
# app-bootstrap — 설계 결정 참조
|
||||
|
||||
애플리케이션 진입점이자 합성 루트(composition root) 모듈. 패키지 루트: `dev.caskeleton.bootstrap`.
|
||||
|
||||
이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 기본 runtime
|
||||
모듈 간 최종 와이어링, 그리고 composition classpath를 대상으로 한 중앙 아키텍처 테스트만 둔다.
|
||||
19개 leaf 전체의 프로젝트 edge와 두 composition root의 실제 runtime membership은 JSON registry를
|
||||
읽는 Gradle gate가 별도로 검사한다. 허용/금지 의존, 책임 범위, 테스트 명령 같은 **모듈 규칙**의
|
||||
SSOT 는 [CLAUDE.md](CLAUDE.md) 다.
|
||||
|
||||
기본 composition은 `build.gradle`의 main project dependency와 registry의
|
||||
`runtime_memberships=["app-bootstrap"]`가 정확히 일치해야 한다. GraphQL, gRPC, WebSocket,
|
||||
MongoDB leaf의 membership은 비어 있어 두 shipped runtime에 포함되지 않는다. object storage는
|
||||
sample runtime에만 있고 file server는 기본 app runtime에 이미 포함된다.
|
||||
|
||||
`conditionalTransportTest`는 GraphQL/gRPC/WebSocket 세 leaf를 test-only classpath에서 함께
|
||||
resolve하는 명시적 qualification composition이다. main `implementation`/`runtimeOnly` edge가 아니므로
|
||||
shipped runtime membership을 바꾸지 않는다. optional leaf를 실제 app에 채택하려면 같은 변경에서
|
||||
`allowed_dependencies`, `runtime_memberships`, `app-bootstrap/build.gradle` main dependency,
|
||||
typed settings/보안 경계/검증을 모두 갱신해야 한다.
|
||||
|
||||
이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다 — 코드를 읽다
|
||||
"왜 이렇게 했나"가 궁금할 때 본다. 본문은 한국어로 쓰고, 클래스·Spring API·메트릭 이름처럼
|
||||
꼭 필요한 기술 용어만 영어로 남긴다. 별도 추적 ID를 몰라도 읽히도록 결정의 배경과
|
||||
트레이드오프를 문장으로 풀어 둔다.
|
||||
|
||||
## 목차
|
||||
|
||||
- [runtime/startup — Fail-fast 시작 파이프라인](#runtimestartup--fail-fast-시작-파이프라인과-시작-실패-분류)
|
||||
- [runtime — 시크릿 소스 선택과 런타임 안전 가드](#runtime--시크릿-소스-선택과-런타임-안전-가드)
|
||||
- [concurrency — 도메인 컨텍스트 전파](#concurrency--도메인-컨텍스트-전파-전략-조립)
|
||||
- [async — 비동기 executor 컨텍스트 전파와 포화 처리](#async--비동기-executor-컨텍스트-전파와-포화-처리)
|
||||
- [idempotency — 멱등성 런타임 조립과 TTL 상한](#idempotency--멱등성-런타임-조립과-ttl-상한)
|
||||
- [lock — 분산 락 메트릭 데코레이터](#lock--분산-락-메트릭-데코레이터-배선)
|
||||
- [outbox — 트랜잭셔널 아웃박스 릴레이](#outbox--트랜잭셔널-아웃박스-릴레이-와이어링)
|
||||
- [logging — 로그 시크릿 마스킹·샘플링·가명화](#logging--로그-시크릿-마스킹샘플링가명화)
|
||||
- [metrics — 메트릭 계약 MeterFilter](#metrics--메트릭-계약-meterfilter-설치)
|
||||
- [management/security — 액추에이터 엔드포인트 보안](#managementsecurity--액추에이터-엔드포인트-보안-체인)
|
||||
- [tracing — 분산 트레이싱 wiring과 샘플링 정책](#tracing--분산-트레이싱-wiring과-샘플링-정책)
|
||||
- [settings — @ConfigurationProperties 검증 정책](#settings--configurationproperties-검증-정책)
|
||||
- [build.gradle — 의존성 구성 근거](#buildgradle--의존성-구성-근거)
|
||||
|
||||
---
|
||||
|
||||
## runtime/startup — Fail-fast 시작 파이프라인과 시작 실패 분류
|
||||
|
||||
잘못 설정된 채로 기동돼 트래픽을 받는 것보다, 기동 시점에 명확한 이유와 함께 멈추는 편이 안전하다.
|
||||
이 패키지는 그 "빨리·명확하게 실패시키기(fail-fast)"를 담당한다.
|
||||
|
||||
### AuthenticationModeCompositionConfig
|
||||
|
||||
`ca-skeleton.security.auth-mode`의 기본값은 `jwt`이며 `jwt|redis-session` 중 정확히 하나만 허용한다.
|
||||
JWT mode는 `jwtDecoder`가 있어야 하고 Redis session repository/filter가 있으면 기동을 거부한다.
|
||||
Redis session mode는 반대로 `jwtDecoder`를 거부하고 `redisVersionedSessionRepository`와
|
||||
`springSessionRepositoryFilter`가 모두 있어야 한다. 이 검증은 bean name만 확인하므로 bootstrap이
|
||||
Spring Session/Redis 구현 타입을 직접 의존하지 않으며, composition 누락과 이중 활성화를 context
|
||||
refresh 완료 전에 실패시킨다.
|
||||
|
||||
### FlywayProdSafetyValidator
|
||||
- **`prod` 프로파일에서 Flyway 안전장치가 꺼지지 못하도록 런타임에서 강제한다.** Flyway 옵션은
|
||||
`application.yml`에 안전값으로 고정돼 있지만(`baseline-on-migrate=false`, `out-of-order=false`,
|
||||
`clean-disabled=true`), 환경별 override 가 프로덕션에서 이 값을 조용히 되돌릴 수 있다. 이 검증기는
|
||||
그런 override 를 시작 시점에 잡아 `PROFILE_MISMATCH`(exit 71)로 부팅을 막는다.
|
||||
- **금지하는 옵션은 각각 "복구 불가능한 위험" 때문이다.** `baseline-on-migrate=true`는 누락된
|
||||
마이그레이션을 감지하는 안전망을 없애고, `out-of-order=true`는 개발자 간 마이그레이션 적용 순서
|
||||
일관성을 깨며, `clean-disabled=false`는 스키마 전체를 드롭하는 파괴적 `clean` 명령을 다시
|
||||
활성화한다.
|
||||
- **프로파일 비교는 대소문자를 무시한다.** `SPRING_PROFILES_ACTIVE=PROD`처럼 대문자 오타가 나도
|
||||
가드가 우회되지 않게 하기 위함이다.
|
||||
- **Flyway repair 는 가드 대상이 아니다.** repair 는 property 가 아니라 Flyway 명령이고 스켈레톤은
|
||||
repair 호출 경로 자체를 배선하지 않으므로, "prod 에서 repair 금지"는 런타임 트리거가 없는
|
||||
문서화된 정책으로 남는다. 부분 스키마 복구는 in-prod Flyway repair 가 아니라
|
||||
runbook(`runbook://migration/manual-recovery`)을 통한 수동 복구로 가도록 실패 메시지에 명시한다.
|
||||
(프로젝트가 repair 경로를 추가한다면 동일하게 prod 게이트 + non-prod 감사 로그를 걸어야 한다.)
|
||||
|
||||
### RequiredEnvironmentValidator
|
||||
- **datasource 연결 환경변수를 마이그레이션 실행 전에 검증해 부팅을 빨리 실패시킨다.** Flyway
|
||||
forward-only 마이그레이션은 context refresh 동안 애플리케이션 datasource 에 대해 실행된다. 연결
|
||||
환경변수가 빠져 있으면 마이그레이션은 시작 단계 구분자 없이 불투명한 driver/connection 에러로
|
||||
한참 뒤에야 실패한다. 이 검증기는 그것을 명시적인 env 검증 실패(`STARTUP_VALIDATION_FAILED`,
|
||||
exit 78)로 바꿔, 누락된 운영자용 env 키를 전부 이름으로 짚어준다.
|
||||
- **검증 범위를 datasource 3종(url/username/driver)으로 의도적으로 좁혔다.** 이것들이 이 브랜치가
|
||||
소유하는 마이그레이션 전제조건이기 때문이다. app-name(`BootstrapSettings`), issuer-uri(security
|
||||
settings) 등 다른 필수값은 각 모듈의 가드가 책임지므로 여기서 중복 검증하지 않는다.
|
||||
- **값이 없거나(blank) 공백이면 모두 "누락"으로 본다.** spring-dotenv 에서 미설정 placeholder
|
||||
`${...}`가 빈 문자열로 해석되기 때문에, null 뿐 아니라 blank 도 누락으로 처리해야 빈틈이 없다.
|
||||
- **메시지는 env 키 이름순으로 정렬해 출력한다.** Map 순회 순서와 무관하게 결정적이고 안정적인 실패
|
||||
메시지를 보장하기 위함이다.
|
||||
|
||||
### MigrationStartupConfig
|
||||
- **시작 마이그레이션 가드와 Flyway 마이그레이션 전략을 한곳에 모아 소유권을 명확히 한다.** prod
|
||||
토글 / 멀티 인스턴스 가드는 env-driven `RuntimeSafetyConfig`가 소유하고, 이 config 는 마이그레이션
|
||||
관련 가드만 배선한다.
|
||||
- **`migrationStartupRunner` 빈 이름은 반드시 유지해야 한다.** 멀티 인스턴스 모드
|
||||
(`APP_MULTI_INSTANCE_ENABLED=true`)에서 `StartupSafetyValidator`가 이 정확한 이름의 빈 존재를
|
||||
단언하기 때문이다. 동시에 이 빈은 `FlywayMigrationStrategy`로서 Spring Boot Flyway
|
||||
오토컨피규레이션이 `migrate()`를 위임하는 전략이 된다.
|
||||
|
||||
### MigrationStartupRunner
|
||||
- **`FlywayMigrationStrategy`로 등록해 context refresh 중에 마이그레이션을 직접 실행한다(오토컨피그
|
||||
기본값을 그대로 두지 않는다).** refresh 안에서 실행한다는 것은 마이그레이션이 완료되거나 부팅을
|
||||
실패시키는 일이 애플리케이션이 readiness 를 보고하기 *전에* 일어난다는 뜻이다. 따라서 절반만
|
||||
마이그레이션된 스키마가 트래픽을 받는 일이 구조적으로 없다(actuator readiness probe 의 형태
|
||||
자체는 health-lifecycle 브랜치가 소유하고, 여기서는 "마이그레이션 → ready" 순서만 책임진다).
|
||||
- **`FlywayException`을 `MigrationFailedException`(exit 70)으로 번역한다.** 일반적이고 구분 안 되는
|
||||
스택트레이스 대신 구조화된 `MIGRATION_FAILED` 로그와 표준 exit 코드를 남기기 위함이다.
|
||||
- **멀티 인스턴스에서는 in-app 마이그레이션 + Flyway 자체 schema-history 락에 의존하는 것이 기본
|
||||
전략이다.** 여러 레플리카의 동시 `migrate()`를 Flyway 의 락이 직렬화한다. 대안인 "in-app
|
||||
마이그레이션을 끄고 플랫폼 one-shot Job 으로 돌리기"는 "migration=Job"을 강제하는 공개 표준이
|
||||
없는 배포 선택이라 스켈레톤 기본값이 아니다. Job 으로 돌리는 프로젝트는 이 빈의 `migrate()`를
|
||||
no-op 으로 만들면 된다.
|
||||
|
||||
### 시작 실패 타입(StartupErrorCode / StartupPhase / StartupFailures / *Exception)
|
||||
- **`StartupErrorCode` — 네 가지 시작 실패 원인을 (레지스트리 error code, 표준 프로세스 exit 코드,
|
||||
`StartupPhase`)로 묶는다.** exit 78(env 누락/오류)과 70(마이그레이션 실패)은 sysexits 표준
|
||||
(`EX_CONFIG`, `EX_SOFTWARE`)과 정렬돼 외부적으로 방어 가능하다. 반면 exit 71(프로파일 불일치)과
|
||||
72(필수 어댑터 비활성)는 ca-tmpl 내부 규약이다 — sysexits 의 `EX_OSERR`/`EX_OSFILE`이 이 의미와
|
||||
맞지 않아, 외부 표준이 아니라 내부 룩업 테이블 값으로 정했다. exit 코드는 carrying 예외가
|
||||
`ExitCodeGenerator`를 구현하기 때문에만 JVM 종료 상태가 된다.
|
||||
- **`StartupPhase` — 실패 원인을 운영자가 로그에서 구분할 수 있게 하는 `startup.phase` 필드다.**
|
||||
시작 실패는 요청이 아직 없으므로 HTTP 에러 응답에 절대 실리지 않는다. 따라서 구조화 로그의
|
||||
`startup.phase`가 `kubectl logs`/`describe`에서 네 가지 원인을 가려내는 유일한 계약이다. wire
|
||||
이름은 dash-case(`env-validation | migration | adapter-enablement | profile-check`)이며, MDC 키가
|
||||
아니라 Logstash structured argument 로 방출되므로 snake_case MDC 키 레지스트리 규칙은 적용되지
|
||||
않는다.
|
||||
- **`StartupFailures` — 시작 실패를 던지는 단일 출처로, 예외 생성 *전에* 구조화 로그를 먼저
|
||||
방출한다.** "throw 직전 각 validator 가 직접 logger 를 호출"하는 패턴을 중앙화해, 어떤 시작 가드도
|
||||
원인/구분자 없는 실패를 던지지 못하게 한다. 로그는 `startup.phase` / `error.code` /
|
||||
`error.category` 세 필드를 Logstash structured argument 로 싣고, cause 가 있으면
|
||||
`error.root_cause.class` / `error.root_cause.message` 요약 필드만 추가한다. 원본 cause 는 반환되는
|
||||
`StartupFailureException`에 보존하지만 SLF4J throwable 인자로 넘기지 않는다. 따라서 canonical
|
||||
startup-failure 로그는 운영자가 분류할 수 있는 요약만 남기고 프레임워크/드라이버 stacktrace 를
|
||||
출력하지 않는다.
|
||||
- **`StartupFailureException` — 네 실패 원인의 베이스 타입이며 `ExitCodeGenerator`로 exit 코드를 JVM
|
||||
종료 상태로 만든다.** context refresh 가 실패하면 `SpringApplication`의 `SpringBootExceptionHandler`
|
||||
(부팅 스레드의 uncaught-exception handler 로 설치됨)가 실패 예외에서 `getExitCode()`를 읽어
|
||||
`System.exit(code)`를 호출한다 — `main()` 수정이 필요 없다. `IllegalStateException`을 상속하는
|
||||
이유는 과거 `StartupSafetyValidator`가 `IllegalStateException`을 던졌기 때문으로, 타입드 예외로
|
||||
업그레이드한 뒤에도 소스/동작 호환을 유지하기 위함이다.
|
||||
- **`StartupFailureExceptionReporter` / `StartupFailureSpringBootLogFilter` — Spring Boot 의 중복
|
||||
startup stacktrace 를 억제한다.** reporter 는 failure cause chain 에 `StartupFailureException`이
|
||||
있을 때만 `true`를 반환해 Boot 의 generic `Application run failed` 출력을 "이미 보고됨"으로
|
||||
처리한다. 이후 Boot 가 반쯤 초기화된 context 를 닫다가 `Unable to close ApplicationContext`를 다시
|
||||
남길 수 있으므로, canonical startup-failure 로그가 이미 찍힌 프로세스에서는 해당
|
||||
`org.springframework.boot.SpringApplication` 중복 메시지만 logback turbo filter 가 차단한다. 일반
|
||||
startup failure 가 아닌 예외와 다른 logger/message 는 기존 Spring Boot 로그 경로를 유지한다.
|
||||
- **`RequiredAdapterDisabledException`은 런타임-라이프사이클의 `ADAPTER_DISABLED`와 의도적으로
|
||||
구분된다.** 이쪽은 *시작* 검증(필수 어댑터/조정 빈이 꺼져 있음)이고, 후자는 비활성 옵션 어댑터에
|
||||
대한 *런타임* 호출이다.
|
||||
|
||||
---
|
||||
|
||||
## runtime — 시크릿 소스 선택과 런타임 안전 가드
|
||||
|
||||
시크릿을 어디서 읽을지(env / Vault / 클라우드 시크릿 매니저)를 인터페이스 뒤로 숨기고, 위험한
|
||||
런타임 설정값은 기동 시점에 막는다.
|
||||
|
||||
### SecretSource
|
||||
- **시크릿 해석을 인터페이스 한 겹 뒤로 숨긴 backend seam 이다.** 시크릿이 필요한 코드는 이
|
||||
인터페이스에만 의존하고, 실제 백엔드(env / Vault / AWS Secrets Manager / GCP Secret Manager)는
|
||||
`SecretSourceFactory`가 설정값으로 고른다. 설정과 구현 선택을 한 factory에 모으는 패턴이다.
|
||||
백엔드를 추가하는 비용이 "새 `SecretSource` 구현 1개 + `SecretSourceStrategy` enum 값 1개 +
|
||||
factory case 1개"로 고정되고, 소비자(`SecretSourceValidator`, 향후 어댑터)는 전혀 손대지 않는다.
|
||||
- **빈 문자열은 "없음"으로 취급한다.** `resolve`가 blank 값을 `Optional.empty()`로 돌려주지 않으면,
|
||||
"값은 존재하지만 비어 있는" 시크릿이 존재 검사(presence check)를 통과해 버려 prod 부팅을 막을 수
|
||||
없게 된다.
|
||||
|
||||
### SecretSourceStrategy
|
||||
- **enum 값 하나가 곧 백엔드 하나라는 확장 규약을 못 박는다.** `ca-skeleton.secret-source.strategy`로
|
||||
바인딩되며 `ENVIRONMENT`만 기본 제공한다. 미래 백엔드(VAULT, AWS_SECRETS_MANAGER,
|
||||
GCP_SECRET_MANAGER)는 주석으로만 남겨 두어, 추가 절차가 "enum 값 + `SecretSource` 구현 + factory
|
||||
case"임을 코드에서 바로 읽히게 했다(rate-limit algorithm enum 패턴과 동일).
|
||||
|
||||
### SecretSourceSettings
|
||||
- **설정이 없어도 동작하는 기본값(ENVIRONMENT)을 박았다.** 스켈레톤은 별도 설정 없이 바로 부팅돼야
|
||||
하므로, `strategy`가 `null`이면 `ENVIRONMENT`로 보정한다. 백엔드 교체는 코드 수정이 아니라 설정
|
||||
한 줄로 끝난다.
|
||||
|
||||
### SecretSourceConfig
|
||||
- **시크릿 백엔드와 그 시작 가드를 composition root 에서만 와이어링한다.** `SecretSourceFactory`로
|
||||
선택된 `SecretSource`(기본 `ENVIRONMENT`)와, 그것을 통해 필수 시크릿을 검증하는
|
||||
`SecretSourceValidator`를 빈으로 노출한다. 검증기를 Spring 스테레오타입 없는 일반 클래스로 둔
|
||||
이유는 단위 테스트 가능성을 지키기 위함이고, 이 config 가 유일한 production 와이어링이다.
|
||||
- **`RuntimeSafetyConfig`와 일부러 분리했다.** 시크릿 계약은 secrets-config-source-contract 가
|
||||
소유하고, 런타임 안전 토글은 env-driven-runtime-configuration 이 소유한다. 소유 경계를 코드 구조로
|
||||
보존하기 위해 한 config 로 합치지 않았다.
|
||||
|
||||
### SecretSourceFactory
|
||||
- **유일한 확장 지점을 `switch` 하나로 모았다.** 새 백엔드는 `SecretSourceStrategy` 값 +
|
||||
`SecretSource` 구현 + 이 `switch`의 case 추가로 끝나고 소비자는 바뀌지 않는다.
|
||||
factory 한곳에 "확장 비용이 어디에 있는가"를 보이게 했다.
|
||||
|
||||
### EnvironmentSecretSource
|
||||
- **기본 백엔드는 Spring `Environment`에서 읽는 것이다.** `ENVIRONMENT` 전략은 시크릿이 env var /
|
||||
마운트 파일 / `application.yml`로 들어와 Spring 자체 property resolution 으로 바인딩됨을 전제한다.
|
||||
Vault·AWS·GCP 연동은 소비자를 건드리지 않고 `SecretSourceFactory` 뒤에 별도 `SecretSource`
|
||||
구현으로 추가하면 된다.
|
||||
|
||||
### SecretSourceValidator
|
||||
- **prod 에서 깨지면 부팅 자체를 거부하는 fail-fast 가드다.** 두 가지 prod 불변식을 context refresh
|
||||
완료 전에 강제한다. (1) `__LOCAL_DEV_` 접두사를 가진 dev/local 가짜 자격증명 값이 prod 프로파일에
|
||||
도달하는 것은 잘못 로드된 사고이므로 부팅을 중단시킨다. (2) 레지스트리에서 `secret` +
|
||||
`prod_default: null`로 분류된 모든 키는 prod 에서 비어 있지 않게 주입돼야 한다 — 빈 시크릿으로
|
||||
부팅하는 것은 금지다. dev/local 프로파일은 두 검사를 건너뛴다(`__LOCAL_DEV_*`가 의도된 로컬
|
||||
폴백이므로).
|
||||
- **`SmartInitializingSingleton` 타이밍을 의도적으로 받아들였다.** 이 콜백은 모든 싱글톤 생성 *후*
|
||||
context refresh 완료 *전*에 한 번 실행된다. 따라서 eager `DataSource`가 이 가드보다 먼저
|
||||
`__LOCAL_DEV_` 자격증명으로 연결을 시도할 가능성은 있다. 그러나 prod 에서 `__LOCAL_DEV_` 값에
|
||||
도달하는 것 자체가 예외적 오적재이고, 트래픽을 받기 전에 context 가 중단되므로 이 타이밍으로
|
||||
충분하다고 판단했다. 더 이른 차단이 필요해지면 `EnvironmentPostProcessor`로 승격한다.
|
||||
- **필수 시크릿 검사는 Spring 환경이 아니라 `SecretSource` 백엔드를 통해 한다.** 존재 검사를
|
||||
백엔드(`secretSource.resolve(key)`) 경유로 하기 때문에, env → Vault/AWS 백엔드 교체 시에도 검사가
|
||||
자동으로 따라간다. 검사를 Spring `Environment`에 고정했다면 백엔드를 바꿀 때 검증 로직이 함께
|
||||
끊겼을 것이다.
|
||||
- **`REQUIRED_PROD_SECRETS`는 레지스트리와 1:1로 강제된다.** 이 in-code 상수는
|
||||
`docs/registries/secrets-classification.yaml`의 secret 행과 정확히 일치해야 하며,
|
||||
`SecretsClassificationRegistryTest`가 1:1 일치를 단언한다. 그래서 레지스트리에 시크릿을 추가/제거하고
|
||||
이 상수를 갱신하지 않으면 빌드가 깨진다 — drift 를 컴파일·테스트 단계에서 잡는다.
|
||||
- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 같은 배포 오타도 prod 가드를
|
||||
발동시켜야 하므로 `equalsIgnoreCase`로 비교한다(`Environment#matchesProfiles`는 대소문자를 구분해
|
||||
"PROD"를 통과시킨다).
|
||||
|
||||
### HikariPoolConstraintValidator
|
||||
- **HikariCP 노브 사이의 상호 제약을 부팅 실패로 바꾼다.** Hikari/Tomcat 은 잘못된 값을 결국
|
||||
거부하지만 pool/connector 초기화 시점에 난해한 메시지로만 거부한다. 이 검증기는 계약을 일찍·명확
|
||||
하게 만든다: resolved Spring property 를 읽고, 위반을 모두 모아 운영자가 실제로 설정한 `APP_*`
|
||||
환경 키를 메시지에 담아 `IllegalStateException`을 던진다. 속성이 *없으면* 건너뛰고(placeholder/
|
||||
프레임워크 기본값이 소유), *있을 때만* 검사한다.
|
||||
- **강제하는 제약과 그 숫자의 근거.** `connection-timeout >= 250ms`(너무 짧으면 정상 연결도 타임
|
||||
아웃). `validation-timeout < connection-timeout`(둘 다 있을 때; validation-timeout 기본 5000ms 가
|
||||
흔한 connection-timeout 5s/5000ms 와 같아지는 충돌을 해소). `keepalive-time < max-lifetime`(둘 다
|
||||
있을 때; keepalive 가 lifetime 보다 길면 의미 없음). `leak-detection-threshold`는 0(비활성)이
|
||||
아니라면 `>= 2000ms`(너무 작으면 정상 사용을 누수로 오탐).
|
||||
- **Spring Boot와 같은 Duration 문법을 검증한다.** `env-keys.yaml`의 `connection-timeout`
|
||||
기본값은 `5s`인데 `src/.env`는 `30000`(ms)을 준다. resolved 값을 `String`으로 읽은 뒤
|
||||
`DurationStyle`로 plain milliseconds, simple duration(`5s`)과 ISO-8601(`PT5S`)을 같은
|
||||
milliseconds 계약으로 변환한다. present-but-invalid 값은 부재로 조용히 취급하지 않고 property
|
||||
이름을 포함한 startup validation failure로 거절한다.
|
||||
- **env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다.** `connection-timeout` /
|
||||
`max-lifetime`만 `env-keys.yaml`에 등록돼 있고, greenfield 노브(validation-timeout, keepalive-time,
|
||||
leak-detection-threshold)는 env 키가 없다. 없는 키 이름을 지어내는 대신 pending 문구를 메시지에 넣는다.
|
||||
|
||||
### OpenInViewSafetyValidator
|
||||
- **OSIV(Open Session In View) OFF 를 hard stop 으로 강제한다.** OSIV 가 켜져 있으면 Hibernate
|
||||
세션이 view 렌더링까지 열려 있어, presentation 레이어에서 lazy 연관을 건드리면 거기서 조용히 DB
|
||||
쿼리가 나간다 — 이것이 정확히 금지된 레이어 경계 위반이다. Spring Boot 는
|
||||
`spring.jpa.open-in-view`가 암묵 기본값일 때 WARN 만 찍을 뿐 명시적으로 `true`로 설정한 배포는
|
||||
막지 못한다. WARN 은 놓치기 쉬우므로, resolved 값이 `true`면 운영자가 실제 설정한
|
||||
`APP_DATASOURCE_OPEN_IN_VIEW` 키를 메시지에 담아 부팅을 실패시킨다.
|
||||
- **읽기만 하고 re-bind 하지 않으며, 부재 값은 Spring 기본에 맡긴다.** `SmartInitializingSingleton`
|
||||
으로 한 번만 검사하고, 값이 *없으면* Spring Boot 기본(이 스켈레톤은 `application.yml`에서 OSIV off
|
||||
가 기본)에 맡기며 *있는 `true`*만 거부한다.
|
||||
|
||||
### JpaSchemaSafetyValidator
|
||||
- **Flyway를 production physical schema의 유일한 writer로 유지한다.** `prod` profile에서는
|
||||
`spring.jpa.hibernate.ddl-auto`가 `none` 또는 `validate`일 때만 허용한다. `update`, `create`,
|
||||
`create-drop` 또는 그 밖의 값이면 `APP_DATASOURCE_DDL_AUTO`를 이름으로 포함한
|
||||
`PROFILE_MISMATCH`로 부팅을 중단한다.
|
||||
- **local 개발 편의와 production 권위를 분리한다.** non-prod profile의 `update`/`create`는 이
|
||||
validator가 막지 않는다. prod profile 비교와 mode 비교는 대소문자를 무시해 `PROD`/`UPDATE`
|
||||
같은 변형도 guard를 우회하지 못한다.
|
||||
|
||||
### RuntimeNumericBoundsValidator
|
||||
- **고위험 숫자 노브(pool/thread 사이징)만 일부러 좁게 검증한다.** pool/connector 사이징 키는
|
||||
Spring-native property(`spring.datasource.hikari.*`, `server.tomcat.*`)로 직결되고 `env-keys.yaml`이
|
||||
각각을 `positive_int` / `non_negative_int`로 표시하지만, 이 표시는 그동안 강제되지 않았다 —
|
||||
Hikari/Tomcat 이 결국 거부하더라도 난해한 메시지로 init 시점에야 거부했다. 이 검증기는 resolved
|
||||
Spring property 를 읽어, 운영자가 실제 설정한 `APP_*` 키를 담은 메시지로 일찍 부팅을 실패시킨다.
|
||||
잘못 설정하면 실제 런타임 장애로 이어지는 키(pool/thread 사이징)에만 범위를 한정했고, Logback
|
||||
소유 로그 사이징·Spring `Duration` 키는 (해당 라이브러리가 검증하므로) 범위 밖이다.
|
||||
- **읽기 전용으로 Spring 바인딩을 중복하지 않고, 부재 키는 skip 한다.** 값이 없으면 placeholder/
|
||||
프레임워크 기본값이 소유하므로 건너뛰고, 범위를 벗어난 *있는* 값만 거부한다("lenient default
|
||||
금지").
|
||||
|
||||
### StartupSafetyValidator
|
||||
- **`SmartInitializingSingleton` 타이밍을 고른 이유가 핵심이다.** 검사는 모든 싱글톤 생성 후 context
|
||||
refresh 완료 전에 한 번 돈다. `EnvironmentPostProcessor`는 bean 정의가 생기기 전에 실행돼 bean
|
||||
*존재 여부*(multi-instance 검사에 필요)를 검사할 수 없고, `ApplicationReadyEvent` 리스너는 트래픽
|
||||
직전에야 발동해 잘못된 부팅을 거부하기엔 너무 늦다. 그래서 이 중간 타이밍을 택했고, 위반 시
|
||||
throw 하여 context 가 시작을 거부한다.
|
||||
- **prod 안전 토글은 잘못 켜져 있으면 PROFILE_MISMATCH(exit 71)로 죽인다.** prod 프로파일에서 내부
|
||||
상세 노출/요청 본문 캡처 토글이 켜져 있으면 구조화된 startup-failure 로그와 함께 부팅을 중단한다.
|
||||
- **multi-instance 가 켜지면 조율 bean 이 모두 존재해야 한다.** `APP_MULTI_INSTANCE_ENABLED=true`일
|
||||
때 `REQUIRED_MULTI_INSTANCE_BEANS`(distributed lock / cache stampede protection / outbox leader
|
||||
election / distributed rate limiter / migration startup job)가 하나라도 빠지면
|
||||
REQUIRED_ADAPTER_DISABLED(exit 72)로 실패시킨다. 이 bean 들은 각각 다른 브랜치가 소유하고, 이
|
||||
검증기는 존재 여부만 단언한다.
|
||||
- **prod 판정은 대소문자 무시다.** `SPRING_PROFILES_ACTIVE=PROD` 오타도 prod-safety 가드를
|
||||
발동시켜야 하므로 `equalsIgnoreCase`로 비교한다.
|
||||
|
||||
---
|
||||
|
||||
## concurrency — 도메인 컨텍스트 전파 전략 조립
|
||||
|
||||
### DomainContextConfig
|
||||
- **`DomainContextPropagator`를 빈으로 노출하고, 설정에서 고른 `DomainContextStrategy`로
|
||||
`DomainContextPropagatorFactory`를 통해 조립한다.** `application-core`는 설정을 직접 읽지 않는다는
|
||||
원칙 때문에, 전략 해석과 propagator 생성은 composition root(여기)에서 끝내고 완성된 propagator 만
|
||||
주입한다. 그래서 use case 코드는 어떤 전파 전략을 쓰는지 몰라도 되고, 전략 교체는 코드가 아니라
|
||||
설정으로만 일어난다.
|
||||
|
||||
### DomainContextSettings
|
||||
- **`ca-skeleton.domain-context.strategy`가 비어 있으면 `THREAD_LOCAL`로 기본값을 채운다.** 스켈레톤은
|
||||
"설정 없이도 바로 동작하는 기본값"을 제공하는 것을 원칙으로 하므로, 전략을 강제로 지정하게 만들지
|
||||
않고 가장 안전한 `THREAD_LOCAL`을 디폴트로 둔다. 이 전략은 rate-limit 알고리즘처럼 전용 환경변수
|
||||
를 두지 않는데, 보안 비밀이나 배포별 값이 아니라 운영상의 선택지라 설정 바인딩만으로 충분하기
|
||||
때문이다.
|
||||
|
||||
---
|
||||
|
||||
## async — 비동기 executor 컨텍스트 전파와 포화 처리
|
||||
|
||||
### AsyncContextTaskDecorator
|
||||
- **executor 경계를 넘을 때 caller 스레드의 컨텍스트를 worker 스레드로 옮기는 단 하나의
|
||||
`TaskDecorator`다.** MDC 는 submit 시점에 맵 전체를 복사한다(`MDC.getCopyOfContextMap()`). 그래서
|
||||
async 전파 대상 4개 foundation 키(`request_id`/`trace_id`/`correlation_id`/`tenant_id`)와
|
||||
`span_id`(SLF4J-Micrometer tracing bridge 가 MDC 에 써넣는 값)가 worker 로그 라인에 그대로
|
||||
따라온다. 특정 상수 holder(`MdcKeys`)에 결합하지 않고 맵을 통째로 복사하는 이유는, 그래야 그
|
||||
시점에 존재하는 모든 키를 빠짐없이 옮길 수 있기 때문이다.
|
||||
- **domain 컨텍스트는 직접 복사하지 않고 shared seam(`DomainContextPropagator.wrap`)에 위임한다.** 이
|
||||
브랜치는 executor 배선만 소유하고, caller→worker 의 실제 hand-off 는 seam 이 소유한다는 책임 분리에
|
||||
따른 것이다.
|
||||
- **trace/span "문자열"만 옮기지 Micrometer `Observation` scope 는 worker 스레드에서 다시 열지
|
||||
않는다.** 그래서 로그 연속성은 유지되지만, worker 에서 새로 만든 child observation 은 caller 의
|
||||
span 아래로 nesting 되지 않는다. 완전한 scope 전파는 `io.micrometer:context-propagation` + Spring
|
||||
의 `ContextPropagatingTaskDecorator`가 필요한데, 그 라이브러리를 의도적으로 classpath 에 두지
|
||||
않았다. 따라서 "수동 4-key copy decorator" 경로를 채택하고, worker 스레드 span 이 정말 필요한
|
||||
프로젝트를 위해 라이브러리 업그레이드를 확장 지점(seam)으로 문서화해 둔다.
|
||||
- **`SecurityContext` principal 은 일부러 전파하지 않는다.** `user_principal`은 전파 대상이 아니고,
|
||||
pooled 로 재사용되는 worker 스레드에 `SecurityContext`를 복사하는 것은 stale-context 위험이기
|
||||
때문이다. principal 이 worker 에서 진짜 필요한 use case 는 자기 executor 를 Spring Security 의
|
||||
`DelegatingSecurityContextTaskExecutor`로 감싸서 명시적으로 opt-in 한다.
|
||||
- **decorator 는 대칭적이다.** 태스크 실행 후 worker 스레드의 이전 MDC 를 복원하므로, pooled
|
||||
스레드가 한 태스크의 컨텍스트를 다음 태스크로 흘리지 않는다.
|
||||
|
||||
### AsyncExecutorConfig
|
||||
- **Spring Boot 가 auto-config 하는 `applicationTaskExecutor`를 bounded pool 로 교체한다.** Boot 기본
|
||||
executor 의 큐는 unbounded(`Integer.MAX_VALUE`)라 금지된다. 빈 이름을 `applicationTaskExecutor`로
|
||||
두는 이유는 Boot 의 `@ConditionalOnMissingBean(Executor.class)` auto-config 를 back-off 시키면서,
|
||||
동시에 `@Async`가 resolve 하는 바로 그 executor 가 되게 하기 위함이다.
|
||||
- **TaskDecorator 미설정 executor 등록은 ApplicationContext 기동 실패로 만든다.**
|
||||
`AsyncContextTaskDecorator`를 executor 빈의 *필수* 생성자 의존으로 두었기 때문에, decorator 없이
|
||||
executor 를 등록하려 하면 컨텍스트가 뜨지 않는다.
|
||||
- **`awaitTerminationSeconds(19)`는 컨테이너 app-shutdown 예산 20s 에서 cleanup margin 1s 를 뺀
|
||||
값이다.** 25s 는 금지된다 — 컨테이너가 강제 종료하기 전에 graceful drain 이 끝나야 하기 때문이다.
|
||||
- **`executor.saturation` gauge 는 살아있는 큐 깊이를 읽는다.** 큐 > 용량 80% 면 p2, rejection
|
||||
발생이면 p1 알림 기준이다.
|
||||
|
||||
### AsyncExecutorSettings
|
||||
- **큐는 반드시 bounded 여야 한다.** unbounded 큐는 `maxPoolSize`를 도달 불가능하게 만든다 — JDK
|
||||
`ThreadPoolExecutor`는 큐가 가득 찼을 때만 core 이상으로 스레드를 늘리기 때문이다. 게다가 Spring
|
||||
`ThreadPoolTaskExecutor`의 큐 기본값은 `Integer.MAX_VALUE`, 즉 사실상 unbounded 다. 그래서 compact
|
||||
생성자가 큐 용량으로 `Integer.MAX_VALUE`를 거부한다 — 그만큼 큰 용량은 이름만 다른 unbounded
|
||||
sentinel 이기 때문이다.
|
||||
- **검증은 fail-fast(ApplicationContext 기동 실패)다.** 잘못 설정된 pool 은 런타임 fault 가 아니라
|
||||
deploy-time 버그이므로, 런타임까지 끌고 가지 않고 기동 시점에 즉시 터뜨린다.
|
||||
- **cross-field 불변식: `maxSize`는 `coreSize`보다 작을 수 없다.** core 보다 작은 max 는 무의미하고,
|
||||
pool 이 설정된 core 크기에조차 도달하지 못하게 만들기 때문이다.
|
||||
|
||||
### BackgroundJobMetrics
|
||||
- **이 브랜치가 소유하는 background-job/async-executor 메트릭 어휘 SSOT 다.** 메트릭 이름·태그는
|
||||
`docs/registries/metrics.yaml`과 verbatim 일치한다: `executor.saturation`(gauge, `executor_name`),
|
||||
`executor.rejected.total`(counter, `executor_name`+`policy`), `job.retry.total`(counter,
|
||||
`job_name`+`outcome`), `job.dlq.total`(counter, `job_name`).
|
||||
- **`executor.*` 두 meter 는 `AsyncExecutorConfig`가 라이브로 배선하고, `job.*` 두 recorder 는 어휘
|
||||
표면만 먼저 출시한다.** retry/DLQ 어휘는 outbox/outbound 브랜치가 나중에 retry carrier 를 고르면
|
||||
소비하라고 이 브랜치가 소유한다. carrier 자체는 아직 정해지지 않았으므로, retry 엔진이 아니라
|
||||
vocabulary surface 만 출시하는 것이다.
|
||||
- **`MeterRegistry` 빈이 없으면(=classpath 에 Actuator 없음) 모든 연산이 no-op 다.** `ObjectProvider`로
|
||||
registry 를 resolve 하며, 이는 `OutboxMetrics` 선례를 그대로 따른다.
|
||||
- **`RetryOutcome` enum 은 retry 어휘를 type-safe 하게 표기한 것이다.** `job.retry.total`의 `outcome`
|
||||
태그 값은 metrics.yaml 에서 4개(SUCCESS/RETRY/EXHAUSTED/DLQ)로 bounded 되어 있고, 미래의 retry
|
||||
carrier 가 이 값을 emit 한다.
|
||||
|
||||
### LoggingAbortPolicy
|
||||
- **기본 포화 정책은 `AbortPolicy`다.** bounded 큐가 가득 차고 pool 이 `maxPoolSize`에 도달하면
|
||||
태스크를 거부하고 그 거부를 caller 에게 전달한다. 이 wrapper 는 bare
|
||||
`ThreadPoolExecutor.AbortPolicy`가 빠뜨리는 두 가지 계약 의무를, abort 를 다시 던지기 *전에*
|
||||
추가한다: (1) `error.code=JOB_EXECUTOR_REJECTED` + category + `executor_name` + `policy`를 담은
|
||||
structured ERROR 로그, (2) `executor.rejected.total{executor_name, policy}` counter 증가.
|
||||
- **그 다음 `RejectedExecutionException`을 던져 AbortPolicy 의미를 유지한다.** fire-and-forget
|
||||
`@Async` caller 의 거부가 조용히 삼켜지지 않도록 하기 위함이다 — 이 예외가 async-exception 계약이
|
||||
흡수하는 신호다.
|
||||
- **허용되는 유일한 대안은 `CallerRunsPolicy`뿐이고, 그것도 명시적 use-case 선언이 있을 때만이다.**
|
||||
caller-runs 의 back-pressure 는 request 스레드 latency 를 갉아먹기 때문이다. unbounded 큐는 아예
|
||||
금지다.
|
||||
|
||||
---
|
||||
|
||||
## idempotency — 멱등성 런타임 조립과 TTL 상한
|
||||
|
||||
### IdempotencyConfig
|
||||
- **공유 `Clock` 빈과 `IdempotencyExecutor`를 composition root 에서 조립하고, `@EnableScheduling`으로
|
||||
만료 레코드 reaper 의 스케줄 purge 를 켠다.** `Clock`을 빈으로 한 번만 정의해 executor·persistence
|
||||
store 어댑터·reaper·rate-limit 인터셉터가 같은 시계를 쓰게 만들어 테스트에서 시간 고정이 쉽고
|
||||
분기마다 시간 해석이 어긋나지 않는다. executor 를 여기서 만드는 이유는 TTL 같은 설정을
|
||||
`application-core`가 직접 읽으면 안 되기 때문이다. `@Scheduled` purge 는 `@EnableScheduling` 없이는
|
||||
동작하지 않으므로 이 설정 클래스에서 명시적으로 활성화한다.
|
||||
|
||||
### IdempotencySettings
|
||||
- **TTL 기본값은 24h 이고 상한은 72h 이며, 이 상한 검사는 컴팩트 생성자에서 fail-fast 로 던진다.**
|
||||
`Duration`의 상한은 JSR-303(`@Valid`) 애너테이션으로 표현할 수 없는 교차 필드 불변식이라 생성자에서
|
||||
직접 검증한다. 검증을 느슨하게 두고 넘어가면 과도하게 긴 TTL 이 저장소를 조용히 부풀리고 멱등성 키
|
||||
추측 공격의 유효 시간 창을 넓히기 때문에, 부팅 시점에 즉시 실패시키는 쪽을 택했다.
|
||||
- **`MAX_TTL`(72h) / 기본 TTL(24h)을 바꿀 때는 보안 베이스라인의 JWT 키 회전 겹침(overlap) 윈도와
|
||||
반드시 함께 검토해야 한다.** 멱등성 레코드의 수명이 키 회전 겹침 윈도(현재 24h)보다 길면, 가명화
|
||||
(pseudonym) 기준이 회전된 principal 에 대해 레코드가 재생(replay)될 수 있다. 이 불변식을 강제하는
|
||||
CI 게이트는 security-operational-baseline 쪽에 위임돼 있다.
|
||||
- **`reaperInterval`은 전용 환경변수가 없고 기본 10분이다.** reaper 실행 주기는 비밀이나 배포별 값이
|
||||
아니라 순수 운영 튜닝 값이라 env key 를 따로 두지 않고, 비어 있거나 0/음수면 10분으로 채운다.
|
||||
|
||||
---
|
||||
|
||||
## lock — 분산 락 메트릭 데코레이터 배선
|
||||
|
||||
### DistributedLockConfig
|
||||
- **`distributedLockProvider` 빈은 `ca-skeleton.runtime.multi-instance-enabled=true`일 때만
|
||||
`@ConditionalOnProperty`로 등록되고 `@Primary`로 우선 적용된다.** 다중 인스턴스 환경에서만
|
||||
`adapter-persistence`의 원시 `jdbcDistributedLock`(JdbcLockRegistry, Flyway 가 프로비저닝한
|
||||
`INT_LOCK` 기반) 어댑터를 `MeteredDistributedLockPort`로 감싸 `lock.acquisition` 카운터를 기록한다.
|
||||
- **단일 인스턴스에서는 이 조건부 빈이 아예 없으므로 `adapter-persistence`가 등록한 `@Primary
|
||||
inProcessDistributedLock`(in-process `DefaultLockRegistry`)이 그대로 선택된다.** 이 경로는 의도적
|
||||
으로 계측하지 않는다 — `lock.acquisition` 카운터는 인스턴스 간 분산 조율을 관측할 때만 의미가
|
||||
있고, JVM 안의 단순 뮤텍스에는 무의미하기 때문이다.
|
||||
- **빈 이름 `"distributedLockProvider"`는 `StartupSafetyValidator`가 이름으로 조회해 검증하는
|
||||
계약이다.** 절대 이름을 바꾸지 말 것.
|
||||
|
||||
### MeteredDistributedLockPort
|
||||
- **`DistributedLockPort`를 감싸는 얇은 Micrometer 데코레이터다.** `tryAcquire` 호출마다
|
||||
`lock.acquisition` 카운터를 `outcome` 태그와 함께 1 증가시킨다. 태그 값은 세 가지: `acquired`(락
|
||||
획득 성공, 위임 핸들 반환), `timeout`(`LockAcquisitionTimeoutException` 발생), `error`(그 외 모든
|
||||
`RuntimeException` 발생). 메트릭 이름과 태그 값은 `docs/registries/metrics.yaml`의 `lock.acquisition`
|
||||
행과 일치해야 한다.
|
||||
- **리스 만료(lease-expiry) CME 흡수.** 보유자가 `close()`를 호출하기 전에 리스(저장소 TTL)가
|
||||
만료되면 그 락 행은 이미 다른 인스턴스가 회수해 갔을 수 있고, 그 시점에 내부 `JdbcLock.unlock()`은
|
||||
`ConcurrentModificationException`(CME)을 던진다. 이 예외를 호출자의 `finally { lock.close(); }`
|
||||
블록 밖으로 그대로 전파하면 보호 구간(critical section)에서 발생한 본래 예외를 가려버린다
|
||||
(masking). 그래서 CME 만 잡아 WARN 로그를 남기고 태그 없는 `lock.lease.expired` 카운터를 1
|
||||
증가시킨 뒤 `close()`는 정상 복귀시킨다. 이렇게 해야 신호(로그+메트릭)는 남기면서도 호출자의 정상
|
||||
흐름과 예외 전파를 방해하지 않는다. 오직 `ConcurrentModificationException`만 흡수하며, DB 장애 같은
|
||||
다른 예외(예: `DataAccessResourceFailureException`)는 리스 만료 신호가 아니므로 손대지 않고 그대로
|
||||
전파한다.
|
||||
- **MeterRegistry 부재 시 no-op.** 레지스트리를 `ObjectProvider.getIfAvailable()`로 해석하므로
|
||||
클래스패스에 `MeterRegistry` 빈이 없으면(Actuator 미탑재) 모든 메트릭 연산이 조용히 no-op 이
|
||||
된다. `BackgroundJobMetrics` 선례와 동일한 패턴이다.
|
||||
- **메트릭 실패가 락 경로를 절대 깨뜨리지 않음.** 카운터 등록·증가는 모두 try/catch 로 감싸 예외를
|
||||
로깅 후 삼킨다(log-and-swallow). Micrometer 쪽 실패가 락 획득·보유·실패 보고를 막는 일은 없어야
|
||||
하기 때문이다.
|
||||
|
||||
---
|
||||
|
||||
## outbox — 트랜잭셔널 아웃박스 릴레이 와이어링
|
||||
|
||||
### OutboxConfig
|
||||
- **릴레이 use case 를 `@Service`가 아니라 `app-bootstrap`에서 수동 조립한다.**
|
||||
`PublishPendingOutboxEventsUseCase`는 `batchSize`와 `inFlightTimeout` 같은 설정값을 생성자로 받아야
|
||||
하는데, `application-core`는 설정(`OutboxSettings`)을 직접 읽으면 안 된다. 그래서 설정을 볼 수 있는
|
||||
합성 루트(`OutboxConfig`)가 값을 꺼내 use case 를 손으로 만들어 넘긴다. use case 클래스의
|
||||
`@UseCaseCapability` 애너테이션은 와이어링 방식과 무관하게 유지된다(ArchUnit 이 강제).
|
||||
- **bootstrap은 failure reporter를 구현하지 않고 주입만 한다.** `MessagingConfig`가 broker 설정에서
|
||||
정확히 하나의 `OutboxRelayFailureReportPort` 구현을 만들고, `OutboxConfig`는 이를 relay 생성자에
|
||||
전달한다. broker가 비활성이어도 reporter bean은 존재한다.
|
||||
- **릴레이 use case 를 독립 컨텍스트 빈으로 등록하지 않는다.** 만약 빈으로 올리면 `adapter-web`의
|
||||
`MethodSecurityConfig` 메서드 보안 pointcut(`@RequiresPermission`)이 이 타입을 CGLIB 프록시로
|
||||
감싼다. 그런데 use case 가 `final` 클래스라 프록시 생성 자체가 실패하고, 설령 된다 해도 스케줄러
|
||||
스레드에는 `Authentication`이 없어 매 릴레이 틱이 fail-closed 로 거부된다. 그래서 빈으로 올리지
|
||||
않고, `outbox:relay` 권한 확인은 스케줄러 컨텍스트에서 관례로 둔다.
|
||||
- **`outboxRelayScheduler` 빈은 `ca-skeleton.outbox.relay-enabled`로 게이팅한다(기본 true,
|
||||
`matchIfMissing=true`).** 릴레이를 끄고 싶을 때 빈 자체가 만들어지지 않게 하기 위함. 키가 없으면
|
||||
켜진 것으로 본다.
|
||||
- **`outboxLeaderElection` 토큰 빈은 조건 없이(unconditional) 항상 등록한다.** `StartupSafetyValidator`
|
||||
가 `APP_MULTI_INSTANCE_ENABLED=true`일 때 이 빈을 반드시 찾을 수 있어야 하기 때문이다. SKIP
|
||||
LOCKED 는 멀티 인스턴스 여부와 무관하게 언제나 릴레이의 리더십 메커니즘이라, `@ConditionalOnProperty`
|
||||
로 끌 수 있게 만들면 단순 조정 기능이 아니라 릴레이 정확성 자체가 깨진다.
|
||||
- **`outboxMetrics` 빈은 `MeterRegistry`가 없을 때 no-op 이다.** Actuator 가 클래스패스에 없는
|
||||
환경에서도 와이어링이 깨지지 않도록 `ObjectProvider`로 레지스트리를 선택적으로 주입한다.
|
||||
|
||||
### OutboxLeaderElectionToken
|
||||
- **로직이 전혀 없는 마커(documentation artifact) 빈이다.** 이 타입은 동작을 갖지 않고, Spring
|
||||
컨텍스트에 존재한다는 사실만으로 멀티 인스턴스 조정 빈 요구사항을 충족시킨다.
|
||||
- **리더 선출을 외부 코디네이터 없이 PostgreSQL `FOR UPDATE SKIP LOCKED`로 구현한다는 것을
|
||||
표현한다.** claim 쿼리에 SKIP LOCKED 를 걸면 각 릴레이 인스턴스가 서로 겹치지 않는(disjoint) 행
|
||||
집합을 가져가므로, 별도 코디네이터 없이도 "각 인스턴스가 자기 파티션의 리더" 형태로 리더 선출
|
||||
의미가 성립한다.
|
||||
|
||||
### OutboxMetrics
|
||||
- **`MeterRegistry`가 없으면 모든 메트릭 연산이 no-op 이다.** Actuator 가 없는 환경에서도 릴레이가
|
||||
정상 동작해야 하므로, 생성자에서 `ObjectProvider`로 레지스트리를 조회해 없으면 게이지도 만들지
|
||||
않고 기록도 건너뛴다.
|
||||
- **카운터는 PUBLISHED/FAILED/DEAD 만 집계하고 IN_FLIGHT 는 제외한다.** IN_FLIGHT 는 릴레이 사이클의
|
||||
종료 결과(terminal outcome)가 아니라 처리 중을 나타내는 일시적 상태라 카운터 outcome 으로 의미가
|
||||
없다.
|
||||
- **MultiGauge 갱신 시 `overwrite=true`로 등록한다.** 매 스케줄러 틱마다 store 를 다시 조회해
|
||||
게이지를 갱신하는데, 이전 틱에 있었지만 지금은 사라진 event type 같은 오래된 time-series 태그를
|
||||
덮어써 제거하기 위함이다.
|
||||
|
||||
### OutboxRelayScheduler
|
||||
- **합성 루트가 직접 등록하고 컴포넌트 스캔하지 않는다.** 이 스케줄러가 구동하는 릴레이 use case 가
|
||||
(위 OutboxConfig 사유로) 일부러 빈이 아니기 때문에, 그 use case 를 조립하는 `OutboxConfig`가
|
||||
스케줄러 등록까지 같이 소유한다.
|
||||
- **릴레이 사이클에서 발생하는 예상치 못한 예외를 잡아 ERROR 로 로깅만 하고 삼킨다.** 스케줄러
|
||||
스레드가 죽으면 릴레이가 조용히 멈추므로, 다음 틱을 위해 스레드를 살려둔다. 단, 개별 발행 실패
|
||||
(FAILED/DEAD 전이)는 relay가 persisted transition 성공 뒤 typed reporter로 canonical ERROR를
|
||||
요청하고 결과에 반영하므로 이 catch 블록까지 오지 않는다 — 여기서 삼키는 것은 어디까지나
|
||||
"예상치 못한" 예외다.
|
||||
- **`@EnableScheduling`을 직접 켜지 않고 fixed-delay 를 쓴다.** 스케줄링은 이미 `IdempotencyConfig`를
|
||||
통해 활성화돼 있어 중복으로 켤 필요가 없고, fixed-delay 는 릴레이 실행 시간과 무관하게 사이클이
|
||||
겹치지 않도록(non-overlapping) 보장한다.
|
||||
|
||||
### OutboxSettings
|
||||
- **여섯 개 설정값 모두 코드 리터럴 기본값을 쓰고 env placeholder 를 두지 않는다.** 신규 env key 를
|
||||
추가하지 않는다는 결정에 따라, 값이 없으면 컴팩트 생성자에서 직접 기본값(예: `PT5S`, `20`,
|
||||
`PT5M`, `PT10M`, `P7D`)을 채운다.
|
||||
- **`reaper-interval`과 `published-retention`은 `adapter-persistence`의 `OutboxReaper`도 property
|
||||
문자열로 읽는다.** 두 모듈이 같은 키를 공유하지만, 이 properties record 가 여섯 값 전체를 문서화
|
||||
하는 단일 지점(single place) 역할을 한다.
|
||||
|
||||
---
|
||||
|
||||
## logging — 로그 시크릿 마스킹·샘플링·가명화
|
||||
|
||||
### LogMaskingPatterns
|
||||
- **마스킹 정규식 규칙을 한 곳에만 둔 단일 진실 원천(SSOT)이다.** JSON 인코더 경로
|
||||
(`SecretMaskingJsonGeneratorDecorator`, staging/prod/default 프로파일)와 사람이 읽기 쉬운 패턴 경로
|
||||
(`SecretMaskingMessageConverter`, local/dev)가 같은 규칙을 공유한다. 규칙을 한 군데로 모아둔 이유는,
|
||||
프로파일이나 로그 포맷을 바꿔도 가려지는 시크릿의 범위가 절대 달라지지 않게 하기 위해서다. 즉
|
||||
"가독성을 위해 포맷을 바꿨더니 시크릿이 다시 노출되는" 사고를 구조적으로 막는다.
|
||||
- **시크릿 값만 가리고 키/스킴은 남긴다.** `token=abc123` → `token=****`,
|
||||
`Authorization: Bearer eyJ...` → `Authorization: Bearer ****`처럼 동작한다. 키와 인증 스킴(`Bearer`
|
||||
등)을 남기는 이유는 진단할 때 "어떤 종류의 자격증명이 있었는지"는 알아야 하기 때문이다.
|
||||
- **정규식 마스킹은 보증이 아니라 심층 방어(defence-in-depth)의 보조 수단이다.** 1차 방어선은
|
||||
"로거가 애초에 본문/페이로드를 받지 않도록 설계한 것"(`FailOpenDependencyLogger`)이고, 이 정규식은
|
||||
그걸 빠져나간 누출을 잡는 그물이다. 키 접두어 없는 Base64URL 블롭 같은 난독화된 형태는 놓칠 수
|
||||
있으므로, 새로운 누출 형태가 보이면 이 규칙 목록을 운영 중에 계속 보강해야 한다.
|
||||
|
||||
### MetricsAsyncAppender
|
||||
- **백프레셔로 로그가 버려질 때 그 사실을 메트릭으로 노출한다.** Logback `AsyncAppender`는 큐 여유가
|
||||
`discardingThreshold` 아래로 떨어지면 `INFO`/`DEBUG` 같은 낮은 심각도 이벤트를 조용히 버린다
|
||||
(`WARN`/`ERROR`는 항상 보존). 이렇게 조용히 사라지는 드롭은 관측이 안 되면 장애 분석 때 "로그가
|
||||
비어 있는데 왜 비었는지 모르는" 상황을 만들기 때문에, 버릴 때마다 `log.appender.dropped.total`
|
||||
Micrometer 카운터를 올리고(`appender`·`level` 태그) 그다음 상위 클래스에 실제 드롭을 위임한다.
|
||||
- **카운터를 `Metrics.globalRegistry`로 발행한다.** Logback 은 Spring 컨텍스트보다 먼저 초기화되는데,
|
||||
Spring Boot 가 시작 시 애플리케이션 `MeterRegistry`를 이 전역 컴포지트에 합류시킨다. 그래서 초기화
|
||||
순서가 어긋나도 결국 평소 메트릭 엔드포인트에서 이 수치가 보인다.
|
||||
- **`level` 태그를 `INFO`/`DEBUG`로만 한정한다.** `metrics.yaml`의 `allowed_values` 계약 때문이다.
|
||||
루트 레벨을 TRACE 로 낮춰야만 가능한 `TRACE` 드롭은 상위 클래스가 여전히 버리지만 카운트하지는
|
||||
않아서, 태그 카디널리티가 레지스트리 계약을 벗어나지 않게 유지한다.
|
||||
|
||||
### PseudonymizationConfig
|
||||
- **`user_principal`(로그에 남는 사용자 식별자)를 전체 HMAC 방식으로 가명화한다.**
|
||||
`UserPrincipalPseudonymizerPort`(application-core)를 HMAC-SHA-256 구현(adapter-identifier)에
|
||||
바인딩하고, 솔트는 `PrivacySettings`에서 가져온다. 원본 식별자를 그대로 로그에 남기지 않으려는
|
||||
개인정보 보호 결정이다. 실제 사용처는 `adapter-web`의 `RequestLoggingFilter`로, 보안 principal 이
|
||||
MDC/로그에 닿기 전에 이 포트로 가린다.
|
||||
- **`@ConditionalOnMissingBean`으로 기본 구현을 둔다.** 이 템플릿을 포크한 프로젝트가 자기만의
|
||||
가명화기(예: 향후 솔트 회전을 지원하는 구현)를 등록하면 이 기본 빈이 비켜주도록, 즉 기본값은
|
||||
제공하되 교체를 막지 않도록 하기 위해서다.
|
||||
|
||||
### SamplingTurboFilter
|
||||
- **레벨을 의식하는 로그 샘플러다.** `WARN`/`ERROR`는 절대 샘플링하지 않고 항상 통과시킨다
|
||||
(`FilterReply.NEUTRAL`). 진단·장애 신호인 경고/오류는 100% 보장해야 하고, 샘플링 대상은 `INFO`
|
||||
이하만이라는 정책 때문이다. `INFO` 이하는 확률 `rate`로 보존하고 나머지는 `FilterReply.DENY`로
|
||||
버린다.
|
||||
- **단일 `rate` 노브로 제어한다.** `APP_LOG_SAMPLING_RATE`(logback `springProperty`)에서 주입되고,
|
||||
운영자가 프로파일별로 설정한다(prod `0.1` = INFO 10% 샘플링, staging/dev/local `1.0` = 전부 보존).
|
||||
고트래픽 vs 일반 엔드포인트의 더 세밀한 분기는 이 전역 rate 위에 마커 기반 확장으로 문서화만 되어
|
||||
있고 여기서는 구현하지 않았다.
|
||||
- **검증은 "경고 후 기본값(warn-and-default)" 방식이다.** `rate`가 `[0.0, 1.0]` 범위를 벗어나면
|
||||
Logback 상태 시스템에 경고를 남기고 `1.0`(전부 보존)으로 폴백한다. 잘못된 설정이 로그를 소리 없이
|
||||
버리는 쪽으로 가지 않고, 항상 안전한 "샘플링 안 함"으로 degrade 되게 한 선택이다.
|
||||
|
||||
### SecretMaskingJsonGeneratorDecorator
|
||||
- **시크릿 마스킹의 JSON 인코더 쪽 팔이다.** `LogMaskingPatterns` 목록을 미리 적재한
|
||||
`MaskingJsonGeneratorDecorator`로, staging/prod/default 프로파일의 `LogstashEncoder`에 연결된다.
|
||||
JSON 생성 시점에 마스킹하기 때문에 어떤 구조화 필드가 시크릿을 담았든 상관없이 message·MDC
|
||||
값·스택 트레이스 텍스트 등 모든 문자열 값을 덮는다. 이 "어디서 새든 잡는 그물(catch-net)" 성질이
|
||||
Redaction Layer 1에 요구되는 핵심이다.
|
||||
|
||||
### SecretMaskingMessageConverter
|
||||
- **시크릿 마스킹의 사람이 읽는 `PatternLayout` 쪽 팔이다.** local/dev 콘솔 패턴의 `%maskedMsg`
|
||||
변환 워드로 등록되어, 가독성 위주의 인코더도 운영 JSON 경로와 똑같은 마스킹을 적용한다. 사람이
|
||||
읽기 좋은 포맷으로 바꿨다는 이유로 시크릿이 다시 노출되지 않게 하려는 것이다. 여기서는 개발자
|
||||
콘솔의 현실적 누출 벡터인 message 본문만 가린다(JSON 경로는 추가로 MDC·스택 트레이스 값까지
|
||||
마스킹).
|
||||
|
||||
---
|
||||
|
||||
## metrics — 메트릭 계약 MeterFilter 설치
|
||||
|
||||
### MetricsCardinalityMeterFilter
|
||||
- **금지 태그가 붙은 미터를 런타임에서 막는 방어 필터다.** 미터의 `Meter.Id`에 붙은 태그 키 중
|
||||
하나라도 `ForbiddenMetricTags.FORBIDDEN`에 들어 있으면 그 미터를 `DENY`하고, 아니면 `NEUTRAL`을
|
||||
반환한다. `user_id`, `request_id`, `raw_url`, `raw_query`, `raw_header_value`, `ip_address` 같은
|
||||
고카디널리티(high-cardinality) 라벨 키는 고유 값마다 Prometheus 시계열을 하나씩 만들어 수백만
|
||||
개로 폭증할 수 있어 차단한다.
|
||||
- **이 필터는 카디널리티 계약의 런타임(runtime) 절반이다.** 정적(static) 절반은 레지스트리 계약
|
||||
테스트(`MetricsAlertingContractTest`)가 담당한다 — 둘이 짝을 이뤄 심층 방어를 구성한다.
|
||||
- **상태가 없어(stateless) 여러 레지스트리에 공유해도 안전하다.** public no-arg 생성자는
|
||||
`MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다.
|
||||
|
||||
### MetricsContractConfig
|
||||
- **`@PostConstruct`에서 `MeterFilter`들을 `MeterRegistry`에 직접 설치한다.** `@Bean MeterFilter`로
|
||||
등록하지 않는 이유: 이 템플릿 클래스패스에는 `@Bean MeterFilter`를 자동 수집하는 Spring Boot
|
||||
Actuator 자동설정(`MeterRegistryCustomizer`)이 없어서 `@Bean MeterFilter`는 그냥 동작하지 않는
|
||||
(inert) 죽은 빈이 되기 때문이다. `OutboundHttpResilienceConfig`에서 확립한 선례를 따른다.
|
||||
- **설치 순서가 중요하다.** (1) `MetricsCardinalityMeterFilter`(deny-list)를 먼저 설치해 금지 태그가
|
||||
붙은 미터가 분포 필터에 닿기 전에 거부되도록 하고, (2) 그다음 `MetricsDistributionMeterFilter`
|
||||
(SLO 기반 히스토그램 설정)를 설치한다.
|
||||
- **필터는 설치 이후 등록되는 미터에만 적용된다.** `@PostConstruct`는 Spring 빈 생명주기 중 애플리
|
||||
케이션 코드가 어떤 미터든 등록하기 전에 실행되므로 모든 미터가 필터 적용 대상이 된다.
|
||||
- **`MeterRegistry` 부재 시 no-op.** `ObjectProvider.getIfAvailable()`로 해석해 레지스트리 빈이
|
||||
없으면(Actuator 미탑재) DEBUG 로그만 남기고 설치를 건너뛴다.
|
||||
- **`install(MeterRegistry)`를 public static 으로 둔 이유.** 계약 테스트가 Spring 컨텍스트 없이
|
||||
`SimpleMeterRegistry`에 대해 직접 구동할 수 있게 하기 위함이다.
|
||||
|
||||
### MetricsDistributionMeterFilter
|
||||
- **소유한(owned) 타이머 메트릭 5종에만 SLO 기반 히스토그램·백분위 설정을 적용한다.** 대상:
|
||||
`http.server.requests`, `http.server.requests.latency`, `dependency.client.requests`,
|
||||
`db.query.duration`, `jvm.gc.pause`. 이들은 `metrics.yaml`에서 이 브랜치가 소유하고
|
||||
`histogram_buckets: slo_driven`인 행이다. `resilience4j.circuitbreaker.calls`,
|
||||
`hikaricp.connections.acquire`처럼 소비만 하고 소유하지 않는(consumed-but-not-owned) 행은 다른
|
||||
브랜치 소유라 의도적으로 제외 — 덮어쓰면 안 된다.
|
||||
- **히스토그램 전략은 두 축이다.**
|
||||
- *인스턴스 간 집계 가능한 진실의 원천*: `percentilesHistogram(true)`(= `publishPercentileHistogram`)가
|
||||
`_bucket` 시계열을 생성하고, Prometheus 에서 `histogram_quantile()`로 인스턴스 간 집계할 수 있다.
|
||||
이것이 다중 인스턴스 배포의 정식 p50/p95/p99 다.
|
||||
- *클라이언트 측 편의값(집계 불가)*: `percentiles(0.5, 0.9, 0.95, 0.99)`(= `publishPercentiles`)는
|
||||
단일 인스턴스 가시성을 위한 사전 계산 분위 게이지를 제공한다. 이 값들은 인스턴스 간 평균을 내면
|
||||
안 된다(통계적으로 틀림). 인스턴스 간 집계는 반드시 `histogram_quantile()`로 한다.
|
||||
- **SLO 경계값 `100ms / 500ms / 1s / 5s`는 잠정 SLO 역산이며 외부 표준에서 유도한 값이 아니다.**
|
||||
정식 SLO 가 채택되면 재검토 대상이다. 함께 설정하는 `minimumExpectedValue=1ms`,
|
||||
`maximumExpectedValue=10s`는 히스토그램 버킷의 관측 범위를 한정한다.
|
||||
- **상태가 없어 여러 레지스트리에 공유해도 안전하고,** public no-arg 생성자는
|
||||
`MetricsContractConfig.install()`이 직접 인스턴스화하기 위한 계약이다.
|
||||
|
||||
---
|
||||
|
||||
## management/security — 액추에이터 엔드포인트 보안 체인
|
||||
|
||||
### ManagementSecurityConfig
|
||||
- **`actuatorSecurityFilterChain`은 `@Order(0)`으로 메인 앱 체인(adapter-web `SecurityConfig`)보다
|
||||
앞서 실행된다.** 그래서 액추에이터 엔드포인트에 매칭되는 요청은 앱 체인이 아니라 이 체인이
|
||||
처리한다. 보안 매처는 `EndpointRequest.toAnyEndpoint()`로, 관리 포트의 `/actuator/**` 경로만 이
|
||||
체인 범위에 든다.
|
||||
- **접근 정책.** `health`, `info`, `prometheus`는 permit-all 이다. 자격 증명 없이 Kubernetes 프로브와
|
||||
Prometheus 스크레이프가 접근할 수 있어야 하고, 이 엔드포인트들은 민감 데이터를 노출하지 않기
|
||||
때문이다(health 상세는 when-authorized). 나머지 액추에이터 엔드포인트는 모두 인증이 필요하다.
|
||||
- **`loggers` 쓰기 차단.** 런타임 로그 레벨 변경은 `POST /actuator/loggers/{name}`이고, 레벨 리셋은
|
||||
`DELETE /actuator/loggers/{name}`이다. 둘 다 변형(mutation) 작업이므로 `denyAll()`로 모두에게
|
||||
(인증된 사용자 포함) 거부한다 — 인증만으로 쓰기 권한을 주지 않는다. `loggers` 읽기는 아래
|
||||
`authenticated()`로 흘러 인증을 요구한다.
|
||||
- **인증 실패 시 기본값 403 대신 401(자격 증명 필요)을 반환한다(`HttpStatusEntryPoint(UNAUTHORIZED)`).**
|
||||
대화형 로그인이나 basic-auth realm 은 제공하지 않는다 — 관리 포트는 네트워크 ACL 뒤에 있으므로
|
||||
올바른 HTTP 의미(credentials required)만 신호하면 된다.
|
||||
- **운영에서 deny-by-default 보장은 관리 포트를 별도 포트로 분리해 네트워크 ACL 수준에서 강제한다.**
|
||||
이 in-process 체인은 그 위의 심층 방어 계층이다.
|
||||
- **SHAPE-OWNERSHIP(하드 규칙): 이 클래스는 `HealthEndpoint` / `HealthIndicator` / `HealthComponent`를
|
||||
import·구현·의존해선 안 된다** — health 엔드포인트의 형태(shape)는 runtime-health 브랜치 소유다.
|
||||
보안 매칭은 오직 엔드포인트 id 문자열로만 하고 health 내부 구조에는 절대 손대지 않는다. ArchUnit
|
||||
규칙 `management_security_does_not_depend_on_health_internals`(`CleanArchitectureTest`)가 이를 정적
|
||||
으로 강제한다.
|
||||
|
||||
---
|
||||
|
||||
## tracing — 분산 트레이싱 wiring과 샘플링 정책
|
||||
|
||||
### TracingConfig
|
||||
- **컴포지션 루트에서 트레이싱을 조립한다.** 두 가지를 한다: (1) 시작 시 `tracing.sampling.rate`
|
||||
게이지 등록, (2) `SpanErrorRecorder` 빈 등록.
|
||||
- **`SpanErrorRecorder` 빈은 `@ConditionalOnBean(Tracer.class)`가 아니라 `ObjectProvider<Tracer>`로
|
||||
런타임 존재 여부를 직접 조회한다.** 이유: `@ConditionalOnBean`은 사용자 정의 `@Configuration`에서
|
||||
autoconfiguration 이 만드는 빈(여기서는 `Tracer`)을 조건으로 쓸 때 빈 등록 순서가 보장되지 않아
|
||||
신뢰할 수 없기 때문이다. `Tracer`가 있으면 `MicrometerSpanErrorRecorder`를, 없으면
|
||||
`SpanErrorRecorder.NOOP`을 반환한다.
|
||||
- **`@ConditionalOnMissingBean(SpanErrorRecorder.class)`를 붙여, 테스트 mock 이나 fork 가 자체
|
||||
`SpanErrorRecorder` 빈을 이미 등록한 경우 이 빈은 backoff 한다.** adapter-web 의
|
||||
`GlobalExceptionHandler`는 `ObjectProvider`로 `SpanErrorRecorder`를 조회하므로, 자기 자신의 NOOP
|
||||
self-default 에서 여기서 등록한 실제 구현으로 자동 교체된다.
|
||||
- **OTel/Micrometer tracer 런타임은 이 repo 에서 seam 이 활성 상태다.** `micrometer-tracing-bridge-otel`
|
||||
+ `opentelemetry-exporter-otlp`가 `app-bootstrap/build.gradle`에 실제로 wiring 되어 있다. 단, span
|
||||
exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비어 있는 동안 꺼져 있고, 엔드포인트를 설정하면
|
||||
export 가 시작된다.
|
||||
- **게이지 등록 시 active profile 은 `Environment.getActiveProfiles()`의 첫 번째 값을 쓴다.** 멀티
|
||||
프로파일 배포에서는 맨 앞에 나열된 프로파일이 권위 있는 배포 환경이라는 규약이다. `MeterRegistry`가
|
||||
classpath 에 없으면(Actuator 미탑재) 게이지 등록은 조용히 no-op 이 된다.
|
||||
- **`TracingSamplingRateGaugeRegistrar` record 는 등록된 active profile 과 resolved rate 를 노출하는
|
||||
값 홀더다.** 정식 포트나 use case 가 아니라, 살아있는 `MeterRegistry` 없이도 테스트가 "무엇이
|
||||
등록되었는지" 검사할 수 있게 하는 bootstrap 내부 전용 장치다.
|
||||
|
||||
### TracingSettings
|
||||
- **`ca-skeleton.tracing.*` 바인딩 `@ConfigurationProperties` record.** 세 env 키가 들어온다:
|
||||
`APP_TRACING_ENABLED` → `enabled`, `APP_TRACING_SAMPLE_RATE` → `sampleRate`,
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT` → `exporter.otlpEndpoint`.
|
||||
- **`sampleRate`가 blank 이면 숫자 검증을 건너뛴다.** blank 는 "값이 잘못됐다"가 아니라 "프로파일별
|
||||
기본값에 위임하라"는 신호이기 때문이다. 실제 프로파일별 기본값(prod=0.01, staging=0.10,
|
||||
dev/local=1.0)은 `TracingSampleRateResolver`가 소유하고, resolved 된 값은
|
||||
`TracingSamplingEnvironmentPostProcessor`가 Spring Boot 네이티브
|
||||
`management.tracing.sampling.probability`로 브리지한다.
|
||||
- **검증은 시작 시 fail-fast 다.** `sampleRate`가 non-blank 인데 [0.0, 1.0] float 이 아니면 시작이
|
||||
실패하고, `otlpEndpoint`가 non-empty 인데 scheme 없는 잘못된 URL 이면 시작이 실패한다. `enabled`는
|
||||
Spring 바인딩이 `boolean` 타입을 강제하므로 추가 검증이 필요 없다. `otlpEndpoint`가 비어 있으면
|
||||
exporter off 상태(seam 기본값)이고, 이것이 정상 동작이므로 검증을 통과시킨다.
|
||||
- **`sampleRateValue()`는 호출 전 `sampleRate().isBlank()` 확인이 caller 규약이다.** blank 인 상태로
|
||||
호출하면 `IllegalStateException`을 던지는데, 이는 파싱 오류가 아니라 caller 규약 위반을 드러내기
|
||||
위한 것이다.
|
||||
|
||||
### TracingSampleRateResolver
|
||||
- **프로파일별 트레이싱 샘플 비율 SSOT 다.** prod=0.01(1%), staging=0.10(10%), dev/local=1.0(100%),
|
||||
그 외=1.0. `APP_TRACING_SAMPLE_RATE`가 non-blank 이고 [0,1] float 로 파싱되면 프로파일 기본값을
|
||||
override 한다.
|
||||
- **Spring 의존성이 전혀 없는 순수 Java 로 작성했다.** 어떤 컨텍스트도 없이 단위 테스트할 수 있고,
|
||||
`EnvironmentPostProcessor`(컨텍스트 생성 이전 단계)와 `TracingConfig`(빈 생성 단계) 양쪽에서 동일
|
||||
로직을 재사용하기 위함이다.
|
||||
|
||||
### TracingSamplingRateGauge
|
||||
- **`tracing.sampling.rate` 게이지를 등록한다.** metric 계약: type=gauge, tag=`profile`(prod/staging/
|
||||
dev/local 허용), value=resolved effective sample rate(double in [0.0, 1.0]).
|
||||
- **오직 active Spring profile 의 시리즈 하나만 방출하므로 cardinality 는 1이다.** tag 는 프로파일
|
||||
이름을 정규화하지 않고 그대로 쓴다. 따라서 비표준 프로파일(예: "qa")로 띄워도 시리즈 하나는 정상
|
||||
방출되고, 단지 4-value allowlist 를 벗어날 뿐이다 — 이는 런타임 에러가 아니라 레지스트리 문서상의
|
||||
관심사다.
|
||||
- **`MeterRegistry`를 `ObjectProvider`로 조회하며, Actuator 미탑재로 빈이 없으면 등록을 조용히
|
||||
건너뛴다.** 이는 `BackgroundJobMetrics` / `OutboxMetrics`의 선례를 그대로 따른다.
|
||||
|
||||
### TracingSamplingEnvironmentPostProcessor
|
||||
- **resolved 된 유효 샘플 비율을 Spring Boot 네이티브 키 `management.tracing.sampling.probability`로
|
||||
단일화(브리지)하는 `EnvironmentPostProcessor`다.** 이를 통해 "프로파일별 기본값 + env override"라는
|
||||
우리 규약을 Spring Boot 의 기본 샘플링 메커니즘에 그대로 연결한다.
|
||||
- **브리지 우선순위.** (1) 사용자가 `management.tracing.sampling.probability`를 이미 명시했으면
|
||||
덮어쓰지 않고 그대로 둔다, (2) `ca-skeleton.tracing.sample-rate`가 non-blank 면 그 값 사용, (3)
|
||||
blank 면 `TracingSampleRateResolver.defaultRateForProfile(...)`로 active profile 기본값 적용.
|
||||
- **`META-INF/spring.factories`에 `org.springframework.boot.EnvironmentPostProcessor` 키로 등록된다.**
|
||||
`EnvironmentPostProcessor`는 ApplicationContext 가 생성되기 전에 실행되므로 일반 빈이 아니라
|
||||
Spring Boot bootstrap factory 로 등록해야 한다.
|
||||
|
||||
### MicrometerSpanErrorRecorder
|
||||
- **NOOP 을 대체하는 Micrometer/OTel 기반 실제 `SpanErrorRecorder`다.** 현재 span 에 예외와
|
||||
`error.code` 태그를 기록한다. 계약 이행: `Span.error(Throwable)` 호출(OTel bridge 를 통해
|
||||
`recordException` + span status ERROR 매핑), `span.tag("error.code", errorCode)`(ca-tmpl 레지스트리
|
||||
속성명), null `error`는 방어적 no-op, 현재 span 이 없으면 no-op.
|
||||
- **"sampled span 에만 스택트레이스 부착" 정책은 의도적으로 구현하지 않았다.** Micrometer Tracing
|
||||
추상화 레벨에서는 sampled 여부를 직접 제어할 수 없기 때문이다(`Span.isNoop()`은 OTel NOOP span
|
||||
여부일 뿐 sampling 여부가 아니다). 다만 OTel SDK 의 unsampled span 은 이미 no-op 으로 처리되므로
|
||||
명시적 sampled-only 분기가 없어도 실제 성능 부담은 없다.
|
||||
|
||||
---
|
||||
|
||||
## settings — @ConfigurationProperties 검증 정책
|
||||
|
||||
이 모듈의 설정 record 들은 대체로 두 가지 검증 전략 중 하나를 고른다: **fail-fast**(잘못되면 기동
|
||||
중단)와 **warn-and-default**(경고만 남기고 안전한 기본값으로 진행). 어느 쪽을 쓰는지가 각 record 의
|
||||
핵심 결정이다.
|
||||
|
||||
### BootstrapSettings
|
||||
- **`appName`은 비어 있으면 시작을 실패시킨다(fail-fast).** `ca-skeleton.bootstrap.*` 바인딩이며,
|
||||
앱 이름은 운영자가 직접 제공해야 하고 합리적인 기본값이 존재하지 않으므로, 비어 있거나 blank 면
|
||||
`@NotBlank`로 막는다(Spring Boot 가 `BindValidationException`을 띄우고 컨텍스트 기동을 거부).
|
||||
다른 settings 의 "warn-and-default"와 달리 여기서 fail-fast 를 택한 이유는, 잘못된 앱 이름으로
|
||||
조용히 기동되는 것보다 즉시 멈추는 편이 안전하기 때문이다.
|
||||
|
||||
### LoggingSettings
|
||||
- **모든 항목이 "warn-and-default" 정책이다.** `ca-skeleton.logging.*` 바인딩(원천은 .env). 잘못된
|
||||
값은 경고 로그를 남기고 안전한 기본값으로 진행할 뿐 시작을 실패시키지 않는다.
|
||||
- **fail-fast 대신 warn-and-default 를 택한 이유.** Logback 은 이 record 가 바인딩되기 전에 자기
|
||||
초기화 단계에서 `<springProperty>` 바인딩으로 이미 같은 값들을 자체 기본값과 함께 소비했다. 따라서
|
||||
이 record 의 역할은 로깅을 다시 강제하는 것이 아니라, 입력이 잘못됐을 때 운영자에게 명확한 경고를
|
||||
표면화하는 것이다.
|
||||
- **size 문자열(`maxSize`, `totalSizeCap`)은 검증하지 않는다.** 이 값들의 파싱 계약은 logback 이
|
||||
소유하므로 logback 에게 맡긴다.
|
||||
|
||||
### PrivacySettings
|
||||
- **보안/감사 로그에서 `user_principal`을 가명화하는 데 쓰는 HMAC salt 를 보관한다.**
|
||||
`ca-skeleton.privacy.*` 바인딩(원천 `APP_PRIVACY_PSEUDONYMIZATION_SALT`). 알고리즘은 HMAC-SHA-256
|
||||
+ 90일 회전 salt 이고, 이 salt 는 `secret`-tier 값이라 prod 에서는 반드시 secret manager 에서
|
||||
공급되어야 한다.
|
||||
- **검증은 `LoggingSettings`와 일관되게 "warn-and-default"다.** blank salt 는 경고를 남기고, 명확히
|
||||
표시된 dev sentinel(`__LOCAL_DEV_` 접두사)로 폴백해 local/test 실행이 절대 기동에 실패하지 않게
|
||||
한다.
|
||||
- **이 sentinel 을 prod 로 승격하는 것은 별도 계약이 독립적으로
|
||||
차단한다** — prod 프로파일에서 `__LOCAL_DEV_` 값이면 시작이 실패한다. 그 게이트는 이 record 의
|
||||
책임 밖이라 여기서 중복 강제하지 않는다.
|
||||
|
||||
### RuntimeSafetySettings
|
||||
- **시작 시 `StartupSafetyValidator`가 강제하는 운영 안전 토글이다.** `ca-skeleton.runtime.*`
|
||||
바인딩(원천 `APP_ERROR_DETAIL_EXPOSURE_ENABLED`, `APP_LOG_BODY_CAPTURE_ENABLED`,
|
||||
`APP_MULTI_INSTANCE_ENABLED`, 모두 기본값 `false`).
|
||||
- **`errorDetailExposureEnabled` / `logBodyCaptureEnabled`는 prod-unsafe 토글이다.** `prod`
|
||||
프로파일에서 둘 중 하나라도 켜면 시작이 실패한다. 그래야 내부 에러 상세나 요청 본문 캡처가
|
||||
프로덕션에서 조용히 켜진 채 남는 일이 없다.
|
||||
- **`multiInstanceEnabled`가 `true`면 인스턴스 조율 capability 빈들(lock / cache-stampede / leader /
|
||||
rate-limit / migration)이 모두 존재하는지 단언하고, 하나라도 없으면 시작을 실패시킨다.** 멀티
|
||||
인스턴스 모드를 켜놓고 조율 인프라가 빠진 채 기동되는 위험한 상태를 막기 위함이다.
|
||||
|
||||
---
|
||||
|
||||
## build.gradle — 의존성 구성 근거
|
||||
|
||||
app-bootstrap 은 합성 루트라 "왜 이 의존성이, 왜 이 scope 로" 결정이 많다. 빌드 파일에는 한 줄
|
||||
요약만 두고, 비자명한 근거는 여기에 모은다. 바탕 원칙은 **api vs implementation 정책**(CLAUDE.md):
|
||||
모듈 간 의존은 기본 `implementation`이라 transitive 로 새지 않는다 — 그래서 테스트에서 그 타입이
|
||||
필요하면 여기서 **명시적으로** 다시 선언한다.
|
||||
|
||||
### 런타임(production) 의존성
|
||||
- **`flyway-core`를 직접 의존하는 이유.** PostgreSQL vendor 모듈이 `flyway-database-postgresql`와
|
||||
마이그레이션 스크립트를 소유하지만, 합성 루트는 Flyway API 자체가 필요하다. `MigrationStartupConfig`의
|
||||
`migrationStartupRunner`(FlywayMigrationStrategy)가 `migrate()`를 직접 구동하고 `FlywayException`을
|
||||
exit-70 `MigrationFailedException`으로 번역하기 때문이다.
|
||||
- **`spring-boot-starter-security`를 compile classpath 에 두는 이유.** adapter-web 이 security 를
|
||||
`implementation`(not `api`)으로 선언해서 Spring Security 타입이 app-bootstrap 컴파일 경로로 새지
|
||||
않는다. 그런데 `ManagementSecurityConfig`가 `HttpSecurity`/`SecurityFilterChain`/`EndpointRequest`를
|
||||
쓴다. cross-cutting 보안 와이어링은 합성 루트가 소유한다는 원칙(AGENTS.md)에 따라 여기서 직접
|
||||
의존한다.
|
||||
- **`micrometer-core`** — `OutboxMetrics` 카운터/게이지용. `ObjectProvider<MeterRegistry>`라 registry
|
||||
가 없으면 no-op 이다(registry 는 Actuator 가 제공).
|
||||
- **tracing(`micrometer-tracing-bridge-otel` + `opentelemetry-exporter-otlp`)** — OTel/Micrometer
|
||||
tracer 런타임. exporter 는 `OTEL_EXPORTER_OTLP_ENDPOINT`가 비면 켜진 채로 대기만 하고 export 는
|
||||
안 한다. 버전은 Spring Boot BOM 이 관리.
|
||||
- **`logstash-logback-encoder`를 `runtimeOnly`가 아니라 `implementation`으로 올린 이유.** 보통 JSON
|
||||
로그 인코더는 런타임에만 있으면 되지만, `StartupFailures`가 `StructuredArguments`를 **compile
|
||||
time**에 호출해 `startup.phase`/`error.code`/`error.category` JSON 필드를 방출하므로 compile
|
||||
classpath 에 있어야 한다.
|
||||
|
||||
### 테스트 의존성 (transitive 로 안 새서 명시 선언)
|
||||
- **`testImplementation project(':adapter-persistence-postgresql')`** — vendor 클래스
|
||||
(`PostgreSqlSqlStateErrorMapping`/`PostgreSqlOutboxClaimRepository`)를 test compile 경로에 올려,
|
||||
full-matrix contract 테스트가 실제 production composition 을 배선하게 한다(런타임 의존은 `runtimeOnly`).
|
||||
- **`testRuntimeOnly postgresql`** — PG JDBC 드라이버. vendor 모듈이 `runtimeOnly`로 선언해
|
||||
app-bootstrap 테스트 경로로 안 새므로 여기서 명시한다.
|
||||
- **`spring-boot-starter-data-jpa` + `HikariCP`** — adapter-persistence-rdbms 가 `implementation`이라
|
||||
JPA/Hikari 가 compile 경로로 오지 않는다. outbox contract 테스트가 minimal Spring context 를 직접
|
||||
만들어 이들이 컴파일 시점에 필요하다.
|
||||
- **`spring-integration-jdbc`** — `JdbcLockRegistry`/`DefaultLockRepository`도 rdbms 의
|
||||
`implementation`이라 안 샌다. `DistributedLockProviderContractTest`가 같은 Testcontainers PG
|
||||
DataSource 에 대해 독립 registry 두 개(= 두 인스턴스 시뮬레이션)를 직접 만들어 상호배제·리스 만료를
|
||||
검증한다. 테스트 전용 — production 와이어링은 전부 `DistributedLockPersistenceConfig`(rdbms) 소유.
|
||||
- **`spring-boot-starter-json`** — `JacksonAutoConfiguration`은 jackson-databind 가 test 경로에 있어야
|
||||
활성화되고, deserialization-policy 경계 테스트가 `DeserializationFeature` enum 을 직접 읽는다.
|
||||
- **Testcontainers(`postgresql`/`junit-jupiter`)** — outbox contract 테스트용. 버전은 Spring Boot
|
||||
BOM 이 관리.
|
||||
- **`spring-security-test`** — `@WithMockUser`로 actuator 보안 인가(permit-all 프로브 / authenticated
|
||||
loggers / loggers 쓰기 거부)를 검증한다.
|
||||
- **`snakeyaml`** — `config/messaging/*.yaml`의 first polling-producer tuple, closed maturity,
|
||||
wildcard-free compatibility, evidence task/scenario/runbook 선언을
|
||||
`MessagingCapabilityRegistryContractTest`가 읽어 검증한다. 이 레지스트리는 런타임 설정이 아니며
|
||||
모든 card가 `not-implemented`인 동안 R2나 release-ready를 뜻하지 않는다.
|
||||
|
||||
### Messaging qualification scaffold
|
||||
|
||||
`config/messaging/`은 first polling-producer tuple의 machine-readable 계획 truth만 보관한다.
|
||||
현재 card 11개는 모두 `maturity: not-implemented`, `evidenceFingerprint: ""`이고 consumer,
|
||||
CDC, EOS, schema-registry 확장 row는 없다. Task 6의 `verifyMessagingJsonSchemaV1`과
|
||||
`verifyMessagingContracts`는 exact qualification test와 test/build 전용 Draft 2020-12 manifest
|
||||
validator를 실행한다. combined task는 JSON-only task/validator에 명시적으로 의존하므로 CLI 순서와
|
||||
무관하게 shared `contracts-schema/manifest.json`의 최종 소유자가 된다. 나머지 `verifyMessaging*`
|
||||
root task는 후속 owner test와 payload-free evidence validator가 구현되기 전까지 공통 fail-closed
|
||||
guard에서 반드시 non-zero로 종료한다. 파일 존재, SKIP, 오래된 evidence, 다른 source digest 또는
|
||||
다른 profile hash를 PASS로 취급하지 않는다. 이 scaffold는 broker client, scheduler, thread,
|
||||
network 또는 다른 런타임 resource를 생성하지 않는다.
|
||||
|
||||
### ArchUnit "violation-as-data" fixture 의존성 (test 컴파일러 전용)
|
||||
ArchUnit 규칙이 **금지**하는 타입을 fixture 가 일부러 import 해서, 규칙이 실제로 그 위반을 잡는지
|
||||
증명한다. production classpath 엔 없어야 하므로 전부 `testCompileOnly`이고, 각 금지 glob 을 독립적으로
|
||||
증명하려고 의존성을 쪼개 둔다.
|
||||
- `spring-tx` — `..architecture.violations.*`의 일반 위반 fixture.
|
||||
- `spring-webmvc` / `spring-websocket` / `jakarta.websocket-api` — streaming 위반 fixture +
|
||||
over-block 가드(streaming-response-contract). spring-web(`org.springframework.http..`)는
|
||||
spring-webmvc 를 통해 transitive 로 도착한다.
|
||||
- `kafka-clients` / `jakarta.ws.rs-api` — transport-free 도메인 이벤트 fixture
|
||||
(domain-modeling-guardrails)가 금지된 broker/wire/HTTP 패키지를 import 한다.
|
||||
- `spring-cloud-context` — `@RefreshScope` 금지(no-refresh-scope) fixture 용. Spring Cloud 는
|
||||
production 에 없고 규칙은 애너테이션을 FQN 문자열로 참조한다. BOM 이 spring-cloud 좌표를 관리하지
|
||||
않아 버전을 명시 고정한다.
|
||||
- **`testImplementation project(':sample-portfolio')`** — ArchUnit 이 템플릿 reference 구현을 분석하려고
|
||||
test 경로에만 둔다. **production 은 절대 sample-portfolio 에 의존 금지** —
|
||||
`production_code_does_not_depend_on_sample_portfolio` 규칙이 강제한다.
|
||||
|
||||
### 빌드 설정
|
||||
- **test JVM UTC 고정(`-Duser.timezone=UTC`)** — `RuntimeHealthLifecycleContractTest`가
|
||||
`TimeZone.getDefault().getID() == "UTC"`를 단언해, 호스트 로케일과 무관하게 타임스탬프 산술이
|
||||
결정적이게 한다. production UTC 는 여기서 강제하지 않으며 container-runtime-contract(Dockerfile
|
||||
`TZ=UTC`)가 소유한다.
|
||||
- **`bootRun.workingDir = rootProject.projectDir`** — `src/.env`를 읽도록 Gradle 루트(src/)에서
|
||||
실행한다.
|
||||
Reference in New Issue
Block a user