Files
clean-architecture-backend-…/docs/testing/TESTING_STRATEGY.md
T
DongHyeonkaandClaude Opus 5 9bc2e75fe5 refactor(build,ci): CI를 단계별로 재편하고 감사 잔여 114건 정리
빌드·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>
2026-09-16 18:27:15 +09:00

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 의 세 레인은 "테스트를 분류하려고" 나눈 것이 아니라 하나의 소스셋으로 표현할 수 없는 클래스패스 차이 때문에 존재한다. 합치면 검증 자체가 성립하지 않는다.

  • sampleOffTestsrc/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-fixturestest 만 자동으로 배선한다:

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-jpapostgresqlIntegrationTestcheck 에 붙어 있지 않다), Docker 없는 환경에서 check 가 실패하지 않게 하는 유일한 방법이다.

7. LLM 에이전트에게 적용할 때

이 저장소는 에이전트 협업을 전제로 설계되어 있다. 테스트 생성을 맡길 때는 다음 순서를 강제한다.

  1. 이 테스트가 §2 의 어느 레벨인지 판정하고 근거를 적는다.
  2. §3 표에서 해당 소스셋을 찾는다.
  3. 이미 존재하는 fixture 를 먼저 검색한다.
  4. 테스트를 작성한다.
  5. 판정한 레벨보다 큰 레벨로 작성하지 않았는지 확인한다 (@SpringBootTest 를 썼다면 왜 필요한지 설명할 수 있어야 한다).