Files
clean-architecture-backend-…/src/app-bootstrap/README.md
T

72 KiB

app-bootstrap — 설계 결정 참조

애플리케이션 진입점이자 합성 루트(composition root) 모듈. 패키지 루트: dev.caskeleton.bootstrap.

이 모듈은 비즈니스 로직을 담지 않는다. Spring Boot 기동, 런타임 설정 바인딩, 기본 runtime 모듈 간 최종 와이어링, 그리고 composition classpath를 대상으로 한 중앙 아키텍처 테스트만 둔다. 19개 leaf 전체의 프로젝트 edge는 JSON registry를 읽는 Gradle gate가 별도로 검사한다. 허용/금지 의존, 책임 범위, 테스트 명령 같은 모듈 규칙의 SSOT 는 CLAUDE.md 다.

기본 composition은 build.gradle에 선언된 runtime leaf만 포함한다. GraphQL, gRPC, WebSocket, MongoDB, file server, object storage 같은 optional leaf는 독립적으로 빌드·테스트되지만 자동으로 기본 애플리케이션에 합성되지 않는다. optional leaf를 활성화하려면 src/config/architecture/modules.jsonapp-bootstrap.allowed_dependencies에 허용 edge를 명시하고, 같은 변경에서 app-bootstrap/build.gradle 의존성과 필요한 typed settings/검증을 추가해야 한다.

이 문서는 코드 주석에서 덜어낸 설계 결정의 근거를 모아둔 참조용 기록이다 — 코드를 읽다 "왜 이렇게 했나"가 궁금할 때 본다. 본문은 한국어로 쓰고, 클래스·Spring API·메트릭 이름처럼 꼭 필요한 기술 용어만 영어로 남긴다. 별도 추적 ID를 몰라도 읽히도록 결정의 배경과 트레이드오프를 문장으로 풀어 둔다.

목차


runtime/startup — Fail-fast 시작 파이프라인과 시작 실패 분류

