# app-bootstrap 모듈 상세 코드·아키텍처 리뷰 - 기준 일자: 2026-09-07 - 기준 Git HEAD: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` - 대상 Gradle leaf: `:app-bootstrap` - 주 대상 경로: `src/app-bootstrap` - 교차 확인 경로: `src/adapter/inbound/web`, `src/adapter/inbound/graphql`, `src/adapter/outbound/*`, `src/shared-contract`, `src/application-core`, `src/config/architecture/modules.json` - 판정: **CHANGES REQUIRED** (설계 판단은 유지, 코드 조직화 규율은 재정비 필요) - 검토 방식: 전체 트리 정적 탐색, 시작 파이프라인 정독, 설정 네임스페이스·예외·로그·검증 경로 교차검증 - 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다. ## 1. 결론 이 leaf는 "Spring Boot 진입점 + 몇 개의 `@Configuration`"이 아니다. 6개 `EnvironmentPostProcessor`, `SpringBootExceptionReporter`, `AutoConfigurationImportFilter`, `ManagementContextConfiguration`, 21개 startup validator, 4단계 종료코드 체계, 15개 capability 자동조립을 담은 **런타임 조립 플랫폼**이다. 프레임워크 확장점을 목적별로 정확히 구분해 쓰고 있고, 주석이 "무엇"이 아니라 "왜"를 기록하며, fail-closed 철학이 일관된다. 이 부분은 보존 대상이다. 문제는 능력이 아니라 **규율**이다. 이 모듈에서 반복 발견되는 결함은 전부 같은 형태를 가진다: > **정답이 이미 모듈 안에 한 번 구현되어 있는데, 나머지 호출부가 그것을 쓰지 않고 각자 재구현한다.** 세 가지 실례가 이를 증명한다. 1. `StartupFailures`가 구조화 로그 + 종료코드 + 오류코드를 한 곳에서 부여하도록 만들어져 있으나, 검증 실패의 약 2/3(67곳)이 `throw new IllegalStateException`으로 이 경로를 우회한다. 결과적으로 **의도적으로 설계된 종료코드 체계(70/71/72/78)가 대부분의 경로에서 무력화된다.** 2. 프로덕션 프로파일 어휘의 정본이 `RuntimeEnvironmentProfileValidator.DEPLOYABLE_ENVIRONMENTS`에 존재하지만, "지금 prod인가"를 7곳이 각자 재구현하고 그중 3곳은 어휘가 다르다. 3. 검증 실패 누적(`List` → 비어있지 않으면 throw) 패턴이 15개 validator에 각자 복제되어 있고, 변수명이 `violations`/`missing`/`offenders`/`contradictions`/`unsafe`/`reasons`로 제각각이다. 4. "인프라 의존 테스트는 별도 소스셋"이 이미 5개 모듈의 표준인데, app-bootstrap만 Testcontainers 테스트를 `src/test`에 두어 기본 test 태스크가 Docker를 요구한다. 5. 관측 어댑터를 어댑터 모듈에 두는 패턴이 `LoggingNotificationAudit`으로 이미 존재하는데, 같은 계열 넷은 bootstrap에 남아 있다. 동시에 **분해 능력이 없는 것도 아니다.** notification은 Config 9개로 정확히 분해되어 있다. 그런데 같은 성격의 redis는 파일 2개에 849줄이 몰려 있다. 즉 좋은 패턴을 알고 있으면서 적용 기준이 문서화되지 않아 그때그때 달라졌다. 이것이 이 모듈 상태를 가장 잘 설명하는 한 문장이다. ## 2. 범위와 증거 경계 ### 2.1 현재 규모 | 항목 | 수치 | | --- | --- | | `src/main` Java | 149 파일 / 12,380 줄 | | 테스트 (`test` + `functionalTest` + `conditionalTransportTest`) | 285 파일 / 33,618 줄 (main 대비 2.7배) | | main 패키지 수 | 27 | | `application.yml` | 974 줄 (프로파일별 3개 합산 1,251 줄) | | `build.gradle` | 391 줄 | | `README.md` | 883 줄 / 74개 heading | | `*Settings` 클래스 | 16 | | `*Config` / `*Configuration` 클래스 | 53 | | `*Validator` / `*Guard` / `*StartupCheck` | 21 | | `EnvironmentPostProcessor` 등록 | 6 | ### 2.2 검토 깊이 - 정독: `runtime/startup/**` 전체, `activation/**` 전체, `redis/**`, `idempotency/**`, `autoconfigure/{fileserver,httpclient,jpa,persistencejpa}` 핵심 경로, `META-INF/spring.factories`, `application.yml` 네임스페이스 구조. - 정량 스캔: 예외 던지기 통계, 로그 호출 통계, 프로파일 판정 구현 위치, `List` 누적 패턴 위치, `@ConfigurationProperties` prefix 전수, `getOrder()` 값 전수. - 교차 확인: filter/`@ControllerAdvice` 구현체의 실제 소유 모듈, 어댑터 모듈의 `resources` 보유 현황, 6개 모듈의 테스트 소스셋 구성과 `testkit`/`testFixtures` 채택 현황, `test` 태스크의 태그·가드 유무. - **미실행**: 이 리뷰는 정적 분석이다. `./gradlew :app-bootstrap:test`를 실행하지 않았다. 따라서 아래 발견 중 "동작이 깨진다"고 단정한 항목은 없으며, 모두 구조·계약 수준의 판정이다. ## 3. 유지할 설계 재작성 과정에서 없애면 안 되는 것들이다. 1. **프레임워크 확장점의 목적별 구분.** `spring.factories`에 `EnvironmentPostProcessor`(6), `SpringBootExceptionReporter`(1), `AutoConfigurationImportFilter`(1), `ApplicationListener`(1)가 각각 제 역할로 등록되어 있다. 특히 `ResolvedProfileLoggingContextListener`의 주석 — *"An ApplicationListener, not a bean: this event fires before there is a context to hold one"* — 는 컨텍스트 생명주기를 이해한 사람만 쓸 수 있는 문장이다. 2. **근거를 남기는 주석.** `HttpClientPlatformSettingsBinder`는 Spring이 `APP_HTTPCLIENT_CLIENTS_0_BASE_URL`을 `clients[0].base.url`로 열거하면서 바인딩은 `base-url`로 한다는 실제 함정을 기록하고, 그래서 시스템 환경변수만 `NoUnboundElementsBindHandler`에서 제외했다고 설명한다. 3. **Fail-closed 기본값.** `RuntimeEnvironmentProfileValidator`는 프로파일 미지정 시 *"a profile that is guessed is a deployment nobody chose"*, 복수 지정 시 *"whichever loses does so silently"*를 근거로 부팅을 거부한다. 4. **strict 바인딩.** 미지의 키를 무시하지 않고 거부한다. 설정 오타가 조용히 기본값으로 도는 사고를 막는다. 5. **아키텍처 테스트의 자기검증.** `architecture/violations/` 아래 40여 개 위반 픽스처 패키지를 실제 클래스로 만들어, ArchUnit 규칙이 진짜로 잡는지를 검증한다. 흔치 않다. 6. **종료코드 체계 자체.** `StartupErrorCode`가 sysexits 관례(78=EX_CONFIG, 70, 71, 72)에 맞춰 `ExitCodeGenerator`로 JVM 종료 상태를 만든다. 설계는 옳다 — 문제는 채택률이다(BOOT-003). ## 4. 제기된 논점에 대한 판정 리뷰 요청자가 제기한 9개 논점에 대한 개별 판정이다. 근거 없이 동의하지 않았고, 전제가 틀린 항목은 틀렸다고 적었다. | # | 논점 | 판정 | 근거 | | --- | --- | --- | --- | | 1 | yaml을 각 모듈이 소유하고 bootstrap이 합쳐야 한다 | **부분 동의** | BOOT-008 | | 2 | 로그 메시지 틀이 일관되지 않다 | **동의** | BOOT-009, BOOT-003 | | 3 | 공통 흐름을 AOP로 해결할 수 있다 | **반대** | 아래 4.1 | | 4 | 검증 순서가 필요하면 `@GroupSequence`는 어떤가 | **반대(목적은 동의)** | 아래 4.2 | | 5 | validator를 하나의 context에서 관리해야 한다 | **동의** | BOOT-004 | | 6 | startup 예외가 Global handler에 안 잡힌다 | **현재 설계가 옳음** | 아래 4.3 | | 7 | 제네릭으로 패턴 적용 가능한 구조를 열어두자 | **조건부 동의** | 아래 4.4 | | 8 | filter 책임이 왜 bootstrap에 있나 | **전제 오류** | 아래 4.5 | | 9 | `Provider` enum을 env로 빼야 한다 | **증상은 맞고 처방은 다름** | BOOT-010 | | 10 | `functionalTest` 등을 `src/test` 안으로 합쳐야 한다 | **반대 — 전제 오류** | 아래 4.6 | | 11 | 테스트 레벨을 소스셋/실행 경계로 분리해야 한다 | **동의 — 다만 적용 대상이 반대** | BOOT-014, 4.7 | | 12 | 참조 프로젝트의 장점을 반영해야 한다 | **상당수 이미 충족, 3건 도입** | §7 | ### 4.1 AOP는 이 모듈의 중복에 적용할 수 없다 AOP는 **빈의 메서드 호출을 프록시로 가로채는** 기법이다. 그런데 이 모듈에서 실제로 중복이 확인된 코드는 거의 전부 **빈이 존재하기 이전 시점**에 실행된다. - `EnvironmentPostProcessor` 6개: `ApplicationContext` 생성 전. 프록시 대상이 아니다. - `AutoConfigurationImportFilter`: 빈 정의가 만들어지기 전. - `SpringBootExceptionReporter`: 컨텍스트 refresh 실패 후. - `@ConfigurationProperties` 레코드의 compact constructor 검증: 바인딩 시점, 프록시 없음. 즉 BOOT-002(프로파일 판정 7중복), BOOT-003(예외 경로 우회), BOOT-004(누적 로직 15중복) 중 **AOP로 제거 가능한 것은 하나도 없다.** 이들은 평범한 헬퍼 추출과 공용 컨텍스트로 해결된다. 런타임 포트 호출(메트릭·트레이싱)은 AOP 후보가 맞지만, 이 저장소는 이미 `MeteredDistributedLockPort` 같은 **명시적 데코레이터**로 처리하고 있고 클린 아키텍처 관점에서 그쪽이 우월하다. 이유는 하나다: 컴포지션 루트의 존재 이유가 "무엇이 무엇을 감싸는지 한 곳에서 보이게 하는 것"인데, AOP는 그 배선을 포인트컷 표현식 뒤로 숨긴다. 어떤 포트가 계측되는지 코드에서 안 보이게 만드는 순간 bootstrap은 자기 역할을 잃는다. **AOP 도입은 권고하지 않는다.** ### 4.2 `@GroupSequence`는 부적합하지만, 그것으로 얻으려던 것은 반드시 필요하다 먼저 두 축을 분리해야 한다. 이 둘은 서로 다른 문제다. - **축 A — 누적(accumulate) vs 즉시중단(fail-fast):** 오류를 모아서 한 번에 보고할 것인가, 첫 오류에서 멈출 것인가. - **축 B — 단계 의존(phase dependency):** 앞 검증이 실패하면 뒤 검증은 **의미가 없어지는가**. **축 A에서는 현재 구조(`List` 누적 후 throw)가 옳다.** 운영자가 잘못된 배포 설정을 고칠 때, 오류를 하나씩 알려주면 "고치고 → 재기동(수십 초) → 다음 오류 발견"을 반복하게 된다. 설정 오류는 전부 모아 한 번에 주는 것이 정답이다. 이건 바꾸면 안 된다. **`@GroupSequence`는 정확히 축 A를 반대 방향으로 바꾸는 장치다.** 그룹 A가 하나라도 실패하면 그룹 B를 아예 평가하지 않는 short-circuit이 그 정의다. 즉 도입하면 운영자가 받는 오류 목록이 **줄어든다.** 얻고 싶은 것과 반대 결과가 나온다. 여기에 더해 이 코드베이스에서는 기술적으로도 성립하지 않는다. 1. **컨텍스트가 없다.** 검증의 상당수가 `EnvironmentPostProcessor` 단계에서 돈다. 이 시점엔 `ApplicationContext`도 `LocalValidatorFactoryBean`도 없다. Bean Validation을 쓰려면 `ValidatorFactory`를 손으로 만들어야 하는데, 그러면 "표준을 써서 단순해진다"는 이점이 사라진다. 2. **검증 대상의 모양이 다르다.** Bean Validation은 *"한 필드가 제약을 만족하는가"*에 최적화되어 있다. 반면 이 모듈의 규칙은 *"prod 프로파일이면서 `ddl-auto`가 `create`이고 Flyway가 켜져 있으면 거부"* 처럼 **프로파일 × 여러 키의 교차 규칙**이다. 클래스 레벨 커스텀 제약으로 옮길 수는 있지만, 검증 로직은 그대로 자바 코드로 남고 애노테이션과 그룹 인터페이스라는 배관만 추가된다. 순증이다. **그러나 축 B의 문제의식은 정확하고, 지금 실제로 깨져 있다.** 이 저장소에는 이미 `StartupPhase`라는 4단계 정의가 존재한다. ``` ENV_VALIDATION → MIGRATION → ADAPTER_ENABLEMENT → PROFILE_CHECK ``` 그런데 이 enum은 **로그 필드 문자열로만 쓰인다.** 실행 순서를 강제하는 코드가 어디에도 없다 (BOOT-005). 그 결과 `RequiredEnvironmentValidator`가 "DB URL이 없다"고 보고하기 **전에** `PostgreSqlTransportSecurityValidator`가 "TLS가 꺼져 있다"고 보고할 수 있다. 없는 URL에 대한 TLS 경고는 순수한 노이즈이고, 운영자를 엉뚱한 곳으로 보낸다. **결론 — 권고안:** `@GroupSequence`를 도입하지 말고, 이미 존재하는 `StartupPhase`를 **실행 순서의 1급 개념으로 승격**한다. 의미론은 이렇게 된다. - 한 phase 안에서는 **모든** 위반을 누적한다 (축 A 유지 → 운영자는 전체 목록을 받는다). - 한 phase가 위반을 냈으면 **다음 phase는 실행하지 않는다** (축 B 확보 → 노이즈 제거). 이것이 `@GroupSequence`가 주려던 "단계 간 short-circuit"을 컨테이너 없이, 프레임워크 의존 없이, 그리고 운영자 경험을 희생하지 않고 얻는 방법이다. 구현은 BOOT-004의 검증 컨텍스트와 같은 작업이다. ### 4.3 startup 예외가 Global handler에 안 잡히는 것은 정상이다 `GlobalExceptionHandler`는 `src/adapter/inbound/web/.../error/GlobalExceptionHandler.java`에 있는 `@ControllerAdvice`다. `@ControllerAdvice`는 **HTTP 요청을 처리하는 중에 발생한 예외**만 볼 수 있다. `StartupFailureException` 계열은 정의상 **컨텍스트 refresh 중**에 던져진다. 그 시점에는 서블릿 컨테이너가 요청을 받고 있지 않고, DispatcherServlet도 아직 없다. 따라서 web handler가 이 예외를 잡는 것은 **가능하지도 않고 바람직하지도 않다.** 잡힌다면 그건 "죽어야 할 프로세스가 200을 반환하며 살아있다"는 뜻이므로 오히려 사고다. 이 계열의 올바른 처리기는 이미 등록되어 있다. - `StartupFailureExceptionReporter` (`SpringBootExceptionReporter`) — 원인 체인을 따라 `StartupFailureException`을 찾아 "이미 보고됨"으로 표시하고 Spring Boot의 generic 스택트레이스를 억제. - `StartupFailureException implements ExitCodeGenerator` — JVM 종료 상태를 오류 원인별로 부여. - `StartupFailures.emit()` — 구조화 로그(`startup.phase` / `error.code` / `error.category`) 발행. 즉 **책임 분담은 이미 올바르다.** 문제는 이 경로를 67곳이 우회한다는 것이다(BOOT-003). 그쪽이 진짜 결함이다. 한편 정리해 둘 구분이 하나 있다. bootstrap이 소유한 예외 중 `MigrationFailedException`, `RequiredAdapterDisabledException`은 전부 시작 시점 전용이라 위 경로가 맞다. 그러나 `IdempotencyProviderSelectionConfig`처럼 `SmartInitializingSingleton` 안에서 던지는 것도 refresh 시점이므로 동일하다. **bootstrap에는 "요청 처리 중 발생하는 예외"가 사실상 없어야 하고, 현재도 없다.** 이 원칙을 문서화해 두면 앞으로 누군가 bootstrap에 런타임 예외를 만들 때 그것이 잘못된 위치라는 신호가 된다. ### 4.4 제네릭으로 열어두기 — 확인된 중복에만 적용한다 방향은 옳다. 다만 **"나중에 패턴을 적용할 수 있도록 미리 제네릭으로 열어둔다"는 그 자체로 speculative generality 안티패턴**이 될 수 있다. 쓰이지 않는 타입 파라미터는 읽는 사람에게 "여기에 여러 구현이 있다"는 거짓 신호를 준다. 판단 기준을 이렇게 두기를 권고한다: **같은 구조가 실제로 2회 이상 나타난 곳에만 적용한다.** 이 기준으로 이 모듈에서 지금 자격이 있는 대상은 셋이다. 1. **검증 누적** — 15회 반복. `StartupValidationContext`로 흡수 (BOOT-004). 2. **strict 설정 바인딩** — 2회 반복. `StrictSettingsBinder`로 흡수 (BOOT-011). 3. **provider 선택** — 현재 1회지만 enum + switch 이중 수정 지점이 이미 존재. `ProviderSpec` 레지스트리로 흡수 (BOOT-010). 반대로 지금 열어두면 안 되는 것: `*Config` 53개를 공통 제네릭 베이스로 묶는 것. 이들은 구조가 같아 보이지만 조립 대상이 전부 달라서, 공통 상위 타입은 아무 의미 없는 마커가 된다. **두 번째 기준은 §7.4에 있다** — *"이 인터페이스 이름에서 도메인 단어를 빼도 뜻이 통하는가?"*. 반복 횟수와 이 기준을 **모두** 만족할 때만 일반화한다. ### 4.5 filter는 bootstrap에 없다 — 전제가 사실과 다르다 전수 확인 결과, `Filter` / `OncePerRequestFilter` / `WebFilter` 구현체 13개는 **전부 inbound 어댑터 소유**다. - `adapter/inbound/web/.../mvc/{budget,throttle,filter}/*` — `WebMvcBudgetFilter`, `WebMvcThrottleFilter`, `WebMvcEvidenceFilter`, `WebMvcRequestIdFilter` - `adapter/inbound/web/.../webflux/{budget,context,throttle}/*` - `adapter/inbound/web/.../filter/{CacheControlFilter,RequestLoggingFilter}` - `adapter/inbound/graphql/.../GraphQlRequestBodyLimitFilter` **bootstrap에는 filter가 0개다.** 배치는 이미 옳다. 다만 리뷰 요청자가 스스로 도달한 결론 — *"web으로 받을지 grpc로 받을지와 상관없이 filter단은 존재하겠네요"* — 은 별개로 정확한 통찰이고, 실제로 이 저장소가 이미 채택한 구조와 일치한다. transport 무관한 것은 **filter 자체가 아니라 filter가 전파하는 값**(request id, MDC, 도메인 컨텍스트)이고, 그것들은 `shared-contract`와 bootstrap의 `concurrency/DomainContextConfig` / `async/AsyncContextTaskDecorator`에 있다. 즉 "메커니즘은 transport 어댑터가, 계약은 공용 모듈이"라는 분리가 이미 성립해 있다. 이 원칙을 `src/app-bootstrap/CLAUDE.md`에 명시해 두면 앞으로 흔들리지 않는다. ### 4.6 세 개의 별도 소스셋은 폴더 취향이 아니라 클래스패스 차이다 **판정: 합치면 안 된다.** 이 셋은 "테스트를 분류하려고" 나눈 것이 아니라, **하나의 소스셋으로는 표현할 수 없는 클래스패스 차이** 때문에 존재한다. `src/app-bootstrap/build.gradle:25-43`이 근거다. **(1) `sampleOffTest` — 같은 소스를 다른 클래스패스로 두 번 컴파일한다.** ```groovy sampleOffTest { java.srcDirs = sourceSets.test.java.srcDirs // ← test와 동일한 소스 디렉터리 java.srcDir 'src/sampleOffTest/java' ... } configurations { testCompileClasspath.extendsFrom sampleFixture // test에는 sample-portfolio가 있고 testRuntimeClasspath.extendsFrom sampleFixture sampleOffTestImplementation.extendsFrom testImplementation // sampleOffTest에는 없다 } ``` `sampleOffTest`는 **`src/test`의 소스 파일을 그대로 재사용**하면서 `sampleFixture` 구성만 뺀 클래스패스로 다시 컴파일한다. 목적은 "sample-portfolio를 삭제해도 이 템플릿이 성립하는가"의 증명이다. **같은 파일을 두 클래스패스로 컴파일하는 것이 이 소스셋의 정의 그 자체**이므로, `src/test` 하위 폴더로 옮기는 것은 물리적으로 불가능하다. 폴더가 아니라 클래스패스가 변수이기 때문이다. **(2) `conditionalTransportTest` — 테스트 전용 opt-in 클래스패스를 증명한다.** ```groovy conditionalTransportTestImplementation project(':adapter:inbound:websocket') ``` 이 소스셋의 계약은 *"GraphQL/gRPC/WebSocket이 기본 클래스패스에 **없다**"*이다. 만약 이 의존을 `testImplementation`으로 옮기면 기본 test 클래스패스에 transport가 들어가고, **증명하려는 명제가 바로 그 순간 거짓이 된다.** 분리는 선택이 아니라 명제의 성립 조건이다. **(3) `functionalTest` — Gradle TestKit이 별도 Gradle 빌드를 띄운다.** TestKit 의존과 실행 환경이 일반 단위 테스트와 다르고, `tasks.named('check') { dependsOn functionalTest }`로 별도 태스크에 묶여 있다. **리뷰 요청자가 인용한 기준 자체가 이 분리를 지지한다.** 인용된 정리의 결론은 다음과 같다. > 테스트 코드는 production 구조를 따라 배치하고, **실행 환경·의존성·비용이 달라지는 테스트만 별도의 > source set이나 module로 분리한다.** 세 소스셋은 전부 "의존성이 달라지는" 경우이므로 이 기준을 **정확히 만족**한다. Gradle 공식 문서가 별도 테스트 스위트의 대표 사유로 드는 것도 동일하게 "별도의 실행 환경과 dependency/classpath"다. 따라서 논점 10과 논점 11은 서로 모순되며, 논점 11의 기준을 채택하면 논점 10은 자동으로 기각된다. **다만 정당한 불만이 하나 남는다 — 명명이 레벨을 말해주지 않는다.** `functionalTest`, `conditionalTransportTest`, `sampleOffTest`라는 이름만 보고는 "이게 어느 테스트 레벨인지"를 알 수 없다. 이 셋은 전부 **빌드/조립 계약 검증(build qualification)**이지 기능 테스트가 아니다. 이름이 목적을 가리키게 바꾸거나(예: `buildQualificationTest`), 최소한 각 소스셋에 `package-info.java`로 레벨을 선언하는 편이 낫다. `src/test/.../integration/package-info.java`가 이미 그 선례를 만들어 두었다. ### 4.7 테스트 전략은 채택할 가치가 있다 — 다만 이 저장소의 현재 상태를 반영해야 한다 인용된 정리의 핵심 원칙 세 가지는 이 저장소에 그대로 적용할 만하다. 1. **분류 축을 섞지 않는다.** `unit/`, `service/`, `regression/` 같은 폴더를 만들지 않고 production 패키지를 그대로 미러링한다. 2. **실행 환경이 달라지는 것만 소스셋으로 분리한다.** 3. **레벨 정의를 SSOT 문서로 고정한다.** 그런데 원문을 그대로 적용하기 전에 **이 저장소가 이미 도달해 있는 지점**을 반영해야 한다. 세 가지가 이미 존재한다. - **패키지 미러링은 이미 지켜지고 있다.** `src/test`는 production 패키지 구조를 따르며 (`activation/`, `runtime/startup/`, `autoconfigure/fileserver/` …), `unit/`·`service/` 같은 축 혼합 폴더가 없다. 원칙 1은 이미 만족한다. - **테스트 레벨 계약이 이미 ArchUnit으로 강제되고 있다.** `architecture/TestTaxonomyArchitectureTest`(220줄)가 다음을 기계적으로 검사한다. - `contract` 트리 Testcontainers 금지 - `architecture` 트리 Testcontainers 금지 - slice 애노테이션 혼합 금지 (`@WebMvcTest` + `@DataJpaTest` 동시 부착) - fixture leak 금지 (production 코드가 test fixture에 의존 금지) - 각 규칙의 **positive control**(규칙이 실제로 발동하는지)까지 검증 - **레벨 선언 관례도 있다.** `integration/package-info.java`가 *"Test Level Matrix: integration — 이 패키지는 Docker 데몬이 필요하다"*를 명시한다. 즉 **필요한 것은 전략의 도입이 아니라 완성**이다. 지금 빠진 조각은 두 개다. 1. 계약이 **패키지 이름**으로만 걸려 있고 **실행 경계**로는 걸려 있지 않다 (BOOT-014). 2. 그 계약을 사람이 읽을 SSOT 문서가 없다 — `docs/testing/`이 존재하지 않는다 (BOOT-016). **저장소 관례상 SSOT 문서는 강제 수단과 함께 온다.** 이 저장소에는 `docs/registries`, `docs/runbooks` 같은 SSOT 트리와 `verifyDocumentedLeafCount`처럼 문서-코드 불일치를 빌드 실패로 만드는 태스크가 이미 있다. 테스트 전략 문서도 같은 방식으로 붙여야 살아 있는 문서가 된다. ## 5. 우선순위 요약 | ID | 제목 | 심각도 | 근거 유형 | | --- | --- | --- | --- | | BOOT-003 | 검증 실패 67곳이 `StartupFailures`를 우회해 종료코드·구조화로그를 잃는다 | **High** | 결정론적 | | BOOT-001 | 설정 네임스페이스가 3중화되고 동일 관심사가 두 곳에 등록된다 | **High** | 결정론적 | | BOOT-005 | `StartupPhase`가 순서를 강제하지 않고, post-processor 순서가 동률이다 | **High** | 결정론적 | | BOOT-002 | 프로덕션 프로파일 판정이 7곳에 복제되고 어휘가 갈라진다 | Medium | 결정론적 | | BOOT-004 | 검증 누적 로직이 15곳에 복제되고 공용 컨텍스트가 없다 | Medium | 결정론적 | | BOOT-006 | 패키지 taxonomy가 두 축으로 섞이고 `Settings` 배치 규칙이 없다 | Medium | 판단 | | BOOT-007 | 같은 분해 문제를 notification과 redis가 다른 방식으로 푼다 | Medium | 판단 | | BOOT-008 | 모든 yaml 책임이 bootstrap에 집중되어 974줄 단일 파일이 된다 | Medium | 판단 | | BOOT-009 | 구조화 로그와 평문 로그가 규칙 없이 섞인다 | Medium | 결정론적 | | BOOT-010 | `Provider` enum과 `switch`가 이중 수정 지점을 만든다 | Low | 판단 | | BOOT-011 | `*SettingsBinder` 2개가 구조 중복이다 | Low | 결정론적 | | BOOT-012 | `CleanArchitectureTest`가 2,792줄 단일 클래스다 | Low | 판단 | | BOOT-013 | `StartupFailureLogState`가 프로세스 전역 가변 상태이며 테스트 병렬화를 막는다 | Low(잠재) | 판단 | | BOOT-014 | Testcontainers 통합 테스트가 `src/test`에 있어 `./gradlew test`가 Docker를 요구한다 | **High** | 결정론적 | | BOOT-015 | 공용 테스트 지원 코드 관례가 `testkit` 소스셋과 `testFixtures` 플러그인으로 갈린다 | Medium | 결정론적 | | BOOT-016 | 테스트 레벨 계약을 사람이 읽을 SSOT 문서가 없다 (`docs/testing/` 부재) | Medium | 판단 | | BOOT-017 | bootstrap이 조립만 하지 않고 아웃바운드 어댑터 구현체 4개를 소유한다 | Medium | 결정론적 | 권장 착수 순서: **BOOT-003 → BOOT-005 → BOOT-004 → BOOT-002 → BOOT-001 → 나머지.** 앞의 넷은 서로 같은 코드 영역을 건드리므로 한 덩어리로 처리하는 편이 효율적이다. --- ## 6. 상세 발견 사항과 구현 명세 ### BOOT-003 — 검증 실패의 2/3이 `StartupFailures`를 우회해 종료코드 체계를 무력화한다 **규칙.** `StartupErrorCode`는 시작 실패를 네 원인으로 나누고 각각 sysexits 종료코드를 부여한다 (78=설정 오류, 70=마이그레이션 실패, 71=프로파일 불일치, 72=필수 어댑터 비활성). 이 코드가 JVM 종료 상태가 되는 유일한 경로는 `StartupFailureException implements ExitCodeGenerator`다. **증거.** | 경로 | 건수 | | --- | --- | | `StartupFailures.*` 호출 (정본 경로) | 32 | | `throw new IllegalStateException` (우회) | **67** | | `throw new IllegalArgumentException` (우회) | 2 | 우회 경로의 실제 사례: - `activation/RuntimeEnvironmentProfileValidator.java` — 프로파일 미지정/복수지정/미지의 프로파일 3건 모두 raw `IllegalStateException`. - `autoconfigure/fileserver/FileserverSecurityConfiguration.java:81` — prod에서 `access-policy=unenforced` 거부. 성격상 `PROFILE_MISMATCH`(71)여야 한다. - `idempotency/IdempotencyProviderSelectionConfig.java` — provider 선택 불일치. 성격상 `REQUIRED_ADAPTER_DISABLED`(72)여야 한다. **영향.** 세 가지가 동시에 사라진다. 1. **종료코드.** `ExitCodeGenerator`를 구현하지 않으므로 JVM은 generic exit 1로 죽는다. "설정 오류(78)는 재시도해도 소용없으니 알림, 마이그레이션 실패(70)는 재시도"같은 운영 자동화 분기가 성립하지 않는다. 2. **구조화 로그.** `startup.phase` / `error.code` / `error.category` 필드가 붙지 않아 로그 기반 실패 원인 집계가 불가능하다. 3. **스택트레이스 억제.** `StartupFailureExceptionReporter`가 인식하지 못하므로 Spring Boot의 generic "Application run failed" 스택트레이스가 그대로 노출된다. 운영자가 봐야 할 한 줄이 수십 줄에 묻힌다. **수정.** 67곳을 성격에 맞는 `StartupFailures` 팩토리로 치환한다. 그리고 재발을 막기 위해 ArchUnit 규칙을 추가한다 — *`dev.caskeleton.bootstrap` 패키지의 production 코드는 `IllegalStateException`을 직접 생성할 수 없다*. 이 저장소는 이미 `architecture/violations/`에 위반 픽스처를 두는 관례가 있으므로 그 관례를 따른다. --- ### BOOT-001 — 설정 네임스페이스가 3중화되고 동일 관심사가 두 곳에 등록된다 **규칙.** 하나의 애플리케이션은 하나의 설정 루트를 가져야 한다. 운영자가 "이 값 어디서 바꾸죠"에 대해 한 개의 답을 받아야 한다. **증거.** `src/main/resources/application.yml`(974줄) 최상위 키: | 라인 | 루트 | | --- | --- | | 356 | `ca-skeleton:` | | 799 | `app:` | | 967 | `backend:` | `@ConfigurationProperties` prefix 전수: `ca-skeleton.*` 14개, `app.jpa-platform` 1개. `PREFIX` 상수: `app.fileserver-platform`, `app.httpclient`. 동일 관심사의 이중 등록: | 관심사 | 등록 위치 | | --- | --- | | notification | `ca-skeleton.notification` + `app.notification` + `ca-skeleton.notification.platform.secrets` | | JPA/persistence | `ca-skeleton.persistence-jpa` + `ca-skeleton.persistence` + `ca-skeleton.jpa` + `app.jpa-platform` | | fileserver | `app.fileserver` + `app.fileserver-platform` | | idempotency | `ca-skeleton.idempotency` + `ca-skeleton.capabilities.idempotency` | **영향.** 운영 사고 경로다. `app.notification`을 고치고 재기동했는데 동작이 안 바뀌면, 원인이 `ca-skeleton.notification`에 있다는 것을 코드를 읽어야만 알 수 있다. strict 바인딩은 오타를 잡지만 **"맞는 키인데 다른 트리"**는 잡지 못한다. **수정.** 1. 루트를 `ca-skeleton.*` 하나로 통일한다(등록 수가 압도적으로 많고 저장소 식별자와 일치). 2. 이전 키는 `MasterSwitchEnvironmentPostProcessor`가 이미 legacy 철자를 거부하는 메커니즘을 갖고 있으므로, 같은 자리에 "구 키가 발견되면 새 키를 안내하며 거부"를 추가한다. 조용한 무시는 금지. 3. 통합 후 `verifyEnvKeys`에 "선언된 prefix 루트는 하나"를 검증하는 항목을 추가한다. --- ### BOOT-005 — `StartupPhase`가 순서를 강제하지 않고 post-processor 순서가 동률이다 **규칙.** 실행 순서가 정확성에 영향을 준다면 그 순서는 코드로 강제되어야 한다. 주석에만 있으면 계약이 아니다. **증거 1 — `StartupPhase`는 라벨로만 쓰인다.** 전수 검색 결과 `StartupPhase`의 사용처는 `StartupErrorCode`의 필드와 `MigrationStartupRunner`의 로그 `kv()` 인자뿐이다. 어떤 validator도 자기 phase를 선언하지 않고, phase 순서로 실행을 배열하는 코드가 없다. **증거 2 — post-processor 순서가 동률이다.** | 클래스 | `getOrder()` | | --- | --- | | `MasterSwitchEnvironmentPostProcessor` | `Ordered.LOWEST_PRECEDENCE` | | `RuntimeEnvironmentProfileValidator` | `Ordered.LOWEST_PRECEDENCE` | | `CapabilityDependencyEnvironmentValidator` | `Ordered.LOWEST_PRECEDENCE` | | `TracingSamplingEnvironmentPostProcessor` | `Ordered` 미구현 (기본 = LOWEST_PRECEDENCE) | | `RedisReadinessGroupPostProcessor` | `ConfigDataEnvironmentPostProcessor.ORDER + 1` | | `DatabaseReadinessGroupPostProcessor` | `ConfigDataEnvironmentPostProcessor.ORDER + 2` | readiness 2개는 결정론적이다. 문제는 앞의 넷이 **모두 같은 값**이라는 점이다. 그리고 `CapabilityDependencyEnvironmentValidator.getOrder()`의 주석은 이렇게 의도를 명시한다. > *"after `MasterSwitchEnvironmentPostProcessor` has rejected legacy switch spellings — a violation > reported against a key the operator was told to stop using would send them somewhere useless."* **주석이 주장하는 순서를 반환값이 강제하지 않는다.** 현재 이 순서가 지켜지는 이유는 오직 `spring.factories`의 나열 순서와 Spring 정렬의 안정성(stable sort) 때문이다. 즉 **`spring.factories`의 줄 순서를 바꾸면 주석이 경고한 그 상황이 조용히 발생한다.** 이건 계약이 아니라 우연이다. **영향.** 현재 동작이 깨져 있다고 단정하지는 않는다(정적 분석이며 테스트를 실행하지 않았다). 그러나 선언된 의도와 강제 수단이 불일치하므로, 파일 편집만으로 회귀가 발생할 수 있고 그 회귀를 잡는 테스트가 없다. **수정.** 1. 네 post-processor에 서로 다른 명시적 order를 부여한다 (`ConfigDataEnvironmentPostProcessor.ORDER + n` 형태로 통일하면 readiness 2개와 같은 축에 놓인다). 2. `StartupPhase`를 실행 순서의 1급 개념으로 승격한다 — 4.2의 권고안. phase 내부는 전부 누적, phase 실패 시 다음 phase 미실행. 3. 순서를 고정하는 테스트를 추가한다. `spring.factories` 편집으로 순서가 바뀌면 실패해야 한다. --- ### BOOT-002 — 프로덕션 프로파일 판정이 7곳에 복제되고 어휘가 갈라진다 **규칙.** 정책의 정의는 한 곳에 있어야 한다. 이 저장소에는 이미 정본이 있다 — `RuntimeEnvironmentProfileValidator.DEPLOYABLE_ENVIRONMENTS = Set.of("local", "dev", "prod")`. **증거.** 동일 의미의 판정이 세 가지 다른 어휘로 7곳에 재구현되어 있다. | 어휘 | 위치 | | --- | --- | | `PROD_PROFILE = "prod"` (6줄 루프 동일 복제) | `runtime/JpaSchemaSafetyValidator:51`, `runtime/PersistenceVendorProdSafetyValidator:81`, `runtime/PostgreSqlTransportSecurityValidator:86`, `runtime/StartupSafetyValidator:94`, `runtime/SecretSourceValidator:173`, `runtime/startup/FlywayProdSafetyValidator:60` | | `Set.of("prod","production")` | `autoconfigure/fileserver/FileserverStartupConfiguration:90`, `autoconfigure/fileserver/FileserverSecurityConfiguration:81` (상수도 각자 선언) | | `"prod"\|\|"production"` 인라인 | `autoconfigure/httpclient/HttpClientProfileAutoConfiguration:101` | **영향 — 정확히 기술한다.** 배포 아티팩트에서는 **현재 실 버그가 아니다.** `RuntimeEnvironmentProfileValidator`가 `local|dev|prod` 중 정확히 하나를 요구하며 부팅을 거부하므로, `production` 단독 프로파일로는 애플리케이션이 뜨지 않는다. 따라서 fileserver/httpclient의 넓은 어휘는 도달 불가능한 죽은 범위다. 그러나 그 게이트에는 조건이 있다. `isDeployableArtifact()`가 메인 클래스가 `dev.caskeleton.bootstrap.CaSkeletonApplication`인지 확인하므로, **슬라이스 테스트와 다른 컨텍스트는 이 게이트를 지나지 않는다.** 그 환경에서는 두 어휘가 실제로 갈린다. 실질 비용은 유지보수다. 프로파일 이름을 하나 추가하거나 정책을 바꾸면 **7곳을 동시에** 고쳐야 하고, 하나를 빠뜨려도 컴파일은 통과한다. **수정.** `RuntimeEnvironment` 판정기를 하나 만들어 정본 어휘를 소유하게 하고, 7곳을 그것으로 치환한다. `DEPLOYABLE_ENVIRONMENTS`도 그 타입으로 옮긴다. `getActiveProfiles()` 직접 호출을 bootstrap production 코드에서 금지하는 ArchUnit 규칙으로 재발을 막는다. --- ### BOOT-004 — 검증 누적 로직이 15곳에 복제되고 공용 컨텍스트가 없다 **규칙.** 같은 구조가 3회 이상 반복되면 추상화 후보다. 여기서는 15회다. **증거.** `List` 누적 → 비어있지 않으면 throw 패턴이 다음 위치에 각자 구현되어 있다. ``` activation/CapabilityDependencyValidator:42 violations autoconfigure/fileserver/FileserverStartupValidator:26,38 violations (+ 지역 헬퍼 failOn()) autoconfigure/httpclient/HttpClientStartupValidator:64 violations autoconfigure/persistencejpa/DataSourceRequirement:29 reasons runtime/RuntimeNumericBoundsValidator:38 violations runtime/StartupSafetyValidator:56,77 unsafe / missing runtime/SecretSourceValidator:70,98 offenders / missing runtime/RedisActivationValidator:46 contradictions runtime/HikariPoolConstraintValidator:38 violations runtime/startup/FlywayProdSafetyValidator:31 violations runtime/startup/RequiredEnvironmentValidator:31 missing security/AuthenticationModeCompositionConfig:24 contradictions runtime/PostgreSqlTransportSecurityValidator:71 modes ``` 변수명이 `violations`/`missing`/`offenders`/`contradictions`/`unsafe`/`reasons`/`modes`로 제각각이라 같은 패턴이라는 사실 자체가 코드에서 보이지 않는다. 오류 메시지 조립 방식도 각자 다르다 (`String.join`, 문자열 연결, `describe()` 헬퍼). **수정 — 검증 컨텍스트 도입.** 4.2의 phase 개념과 함께 구현한다. ```java public final class StartupValidationContext { private final Map> byPhase = new EnumMap<>(StartupPhase.class); public StartupValidationContext require(boolean ok, StartupPhase phase, String detail) { ... } public boolean hasFailures(StartupPhase phase) { ... } public void failFastAtEndOfPhase(StartupPhase phase) { ... } // StartupFailures 경유 } ``` 효과가 겹친다는 점이 중요하다. 이 하나의 컨텍스트가 BOOT-004(중복 제거), BOOT-005(phase 순서 강제), BOOT-003(정본 예외 경로 강제)를 동시에 해결한다. 세 발견을 한 덩어리로 처리하라고 권고한 이유다. 제네릭은 여기에 필요 없다. 누적 대상이 항상 `String` 메시지이므로 타입 파라미터는 가치를 더하지 않는다 — 4.4의 기준을 그대로 적용한 결과다. --- ### BOOT-006 — 패키지 taxonomy가 두 축으로 섞이고 `Settings` 배치 규칙이 없다 **증거 1 — capability 배치의 이원화.** ``` autoconfigure/{fileserver, httpclient, jpa, messaging, persistencejpa} ← autoconfigure 하위 {redis, notification, mongo, outbox, tracing, idempotency, lock}/ ← 최상위 ``` 성격이 동일한 capability 자동조립인데 절반은 `autoconfigure/` 안, 절반은 밖이다. 게다가 `autoconfigure/jpa`(10파일)와 `autoconfigure/persistencejpa`(3파일)가 별도로 존재하고, JPA 관심사는 `runtime/`의 validator 6개까지 합쳐 **3개 패키지**에 흩어져 있다. **증거 2 — `Settings` 배치 규칙 없음.** 16개 중 4개는 `settings/` 패키지에, 12개는 각 기능 패키지에 있다. 어느 쪽이 원칙인지 코드가 말하지 않는다. ``` settings/{Bootstrap, Logging, Privacy, RuntimeSafety}Settings async/AsyncExecutorSettings, redis/RedisCapabilitySettings, outbox/OutboxSettings, ... (12개) ``` **수정.** 규칙을 하나로 정하고 `src/app-bootstrap/CLAUDE.md`에 기록한다. 권고안: - capability 조립은 전부 `autoconfigure//` 아래. - `Settings`는 자기 capability 패키지 안에 둔다 (`settings/` 패키지는 capability에 속하지 않는 모듈 전역 설정만 유지). - capability에 속하지 않는 시작 안전장치만 `runtime/`에 남긴다. JPA 전용 validator 6개는 `autoconfigure/jpa/`로 이동. - `autoconfigure/jpa`와 `autoconfigure/persistencejpa`를 병합한다. --- ### BOOT-007 — 같은 분해 문제를 두 방식으로 푼다 **증거.** | capability | 방식 | 규모 | | --- | --- | --- | | notification | Config 9개로 분해 (Codec / Dispatch / Policy / Provider / Registries / Runtime / Secrets / Worker / Observability) | 최대 파일 442줄, `@Bean` 19 | | redis | 파일 2개 | `RedisCapabilityConfig` 376줄(`@Bean` 6) + `RedisCapabilitySettings` **473줄** | | fileserver | Configuration 8개 + 지원 7개 | `FileserverPlatformAutoConfiguration` 318줄, `@Bean` 24 | notification의 분해 축(codec/dispatch/policy/provider/registry/worker/observability/secrets)은 좋은 기준이다. redis는 같은 기준을 적용할 수 있는데 적용되지 않았다. 특히 `RedisCapabilitySettings` 473줄은 **단일 `Settings` 클래스로는 이 모듈 최대**이며, 하나의 `@ConfigurationProperties` 안에 cache / rate-limit / lease / idempotency 네 개의 서로 다른 capability 설정이 들어 있다(내부 `namespaceOf`, `KeyDigest` 중첩 타입 포함). **수정.** notification의 분해 기준을 redis에 적용한다. 그리고 **분해 기준 자체를 문서화한다** — 이것이 핵심이다. 기준이 문서에 없으면 다음 capability에서 또 갈린다. 권고 기준: > 하나의 `@Configuration`은 하나의 조립 관심사만 가진다. `@Bean`이 8개를 넘거나 서로 다른 포트 계열을 > 조립하기 시작하면 분해한다. 하나의 `Settings`는 하나의 capability 네임스페이스만 소유한다. --- ### BOOT-008 — 모든 yaml 책임이 bootstrap에 집중되어 974줄 단일 파일이 된다 **제기된 논점에 대한 판정: 부분 동의.** 방향은 옳지만 그대로 실행하면 클린 아키텍처 문제가 생긴다. 분리 기준을 정확히 잡아야 한다. **증거.** `application.yml` 974줄에 15개 capability의 설정이 전부 들어 있다. 한편 어댑터 모듈들은 이미 `src/main/resources`를 갖고 있고 일부는 자기 정책 yaml을 소유한다 (`adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml`, `adapter/inbound/web/src/main/resources/META-INF/web/problem-catalog.yaml`). 즉 **선례가 이미 있다.** **그대로 실행하면 안 되는 이유.** 설정 값에는 성격이 다른 두 종류가 섞여 있다. | 종류 | 소유자 | 이유 | | --- | --- | --- | | **기본값 · 제약** (타임아웃 기본치, 허용 범위, 키 이름) | **모듈** | 그 모듈만이 무엇이 유효한지 안다 | | **배포별 값** (prod의 pool size, dev의 엔드포인트, 활성 여부) | **bootstrap** | 배포 결정이며 모듈은 자기가 어디 배포되는지 몰라야 한다 | `application-{local,dev,prod}.yml`을 프로파일별로 나눈 것은 두 번째 종류에 대한 처리이고 **그 자체로 옳다.** 만약 모듈이 `application-prod.yml`을 소유하게 되면, 어댑터가 "prod라는 배포 환경"을 알게 되어 의존 방향이 오염된다. **수정 — 권고안.** 1. **기본값은 코드로 내린다.** 이미 대부분의 `Settings`가 레코드 compact constructor에서 기본값을 부여한다(`IdempotencyProviderSettings`의 `provider == null ? Provider.JDBC`). 이 방식을 확대하면 yaml에서 "기본값을 다시 적는" 줄이 사라진다. **가장 비용이 낮고 효과가 큰 조치다.** 2. **모듈이 shipping할 것이 남으면** `classpath:/META-INF/caskeleton/.yml`로 두고 bootstrap이 `spring.config.import`로 합성한다. 의존 방향은 bootstrap → 모듈이므로 안전하다. 3. **배포별 오버라이드는 bootstrap이 계속 소유한다.** `application-{local,dev,prod}.yml` 구조는 유지. 4. 그 결과 `application.yml`은 "이 배포가 실제로 무엇을 켜고 어떤 값을 덮어쓰는지"만 남는다. BOOT-001(네임스페이스 통일)을 먼저 처리한 뒤 이 작업을 하는 편이 낫다. 순서를 뒤집으면 파일을 옮기고 나서 키를 다시 옮기게 된다. --- ### BOOT-009 — 구조화 로그와 평문 로그가 규칙 없이 섞인다 **증거.** production 코드의 로그 호출은 총 25건이다 (`error` 4, `warn` 17, `info` 3, `debug` 1). 그중 구조화 인자(`StructuredArguments.kv`)를 쓰는 파일은 **3개뿐**이다 — `runtime/startup/StartupFailures`, `runtime/startup/MigrationStartupRunner`, `async/LoggingAbortPolicy`. `StartupFailures.emit()`은 이미 **정확히 요청된 형태의 틀**을 갖고 있다. 고정 필드 (`startup.phase`, `error.code`, `error.category`)와 가변 부분(`detail`)이 분리되어 있고, `cause` 유무로 두 형태만 존재한다. ```java log.error("startup failure in phase {}: {}", kv("startup.phase", code.phase().wireName()), detail, kv("error.code", code.name()), kv("error.category", code.category())); ``` **진단.** 로그 일관성 문제의 뿌리는 로그 코드가 아니라 BOOT-003이다. 67곳이 `StartupFailures`를 우회한다는 것은 곧 **67곳이 이 틀을 통과하지 않는다**는 뜻이다. 우회 경로는 로그를 아예 남기지 않거나(예외 메시지만), 각자 다른 평문으로 남긴다. **수정.** BOOT-003을 처리하면 이 항목의 대부분이 함께 해소된다. 추가로 필요한 것은 두 가지다. 1. **경고 로그의 틀 정의.** `warn` 17건은 시작 실패가 아니므로 `StartupFailures` 대상이 아니다. 이들을 위한 대응물(예: `RuntimeWarnings.capabilityDegraded(capability, detail)`)을 만들어 `capability` / `reason` 고정 필드를 부여한다. 2. **필드 이름 사전을 문서화한다.** `startup.phase`, `error.code`, `error.category`가 이미 쓰이고 있으므로 그 목록을 `README` 또는 runbook에 고정하고, 새 필드는 거기 등록 후 사용하도록 한다. 이름이 사전에 없으면 로그 검색이 성립하지 않는다. --- ### BOOT-010 — `Provider` enum과 `switch`가 이중 수정 지점을 만든다 **제기된 논점에 대한 판정: 증상 진단은 정확하고, 처방은 조정이 필요하다.** **증거.** `idempotency/IdempotencyProviderSettings.java` ```java public enum Provider { DISABLED, JDBC, REDIS, POSTGRESQL } ``` `idempotency/IdempotencyProviderSelectionConfig.java` ```java switch (settings.provider()) { case DISABLED -> require(found, new Counts(0, 0, 0, 0)); case JDBC -> require(found, new Counts(1, 1, 0, 0)); case REDIS, POSTGRESQL -> require(found, new Counts(0, 0, 1, 1)); default -> throw new IllegalStateException("Unsupported idempotency provider: " + ...); } ``` provider를 추가하려면 **enum과 switch 두 곳**을 고쳐야 한다. **왜 env로 빼는 것이 답이 아닌가.** provider 문자열을 env로 받아도 문제가 해결되지 않는다. `CASSANDRA`를 env로 넣을 수 있게 만들어도, 그에 대응하는 `IdempotencyStorePortV2` 빈이 클래스패스에 없으면 부팅이 실패한다. **컴파일 오류가 런타임 오류로 옮겨갈 뿐 확장성은 생기지 않는다.** 게다가 이 enum의 Javadoc은 설계 의도를 명시한다 — *"Exact provider selector; provider precedence is never inferred from the classpath."* 즉 "클래스패스에 있는 걸로 자동 추론하지 않는다"는 것이 **의도적 결정**이다. env 기반 자동 발견은 이 결정을 정면으로 뒤집는 것이므로, 뒤집으려면 그만한 근거가 필요하다. 현재 그 근거는 없다고 본다. **진짜 결합점은 enum이 아니라 `switch`다.** enum은 "선택 가능한 provider의 목록"이라는 사실을 타입으로 표현하는 올바른 도구다. 문제는 **각 provider가 요구하는 빈 조합을 bootstrap의 switch가 알고 있다는 것**이다. 그 지식은 provider 자신이 가져야 한다. **수정 — 4.4의 제네릭 방향과 일치하는 처방.** ```java public interface ProviderSpec

