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:
co-authored by
Claude Opus 5
parent
21234e38cd
commit
2a8d34f85c
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
@@ -1,4 +1,6 @@
|
||||
# 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
|
||||
/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
|
||||
./gradlew :adapter:outbound:messaging:test --console=plain
|
||||
./gradlew :app-bootstrap:test \
|
||||
--tests 'dev.caskeleton.bootstrap.outbox.*' --console=plain
|
||||
--tests 'dev.caskeleton.bootstrap.autoconfigure.outbox.*' --console=plain
|
||||
./gradlew verifyApplicationCoreDependencyPurity --console=plain
|
||||
./gradlew :application-core:dependencies \
|
||||
--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.postgresql.PostgreSqlOutboxClaimRepository
|
||||
dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper
|
||||
dev.caskeleton.bootstrap.outbox.OutboxLeaderElectionToken
|
||||
dev.caskeleton.bootstrap.outbox.OutboxLegacyToV2CutoverCoordinator
|
||||
dev.caskeleton.bootstrap.outbox.OutboxLegacyPreCommitRecoveryCoordinator
|
||||
dev.caskeleton.bootstrap.outbox.LegacyOutboxRelayControlAdapter
|
||||
dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverJobSettings
|
||||
dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverApplicationRunner
|
||||
dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLeaderElectionToken
|
||||
dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyToV2CutoverCoordinator
|
||||
dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyPreCommitRecoveryCoordinator
|
||||
dev.caskeleton.bootstrap.autoconfigure.outbox.LegacyOutboxRelayControlAdapter
|
||||
dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverJobSettings
|
||||
dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverApplicationRunner
|
||||
OutboxEventJpaRepository.deletePublishedBefore
|
||||
OutboxEventJpaRepository.countGroupedByStatus
|
||||
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
|
||||
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
|
||||
`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
|
||||
gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as
|
||||
`FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`.
|
||||
|
||||
@@ -382,7 +382,7 @@ public final class AdapterActivationInventory {
|
||||
NOTIFICATION(
|
||||
Set.of(
|
||||
"dev.caskeleton.adapter.outbound.notification",
|
||||
"dev.caskeleton.bootstrap.notification"),
|
||||
"dev.caskeleton.bootstrap.autoconfigure.notification"),
|
||||
Set.of(),
|
||||
"notification-"),
|
||||
GRAPHQL(
|
||||
|
||||
@@ -668,7 +668,7 @@ class CompositionScanNarrownessTest {
|
||||
Pattern excluded = Pattern.compile(regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters()));
|
||||
for (String type :
|
||||
new String[] {
|
||||
"dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration",
|
||||
"dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration",
|
||||
"dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig",
|
||||
"dev.caskeleton.adapter.outbound.messaging.MessagingSettings",
|
||||
"dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings",
|
||||
@@ -800,7 +800,7 @@ design:
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```java
|
||||
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
|
||||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -871,7 +871,7 @@ class JpaMasterGateTest {
|
||||
- [ ] **Step 3: Write `DataSourceRequirement`**
|
||||
|
||||
```java
|
||||
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
|
||||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||||
|
||||
import dev.caskeleton.shared.activation.MasterSwitch;
|
||||
import java.util.ArrayList;
|
||||
@@ -943,7 +943,7 @@ public final class DataSourceRequirement {
|
||||
- [ ] **Step 4: Write the off-filter**
|
||||
|
||||
```java
|
||||
package dev.caskeleton.bootstrap.autoconfigure.persistencejpa;
|
||||
package dev.caskeleton.bootstrap.autoconfigure.jpa;
|
||||
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter;
|
||||
@@ -1008,7 +1008,7 @@ public final class JpaOffAutoConfigurationImportFilter
|
||||
Create `PersistenceJpaRootAutoConfiguration`:
|
||||
|
||||
```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.JpaSafetySettings;
|
||||
@@ -1048,9 +1048,9 @@ Then:
|
||||
owns that import — and keep the vendor-specific beans.
|
||||
- Remove `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration` from
|
||||
`AutoConfiguration.imports` and add
|
||||
`dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration`.
|
||||
`dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration`.
|
||||
- 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.**
|
||||
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.platform.autoconfigure.NotificationPlatformSettings
|
||||
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
|
||||
|
||||
@@ -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` 를 썼다면 왜 필요한지
|
||||
설명할 수 있어야 한다).
|
||||
Reference in New Issue
Block a user