잘못 설정된 채로 기동돼 트래픽을 받는 것보다, 기동 시점에 명확한 이유와 함께 멈추는 편이 안전하다. 이 패키지는 그 "빨리·명확하게 실패시키기(fail-fast)"를 담당한다.

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" 순서만 책임진다).
  • FlywayExceptionMigrationFailedException(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.phasekubectl 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 가 실패하면 SpringApplicationSpringBootExceptionHandler (부팅 스레드의 uncaught-exception handler 로 설치됨)가 실패 예외에서 getExitCode()를 읽어 System.exit(code)를 호출한다 — main() 수정이 필요 없다. IllegalStateException을 상속하는 이유는 과거 StartupSafetyValidatorIllegalStateException을 던졌기 때문으로, 타입드 예외로 업그레이드한 뒤에도 소스/동작 호환을 유지하기 위함이다.
  • 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가 설정값으로 고른다. RateLimiter / RateLimiterFactory와 같은 패턴이다. 백엔드를 추가하는 비용이 "새 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)을 박았다. 스켈레톤은 별도 설정 없이 바로 부팅돼야 하므로, strategynull이면 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 추가로 끝나고 소비자는 바뀌지 않는다. RateLimiterFactory와 같은 형태로, "확장 비용이 어디에 있는가"를 한곳에서 보이게 했다.

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(너무 작으면 정상 사용을 누수로 오탐).
  • 모든 노브를 String으로 읽어 직접 파싱하는 방어적 처리. env-keys.yamlconnection-timeout 기본값은 Duration 문자열 5s인데 src/.env30000(ms)을 준다. Environment#getProperty(..., Long.class)"5s"에 호출하면 ConversionFailedException이 난다. 그래서 각 값을 String으로 읽어 parseMillis로 넘기고, null/blank 또는 plain-integer 가 아닌 값은 null(= 부재로 간주, 조용히 skip)로 처리한다. 덕분에 검증기가 형식 drift 값에 절대 죽지 않는다.
  • env 키가 아직 없는 노브는 "env key pending" 문구를 쓴다. connection-timeout / max-lifetimeenv-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*만 거부한다.

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를 빈으로 노출하고, 설정에서 고른 DomainContextStrategyDomainContextPropagatorFactory를 통해 조립한다. 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 불변식: maxSizecoreSize보다 작을 수 없다. 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.totaloutcome 태그 값은 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.yamllock.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에서 수동 조립한다. PublishPendingOutboxEventsUseCasebatchSizeinFlightTimeout 같은 설정값을 생성자로 받아야 하는데, application-core는 설정(OutboxSettings)을 직접 읽으면 안 된다. 그래서 설정을 볼 수 있는 합성 루트(OutboxConfig)가 값을 꺼내 use case 를 손으로 만들어 넘긴다. use case 클래스의 @UseCaseCapability 애너테이션은 와이어링 방식과 무관하게 유지된다(ArchUnit 이 강제).
  • bootstrap은 failure reporter를 구현하지 않고 주입만 한다. MessagingConfig가 broker 설정에서 정확히 하나의 OutboxRelayFailureReportPort 구현을 만들고, OutboxConfig는 이를 relay 생성자에 전달한다. broker가 비활성이어도 reporter bean은 존재한다.
  • 릴레이 use case 를 독립 컨텍스트 빈으로 등록하지 않는다. 만약 빈으로 올리면 adapter-webMethodSecurityConfig 메서드 보안 pointcut(@RequiresPermission)이 이 타입을 CGLIB 프록시로 감싼다. 그런데 use case 가 final 클래스라 프록시 생성 자체가 실패하고, 설령 된다 해도 스케줄러 스레드에는 Authentication이 없어 매 릴레이 틱이 fail-closed 로 거부된다. 그래서 빈으로 올리지 않고, outbox:relay 권한 확인은 스케줄러 컨텍스트에서 관례로 둔다.
  • outboxRelayScheduler 빈은 ca-skeleton.outbox.relay-enabled로 게이팅한다(기본 true, matchIfMissing=true). 릴레이를 끄고 싶을 때 빈 자체가 만들어지지 않게 하기 위함. 키가 없으면 켜진 것으로 본다.
  • outboxLeaderElection 토큰 빈은 조건 없이(unconditional) 항상 등록한다. StartupSafetyValidatorAPP_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-intervalpublished-retentionadapter-persistenceOutboxReaper도 property 문자열로 읽는다. 두 모듈이 같은 키를 공유하지만, 이 properties record 가 여섯 값 전체를 문서화 하는 단일 지점(single place) 역할을 한다.

logging — 로그 시크릿 마스킹·샘플링·가명화

LogMaskingPatterns

  • 마스킹 정규식 규칙을 한 곳에만 둔 단일 진실 원천(SSOT)이다. JSON 인코더 경로 (SecretMaskingJsonGeneratorDecorator, staging/prod/default 프로파일)와 사람이 읽기 쉬운 패턴 경로 (SecretMaskingMessageConverter, local/dev)가 같은 규칙을 공유한다. 규칙을 한 군데로 모아둔 이유는, 프로파일이나 로그 포맷을 바꿔도 가려지는 시크릿의 범위가 절대 달라지지 않게 하기 위해서다. 즉 "가독성을 위해 포맷을 바꿨더니 시크릿이 다시 노출되는" 사고를 구조적으로 막는다.
  • 시크릿 값만 가리고 키/스킴은 남긴다. token=abc123token=****, 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.yamlallowed_values 계약 때문이다. 루트 레벨을 TRACE 로 낮춰야만 가능한 TRACE 드롭은 상위 클래스가 여전히 버리지만 카운트하지는 않아서, 태그 카디널리티가 레지스트리 계약을 벗어나지 않게 유지한다.

PseudonymizationConfig

  • user_principal(로그에 남는 사용자 식별자)를 전체 HMAC 방식으로 가명화한다. UserPrincipalPseudonymizerPort(application-core)를 HMAC-SHA-256 구현(adapter-identifier)에 바인딩하고, 솔트는 PrivacySettings에서 가져온다. 원본 식별자를 그대로 로그에 남기지 않으려는 개인정보 보호 결정이다. 실제 사용처는 adapter-webRequestLoggingFilter로, 보안 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 의 GlobalExceptionHandlerObjectProviderSpanErrorRecorder를 조회하므로, 자기 자신의 NOOP self-default 에서 여기서 등록한 실제 구현으로 자동 교체된다.
  • OTel/Micrometer tracer 런타임은 이 repo 에서 seam 이 활성 상태다. micrometer-tracing-bridge-otel
    • opentelemetry-exporter-otlpapp-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_ENABLEDenabled, APP_TRACING_SAMPLE_RATEsampleRate, OTEL_EXPORTER_OTLP_ENDPOINTexporter.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 를 벗어날 뿐이다 — 이는 런타임 에러가 아니라 레지스트리 문서상의 관심사다.
  • MeterRegistryObjectProvider로 조회하며, 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.factoriesorg.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 프로파일에서 둘 중 하나라도 켜면 시작이 실패한다. 그래야 내부 에러 상세나 요청 본문 캡처가 프로덕션에서 조용히 켜진 채 남는 일이 없다.
  • multiInstanceEnabledtrue면 인스턴스 조율 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 자체가 필요하다. MigrationStartupConfigmigrationStartupRunner(FlywayMigrationStrategy)가 migrate()를 직접 구동하고 FlywayException을 exit-70 MigrationFailedException으로 번역하기 때문이다.
  • spring-boot-starter-security를 compile classpath 에 두는 이유. adapter-web 이 security 를 implementation(not api)으로 선언해서 Spring Security 타입이 app-bootstrap 컴파일 경로로 새지 않는다. 그런데 ManagementSecurityConfigHttpSecurity/SecurityFilterChain/EndpointRequest를 쓴다. cross-cutting 보안 와이어링은 합성 루트가 소유한다는 원칙(AGENTS.md)에 따라 여기서 직접 의존한다.
  • micrometer-coreOutboxMetrics 카운터/게이지용. 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-encoderruntimeOnly가 아니라 implementation으로 올린 이유. 보통 JSON 로그 인코더는 런타임에만 있으면 되지만, StartupFailuresStructuredArgumentscompile 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-jdbcJdbcLockRegistry/DefaultLockRepository도 rdbms 의 implementation이라 안 샌다. DistributedLockProviderContractTest가 같은 Testcontainers PG DataSource 에 대해 독립 registry 두 개(= 두 인스턴스 시뮬레이션)를 직접 만들어 상호배제·리스 만료를 검증한다. 테스트 전용 — production 와이어링은 전부 DistributedLockPersistenceConfig(rdbms) 소유.
  • spring-boot-starter-jsonJacksonAutoConfiguration은 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 쓰기 거부)를 검증한다.
  • snakeyamlconfig/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의 verifyMessagingJsonSchemaV1verifyMessagingContracts는 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)RuntimeHealthLifecycleContractTestTimeZone.getDefault().getID() == "UTC"를 단언해, 호스트 로케일과 무관하게 타임스탬프 산술이 결정적이게 한다. production UTC 는 여기서 강제하지 않으며 container-runtime-contract(Dockerfile TZ=UTC)가 소유한다.
  • bootRun.workingDir = rootProject.projectDirsrc/.env를 읽도록 Gradle 루트(src/)에서 실행한다.