chore: initialize from backend template 0a6dd0e

This commit is contained in:
DongHyeonka
2026-08-13 20:31:02 +09:00
commit e64e701fe5
3223 changed files with 388401 additions and 0 deletions
+883
View File
@@ -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/)에서
실행한다.