> { P id(); ExpectedBeans expectation(); // 이 provider가 요구하는 빈 조합 } ``` 각 provider 모듈이 자기 `ProviderSpec`을 빈으로 등록하고, bootstrap은 선택된 id에 해당하는 spec을 찾아 검증만 한다. switch가 사라지고, provider 추가 = 모듈 추가가 된다. enum은 그대로 유지된다 — "선택 가능한 값의 닫힌 집합"이라는 타입 안전성은 계속 필요하기 때문이다. **부수 발견.** 위 `switch`의 `default ->` 분기는 도달 불가능하다. 네 개 enum 상수를 모두 다루고 있고 Java의 enum switch는 망라성이 보장되므로, 이 분기는 죽은 코드이며 "새 상수를 추가해도 컴파일러가 경고하지 않게" 만드는 부작용이 있다. 제거하면 컴파일러가 누락을 잡아준다. --- ### BOOT-011 — `*SettingsBinder` 2개가 구조 중복이다 **증거.** `FileserverPlatformSettingsBinder`(41줄)와 `HttpClientPlatformSettingsBinder`(54줄)는 동일 구조다 — `NoUnboundElementsBindHandler` 생성 → `Binder.get(env).bind(PREFIX, Bindable.of(T), strict)` → `orElseThrow`. 차이는 둘뿐이다. 1. httpclient는 시스템 환경변수 소스를 handler에서 제외하고 `HttpClientEnvironmentKeys.rejectUnrecognised`로 따로 검사한다 (근거가 Javadoc에 정확히 기록되어 있다). 2. 예외 메시지 문구 ("the capability is enabled" vs "the platform is enabled"). **수정.** ```java static T bind(Environment env, String prefix, Class type, Predicate handlerScope) { ... } ``` 두 번째 차이(문구)는 통일하면 그만이다. 첫 번째 차이는 파라미터로 흡수된다. 이 항목은 심각도가 낮고 독립적이므로 다른 작업 뒤에 처리해도 된다. --- ### BOOT-012 — `CleanArchitectureTest`가 2,792줄 단일 클래스다 **증거.** `src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` — 2,792줄, `@Test` 75개, `@Nested` **0개**, ArchUnit 규칙 정의 약 101개. 이 저장소에서 가장 큰 단일 파일이며, 두 번째(`DeveloperExperienceContractTest` 1,130줄)의 2.5배다. **영향.** 규칙군의 경계가 보이지 않아 "이 규칙이 어느 계층 계약인지"를 파일을 읽어야 알 수 있고, 실패 시 리포트에서도 그룹이 드러나지 않는다. **수정.** `@Nested` 클래스로 규칙군을 분리한다 (예: `LayerDependencies`, `DomainPurity`, `AdapterBoundaries`, `ContractIsolation`, `NamingConventions`). 규칙 내용은 손대지 않고 구조만 바꾸는 작업이므로 위험이 낮다. 파일 분할까지 갈 필요는 없다 — `@Nested`만으로 목적이 달성되고, ArchUnit 클래스 캐시를 공유할 수 있어 실행 시간에도 유리하다. --- ### BOOT-013 — `StartupFailureLogState`가 프로세스 전역 가변 상태다 **증거.** `runtime/startup/StartupFailureLogState`는 `static AtomicBoolean`을 들고 있고, production 코드에 `public static void clearForTest()`를 노출한다. 실제 사용처는 `logging/StartupFailureSpringBootLogFilter`(logback TurboFilter)와 `StartupFailureSpringBootLogFilterTest` 두 곳이다. **현재는 문제가 아니다.** `build.gradle`에 `maxParallelForks`도 `junit-platform.properties`의 병렬 실행 설정도 없다. 테스트는 순차 실행되므로 상태 간섭이 발생하지 않는다. **설계상의 정당성도 일부 있다.** logback `TurboFilter`는 Spring 빈이 아니라 logback이 직접 생성하므로 주입을 받을 수 없다. static 접근이 사실상 유일한 통로다. **남는 문제 두 가지.** 1. 테스트 병렬화를 켜는 순간 조용히 깨진다. 지금 막고 있는 것은 설계가 아니라 "아직 안 켰다"는 사실뿐이다. 2. `clearForTest()`가 production 소스에 public으로 있다. production 아티팩트에 테스트 전용 API가 포함된다. **수정(선택).** 지금 고쳐야 할 항목은 아니다. 다만 병렬 실행을 도입할 때 반드시 함께 처리해야 하므로 기록해 둔다. 처리 방향은 `LoggerContext`의 object map에 상태를 저장해 logback 컨텍스트 수명에 묶는 것이다 — 그러면 상태가 프로세스가 아니라 로깅 컨텍스트 단위가 되고 `clearForTest()`도 사라진다. --- ### BOOT-014 — Testcontainers 통합 테스트가 `src/test`에 있어 기본 test 태스크가 Docker를 요구한다 **규칙.** 이 저장소는 이미 "실행 환경이 다른 테스트는 별도 소스셋"을 표준으로 삼고 있다. 문제는 app-bootstrap만 그 표준을 지키지 않는다는 것이다. **증거 1 — 저장소의 실제 표준.** 주요 모듈의 `src/` 하위 소스셋 목록이다. | 모듈 | 소스셋 | | --- | --- | | `adapter/outbound/persistence-jpa` | main, test, **postgresqlIntegrationTest**, **jpaPlatformPerformanceTest**, testkit | | `adapter/inbound/web` | main, test, **jettyCompatTest**, **nginxProxyTest**, **webfluxContractTest**, testkit | | `adapter/inbound/websocket` | main, test, **brokerRelayTest**, **jettyWebSocketTest**, **nginxWebSocketTest**, testkit | | `adapter/outbound/objectstorage` | main, test, **objectStorageMinioContractTest**, **objectStorageMinioFaultTest**, **objectStorageAwsQualificationTest** | | `adapter/inbound/graphql` | main, test, **testFixtures** | | **`app-bootstrap`** | main, test, conditionalTransportTest, functionalTest, sampleOffTest | 다섯 모듈이 인프라 의존 테스트를 별도 소스셋으로 뺐다. **app-bootstrap의 세 소스셋은 전부 빌드 조립 계약이고, 인프라 테스트용 소스셋은 하나도 없다.** **증거 2 — 인프라 테스트가 `src/test` 안에 있다.** `org.testcontainers`를 참조하는 파일: ``` integration/PostgreSqlTestContainer.java integration/DistributedLockProviderContractTest.java integration/IdempotencyUniqueScopeContractTest.java integration/FlywayMigrationCompatibilityContractTest.java integration/fileserver/FileserverRoundTripContractTest.java integration/outbox/OutboxContainerTestSupport.java integration/outbox/OutboxRowLifecycleContractTest.java (728줄) integration/outbox/OutboxAppendTransactionalContractTest.java integration/outbox/OutboxPublisherLeaderElectionContractTest.java ``` 그리고 `integration/package-info.java`는 이를 명시적으로 인정한다. > *"Tests in this package require a running Docker daemon. They boot real external services > (PostgreSQL via Testcontainers)…"* **증거 3 — 실행 경계가 없다.** `build.gradle`의 `test` 태스크에 `excludeTags`도 태그 필터도 없다. `@Testcontainers`가 붙은 5개 클래스 중 가드(`@EnabledIf`)를 가진 것은 `FileserverRoundTripContractTest` 하나뿐이다. 나머지 4개 — `IdempotencyUniqueScopeContractTest`, `OutboxRowLifecycleContractTest`, `OutboxAppendTransactionalContractTest`, `OutboxPublisherLeaderElectionContractTest` — 는 무조건 실행된다. (`DistributedLockProviderContractTest`와 `FlywayMigrationCompatibilityContractTest`는 가정문 형태의 가드를 갖고 있다.) **영향.** `./gradlew :app-bootstrap:test`가 **Docker 데몬을 요구한다.** 도메인 로직 한 줄을 고치고 검증하려는 개발자가 PostgreSQL 컨테이너 기동을 기다린다. 이 저장소의 `CLAUDE.md`가 권장하는 "focused test" 워크플로 — *leaf의 gradle_path로 `:app-bootstrap:test` 실행* — 이 가장 무거운 명령이 된다. Docker가 없는 환경(CI의 경량 잡, 오프라인 작업)에서는 아예 실패한다. **수정.** 저장소 표준을 그대로 따른다. 1. `src/test/java/dev/caskeleton/bootstrap/integration/**` 를 새 소스셋 `src/integrationTest/java/dev/caskeleton/bootstrap/integration/**` 로 이동한다. 패키지 경로는 그대로 유지한다(production 미러링 원칙 유지). 2. `integrationTest` Test 태스크를 등록하고 `check`에는 붙이되 `test`에는 붙이지 않는다. `persistence-jpa`의 `postgresqlIntegrationTest` 정의를 참고 구현으로 삼는다. 3. `TestTaxonomyArchitectureTest`에 규칙을 하나 추가한다 — *`src/test` 소스셋의 클래스는 `org.testcontainers`에 의존할 수 없다*. 현재 이 규칙은 `contract`/`architecture` 트리에만 걸려 있어 `integration` 트리가 `src/test`로 되돌아오는 것을 막지 못한다. 이 규칙이 BOOT-014의 재발 방지 장치다. **주의.** 이동 시 `OutboxContainerTestSupport`(231줄)와 `PostgreSqlTestContainer`는 공용 지원 코드이므로 BOOT-015와 함께 배치를 정해야 한다. 통합 테스트만 쓰는 것이 확실하면 `integrationTest` 소스셋 안에 두고, `src/test`도 쓴다면 `testFixtures`로 올린다. --- ### BOOT-015 — 공용 테스트 지원 코드 관례가 두 갈래다 **증거.** 같은 목적(모듈 간 공유 가능한 테스트 지원 코드)에 두 가지 방식이 공존한다. | 방식 | 채택 모듈 | | --- | --- | | `testkit` 커스텀 소스셋 | `persistence-jpa`, `web`, `websocket` | | `java-test-fixtures` Gradle 플러그인 | `graphql` | | 둘 다 없음 (`src/test` 안에 지원 클래스 산재) | **`app-bootstrap`** | `graphql`의 `build.gradle`은 심지어 fixture가 잘못된 위치에 있으면 빌드를 실패시키는 커스텀 검증까지 갖고 있다(99–141행). > *"Move them to src/testFixtures/java, or declare them with testFixturesImplementation."* 한편 app-bootstrap의 지원 코드는 일반 테스트 트리에 섞여 있다 — `contract/support/RepositoryContractResources`(191줄), `contract/support/conditional/*`(3개), `integration/outbox/OutboxContainerTestSupport`(231줄), `integration/PostgreSqlTestContainer`, `taxonomyfixtures/*`, `activation/AdapterActivationInventory`(242줄). **영향.** 지원 코드와 테스트 코드가 이름으로만 구분된다. 다른 모듈이 이 지원 코드를 재사용할 방법이 없고, 반대로 production이 실수로 참조해도 `graphql`이 가진 것 같은 방어막이 없다 (다만 `TestTaxonomyArchitectureTest`의 fixture-leak 규칙이 일부를 막는다). **수정.** 저장소 차원에서 **하나를 표준으로 정한다.** 권고는 `java-test-fixtures`다 — Gradle 공식 플러그인이고, 소비 측 선언(`testFixturesImplementation`)이 명시적이며, `graphql`에 이미 강제 장치가 구현되어 있어 그대로 복제할 수 있다. `testkit` 소스셋 3개는 점진적으로 이관한다. 이 결정은 app-bootstrap 단독 결정이 아니므로 ADR로 남기는 것이 맞다. 디렉터리는 역할을 드러내는 형태를 권고한다 (`testing/{fixture,fake,container,assertion}/`). `TestUtil`/`CommonUtil` 같은 무의미한 이름 묶음은 금지한다. --- ### BOOT-016 — 테스트 레벨 계약을 사람이 읽을 SSOT 문서가 없다 **증거.** `docs/testing/` 디렉터리가 존재하지 않는다. 테스트 레벨 계약은 현재 세 곳에 흩어져 있다. 1. `TestTaxonomyArchitectureTest`의 ArchUnit 규칙 (기계는 읽지만 사람이 훑기 어렵다) 2. `integration/package-info.java`의 Javadoc (한 패키지에만 있다) 3. 각 모듈 `build.gradle`의 소스셋 선언 (의도가 아니라 결과만 보인다) 기여자가 "이 테스트를 어디에 써야 하나"를 물었을 때 답을 주는 단일 문서가 없다. **왜 중요한가.** 이 저장소는 LLM 에이전트 협업을 전제로 설계되어 있다(`CLAUDE.md`, `AGENTS.md`, `docs/superpowers`). 테스트 레벨 규칙이 SSOT로 고정되어 있지 않으면 생성되는 테스트가 `@SpringBootTest` 남발, happy-path 편중, 중복 fixture 쪽으로 흐른다. 이건 사람 기여자에게도 동일하다. **수정.** `docs/testing/TESTING_STRATEGY.md`를 만들고 다음을 고정한다. - **레벨 정의와 소스셋 매핑.** unit(`src/test`, 네트워크·DB·Docker·`@SpringBootTest` 금지) / slice / integration(`src/integrationTest`, Testcontainers 허용, 외부 SaaS 호출 금지) / contract / build-qualification(`functionalTest`·`sampleOffTest`·`conditionalTransportTest`) / architecture. - **판단표.** 대상 코드 유형별(Controller, UseCase, JPA Repository, Kafka, Redis, 시작 validator)로 "어느 레벨 / Mock·Fake·Real 중 무엇 / 어느 소스셋"이 기계적으로 결정되도록 한다. - **배치 원칙.** production 패키지를 미러링한다. `unit/`·`service/`·`regression/` 같은 축 혼합 폴더 금지. - **CI 단계 매핑.** unit(커밋) → integration·contract(PR) → build-qualification(머지) → performance(야간). **저장소 관례에 맞춰 강제 수단을 함께 붙인다.** 문서만 만들면 드리프트한다. 이 저장소에는 `verifyDocumentedLeafCount`처럼 문서-코드 불일치를 빌드 실패로 만드는 선례가 있으므로, `TESTING_STRATEGY.md`가 선언한 소스셋 목록과 실제 `sourceSets` 선언이 어긋나면 실패하는 검증 태스크를 추가한다. 그러면 이 문서는 설명서가 아니라 계약이 된다. --- ### BOOT-017 — bootstrap이 조립만 하지 않고 아웃바운드 어댑터 구현체를 소유한다 **규칙.** Composition Root는 "무엇을 무엇에 연결하는가"를 결정하는 곳이지, 포트를 구현하는 곳이 아니다. 포트 구현체는 정의상 아웃바운드 어댑터이며 `adapter/outbound/*`에 속한다. **증거.** `app-bootstrap`의 production 코드에서 application/domain 포트를 직접 구현하는 클래스는 넷이다. | 클래스 | 구현 포트 | 성격 | | --- | --- | --- | | `notification/observation/MicrometerNotificationMetrics` | `NotificationMetricsPort` | Micrometer 어댑터 | | `autoconfigure/fileserver/MicrometerFileserverMetrics` (190줄) | `FileserverMetricsPort` | Micrometer 어댑터 | | `runtime/EnvironmentSecretSource` | `SecretSource` | 환경변수 어댑터 | | `lock/MeteredDistributedLockPort` | `DistributedLockPort` | 계측 데코레이터 (논쟁적, 아래) | **결정적 증거 — 저장소 자신이 정답을 알고 있다.** 같은 계열의 `LoggingNotificationAudit` (`NotificationAuditPort` 구현)은 `adapter/outbound/notification/.../platform/observation/`에 **올바르게 배치되어 있다.** `NotificationPlatformObservabilityConfig:104`가 그 빈을 조립할 뿐이다. 즉 "관측 어댑터는 어댑터 모듈에 두고 bootstrap은 조립만 한다"는 패턴이 이미 이 저장소 안에 존재하는데, 위 넷만 그 패턴을 따르지 않는다. **집도 이미 있다.** `adapter:outbound:support` leaf가 존재하며 클래스가 셋뿐이다 (`OutboundSupportConfig`, `FailOpenDependencyLogger`, `OutboundCorrelation`). 횡단 아웃바운드 지원 어댑터의 자연스러운 소유자다. **방어막이 없다.** `CleanArchitectureTest`(101개 규칙)에 *bootstrap은 포트를 구현할 수 없다*는 규칙이 없다. 그래서 넷이 컴파일·검증을 모두 통과한다. **`MeteredDistributedLockPort`는 논쟁적이며, 그 사실을 기록해 둔다.** 이것은 포트 어댑터가 아니라 **데코레이터**다. "어느 포트를 계측할 것인가"는 조립 결정이라는 논리로 bootstrap에 둘 수 있고, 반대로 계측은 로깅·재시도와 같은 횡단 관심사이므로 별도 모듈이 맞다는 논리도 선다. 현재 데코레이터가 하나뿐이라 bootstrap에 둬도 무해하다. **데코레이터가 둘 이상 되는 순간을 분리 신호로 삼는다.** 반면 나머지 셋은 논쟁의 여지가 없는 어댑터다. **영향.** 지금 동작이 깨지지는 않는다. 비용은 두 가지다. (1) bootstrap이 커지는 방향으로만 압력을 받는다 — 새 관측 포트가 생길 때마다 여기로 온다. (2) 파생 서비스가 bootstrap을 교체할 때 어댑터 구현까지 함께 다시 써야 한다. 이 저장소가 템플릿이라는 점에서 두 번째가 실질적이다. **수정.** 1. `MicrometerNotificationMetrics` → `adapter/outbound/notification` (`LoggingNotificationAudit` 옆, 동일 패키지 관례). 2. `MicrometerFileserverMetrics` → fileserver 어댑터 leaf, 없으면 `adapter:outbound:support`. 3. `EnvironmentSecretSource` → `adapter:outbound:support`. 4. `MeteredDistributedLockPort` → 당분간 유지, 데코레이터 2개째에 `adapter:outbound:support`로 이동. 5. `CleanArchitectureTest`에 규칙 추가 — *`..bootstrap..`의 클래스는 `..application..`/`..domain..`의 포트 인터페이스를 구현할 수 없다*. 데코레이터 예외가 필요하면 명시적 allowlist로 두어, 예외가 늘어나는 것이 눈에 보이게 한다. **이동 시 확인.** `modules.json`의 `allowed_dependencies`와 `runtime_memberships`를 함께 갱신해야 하며, `verifyCleanArchitectureDependencies`가 게이트 역할을 한다. --- ## 7. 참조 프로젝트 대비 격차 분석 별도의 헥사고날 템플릿(이하 "참조 프로젝트")에 대한 외부 분석을 이 저장소에 대입한 결과다. 참조 프로젝트는 Book/Author 도메인을 가진 **예제 중심 템플릿**이고, 이 저장소는 도메인 예제가 최소인 **인프라 스켈레톤**이다. 성격이 다르므로 항목별로 적용 가능성을 나누어 판정했다. ### 7.1 이미 갖추었거나 더 강한 항목 | 참조 항목 | 이 저장소의 상태 | 판정 | | --- | --- | --- | | 의존 방향을 빌드가 강제 | `modules.json` 레지스트리 + `verifyCleanArchitectureDependencies` + ArchUnit 101규칙 **3중** | **더 강함** | | `spring-tx`만 의존해 코어를 얇게 | `application-core`의 의존은 `implementation project(':shared-contract')` **하나뿐**. Spring 의존 0개이며 트랜잭션도 포트로 추상화 | **더 강함** | | 코어 프레임워크 프리 | `domain-core`의 `dependencies { }` — 비어 있음 | 동등 | | 어댑터 선택 property 기반 + fail-fast | `MasterSwitchEnvironmentPostProcessor`, `CapabilityDependencyValidator`, `RuntimeEnvironmentProfileValidator` | **더 정교함** | | 전송 중립 에러 모델 | `shared-contract/error/{Category, ApiErrorCode, OperationalError, ApiErrorCarrier}` + web/gRPC/GraphQL 각 핸들러 | 동등 | | MDC 키 상수화 | `MdcKeys` + `docs/registries`의 `mdc_key` 값과의 정합성을 테스트가 검증 (`DistributedTracingContractTest:170`) | **더 강함** | | `-parameters` 컴파일 플래그 | `src/build.gradle:396` — `-Werror`, `-Xlint` 동반 | **더 강함** | | 가상 스레드 / graceful shutdown | `spring.threads.virtual`, `timeout-per-shutdown-phase` | 동등 | | Actuator liveness/readiness 분리 | 그룹 분리 + 위험 엔드포인트 exclude 목록 + 의존성 taxonomy | **더 강함** | | Testcontainers 실 DB 통합테스트 | 존재 (다만 배치가 BOOT-014) | 동등 | **요약:** 참조 프로젝트가 "잘한 점"으로 꼽은 A-1~A-5, A-9의 상당수를 이 저장소는 이미 만족하거나 더 엄격하게 구현하고 있다. 특히 **코어 순수성은 참조 프로젝트보다 한 단계 더 나아갔다** — 참조는 `spring-tx`를 남겼지만 이 저장소는 트랜잭션마저 포트로 밀어냈다. ### 7.2 없는 것 — 도입 후보 | 참조 항목 | 이 저장소 | 대응 | | --- | --- | --- | | **모듈이 자기 설정 파일 소유 (`config/.yml` 12개 + `spring.config.import`)** | `spring.config.import`가 **저장소 전체에서 0회** 사용. 설정 974줄이 bootstrap 단일 파일 | **BOOT-008의 참조 구현** | | BOM 다중화로 버전 단일화 | Spring Boot BOM 하나만. 모듈 build.gradle에 개별 버전 18개 이상 하드코딩 | 아래 7.2.1 | | `withSourcesJar()` | 없음 | 저비용, 선택 | | `.env` 시트 + 우선순위 문서화 | `me.paulschwarz:spring-dotenv:4.0.0` 의존은 있으나 저장소 루트에 `.env`/`.env.example` 없음 | 문서화 후보 | **7.2.1 버전 하드코딩 증거.** 모듈 `build.gradle`에 BOM 밖 버전이 직접 박혀 있다. 그중 하나는 이미 드리프트했다. ``` 'org.spockframework:spock-core:2.3-groovy-4.0' 'org.spockframework:spock-core:2.4-groovy-5.0' ← 같은 아티팩트, 다른 버전 'com.tngtech.archunit:archunit-junit5:1.3.0' 'net.logstash.logback:logstash-logback-encoder:8.0' 'com.google.protobuf:protobuf-java:4.33.2' 'tools.jackson.core:jackson-databind:3.0.2' … (총 18개 이상) ``` Spock 두 버전이 각각 다른 Groovy 계열을 겨냥한 의도적 선택일 가능성은 있다. 다만 그 의도가 어디에도 기록되어 있지 않고, **어떤 모듈이 어떤 버전을 쓰는지 한 곳에서 볼 수 없다는 점**이 문제다. Gradle version catalog(`gradle/libs.versions.toml`)로 옮기면 버전이 한 파일에 모이고 드리프트가 리뷰에서 보인다. `gradle.lockfile`이 이미 존재하므로 재현성은 확보되어 있고, 이 조치는 재현성이 아니라 **가시성**을 위한 것이다. **7.2.2 `config/.yml`이 BOOT-008의 정확한 답이다.** 참조 프로젝트는 12개 모듈이 각자 `config/.yml`을 소유하고 `spring.config.import`로 취합하며, `domain.yml`·`common.yml`처럼 스프링을 쓰지 않는 모듈까지 일관되게 포함시킨다. BOOT-008에서 권고한 구조가 실제로 동작하는 형태로 존재한다는 뜻이므로, 구현 시 이를 참조 구현으로 삼는다. **단, BOOT-008에서 이미 적은 경계는 유지한다.** 프로파일별 값(`application-{local,dev,prod}.yml`)은 **배포 결정**이므로 bootstrap이 계속 소유해야 한다. 모듈이 `application-prod.yml`을 갖게 되면 어댑터가 "prod라는 배포 환경"을 알게 되어 의존 방향이 오염된다. 참조 프로젝트의 분리선도 모듈=기본값, 루트=배포별 오버라이드다. ### 7.3 이 저장소에는 적용되지 않는 항목 | 참조 항목 | 이유 | | --- | --- | | `pattern/{creation,pricing,validation,report,event}` 격리 | 이 저장소에는 Book 같은 예제 도메인이 없다. 그 역할은 `sample-portfolio` leaf가 맡으며, 프로덕션 의존이 금지된 픽스처다 | | `PageQuery` clamp, `Page.map()`, `PageResponse` 봉투 | 도메인 예제가 최소라 공통 페이징 추상화가 없다. `InboxPage`/`InboxCursor`가 부분적으로 존재. **파생 서비스에서 도메인이 생길 때 도입할 항목** | | `Money` VO, `BookCriteria`, 애그리게이트 id 참조 | 동일 — 도메인 설계 지침이지 스켈레톤 지침이 아니다 | | B절 "패턴 협력자를 생성자 주입으로" | 이 저장소에는 `BookService`에 해당하는 서비스가 없다. 다만 **판별 기준은 채택 가치가 있다 (7.4)** | | docker compose 테스트 벤치 | Testcontainers로 대체되어 있으며, 테스트가 자기 인프라를 소유한다는 점에서 오히려 낫다 | ### 7.4 참조 분석에서 채택할 판별 기준 참조 분석 B절이 제시한 일반화 판별 기준은 §4.4의 "실제로 2회 이상 반복된 곳에만 적용한다"를 보완하므로 함께 채택한다. > **"이 인터페이스 이름에서 도메인 단어를 빼도 뜻이 통하는가?"** → 통하면 일반화, 안 통하면 그대로. `Strategy`는 사실상 `Function`이므로 자기 이름을 붙일 가치가 없고, `PricingStrategy`처럼 도메인 어휘가 실린 이름은 일반화하면 의미를 잃는다는 논지다. 이 저장소에 대입하면 BOOT-010의 `ProviderSpec

` 권고가 이 기준을 통과한다 — "provider가 요구하는 빈 조합"에서 idempotency라는 도메인 단어를 빼도 뜻이 통하기 때문이다. 반대로 `EdgeRateLimitPort`, `NotificationMetricsPort` 같은 이름은 일반화 대상이 아니다. **두 기준을 함께 쓴다:** 반복 2회 이상(§4.4) **그리고** 도메인 단어를 빼도 뜻이 통할 것(7.4). 둘 중 하나만 만족하면 일반화하지 않는다. --- ## 8. 다음 단계 제안 한 덩어리로 처리할 것을 권고한다 (BOOT-003 / 004 / 005는 같은 코드 영역이다). 1. **0차 — 피드백 루프 복구 (선행).** `integration` 트리를 `src/integrationTest` 소스셋으로 분리하고 `test`에서 떼어낸다. 재발 방지 ArchUnit 규칙 추가. → BOOT-014. **먼저 하는 이유:** 이 작업이 끝나야 `./gradlew :app-bootstrap:test`가 Docker 없이 빠르게 돌고, 이후 1~3차 작업의 반복 검증 비용이 내려간다. 다른 항목과 코드 영역이 겹치지 않아 병행도 가능하다. 2. **1차 — 시작 검증 정본화.** `StartupValidationContext` + `RuntimeEnvironment` 도입, `StartupFailures` 채택률 100%, phase 순서 강제, post-processor order 명시. 재발 방지 ArchUnit 규칙 2개 추가. → BOOT-003, 004, 005, 002 동시 해소. 3. **2차 — 설정 정리.** 네임스페이스 `ca-skeleton.*` 통일 → 기본값을 `Settings` 레코드로 내림 → `application.yml` 축소. → BOOT-001, 008. 4. **3차 — 패키지·분해 규칙 확정 + 어댑터 반출.** capability 배치 규칙과 분해 기준을 `CLAUDE.md`에 기록한 뒤 redis 분해와 jpa 패키지 병합을 실행. 같은 작업 단위에서 포트 구현체 3개를 `adapter/outbound/*`로 이동하고 ArchUnit 규칙으로 재발을 막는다. → BOOT-006, 007, 017. 5. **4차 — 테스트 표준 확정.** `docs/testing/TESTING_STRATEGY.md` 작성 + 소스셋 선언 정합성 검증 태스크 추가, `testkit`/`testFixtures` 단일화 ADR. → BOOT-015, 016. 6. **5차 — 잔여.** BOOT-009 경고 로그 틀, BOOT-010 `ProviderSpec`, BOOT-011 binder 통합, BOOT-012(§9.1-5 참조 — `@Nested` 아님), BOOT-013(테스트 병렬화 도입 시), version catalog 이관(§7.2.1). **가장 중요한 것.** 1~3차를 완료해도 규칙이 문서화되지 않으면 다음 capability에서 같은 발산이 재발한다. 현재 `src/app-bootstrap/CLAUDE.md`(40줄)에는 책임과 금지사항만 있고 **"capability를 새로 추가할 때 파일을 어디에 어떤 기준으로 나누는가"**가 없다. 이 모듈에서 발견된 결함은 사실상 전부 그 한 문단의 부재에서 파생되었다. --- ## 9. 실행 결과 (2026-09-07) §8의 순서대로 실행했다. 아래는 상태와, **실행 중 리뷰 자체가 틀렸음이 드러난 항목들**이다. 후자가 더 중요하므로 먼저 적는다. ### 9.1 리뷰가 틀렸던 곳 | # | 리뷰의 주장 | 실제 | 근거 | | --- | --- | --- | --- | | 1 | BOOT-017: `EnvironmentSecretSource`는 잘못 배치된 어댑터 | **아님.** `SecretSource`는 `bootstrap/runtime`이 소유한 포트다. 자기 leaf가 선언한 인터페이스의 전략 구현은 어댑터가 아니다 | `grep -rn 'interface SecretSource'` → `app-bootstrap/.../runtime/SecretSource.java` | | 2 | BOOT-006: JPA 전용 validator 6개를 `autoconfigure/jpa/`로 이동 | **하면 안 됨.** `JpaSchemaSafetyValidator` 등은 JPA가 **꺼져 있을 때도** 도는 무조건 가드다. 소재(subject matter)가 소유(ownership)를 정하지 않는다 | 이동하자 `FiveAdapterOffInventoryTest`가 실패 — "JPA off인데 JPA 빈 5개가 조립됨". 되돌림 | | 3 | BOOT-001: `app.*` → `ca-skeleton.*` 통일 | **httpclient는 불가.** `app.httpclient.*`의 리스트 설정은 환경변수에서 relaxed binding으로 직접 바인딩된다. prefix를 바꾸면 `APP_HTTPCLIENT_*`가 전부 `CA_SKELETON_HTTPCLIENT_*`가 되어 저장소의 `APP_` 환경변수 규약을 깬다. `app.messaging.*`은 벤더드 모듈 소유라 애초에 대상이 아니다 | `HttpClientEnvironmentKeys.ENV_PREFIX`, `fieldToEnvTemplate()` | | 5 | BOOT-012: `@Nested`로 규칙군을 분리한다 | **하면 안 됨 — 위험하다.** ArchUnit 엔진은 중첩 클래스 안의 `@ArchTest` 필드를 **발견하지 못한다**. 탐침 테스트 결과 빌드는 초록, 리포트는 생성되지 않고, 규칙은 그냥 실행되지 않았다. 권고대로 했다면 아키텍처 규칙 74개가 조용히 꺼진 채 모든 빌드가 통과했을 것이다 | `NestedArchRuleProbeTest` (탐침 후 삭제) — `build/test-results/test/`에 결과 파일 없음 | | 4 | BOOT-014: integration 레인을 `check`에 붙인다 | **저장소 관례와 다름.** `persistence-jpa`의 `postgresqlIntegrationTest`도 `check`에 붙어 있지 않다. 붙이면 Docker 없는 환경에서 `check`가 실패한다 | 관례를 따라 `check`에 붙이지 않음 | ### 9.2 실행 중 새로 발견한 것 리뷰가 잡지 못했는데 작업 중 규칙이 잡아낸 것들이다. 규칙을 먼저 만들고 코드를 고친 순서가 그대로 효과를 냈다. 1. **`NotificationPlatformRegistriesConfig`의 익명 클래스 2개가 application 포트 구현이었다.** BOOT-017 규칙이 잡았다. 더 나쁜 것은 그중 하나(`require(profileId)`)가 **요청 시점** 조회 실패인데, BOOT-003 일괄 변환이 이를 startup 실패(종료코드 72)로 잘못 분류했다는 점이다. 어댑터로 옮기면서 둘 다 해소 — 이동이 오분류를 드러냈다. 2. **람다는 ArchUnit이 못 잡는다.** 같은 파일의 `ProviderEventProjectorRegistry`는 람다여서 규칙에 걸리지 않았다. 규칙이 볼 수 있는 곳에서만 경계가 지켜지는 상태가 규칙 없는 상태보다 나쁘므로 함께 이동했다. 3. **readiness 그룹 기여 로직이 2벌 중복.** Redis/Database 두 post-processor가 같은 병합 코드를 갖고 있었고, 프로퍼티 키는 Redis 쪽이 "먼저 선언했다"는 이유로 소유하고 있었다. `runtime/ReadinessGroup` 으로 추출. 4. **버전 드리프트가 하나 더.** `protobuf-java`가 4.33.2와 4.29.3 두 벌이었다(§7.2.1은 spock만 보고했다). 카탈로그에서 별칭 두 개로 **보이게** 두었다 — 합치는 것은 해석 결과를 바꾸는 별개 결정이다. 5. **`RedisCapabilityConfig`의 rate-limit switch에도 죽은 `default`가 있었다.** BOOT-010과 같은 결함, 다른 위치. `RateLimitAlgorithm`은 상수가 정확히 3개다. 6. **빌드 파일을 텍스트로 파싱하는 계약 테스트 2개가 표기법에 결합돼 있었다.** `MessagingPublicSurfaceContractTest`와 `BinaryCodecBackendScopeTest`는 `api 'group:artifact:version'` 문자열만 인식해서, version catalog 이관만으로 "선언되지 않았다"고 보고했다. 계약은 그대로인데 표기가 바뀌어 실패하는 것은 게이트가 낼 수 있는 가장 비싼 거짓 양성이므로, 두 테스트가 카탈로그 별칭도 해석하도록 고쳤다. 7. **`@ArchTest` 인벤토리 가드에 구멍이 있었다.** 분할 작업이 찾아냈다: `ArchRuleDiscoveryContractTest` 의 정규식이 `@ArchTest` 바로 다음 줄에 선언이 오는 형태만 인식해서, `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES`(애노테이션과 선언 사이에 9줄짜리 "known defect" 주석이 있다)를 놓치고 있었다. **규칙이 주석을 다는 것만으로 가드에서 숨을 수 있었다** — 그리고 주석이 달릴 만한 규칙일수록 지켜볼 값어치가 있다. 86 → 87로 정정. 8. **`java-test-fixtures` 적용이 ArchUnit 코퍼스를 정반대 두 방향으로 깨뜨렸다.** `httpclient` 는 `build/classes/java/testkit` 을 **제외**하고 있었는데 fixture 가 `…-test-fixtures.jar` 로 도착해 제외가 빗나갔고, fixture 간 호출 258건이 "production이 testkit에 의존"으로 보고됐다. `persistence-jpa` 는 `build/classes/java/main` 만 **포함**하고 있었는데, `java-test-fixtures` 를 적용하면 모듈 자신의 테스트 클래스패스가 클래스 디렉터리 대신 **jar**를 싣는다 — 코퍼스가 비었다. 후자가 위험한 쪽이다(빈 코퍼스에서는 모든 `noClasses()` 가 통과한다). 그 스위트가 "코퍼스가 비어 있지 않다"를 먼저 단언하기 때문에 드러났다. 9. **`sample-portfolio`에 사전 존재 결함이 있다 — 이번 작업과 무관.** `SampleApplicationContextTest` 3건이 실패한다: `JpaLiveEventReplayAdapter`가 `java.time.Duration` 빈을 생성자로 요구하는데 저장소 전체에 `@Bean Duration`이 하나도 없다. `git worktree`로 HEAD를 그대로 체크아웃해 확인한 결과 동일하게 실패한다(`NoSuchBeanDefinitionException`). **별도 처리 대상이다.** ### 9.3 항목별 상태 | ID | 상태 | 무엇을 했나 | | --- | --- | --- | | BOOT-014 | **완료** | `src/integrationTest` 소스셋 신설(`strictTestLanes` DSL 채택), Testcontainers 9클래스 이동, `testCompileOnly`로 픽스처만 유지, `TEST_SOURCE_SET_DOES_NOT_DEPEND_ON_TESTCONTAINERS` 규칙 추가 | | BOOT-003 | **완료** | 65곳 중 60곳을 `StartupFailures`로 전환(6곳은 근거와 함께 유지), `envValidation(detail, cause)` 오버로드 추가, `StartupFailureContractArchitectureTest` 추가 | | BOOT-002 | **완료** | `shared-contract`에 `RuntimeEnvironment` 신설, 7곳 + objectstorage 1곳 교체, 단위 테스트 10건 | | BOOT-004 | **완료** | `StartupValidationContext` 신설, 8개 validator 전환(누적 유지 + phase가 예외를 선택) | | BOOT-005 | **완료** | `StartupOrder`에 6개 post-processor 순서를 명시, `StartupPhase`를 예외 선택에 실제로 사용 | | BOOT-001 | **부분 — 근거와 함께** | 전면 개명은 9.1-3 사유로 기각. 대신 `ConfigurationNamespaceContractTest`로 루트 3개와 prefix 17개를 고정 — 네 번째 루트나 새 prefix는 이제 빌드가 막는다. 운영자 관점 문제(같은 관심사가 470줄 떨어져 있음)는 BOOT-008이 해소 | | BOOT-008 | **완료** | `application.yml` 974줄 → 248줄 + `config/*.yml` 15개. 키 307개 전수 대조로 무손실 확인. `spring:`만 남긴 이유는 `spring.profiles.active`가 import 문서에서 위험하기 때문 | | BOOT-006 | **완료(수정 포함)** | capability 7개를 `autoconfigure/` 아래로, `jpa`+`persistencejpa` 병합, Redis validator 2개 이동. JPA 무조건 가드 5개는 9.1-2에 따라 `runtime/` 유지 | | BOOT-007 | **완료** | `RedisCapabilityConfig` 383줄 → 68줄 + 4개 능력별 Config + `RedisKeys`. notification의 분해 패턴을 그대로 적용 | | BOOT-017 | **완료** | 포트 구현 4개를 어댑터 leaf로 이동(Micrometer 2, 레지스트리 2), `CompositionRootBoundaryArchitectureTest` 추가. `MeteredDistributedLockPort`는 데코레이터 2개째를 임계값으로 명시하고 유지 | | BOOT-009 | **완료** | `RuntimeWarnings` 신설(meter-unavailable / setting-fallback 2프레임), 16곳 전환, 구조화 필드 부여 | | BOOT-011 | **완료** | `StrictSettingsBinder`로 통합, 두 바인더는 각자의 실제 차이만 남김 | | BOOT-015 | **완료** | `ADR-BUILD-001` 작성 + `testkit` 소스셋 5개 전부 `testFixtures` 로 이관, `ca.testkit-publisher` 컨벤션 플러그인 삭제. 두 관례 공존 상태 해소 | | BOOT-016 | **완료** | `docs/testing/TESTING_STRATEGY.md` 작성 + `verifyTestSourceSetRegistry` 게이트 추가(`check`에 배선). 반증 테스트로 실제 실패함을 확인 | | BOOT-010 | **완료(재정의)** | 죽은 `default` 분기 2곳 제거. `ProviderSpec

` 크로스모듈 SPI 대신 **선택값이 자기 요구를 갖게** 했다 — `Provider` 는 필요한 빈 수를, `AuthenticationMode` 는 필요/금지 빈 이름을 상수 자체가 선언한다. 어댑터가 "컴포지션 루트가 스토어를 몇 개 들고 있어야 하는지"를 선언하는 건 조립 지식을 어댑터에 주는 것이라 리뷰의 스케치보다 이쪽이 맞다 | | BOOT-012 | **완료(재정의)** | `@Nested` 는 9.1-5 사유로 기각. 먼저 `ArchRuleDiscoveryContractTest` 로 규칙 87개를 고정한 뒤, 2,792줄을 **별도 최상위 `@AnalyzeClasses` 스위트 5개**(domain purity / application boundary / adapter boundary / type boundary / forbidden API, 250~841줄)와 공용 헬퍼 2개짜리 `CleanArchitectureRuleSupport` 로 분할. 안전망이 분할 중 규칙 누락이 없었음을 증명했다 | | BOOT-013 | **미실행(의도)** | 테스트 병렬화를 켤 때 함께 처리 | | §7.2.1 | **완료** | `gradle/libs.versions.toml` 신설, 59개 좌표를 35개 별칭으로 이관, 20개 build.gradle 갱신 | ### 9.4 검증 전부 통과했다. ``` ./gradlew :app-bootstrap:test 993 tests, 0 failed ./gradlew :adapter:outbound:notification:test ./gradlew :adapter:outbound:fileserver:test ./gradlew :adapter:outbound:objectstorage:test ./gradlew :shared-contract:test ./gradlew verifyCleanArchitectureDependencies ./gradlew verifyDocumentedLeafCount ./gradlew verifyTestSourceSetRegistry ``` **실패하지만 이번 작업과 무관:** `:sample-portfolio:test`의 `SampleApplicationContextTest` 3건. HEAD 그대로에서도 동일하게 실패한다(§9.2-9). **미실행:** `integrationTest` 레인(Docker 데몬 필요), 전체 `check`(워크플로 승인 대상), 벤더 qualification 레인. 이들은 이번 변경으로 소스셋 배치와 잠금 상태가 바뀌었으므로 Docker 가능한 환경에서 한 번 돌려야 한다. **커밋하지 않았다.** 저장소 정책이 `human-only`다. 커밋할 때 함께 넣어야 하는 생성 파일이 둘 있다: - `src/gradle/libs.versions.toml` — 새 version catalog. - `src/settings-gradle.lockfile` — 카탈로그를 추가하면 Gradle이 settings 수준 configuration (`incomingCatalogForLibs0`)에 대해 만드는 잠금 파일. Gradle 자신이 "source control에 포함되어야 한다"고 헤더에 적는다. 작업 중 만든 `git stash@{0}` 항목이 하나 남아 있다. 작업 트리와 같은 내용의 중복 스냅샷이며, 검증이 끝난 뒤 `git stash drop` 으로 지우면 된다. ### 9.5 남은 것 리뷰의 발견 항목은 전부 처리했다. 남은 것은 이 환경에서 실행할 수 없는 검증 하나와, 이번 작업이 새로 드러낸 별개 결함 하나다. 1. **`integrationTest` 레인 실행 확인** — Docker 데몬이 필요하다. 이번 변경으로 소스셋 배치와 잠금 상태가 바뀌었으므로 Docker 가능한 환경에서 한 번 돌려야 한다. 2. **`sample-portfolio` 의 사전 존재 결함**(§9.2-9) — `JpaLiveEventReplayAdapter` 가 `Duration` 빈을 요구하는데 저장소 전체에 `@Bean Duration` 이 없다. 이번 작업과 무관하며 별도 처리 대상이다. 3. 전체 `check` — 워크플로 승인 대상이라 실행하지 않았다.