- 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>
1232 lines
84 KiB
Markdown
1232 lines
84 KiB
Markdown
# 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.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<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` — 같은 소스를 다른 클래스패스로 두 번 컴파일한다.**
|
||
|
||
```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<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 개념과 함께 구현한다.
|
||
|
||
```java
|
||
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/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/<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` 유무로
|
||
두 형태만 존재한다.
|
||
|
||
```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 extends Enum<P>> {
|
||
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> 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/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/<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`는 잘못 배치된 어댑터 | **아님.** `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<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: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` — 워크플로 승인 대상이라 실행하지 않았다.
|