Files
clean-architecture-backend-…/docs/reviews/2026-09-07-app-bootstrap-module-code-review.md
DongHyeonkaandClaude Opus 5 2a8d34f85c docs: 빌드·CI 감사 계획과 진행 중이던 문서 정리
- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md).
  빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다.
- public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성.
  이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다.
- 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:52:37 +09:00

84 KiB
Raw Permalink Blame History

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<String> → 비어있지 않으면 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.factoriesEnvironmentPostProcessor(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_URLclients[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 단계에서 돈다. 이 시점엔 ApplicationContextLocalValidatorFactoryBean도 없다. Bean Validation을 쓰려면 ValidatorFactory를 손으로 만들어야 하는데, 그러면 "표준을 써서 단순해진다"는 이점이 사라진다.
  2. 검증 대상의 모양이 다르다. Bean Validation은 *"한 필드가 제약을 만족하는가"*에 최적화되어 있다. 반면 이 모듈의 규칙은 "prod 프로파일이면서 ddl-autocreate이고 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에 안 잡히는 것은 정상이다

GlobalExceptionHandlersrc/adapter/inbound/web/.../error/GlobalExceptionHandler.java에 있는 @ControllerAdvice다. @ControllerAdviceHTTP 요청을 처리하는 중에 발생한 예외만 볼 수 있다.

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<T>로 흡수 (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 — 같은 소스를 다른 클래스패스로 두 번 컴파일한다.

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에는 없다
}

sampleOffTestsrc/test의 소스 파일을 그대로 재사용하면서 sampleFixture 구성만 뺀 클래스패스로 다시 컴파일한다. 목적은 "sample-portfolio를 삭제해도 이 템플릿이 성립하는가"의 증명이다. 같은 파일을 두 클래스패스로 컴파일하는 것이 이 소스셋의 정의 그 자체이므로, src/test 하위 폴더로 옮기는 것은 물리적으로 불가능하다. 폴더가 아니라 클래스패스가 변수이기 때문이다.

(2) conditionalTransportTest — 테스트 전용 opt-in 클래스패스를 증명한다.

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

영향 — 정확히 기술한다. 배포 아티팩트에서는 현재 실 버그가 아니다. RuntimeEnvironmentProfileValidatorlocal|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<String> 누적 → 비어있지 않으면 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 개념과 함께 구현한다.

public final class StartupValidationContext {
  private final Map<StartupPhase, List<String>> 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/<capability>/ 아래.
  • <Capability>Settings는 자기 capability 패키지 안에 둔다 (settings/ 패키지는 capability에 속하지 않는 모듈 전역 설정만 유지).
  • capability에 속하지 않는 시작 안전장치만 runtime/에 남긴다. JPA 전용 validator 6개는 autoconfigure/jpa/로 이동.
  • autoconfigure/jpaautoconfigure/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에서 기본값을 부여한다(IdempotencyProviderSettingsprovider == null ? Provider.JDBC). 이 방식을 확대하면 yaml에서 "기본값을 다시 적는" 줄이 사라진다. 가장 비용이 낮고 효과가 큰 조치다.
  2. 모듈이 shipping할 것이 남으면 classpath:/META-INF/caskeleton/<capability>.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 유무로 두 형태만 존재한다.

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

public enum Provider { DISABLED, JDBC, REDIS, POSTGRESQL }

idempotency/IdempotencyProviderSelectionConfig.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의 제네릭 방향과 일치하는 처방.

public interface ProviderSpec<P extends Enum<P>> {
  P id();
  ExpectedBeans expectation();   // 이 provider가 요구하는 빈 조합
}

각 provider 모듈이 자기 ProviderSpec을 빈으로 등록하고, bootstrap은 선택된 id에 해당하는 spec을 찾아 검증만 한다. switch가 사라지고, provider 추가 = 모듈 추가가 된다. enum은 그대로 유지된다 — "선택 가능한 값의 닫힌 집합"이라는 타입 안전성은 계속 필요하기 때문이다.

부수 발견.switchdefault -> 분기는 도달 불가능하다. 네 개 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").

수정.

static <T> T bind(Environment env, String prefix, Class<T> type,
                  Predicate<ConfigurationPropertySource> 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/StartupFailureLogStatestatic AtomicBoolean을 들고 있고, production 코드에 public static void clearForTest()를 노출한다. 실제 사용처는 logging/StartupFailureSpringBootLogFilter(logback TurboFilter)와 StartupFailureSpringBootLogFilterTest 두 곳이다.

현재는 문제가 아니다. build.gradlemaxParallelForksjunit-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.gradletest 태스크에 excludeTags도 태그 필터도 없다. @Testcontainers가 붙은 5개 클래스 중 가드(@EnabledIf)를 가진 것은 FileserverRoundTripContractTest 하나뿐이다. 나머지 4개 — IdempotencyUniqueScopeContractTest, OutboxRowLifecycleContractTest, OutboxAppendTransactionalContractTest, OutboxPublisherLeaderElectionContractTest — 는 무조건 실행된다. (DistributedLockProviderContractTestFlywayMigrationCompatibilityContractTest는 가정문 형태의 가드를 갖고 있다.)

영향. ./gradlew :app-bootstrap:testDocker 데몬을 요구한다. 도메인 로직 한 줄을 고치고 검증하려는 개발자가 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-jpapostgresqlIntegrationTest 정의를 참고 구현으로 삼는다.
  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

graphqlbuild.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. MicrometerNotificationMetricsadapter/outbound/notification (LoggingNotificationAudit 옆, 동일 패키지 관례).
  2. MicrometerFileserverMetrics → fileserver 어댑터 leaf, 없으면 adapter:outbound:support.
  3. EnvironmentSecretSourceadapter:outbound:support.
  4. MeteredDistributedLockPort → 당분간 유지, 데코레이터 2개째에 adapter:outbound:support로 이동.
  5. CleanArchitectureTest에 규칙 추가 — ..bootstrap..의 클래스는 ..application../..domain..의 포트 인터페이스를 구현할 수 없다. 데코레이터 예외가 필요하면 명시적 allowlist로 두어, 예외가 늘어나는 것이 눈에 보이게 한다.

이동 시 확인. modules.jsonallowed_dependenciesruntime_memberships를 함께 갱신해야 하며, verifyCleanArchitectureDependencies가 게이트 역할을 한다.


7. 참조 프로젝트 대비 격차 분석

별도의 헥사고날 템플릿(이하 "참조 프로젝트")에 대한 외부 분석을 이 저장소에 대입한 결과다. 참조 프로젝트는 Book/Author 도메인을 가진 예제 중심 템플릿이고, 이 저장소는 도메인 예제가 최소인 인프라 스켈레톤이다. 성격이 다르므로 항목별로 적용 가능성을 나누어 판정했다.

7.1 이미 갖추었거나 더 강한 항목

참조 항목 이 저장소의 상태 판정
의존 방향을 빌드가 강제 modules.json 레지스트리 + verifyCleanArchitectureDependencies + ArchUnit 101규칙 3중 더 강함
spring-tx만 의존해 코어를 얇게 application-core의 의존은 implementation project(':shared-contract') 하나뿐. Spring 의존 0개이며 트랜잭션도 포트로 추상화 더 강함
코어 프레임워크 프리 domain-coredependencies { } — 비어 있음 동등
어댑터 선택 property 기반 + fail-fast MasterSwitchEnvironmentPostProcessor, CapabilityDependencyValidator, RuntimeEnvironmentProfileValidator 더 정교함
전송 중립 에러 모델 shared-contract/error/{Category, ApiErrorCode, OperationalError, ApiErrorCarrier} + web/gRPC/GraphQL 각 핸들러 동등
MDC 키 상수화 MdcKeys + docs/registriesmdc_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/<module>.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/<module>.yml이 BOOT-008의 정확한 답이다. 참조 프로젝트는 12개 모듈이 각자 config/<module>.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<T> 봉투 도메인 예제가 최소라 공통 페이징 추상화가 없다. InboxPage/InboxCursor가 부분적으로 존재. 파생 서비스에서 도메인이 생길 때 도입할 항목
Money VO, BookCriteria, 애그리게이트 id 참조 동일 — 도메인 설계 지침이지 스켈레톤 지침이 아니다
B절 "패턴 협력자를 생성자 주입으로" 이 저장소에는 BookService에 해당하는 서비스가 없다. 다만 판별 기준은 채택 가치가 있다 (7.4)
docker compose 테스트 벤치 Testcontainers로 대체되어 있으며, 테스트가 자기 인프라를 소유한다는 점에서 오히려 낫다

7.4 참조 분석에서 채택할 판별 기준

참조 분석 B절이 제시한 일반화 판별 기준은 §4.4의 "실제로 2회 이상 반복된 곳에만 적용한다"를 보완하므로 함께 채택한다.

"이 인터페이스 이름에서 도메인 단어를 빼도 뜻이 통하는가?" → 통하면 일반화, 안 통하면 그대로.

Strategy<T,R>는 사실상 Function<T,R>이므로 자기 이름을 붙일 가치가 없고, PricingStrategy처럼 도메인 어휘가 실린 이름은 일반화하면 의미를 잃는다는 논지다. 이 저장소에 대입하면 BOOT-010의 ProviderSpec<P> 권고가 이 기준을 통과한다 — "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는 잘못 배치된 어댑터 아님. SecretSourcebootstrap/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-jpapostgresqlIntegrationTestcheck에 붙어 있지 않다. 붙이면 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개가 표기법에 결합돼 있었다. MessagingPublicSurfaceContractTestBinaryCodecBackendScopeTestapi '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 코퍼스를 정반대 두 방향으로 깨뜨렸다. httpclientbuild/classes/java/testkit제외하고 있었는데 fixture 가 …-test-fixtures.jar 로 도착해 제외가 빗나갔고, fixture 간 호출 258건이 "production이 testkit에 의존"으로 보고됐다. persistence-jpabuild/classes/java/main포함하고 있었는데, java-test-fixtures 를 적용하면 모듈 자신의 테스트 클래스패스가 클래스 디렉터리 대신 jar를 싣는다 — 코퍼스가 비었다. 후자가 위험한 쪽이다(빈 코퍼스에서는 모든 noClasses() 가 통과한다). 그 스위트가 "코퍼스가 비어 있지 않다"를 먼저 단언하기 때문에 드러났다.
  9. sample-portfolio에 사전 존재 결함이 있다 — 이번 작업과 무관. SampleApplicationContextTest 3건이 실패한다: JpaLiveEventReplayAdapterjava.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-contractRuntimeEnvironment 신설, 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<P> 크로스모듈 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:testSampleApplicationContextTest 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) — JpaLiveEventReplayAdapterDuration 빈을 요구하는데 저장소 전체에 @Bean Duration 이 없다. 이번 작업과 무관하며 별도 처리 대상이다.
  3. 전체 check — 워크플로 승인 대상이라 실행하지 않았다.