빌드·CI 레이어 전수 감사(133건) 중 Track A 에서 처리한 E등급 19건을 뺀
나머지를 마무리한다. 한 커밋인 이유는 서로 얽혀 있기 때문이다 — 워크플로가
`checkstyleMain` 을 부르려면 루트가 먼저 Checkstyle 을 붙여야 하고, 모듈 43개가
convention 을 적용하려면 build-logic 이 먼저 그 플러그인을 등록해야 한다.
영역별로 쪼개면 중간 커밋이 빌드되지 않는다.
## CI 단계 분리 (워크플로 29파일 3,360줄 → 19파일 2,692줄, 잡 69 → 64)
모듈이 아니라 단계로 자른다. 기존 28개는 `web-*` `jpa-*` 처럼 모듈로 갈려
있어서 같은 일이 파일마다 중복됐다.
- stage 1 `ci-quality-gates.yml` + `pr-adapters.yml`(신규, 잡 단위 경로 필터) — pull_request
- stage 2 `integration-main.yml`(신규) — push:main + 03:00. 문서 게이트는 여기에 둔다
- stage 3 `release.yml`(신규, 릴리스 워크플로 7개 중 5개 흡수) — push: tags v*
setup 블록 59회 복붙 → `.github/actions/setup-gradle-java` 1개(잡당 13줄 → 5줄).
잡 8개 삭제, 각각 대체 잡을 확인했다. `verifyCleanArchitectureDependencies` 실행
횟수가 태그당 9 → 6, PR당 8 → 4 로 줄었다.
## 컨테이너 릴리스 신설
이미지를 만드는 것이 아무것도 없었다. Dockerfile 은 있었지만
build-push-action / bootBuildImage / jib 사용처가 0건이고, `*-release.yml` 8개는
테스트 후 아티팩트만 올렸다 — 이름만 릴리스였다.
Boot 레이어드 추출 + thin-JAR 엔트리포인트로 Dockerfile 을 고치고 릴리스
워크플로에 이미지 빌드·푸시·SBOM·스캔을 넣었다. 로컬 빌드로 검증했다:
레지스트리 content 241MB, 기동 3.7초, uid 1000, 헬스체크 200.
코드만 바뀐 릴리스는 7.68MB 만 재푸시한다(이전이라면 156MB).
CI 는 배포하지 않는다 — 매니페스트와 ArgoCD 는 별도 repo 로 간다.
## 게이트 정리
- gate-matrix 의 개수 고정 해제: `EXPECTED_GATE_COUNT=49` 와 하드코딩된 49개 id
목록을 지우고 불변식으로 대체(필드·enum, 워크플로/잡 실재, id 중복,
`release_blocking: true` 는 실제로 release-gate 의 needs 여야 함).
행을 추가하려면 테스트부터 고쳐야 하던 구조를 풀었다. 커버리지 8/28 → 28/28
- 문서 게이트 4개를 `check` 에서 떼어 `verifyDocumentationContracts` 로 묶고
stage 2 에 배치. 어겨도 런타임은 멀쩡하므로 개발을 막지 않는다
- `verifyOneTypePerFile`(정규식 Java 파싱, 126파일 미탐) → Checkstyle
`OneTopLevelClass` + `OuterTypeFilename`. main 위반 0건, test 의 fixture 29건은
정책을 넓히지 않고 suppressions 에 사유와 함께 명시 제외
- leaf 하나의 `check` 가 끌고 오던 저장소 전역 게이트 18개를 재배치.
`:domain-core:check` 가 13 태스크 11초로 끝난다
- convention 플러그인 2개 신설(`ca.platform-module`, `ca.grpc-platform-module`),
플랫폼 모듈 43개에 적용. 손수 짠 Test 태스크 17개를 `strictTestLanes` 로 전환
(태스크 이름 전부 보존 — CI 가 이름으로 부른다)
- `ca.api-surface` 의 정규식 Java 파서를 javac parse-only 로 교체
(기존 베이스라인 3개와 바이트 동일 확인)
- 죽은 태스크 5개 삭제, `src/gradle` 1,713 → 1,440줄, 모듈 build.gradle
3,072 → 2,977줄
## 검사가 검사를 못 하고 있던 것들
- 11개 계약 테스트가 gitignore 된 `src/.env` 를 요구했다. `.gitignore` 자신이
"examples beside it are the tracked contract, never a real one" 이라고 적어둔
규칙과 어긋난다. 깨끗한 체크아웃에는 그 파일이 없으므로 CI 에서 돌 수 없었다.
추적되는 `.env.example` 로 돌린다
- **`.env.local.example` 이 5432 를 가리키는데 compose 는 5433 을 게시한다.**
이 파일을 복사해 시작하는 신규 개발자는 DB 연결에 실패한다. 이걸 잡으라고
만든 테스트가 추적 안 되는 파일을 읽어서, 이미 설정이 끝난 머신에서만 돌고
정작 처음 받는 사람에겐 아무 검사도 안 하고 있었다. 포트를 고치고 테스트를
추적 파일로 돌렸다
- `MongoModuleBoundaryTest` 의 `DO_NOT_INCLUDE_JARS` 때문에 임포트가 0개가 되어
규칙 10개가 "failed to check any classes" 로 실패 중이었다. 이 레인에서는
모듈 자기 클래스가 jar 로 올라온다. `importPackages(ROOT)` 가 이미 서드파티를
거르므로 옵션은 불필요했다
- `ReleaseManifestTaskExistenceTest` 가 build 파일 텍스트에서 `tasks.register(`
만 찾아, convention 의 `lane('...')` 로 바뀐 태스크를 미등록으로 오판했다
- `ProfileSeparationContractTest` 는 런처가 주입하는 `src/.env` 가 맞는 대상이라
그대로 두되, 파일이 없으면 명시적으로 skip 한다 — "안 돌았다" 가 "통과했다"
로 읽히지 않게
## 검증 (전부 깨끗한 체크아웃에서, 커밋 전에 실행)
`verify-gradle-wrapper.sh` PASS · `verify-gate-matrix.sh` OK(drift 0) ·
워크플로 YAML 전수 파싱 OK · actionlint 지적 0 · `gradlew help` ·
`verifyCleanArchitectureDependencies` · `build-logic test` ·
`:app-bootstrap:test` **1001 tests 실패 0 스킵 5** · `:domain-core:check` ·
`verifyDocumentationContracts`.
## 남은 문제
- 첫 `v*` 태그는 이미지 취약점 스캔에서 실패한다(CRITICAL/HIGH 9건:
ubuntu 베이스 2, tomcat-embed-core 3, amqp-client 3, httpcore5 2).
억제를 넣지 않았다 — 릴리스 1회차를 초록으로 만들려고 임계값을 내리면
게이트가 장식이 된다. 의존성·베이스 갱신이 선행돼야 한다
- `fileserver-v*` / `web-v*` / `websocket-v*` 태그는 이제 아무 run 도 만들지
않는다(배포 단위가 하나라는 결정에 따른 것)
- main push 마다 무거운 레인 3개가 새로 돈다 — 러너 분이 늘어난다
- `ProfileSeparationContractTest` 가 찾아낸 4개 값(cache command-timeout,
cache positive-soft-ttl, idempotency provider, rate-limit command-timeout)이
`.env.example` 과 인라인 기본값 사이에서 갈린다. 런타임 설정 판단이라
건드리지 않았다
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 KiB
테스트 전략 — 레벨 정의와 소스셋 매핑 (SSOT)
- 기준 일자: 2026-09-07
- 상태: 활성 계약.
verifyTestSourceSetRegistry가 이 문서의 §3 표와 실제 Gradle 소스셋 선언의 불일치를 빌드 실패로 만든다. - 근거 리뷰:
docs/reviews/2026-09-07-app-bootstrap-module-code-review.md(BOOT-014, BOOT-015, BOOT-016)
1. 이 문서가 존재하는 이유
이 저장소는 이미 테스트 레벨 계약을 기계로 강제하고 있었다 —
TestTaxonomyArchitectureTest 가 contract/architecture 트리의 Testcontainers 의존을 금지하고,
slice 애노테이션 혼합을 막고, fixture 유출을 잡는다. 없던 것은 사람이 읽을 수 있는 정의였다.
그 결과 계약은 "패키지 이름"에만 걸려 있었고 "어느 소스셋이 컴파일하는가"에는 걸려 있지 않았다.
Testcontainers 를 쓰는 통합 테스트 9개가 app-bootstrap/src/test 안에 있었고, @Testcontainers 5개
중 가드가 있는 것은 하나뿐이었다. 즉 ./gradlew :app-bootstrap:test — 이 저장소가 leaf 별 기본
명령으로 권장하는 바로 그 명령 — 이 Docker 데몬을 요구했다 (BOOT-014).
그래서 규칙을 두 가지 방식으로 동시에 고정한다. 사람은 이 문서를 읽고, 빌드는 §3 표를 읽는다.
2. 레벨 정의
레벨은 **이름이 아니라 "어디까지 실제로 붙여서 검증하는가"**로 정의한다. smoke, regression,
acceptance 같은 말은 범위가 아니라 목적이라 레벨이 될 수 없다 — 하나의 E2E 테스트가 동시에
smoke 이고 regression 일 수 있다.
| 레벨 | 무엇을 검증 | 외부 시스템 | 소스셋 |
|---|---|---|---|
| unit | 클래스·함수·도메인 규칙 | 없음 | src/test |
| slice | 프레임워크 한 계층 | 인메모리/모의 | src/test |
| contract | 모듈 경계의 형태와 약속 | 없음 | src/test |
| architecture | 코드 의존 관계, 테스트 분류 자체 | 없음 | src/test |
| integration | 실제 인프라와의 연결 | 실제 DB/브로커/스토리지 | src/integrationTest 또는 leaf 전용 레인 |
| qualification | 벤더·프로토콜·배포 형상 | 실제 벤더 런타임 | leaf 전용 레인 |
| build-qualification | 빌드·조립 계약 자체 | 없음 (별도 클래스패스) | leaf 전용 레인 |
| performance | 지연·처리량 | 실제에 가까움 | leaf 전용 레인 |
2.1 소스셋을 나누는 기준은 하나다
테스트 코드는 production 패키지 구조를 그대로 미러링한다. 별도 소스셋으로 분리하는 것은 실행 환경·의존성·클래스패스가 달라지는 경우뿐이다.
unit/, service/, repository/, regression/ 같은 폴더는 만들지 않는다. 서로 다른 분류 축을
한 디렉터리에 섞으면 UserServiceTest 가 어디에 속하는지 아무도 답할 수 없게 된다. 이 저장소의
src/test 는 이미 production 패키지를 미러링하고 있으며 그 상태를 유지한다.
2.2 build-qualification 은 폴더 취향이 아니다
app-bootstrap 의 세 레인은 "테스트를 분류하려고" 나눈 것이 아니라 하나의 소스셋으로 표현할 수
없는 클래스패스 차이 때문에 존재한다. 합치면 검증 자체가 성립하지 않는다.
sampleOffTest—src/test와 같은 소스 파일을sample-portfolio없는 클래스패스로 다시 컴파일한다. "샘플을 지워도 템플릿이 성립하는가"의 증명이며, 같은 파일을 두 클래스패스로 컴파일하는 것이 그 정의다.conditionalTransportTest— GraphQL/gRPC/WebSocket 을 테스트 전용으로만 클래스패스에 올린다. 이 의존을testImplementation으로 옮기면 "기본 클래스패스에는 없다"는 증명 대상 명제가 그 순간 거짓이 된다.functionalTest— Gradle TestKit 이 별도 Gradle 빌드를 띄운다.
3. 소스셋 레지스트리 (기계 검증 대상)
verifyTestSourceSetRegistry 가 이 표를 읽어 실제 sourceSets 선언과 대조한다. 표에 없는 소스셋을
추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다.
| Gradle 경로 | 소스셋 | 레벨 |
|---|---|---|
:adapter:inbound:graphql |
testFixtures |
fixtures |
:adapter:inbound:web |
jettyCompatTest |
qualification |
:adapter:inbound:web |
nginxProxyTest |
qualification |
:adapter:inbound:web |
testFixtures |
fixtures |
:adapter:inbound:web |
webfluxContractTest |
qualification |
:adapter:inbound:websocket |
jettyWebSocketTest |
qualification |
:adapter:inbound:websocket |
nginxWebSocketTest |
qualification |
:adapter:inbound:websocket |
testFixtures |
fixtures |
:adapter:outbound:httpclient |
httpClientPerformanceTest |
performance |
:adapter:outbound:httpclient |
jmh |
performance |
:adapter:outbound:httpclient |
testFixtures |
fixtures |
:adapter:outbound:objectstorage |
objectStorageAwsQualificationTest |
qualification |
:adapter:outbound:objectstorage |
objectStorageMinioContractTest |
integration |
:adapter:outbound:objectstorage |
objectStorageMinioFaultTest |
integration |
:adapter:outbound:persistence-jpa |
jpaPlatformPerformanceTest |
performance |
:adapter:outbound:persistence-jpa |
postgresqlIntegrationTest |
integration |
:adapter:outbound:persistence-jpa |
testFixtures |
fixtures |
:adapter:outbound:persistence-mongo |
mongoPerformanceTest |
performance |
:adapter:outbound:persistence-mongo |
testFixtures |
fixtures |
:app-bootstrap |
conditionalTransportTest |
build-qualification |
:app-bootstrap |
functionalTest |
build-qualification |
:app-bootstrap |
integrationTest |
integration |
:app-bootstrap |
sampleOffTest |
build-qualification |
:messaging:messaging-kafka |
jmh |
performance |
:messaging:messaging-rabbit |
jmh |
performance |
:messaging:messaging-testkit |
jmh |
performance |
:sample-portfolio |
posterImageMigrationTest |
qualification |
:shared-contract |
edgeRateLimitContractTest |
contract |
src/test 는 모든 leaf 가 갖는 기본 소스셋이므로 표에 적지 않는다.
4. 판단표 — 새 테스트를 어디에 쓰는가
대상 코드가 정해지면 위치와 방식이 기계적으로 결정되어야 한다.
| 대상 | 레벨 | 협력자 | 위치 |
|---|---|---|---|
| 도메인 엔티티·값 객체 | unit | 없음 | 해당 leaf src/test |
| 유스케이스 | unit | 손으로 만든 Fake (Mockito 아님) | application-core/src/test |
시작 검증기 (*Validator) |
unit | MockEnvironment |
app-bootstrap/src/test |
@Configuration 조립 |
slice | ApplicationContextRunner |
app-bootstrap/src/test |
| 컨트롤러 | slice | @WebMvcTest + 모의 유스케이스 |
adapter/inbound/web/src/test |
| JPA 리포지토리 매핑 | integration | Testcontainers PostgreSQL | postgresqlIntegrationTest |
| 아웃박스·멱등성 행 수명주기 | integration | Testcontainers PostgreSQL | app-bootstrap/src/integrationTest |
| 브로커 발행/수신 | integration | 실제 브로커 | leaf 전용 레인 |
| 에러 응답 스키마 | contract | 없음 (스냅샷) | app-bootstrap/src/test/.../contract |
| 의존 방향·패키지 경계 | architecture | 없음 (ArchUnit) | app-bootstrap/src/test/.../architecture |
4.1 금지
src/test안에서org.testcontainers의존 —TestTaxonomyArchitectureTest가 막는다 (BOOT-014).- 필요 없는
@SpringBootTest. 조립을 검증할 것이 아니면ApplicationContextRunner나 순수 단위 테스트로 충분하다. - slice 애노테이션 혼합 (
@WebMvcTest+@DataJpaTest) — Spring 이 지원하지 않는다. - production 코드가 test fixture 에 의존하는 것.
- 픽스처를
TestUtil·CommonUtil같은 이름으로 묶는 것. 역할을 드러내는 이름 (fixture/,fake/,container/,assertion/) 을 쓴다.
5. 공용 테스트 지원 코드 — testFixtures
표준은 java-test-fixtures 하나다 (ADR-BUILD-001). 공용 테스트 지원 코드는
src/testFixtures/java 에 두고, 다른 leaf 는 testFixtures(project(':x')) 로 소비한다.
두 관례가 공존하던 상태(BOOT-015)는 해소됐다. ca.testkit-publisher 컨벤션 플러그인과 그것을 쓰던
testkit 소스셋 5개는 모두 이관됐고, 플러그인 자체도 제거됐다. 이관하면서 드러난 사실 하나는 기록해
둘 값어치가 있다: testkit* 구성이 testImplementation 을 상속했기 때문에 fixture 들은 각 leaf 가
선언한 모든 테스트 라이브러리를 말없이 보고 있었다. testFixturesImplementation 으로 옮기면서
그 표면이 드러났고, 다섯 leaf 에서 도합 30개가 넘는 의존을 명시적으로 적어야 했다.
test 가 아닌 lane 은 fixture 를 소비한다고 선언해야 한다 — java-test-fixtures 는 test 만
자동으로 배선한다:
strictTestLanes {
sourceSet('postgresqlIntegrationTest') { compilesAgainst 'main', 'testFixtures' }
}
디렉터리는 역할을 드러내는 형태를 권고한다 (fixture/, fake/, container/, assertion/).
TestUtil·CommonUtil 같은 무의미한 이름 묶음은 금지한다.
6. CI 단계 매핑
폴더만 나누고 CI 에서 한꺼번에 돌리면 분리의 의미가 없다.
커밋 / IDE → unit · slice · contract · architecture (`test`)
Pull Request → + integration (integration 레인)
머지 / 스테이징 → + build-qualification (functionalTest, sampleOffTest,
conditionalTransportTest)
야간 / 스케줄 → + qualification · performance
check 에는 인프라 레인을 붙이지 않는다. 이것은 이 저장소가 이미 따르고 있는 관례이며
(persistence-jpa 의 postgresqlIntegrationTest 도 check 에 붙어 있지 않다), Docker 없는
환경에서 check 가 실패하지 않게 하는 유일한 방법이다.
7. LLM 에이전트에게 적용할 때
이 저장소는 에이전트 협업을 전제로 설계되어 있다. 테스트 생성을 맡길 때는 다음 순서를 강제한다.
- 이 테스트가 §2 의 어느 레벨인지 판정하고 근거를 적는다.
- §3 표에서 해당 소스셋을 찾는다.
- 이미 존재하는 fixture 를 먼저 검색한다.
- 테스트를 작성한다.
- 판정한 레벨보다 큰 레벨로 작성하지 않았는지 확인한다 (
@SpringBootTest를 썼다면 왜 필요한지 설명할 수 있어야 한다).