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>
This commit is contained in:
DongHyeonka
2026-09-16 16:52:37 +09:00
co-authored by Claude Opus 5
parent 21234e38cd
commit 2a8d34f85c
14 changed files with 5313 additions and 6930 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
# ADR-BUILD-001: `java-test-fixtures` is the standard for shared test code
- Status: Accepted
- Date: 2026-09-07
- Scope: every leaf that publishes or consumes shared test code
- Source: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` BOOT-015
## Context
Two conventions do the same job in this repository.
`ca.testkit-publisher` — a convention plugin — gives a leaf a `testkit` source set, wires its output
onto the lanes that leaf names, and optionally publishes it as a consumable configuration. Five
leaves use it: `persistence-jpa` (published as `jpaTestkit`), `web` (`webTestkit`), `websocket`
(`websocketTestkit`), `persistence-mongo` and `httpclient` (both unpublished).
`java-test-fixtures` — Gradle's own plugin — gives a leaf a `testFixtures` source set, puts it on
`test`'s classpath automatically, and always publishes it as a variant consumers reach with
`testFixtures(project(':x'))`. One leaf uses it: `graphql`, which additionally fails its build when a
fixture is written outside `src/testFixtures/java`.
Two conventions for one purpose is the defect. A contributor adding shared test code has to know
which leaf they are in before they know where the file goes, and the two answers are not
interchangeable: a consumer of the first writes `project(path: ':x', configuration: 'jpaTestkit')`
and has to know the configuration's name, while a consumer of the second writes
`testFixtures(project(':x'))` and does not.
## Decision
**`java-test-fixtures` is the standard.** New shared test code goes in `src/testFixtures/java`, and a
consumer depends on it with `testFixtures(project(':x'))`.
Three reasons, in order of weight:
1. **The consumer side describes itself.** `testFixtures(project(':adapter:inbound:web'))` says what
it is. `project(path: ':adapter:inbound:web', configuration: 'webTestkit')` says where to look,
and only after the reader has learned that `webTestkit` is a testkit rather than a lane.
2. **The enforcement already exists and is copyable.** `graphql`'s build fails when a fixture is
declared in the wrong place. The same guard applies unchanged to any leaf that adopts the plugin.
3. **It is one fewer local concept.** A convention plugin that reimplements a Gradle plugin has to be
maintained against it.
## What the local plugin does better, and how it is replaced
This is worth writing down, because the review that prompted this ADR recommended the migration
before reading `ca.testkit-publisher`, and the plugin turns out to encode two deliberate decisions
rather than being an oversight.
**Publishing is opt-in.** `persistence-mongo` and `httpclient` have a testkit and publish nothing;
`persistence-jpa` publishes. The plugin's own comment names this as "a real difference in what each
leaf offers rather than an oversight to normalise away". `java-test-fixtures` always creates the
variant, so the distinction is lost — a leaf that never meant to offer its fixtures will offer them.
> Replacement: none at the build level. The distinction moves to review: the fixtures of a leaf that
> nobody consumes are simply unconsumed. This is a real, accepted loss.
**Lane consumption is declared.** `persistence-jpa` says `consumedBy 'test', 'postgresqlIntegrationTest'`.
`java-test-fixtures` puts fixtures on `test` only, so every other lane needs the output added
explicitly.
> Replacement: `strictTestLanes`' existing `compilesAgainst` expresses this unchanged — a lane
> declares `compilesAgainst 'main', 'testFixtures'`. The first draft of this ADR assumed the DSL
> would need a change, because `sourceSet(name)` creates what it is given and `testFixtures` already
> exists. The `persistence-mongo` migration showed otherwise: `compilesAgainst` only *looks a source
> set up*, so naming a plugin-created one works as-is. What the leaf drops is the
> `sourceSet('testkit')` declaration, not the lane's.
## Migration: done, and what it cost
Five leaves, eleven lanes, two published testkits, all migrated leaf by leaf with the suite run
between each. `ca.testkit-publisher` is deleted.
The order was chosen so a mistake would be cheap: unpublished leaves first, published ones last with
their consumer in the same step.
1. `persistence-mongo` — one leaf, two lanes, no cross-module consumer; the proof the path works.
What it took, per leaf:
- `apply plugin: 'java-test-fixtures'` at the top of the leaf build file;
- `git mv src/testkit src/testFixtures`;
- drop `sourceSet('testkit')` and the whole `testkitPublisher` block; keep every other lane's
`compilesAgainst`, renaming `'testkit'` to `'testFixtures'`;
- rename `testkitImplementation` to `testFixturesImplementation`, **and add what the old source
set was inheriting silently**. This is the one non-mechanical step: `testkit*` extended
`testImplementation`, so the fixtures saw every test library the leaf declared. Mongo's needed
four more lines (AssertJ, BSON, Spring Data commons, Toxiproxy) — none of which the leaf had
ever stated the fixtures depended on;
- regenerate the leaf's lock state.
2. `httpclient`, then `websocket` — unpublished as well, more lanes.
3. `web` and `persistence-jpa` with `app-bootstrap`'s two consumer declarations, which became
`testImplementation(testFixtures(project(':…')))`.
4. `ca.testkit-publisher` deleted, along with its `plugins {}` entry and its application in the root
build.
### Two things the migration broke, and what they taught
Both were caught by tests that exist to catch exactly this, which is the argument for having them.
**ArchUnit corpora went wrong in opposite directions.** `httpclient`'s boundary rules *excluded*
`build/classes/java/testkit`; after the move the fixtures arrived as a `…-test-fixtures.jar` on the
same classpath, so the exclusion missed them and 258 fixture-to-fixture calls were reported as
production depending on the testkit. `persistence-jpa`'s rules *included* only
`build/classes/java/main`; applying `java-test-fixtures` makes the module's own test classpath carry
the module as a **jar** rather than as a class directory, so its corpus became empty. The second is
the dangerous one — an empty corpus makes every `noClasses()` rule pass — and it surfaced only
because that suite asserts its corpus is non-empty before asserting anything about it.
**Fixtures had invisible dependencies.** `testkit*` configurations extended `testImplementation`, so
the fixtures compiled against every test library their leaf declared without ever naming one. Making
them explicit took roughly thirty `testFixturesImplementation` lines across the five leaves —
Micrometer, Spring Web, Netty, logback, Jackson, JUnit, AssertJ, Spring Data. None of them were
wrong; none of them were stated.
## Consequences
- `docs/testing/TESTING_STRATEGY.md` §5 records the standard; this ADR records why and at what cost.
- Until step 5, two conventions remain visible. The strategy document says so explicitly, so a
contributor reading it is not left to infer which one is current.
- The opt-in-publishing distinction is given up. If it later proves load-bearing — a leaf whose
fixtures genuinely must not be reachable — the answer is a separate module, not a third convention.
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,4 +1,6 @@
# feature-security-operational-baseline D5 — deny-by-default public path snapshot. # feature-security-operational-baseline D5 — deny-by-default public path snapshot.
# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated. # SSOT: ca-skeleton.security.public-paths default in app-bootstrap/src/main/resources/config/security.yml
# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own SECURITY_PUBLIC_PATHS
# overrides it at run time and is outside this snapshot.
# Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange # Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange
/api/healthcheck /v1/healthcheck
File diff suppressed because it is too large Load Diff
@@ -79,7 +79,7 @@ Run:
--tests 'dev.caskeleton.application.outbox.*' --console=plain --tests 'dev.caskeleton.application.outbox.*' --console=plain
./gradlew :adapter:outbound:messaging:test --console=plain ./gradlew :adapter:outbound:messaging:test --console=plain
./gradlew :app-bootstrap:test \ ./gradlew :app-bootstrap:test \
--tests 'dev.caskeleton.bootstrap.outbox.*' --console=plain --tests 'dev.caskeleton.bootstrap.autoconfigure.outbox.*' --console=plain
./gradlew verifyApplicationCoreDependencyPurity --console=plain ./gradlew verifyApplicationCoreDependencyPurity --console=plain
./gradlew :application-core:dependencies \ ./gradlew :application-core:dependencies \
--configuration runtimeClasspath --console=plain --configuration runtimeClasspath --console=plain
@@ -2899,12 +2899,12 @@ authority and destructive downgrade are forbidden.
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
dev.caskeleton.bootstrap.outbox.OutboxLeaderElectionToken dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLeaderElectionToken
dev.caskeleton.bootstrap.outbox.OutboxLegacyToV2CutoverCoordinator dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyToV2CutoverCoordinator
dev.caskeleton.bootstrap.outbox.OutboxLegacyPreCommitRecoveryCoordinator dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyPreCommitRecoveryCoordinator
dev.caskeleton.bootstrap.outbox.LegacyOutboxRelayControlAdapter dev.caskeleton.bootstrap.autoconfigure.outbox.LegacyOutboxRelayControlAdapter
dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverJobSettings dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverJobSettings
dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverApplicationRunner dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverApplicationRunner
OutboxEventJpaRepository.deletePublishedBefore OutboxEventJpaRepository.deletePublishedBefore
OutboxEventJpaRepository.countGroupedByStatus OutboxEventJpaRepository.countGroupedByStatus
OutboxEventJpaRepository.findOldestUnpublishedOccurredAtByEventType OutboxEventJpaRepository.findOldestUnpublishedOccurredAtByEventType
@@ -162,7 +162,7 @@ into the new test package, rewritten to run through the single auto-configuratio
`@Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock()`. Once the whole capability is `@Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock()`. Once the whole capability is
gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver
all inject `Clock`. The application context is unaffected because all inject `Clock`. The application context is unaffected because
`dev.caskeleton.bootstrap.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in `dev.caskeleton.bootstrap.autoconfigure.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in
a scanned package, so the httpclient copy is redundant *in the application* and dangerous *in the a scanned package, so the httpclient copy is redundant *in the application* and dangerous *in the
gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as
`FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`. `FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`.
@@ -382,7 +382,7 @@ public final class AdapterActivationInventory {
NOTIFICATION( NOTIFICATION(
Set.of( Set.of(
"dev.caskeleton.adapter.outbound.notification", "dev.caskeleton.adapter.outbound.notification",
"dev.caskeleton.bootstrap.notification"), "dev.caskeleton.bootstrap.autoconfigure.notification"),
Set.of(), Set.of(),
"notification-"), "notification-"),
GRAPHQL( GRAPHQL(
@@ -668,7 +668,7 @@ class CompositionScanNarrownessTest {
Pattern excluded = Pattern.compile(regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters())); Pattern excluded = Pattern.compile(regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters()));
for (String type : for (String type :
new String[] { new String[] {
"dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration", "dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration",
"dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig", "dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig",
"dev.caskeleton.adapter.outbound.messaging.MessagingSettings", "dev.caskeleton.adapter.outbound.messaging.MessagingSettings",
"dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings", "dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings",
@@ -800,7 +800,7 @@ design:
- [ ] **Step 1: Write the failing test** - [ ] **Step 1: Write the failing test**
```java ```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; package dev.caskeleton.bootstrap.autoconfigure.jpa;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -871,7 +871,7 @@ class JpaMasterGateTest {
- [ ] **Step 3: Write `DataSourceRequirement`** - [ ] **Step 3: Write `DataSourceRequirement`**
```java ```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; package dev.caskeleton.bootstrap.autoconfigure.jpa;
import dev.caskeleton.shared.activation.MasterSwitch; import dev.caskeleton.shared.activation.MasterSwitch;
import java.util.ArrayList; import java.util.ArrayList;
@@ -943,7 +943,7 @@ public final class DataSourceRequirement {
- [ ] **Step 4: Write the off-filter** - [ ] **Step 4: Write the off-filter**
```java ```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; package dev.caskeleton.bootstrap.autoconfigure.jpa;
import java.util.Set; import java.util.Set;
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter; import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
@@ -1008,7 +1008,7 @@ public final class JpaOffAutoConfigurationImportFilter
Create `PersistenceJpaRootAutoConfiguration`: Create `PersistenceJpaRootAutoConfiguration`:
```java ```java
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; package dev.caskeleton.bootstrap.autoconfigure.jpa;
import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration; import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration;
import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaSafetySettings; import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaSafetySettings;
@@ -1048,9 +1048,9 @@ Then:
owns that import — and keep the vendor-specific beans. owns that import — and keep the vendor-specific beans.
- Remove `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration` from - Remove `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration` from
`AutoConfiguration.imports` and add `AutoConfiguration.imports` and add
`dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration`. `dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration`.
- Create `AutoConfigurationImportFilter.imports` containing - Create `AutoConfigurationImportFilter.imports` containing
`dev.caskeleton.bootstrap.autoconfigure.persistencejpa.JpaOffAutoConfigurationImportFilter`. `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaOffAutoConfigurationImportFilter`.
- [ ] **Step 6: Run the test to verify it passes.** - [ ] **Step 6: Run the test to verify it passes.**
Run: `cd src && ./gradlew :app-bootstrap:test --tests '*JpaMasterGateTest*' --console=plain --no-daemon` Run: `cd src && ./gradlew :app-bootstrap:test --tests '*JpaMasterGateTest*' --console=plain --no-daemon`
@@ -0,0 +1,108 @@
# CI 단계 분리 + 컨테이너 릴리스 도입
- 작성: 2026-09-16
- 상태: Track A 진행 중 / Track B·C 착수 대기
- 근거 감사: 빌드·CI 레이어 전수 리뷰 133건 (파일 110개 / 12,800줄)
## 확정된 결정
1. **배포 단위는 `app-bootstrap` 하나.** 어댑터는 독립 배포되지 않는다.
따라서 release 워크플로는 8개가 아니라 1개다. 어댑터별로 필요한 것은
release가 아니라 PR 단계의 선택적 테스트다.
2. **GitOps 매니페스트는 별도 repo.** 단, 이번 작업 범위 밖이다.
이 repo는 "이미지를 만들고 태그를 확정"하는 데서 끝난다.
ArgoCD Application 정의와 매니페스트는 이미지가 생긴 뒤 착수한다.
3. **작업은 main에서 직접 하고 커밋한다** (사용자 지시).
`CLAUDE.md:36``commit policy is human-only` 와 충돌하므로
그 줄도 이번에 함께 갱신한다. push 는 하지 않는다.
## 현재 구조의 문제 — 한 줄
**워크플로가 단계가 아니라 모듈로 쪼개져 있다.**
28개 워크플로가 전부 "어느 모듈이냐"(`web-*`, `jpa-*`, `httpclient-*` …)로 갈렸고
"어느 단계냐"로는 갈리지 않았다. 결과:
- 같은 성격의 일이 9개 파일에 흩어진다
- 한 파일 안에 PR 검증과 릴리스 게이트가 섞인다
- 동일한 13줄 setup 블록이 35회 복붙됐다 (CI 366줄)
- 어떤 게이트가 개발을 막고 어떤 게이트가 안 막는지 파일만 봐서는 모른다
이 상태에서는 "이 게이트가 쓸모 있나"를 물을 수 없다. 단계가 하나뿐이면
모든 게이트가 똑같이 개발을 막기 때문이다.
## 목표 구조
| 단계 | 답하는 질문 | 예산 | 트리거 | 실패 시 |
| --- | --- | --- | --- | --- |
| 1 PR 게이트 | 이 diff가 안전한가 | 5분 | PR, 변경 모듈만 | 머지 차단 |
| 2 통합 | 합쳐진 상태가 건강한가 | 30분 | main push | 알림, 머지는 이미 끝남 |
| 3 릴리스 | 배포 가능한 산출물 생성 | — | tag | 릴리스 중단 |
| 4 CD | 클러스터를 산출물로 수렴 | — | ArgoCD 폴링 | (이번 범위 밖) |
### 게이트 재배치 원칙
감사에서 나온 B등급 36건(어겨도 프로그램은 도는 문서·네이밍·개수 검증)은
**삭제 여부를 논쟁하지 않는다. 단계를 지정한다.**
- 1단계: 컴파일, 의존성 방향, 잠금파일, 시크릿·취약점 스캔, 변경 모듈 테스트
- 2단계: 문서-코드 일치, 공개 경로 스냅샷, env 키, 전체 테스트, 느린 통합 테스트
- 3단계: 이미지 빌드, SBOM, 서명, 릴리스 차단 게이트 집계
1단계에 있으면 개발을 막고, 2단계에 있으면 안 막는다. 이 배치가
"쓸모 있나"라는 질문을 대체한다.
## 작업 순서
### Track A — 깨진 게이트 (진행 중)
설계 논쟁이 필요 없는 E등급 24건. 구조는 건드리지 않는다.
핵심: **아키텍처 게이트가 규칙을 0개 실행하고 있다.**
워크플로 7곳과 `CLAUDE.md:109``--tests '*CleanArchitectureTest'` 를 지정하는데
그 이름의 클래스는 존재하지 않는다. 실재하는 ArchUnit 테스트는 17개
(`AdapterBoundaryArchitectureTest`, `DomainPurityArchitectureTest` 등, ArchUnit 사용 37개 클래스).
4곳은 하드 실패하고, 3곳은 다른 필터와 병기돼 아키텍처 규칙 0개를 돌고 초록으로 통과한다.
그 외: `release_blocking` 미강제(trivy-fs 가 빨개도 release-gate 초록),
`strict-test-lane` 이 skip 을 실행으로 셈, `public-path-snapshot` 이 gitignore 된 `src/.env` 를 읽음,
`verifyEnvKeys` 가 build 산출물을 소스로 읽음, `jpa-next-*` 3개가 실제 테스트 없이 continue-on-error,
`spring70CompatibilityTest` fail-closed 상실, `fileserver-pr` 존재하지 않는 path 필터.
### Track B — 단계 분리
1. 재사용 워크플로(`workflow_call`) + composite action 으로 setup 블록 공통화
2. release 워크플로 8개 → 1개. 릴리스 태그 네임스페이스 분열 버그도 여기서 해소
3. PR 단계는 경로 필터로 변경 모듈만 실행
4. 게이트를 위 표대로 재배치. 문서 검증류는 2단계로 내린다
5. `ci-gate-matrix.yml` 이 실제로 release 차단을 강제하도록 연결
(이 파일은 죽은 문서가 아니다 — `ci-quality-gates.yml:88` 이 런타임 파싱한다)
6. 도달 불가 Gradle 태스크 39개 정리 (grpc 워크플로가 0개인 것이 주원인)
### Track C — 컨테이너 릴리스 (축소된 범위)
지금 없는 것: 이미지 빌드·푸시. `src/Dockerfile` 은 있으나
`build-push-action` / `bootBuildImage` / `jib` 사용처가 0건이다.
`*-release.yml` 8개는 테스트 실행 + evidence 업로드로 끝난다 — 이름만 릴리스다.
1. 3단계 릴리스 워크플로에 이미지 빌드 + 레지스트리 푸시 추가 (기본 ghcr.io)
2. 태그 규칙 확정 (semver + git sha)
3. SBOM 생성, 이미지 스캔
4. **CI 는 배포하지 않는다.** `kubectl apply` 를 CI 에 넣지 않는다 — GitOps 원칙.
현재 그런 코드가 없으므로 걷어낼 것도 없다.
이후(별도 작업): GitOps repo, 매니페스트, ArgoCD Application, image tag bump 연결.
## 검증
- Track A 완료 시: 변경한 YAML 전수 파싱, 아키텍처 테스트가 실제로 실행되는지 확인
- Track B 완료 시: `verifyCleanArchitectureDependencies`, 아키텍처 테스트, 워크플로 파싱
- 각 Track 종료 시 무엇을 실행했고 무엇을 실행하지 못했는지 명시한다.
실행하지 못한 검증은 "실행하지 않음"이라고 적는다.
## 감사 산출물
- 루브릭: `scratchpad/gradle-audit/RUBRIC.md`
- finding 전체(243KB, `file:line` 근거): `scratchpad/gradle-audit/findings/R1~R8.md`
- 등급 분포: A=25 B=36 C=17 D=31 E=24 · 정리 시 3,368줄 감소 추정
- 아키텍처 위반 0건 (`modules.json` 전수 대조, messaging/grpc 격리 확인)
@@ -113,7 +113,7 @@ dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier
dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig
dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings
dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig
dev.caskeleton.bootstrap.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings dev.caskeleton.bootstrap.autoconfigure.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings
``` ```
## Task 5 — default-profile boot ## Task 5 — default-profile boot
+177
View File
@@ -0,0 +1,177 @@
# 테스트 전략 — 레벨 정의와 소스셋 매핑 (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` 선언과 대조한다. 표에 없는 소스셋을
추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다.
<!-- registry:begin -->
| 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` | `brokerRelayTest` | integration |
| `: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 |
<!-- registry:end -->
`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`
자동으로 배선한다:
```groovy
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 에이전트에게 적용할 때
이 저장소는 에이전트 협업을 전제로 설계되어 있다. 테스트 생성을 맡길 때는 다음 순서를 강제한다.
1. 이 테스트가 §2 의 어느 레벨인지 판정하고 근거를 적는다.
2. §3 표에서 해당 소스셋을 찾는다.
3. 이미 존재하는 fixture 를 먼저 검색한다.
4. 테스트를 작성한다.
5. 판정한 레벨보다 큰 레벨로 작성하지 않았는지 확인한다 (`@SpringBootTest` 를 썼다면 왜 필요한지
설명할 수 있어야 한다).