Files
clean-architecture-backend-…/docs/superpowers/specs/2026-09-16-verification-surface-reduction-design.md
2026-09-17 15:03:36 +09:00

281 lines
18 KiB
Markdown

# 검증 표면 축소 설계 — 스켈레톤을 qualification framework에서 되돌리기
날짜: 2026-09-16
근거: 외부 리뷰 "현재 상태를 유지하기 위한 검증이 너무 많고, 그 검증 자체를 다시 검증하는 구조까지 생겼다"
## 0. 리뷰 기준점과 현재 체크아웃의 차이
리뷰는 이 저장소의 **이전 스냅샷**을 보고 작성됐다. 실제 작업 전에 항목별로 재측정했고,
이미 해결된 항목은 "완료"로 확정하고 남은 항목만 작업 대상으로 삼는다.
| 리뷰 주장 | 리뷰가 본 값 | 현재 실측 | 판정 |
| --- | --- | --- | --- |
| `settings.gradle` 183줄 validator | 183줄 | 16줄 (`ca.architecture-registry` 설정 플러그인으로 이전) | 완료 |
| 모듈 수 정확히 18개 강제 | 있음 | 없음 | 완료 |
| runtime composition이 정확히 `app-bootstrap` | 있음 | `runtime_compositions`를 JSON에서 읽음 | 완료 |
| JSON 필드 집합 정확히 일치 | 있음 | `ModuleRegistry.groovy:88,124`에 그대로 있음 | **작업 대상** |
| `sample-portfolio` negative re-entry guard | 있음 | `ModuleRegistry.groovy:215`에 그대로 있음 | **작업 대상** |
| `build-logic` 없음 | 없음 | 존재 (9개 convention plugin) | 부분 완료 |
| version catalog 없음 | 없음 | `gradle/libs.versions.toml` 140줄 | 완료 |
| `adapter/inbound/web/build.gradle` 799줄 OpenAPI | 799줄 | 256줄, codegen 없음 | 완료 |
| leaf `check`가 저장소 전체 검사 | 그랬음 | 루트 `check`로 이미 이전 | 부분 완료 |
| `fileserver-release.yml``kubectl apply` | 있음 | 이미 제거됨 | 완료 |
| `httpclient-release.yml` | 있음 | 파일 자체가 없음 | 해당 없음 |
| `ci-gate-matrix.yml` 282줄 / 37 gate | 282줄 | **1,025줄 / 107 gate** | **작업 대상(악화)** |
| `verify-gate-matrix.sh` | 있음 | 568줄 | **작업 대상** |
| `verify-gradle-wrapper.sh` 740줄 | 740줄 | **799줄** | **작업 대상** |
| `DeveloperExperienceContractTest` 1,100줄 | 1,100줄 | 1,141줄 (CI YAML mutation test 25개) | **작업 대상** |
| `src/build.gradle` 2,469줄 | 2,469줄 | **3,211줄** | **작업 대상(악화)** |
| always-fail Messaging task | 있음 | 9개 그대로 | **작업 대상** |
| 모든 빌드에 Git SHA 강제 | 있음 | 그대로 (`build.gradle:47`) | **작업 대상** |
## 1. 채택하는 판단 기준
리뷰의 핵심 원칙을 이 저장소의 결정 규칙으로 승격한다.
1. **현재 상태(Current State)가 아니라 불변조건(Invariant)을 검증한다.**
"모듈이 N개다", "필드가 정확히 이 집합이다", "문서에 적힌 수가 레지스트리와 같다"는 현재 상태다.
"ID가 중복되지 않는다", "domain이 framework를 참조하지 않는다"는 불변조건이다.
2. **검증기를 검증하지 않는다.** validator를 mutation해서 validator가 실패하는지 보는 task는
스켈레톤의 기본 빌드 정책이 아니다.
3. **자동으로 구성할 수 있는 것은 검증으로 강제하지 않는다.** convention plugin으로 주입한다.
4. **로컬 `check`는 로컬이어야 한다.** leaf의 `check`는 그 leaf만 검사한다.
5. **릴리스 불변조건을 일반 개발 빌드에 강제하지 않는다.**
6. **문서 drift는 빌드 실패 사유가 아니다.** 커스텀 Markdown/Java 파서를 유지하지 않는다.
7. **GitHub Actions = CI + artifact 생산, Argo CD = CD.** CI에 클러스터 배포 자격증명을 넣지 않는다.
8. **Template maintainer용 검증과 Template consumer용 검증을 분리한다.**
이 기준은 기존의 D8 결정("quality 블록을 convention plugin으로 빼지 않는다")을 **대체한다**.
D8의 3번 근거(build-logic이 플러그인 버전을 두 번 선언하게 된다)는 이미 무효다 —
`build-logic/settings.gradle`이 메인 빌드의 `libs.versions.toml`을 읽고 있으므로 버전은 한 곳에 있다.
## 2. 목표 task 계층
```text
:<leaf>:check 컴파일 + 단위 테스트 + spotless + checkstyle + errorprone (그 leaf만)
check (root) 모든 leaf의 check
architectureCheck 의존 방향 · 런타임 멤버십 · application-core 순수성 · Git 미추적 패키지
qualityCheck SpotBugs + FindSecBugs (전 leaf)
configContractCheck :app-bootstrap:verifyEnvKeys
integrationCheck 통합/슬라이스 레인
ci check + architectureCheck + qualityCheck + configContractCheck
releaseCheck ci + 아카이브 위생 + public path snapshot + 릴리스 provenance
```
qualification(JPA readiness, Messaging evidence, notification evidence, transport 등)은
어느 것도 `check` / `ci`에 걸지 않는다. 명시적으로 이름을 불러야 실행된다.
## 3. 변경 목록
### 3.1 삭제
| 대상 | 줄 수 | 이유 |
| --- | ---: | --- |
| `.github/ci-gate-matrix.yml` | 1,025 | Gradle task graph와 workflow graph에 이미 있는 정보의 3중 복제 |
| `.github/scripts/verify-gate-matrix.sh` | 568 | 위 복제본의 정합성 검사기 |
| `.github/scripts/verify-gradle-wrapper.sh` | 799 | workflow 바이트 해시 잠금. 공격자는 해시도 같이 고치면 되고, 개발자는 주석 하나에 해시를 갱신해야 한다 |
| `DeveloperExperienceContractTest`의 wrapper/gate mutation test 25개 | ~700 | 애플리케이션 test suite가 GitHub Actions YAML 파서를 검증 |
| always-fail Messaging skeleton task 9개 | ~85 | 정상 입력으로도 성공할 수 없는 task. TODO를 Gradle API로 만든 것 |
| `verifyReadmeCommands` | 105 | 커스텀 Markdown 명령 파서 |
| `verifyRunbookReferences` | 75 | 커스텀 runbook 식별자 파서 |
| `verifyDocumentedLeafCount` | 78 | 문서에 적힌 leaf 수 = 전형적인 현재 상태 검증 |
| `verifyTestSourceSetRegistry` | 92 | 문서 표 ↔ source set 대조 파서 |
| `verifySpotBugsAnalysisFailureContract` | 58 | 검증기의 검증 |
| `verifyConfigurationPropertiesProcessor` | 90 | build.gradle을 regex로 읽는 검증 → convention으로 대체 |
| `verifyOneTypePerFile` | 8 | 이미 `checkstyleMain` 별칭. 호출자를 `checkstyleMain`으로 바꾸고 이름 폐기 |
| `verifyTrivyignore` | 105 | 빈 registry를 지키는 커스텀 YAML 파서 |
| `verifyQuarantineSunset` | 250 | 빈 registry를 지키는 커스텀 YAML + Java 파서 |
| `blankJavaCommentsAndLiterals` | 95 | 위 두 개만 쓰던 Java 렉서 흉내 |
| `ModuleRegistry`의 필드 집합 정확 일치 · sample-portfolio negative guard | ~25 | 확장 차단 · 삭제된 모듈의 역사가 영구 invariant |
합계 약 4,150줄.
### 3.2 이동
| 대상 | 현 위치 | 새 위치 | 이유 |
| --- | --- | --- | --- |
| java/quality/spring 공통 설정 | `build.gradle``configure(subprojects…)` | `ca.java-conventions` · `ca.quality-conventions` · `ca.java-library` · `ca.spring-library` | 모듈이 자신의 성격을 스스로 선언 |
| `verifyCleanArchitectureDependencies` 외 3개 | `build.gradle` | `ca.architecture` | 아키텍처 규칙을 한 곳에 |
| JPA readiness registry + release gate | `build.gradle` ~610줄 | `gradle/qualification/jpa-qualification.gradle` | 빌드 정책과 certification 분리 |
| Messaging evidence manifest | `build.gradle` ~600줄 | `gradle/qualification/messaging-qualification.gradle` | 동일 |
| `verifyEnvKeys` | 루트 task, 루트 `check` | `:app-bootstrap` 소유, `configContractCheck` | 환경 계약은 composition root의 책임 |
| 모듈 의존 edge 존재/자기참조 검사 | settings 단계(`ModuleRegistry`) | `verifyCleanArchitectureDependencies` | settings에서 죽으면 복구 수단이 없다 |
### 3.3 완화
| 대상 | 현재 | 변경 후 |
| --- | --- | --- |
| Git revision | 없으면 **모든** 빌드가 configuration 단계에서 실패 | 일반 빌드는 `0.0.1-SNAPSHOT`/`unknown`. `releaseCheck`·아카이브 생성에서만 요구 |
| SpotBugs / FindSecBugs | 전 leaf `check` 블로킹 | `qualityCheck` (CI lane). 로컬 `check`에서 제외 |
| `.trivyignore.yaml` | 커스텀 파서가 expiry/reason 강제 | 파일은 유지, 규칙은 문서화 + CODEOWNERS 승인 |
| `flaky-quarantine.yaml` | 커스텀 파서 + 14일 sunset 강제 | 레지스트리 삭제. `@Tag("quarantine")` 제외와 `quarantineTest`는 유지(각 3줄) |
### 3.4 CI
```text
.github/workflows/
├── _reusable-gradle.yml 신규 — checkout + wrapper validation + JDK/캐시 + Gradle 호출
├── ci-quality-gates.yml → 재사용 workflow 호출로 축약
├── dependency-vulnerability.yml 유지 (dependency-review + submission + Trivy)
├── link-check.yml 유지
├── release.yml image/SBOM 생산까지. 클러스터 배포 없음
├── fileserver-certification.yml ← fileserver-release.yml 개명 (CD가 아니라 certification)
└── 나머지 feature qualification 유지, 전부 재사용 workflow 사용
```
wrapper 검증은 `gradle/actions/wrapper-validation`(full SHA 핀)에 맡기고, 재사용 workflow
한 곳에서만 선언한다. full SHA 핀은 리뷰 판단대로 **유지**한다.
### 3.5 CI/CD 경계
```text
GitHub Actions ──► test / scan / image build / SBOM / push ──► GitOps repo manifest ──► Argo CD ──► K8s
```
`docs/ci-cd/boundary.md`로 고정한다. GitHub Actions는 `kubectl apply` / `helm upgrade` /
`argocd app sync`를 하지 않는다. Argo CD auto-sync를 쓰면 CI에 클러스터 자격증명이 필요 없다.
### 3.6 Template maintainer vs consumer
`docs/ci-cd/template-vs-consumer.md`로 구분을 명시한다.
- Template CI: sample 모듈 제거 가능성, optional 모듈 조합 빌드, 레지스트리 확장 가능성
- Consumer CI: 자기 애플리케이션의 test / architecture / security / release
파생 프로젝트가 가져가면 안 되는 workflow와 task를 목록으로 적는다.
## 4. 유지하는 것 (리뷰가 "잘한 것"으로 분류)
`verifyCleanArchitectureDependencies`, dependency locking(STRICT), full SHA action 핀,
dependency-review, Trivy 스캔, path filter 기반 feature CI, nightly 분리, Spotless,
`-Werror`/`-Xlint`, ErrorProne, 재현 가능한 아카이브.
## 5. 검증 방법
- `./gradlew help --offline`로 configuration 성공
- 변경한 leaf마다 `./gradlew <path>:check --offline`
- `./gradlew architectureCheck --offline`
- 워크플로 YAML은 `python3 -c "import yaml…"`로 파싱 확인
- 삭제한 task 이름이 저장소 어디에도 남지 않았는지 `grep`
## 6. 명시적 위험
1. leaf 62개에 `plugins {}` 블록을 추가한다. 적용 순서가 바뀌므로 leaf별 `check`로 확인한다.
2. dependency locking이 STRICT라, 어떤 leaf의 configuration에 의존성이 추가되면 락 파일이 깨진다.
따라서 convention 이동은 **해석되는 의존성 집합을 바꾸지 않는 범위**로 제한한다.
`ca.spring-config`는 이미 processor를 선언한 leaf만 opt-in한다.
3. 삭제하는 task 이름을 참조하는 workflow/문서/테스트를 같은 변경에서 고친다.
---
# 실행 결과 (2026-09-16)
## 실제로 한 것
### Gradle
| 파일 | 전 | 후 |
| --- | ---: | ---: |
| `src/build.gradle` | 3,211줄 | 349줄 |
| `src/settings.gradle` | 16줄 | 16줄 (변경 없음) |
| `src/build-logic/src/main/groovy/` | 9 플러그인 | 16 플러그인 |
루트에서 사라진 것과 간 곳:
| 대상 | 처리 |
| --- | --- |
| `configure(subprojects)` 306줄 | `ca.java-conventions` + `ca.quality-conventions` + `ca.java-library` / `ca.spring-library` / `ca.spring-config` / `ca.jmh-benchmarks` |
| `verifyCleanArchitectureDependencies`, `verifyApplicationCoreDependencyPurity`, `verifyNoIgnoredSourcePackages` | `ca.architecture` (루트 적용) + `architectureCheck` |
| JPA readiness registry + release gate 685줄 | `gradle/qualification/jpa-qualification.gradle` |
| Messaging evidence 300줄 | `gradle/qualification/messaging-qualification.gradle` |
| `verifyEnvKeys` 280줄 | `gradle/config-contract.gradle`, `:app-bootstrap`이 적용, `configContractCheck` |
| always-fail Messaging 태스크 9개 | 삭제 → `docs/roadmap/messaging-r2.md` |
| `verifyReadmeCommands`, `verifyRunbookReferences`, `verifyDocumentedLeafCount`, `verifyTestSourceSetRegistry`, `verifyDocumentationContracts` | 삭제 |
| `verifyConfigurationPropertiesProcessor` + `blankJavaCommentsAndLiterals` | 삭제 → `ca.spring-config` |
| `verifyOneTypePerFile` | 삭제 → 호출자가 `checkstyleMain`을 직접 부른다 |
| `verifyTrivyignore`, `verifyQuarantineSunset` | 삭제. `flaky-quarantine.yaml`도 삭제 |
| `verifySpotBugsAnalysisFailureContract` | 삭제. 검사기 본체(`spotBugsAnalysisFailures`)는 `ca.quality-conventions`로 이동 |
`ModuleRegistry`: 필드 집합 정확 일치 → 필수 필드 존재로 완화. self-dependency / 미지의 id /
sample-portfolio edge 검사는 settings에서 `verifyCleanArchitectureDependencies`로 이동.
Git revision: 없으면 configuration 실패 → `0.0.1-SNAPSHOT` + `sourceRevision='unknown'`.
`verifyReleaseProvenance``releaseCheck`에서만 요구한다.
### CI
| 대상 | 처리 |
| --- | --- |
| `.github/ci-gate-matrix.yml` (1,025줄 / 107 gate) | 삭제 |
| `.github/scripts/verify-gate-matrix.sh` (568줄) | 삭제 |
| `.github/scripts/verify-gradle-wrapper.sh` (799줄) | 삭제 → `gradle/actions/setup-gradle`의 기본 wrapper validation |
| 잡마다 반복되던 wrapper-validation 3줄 블록 59개 | composite action 안으로 이동 |
| `_reusable-gradle.yml` | 신규. 단순 Gradle 잡이 호출 |
| `fileserver-release.yml` | `fileserver-certification.yml`로 개명 |
| `docs/ci-cd/boundary.md`, `docs/ci-cd/template-vs-consumer.md` | 신규 |
### 테스트
| 대상 | 전 | 후 |
| --- | ---: | ---: |
| `DeveloperExperienceContractTest` | 1,141줄 (wrapper mutation 24개) | 379줄 |
| `ConditionalTransportQualificationContractTest` | 860줄 (gate matrix 18개) | 94줄 |
| `MessagingCapabilityRegistryContractTest` | 태스크 존재를 요구 | 태스크 부재를 요구 |
`SampleRemovalSmokeContractTest`의 gate matrix 대조 제거.
## 리뷰와 다르게 결정한 것
1. **`ci-quality-gates.yml``ci.yml`로 개명하지 않았다.** 리뷰 §25는 워크플로 이름 정리를
제안했지만, 같은 절에서 "중요한 것은 workflow 개수가 아니라 공통 setup을 복사하지 않는 것"이라고
했다. 개명은 문서·테스트 20여 곳을 건드리고 얻는 것이 이름뿐이다. `fileserver-release.yml`
개명했다 — 그건 이름이 틀렸기 때문이다(릴리스하지 않는다).
2. **`ca.spring-config`는 opt-in이다.** 리뷰 §14는 convention이 자동으로 processor를 넣는 그림을
보여 주지만, 모든 configuration이 STRICT로 락되어 있어서 지금 선언하지 않은 leaf에 넣으면 락이
깨진다. 지금 선언한 15개 leaf가 명시적으로 적용한다.
3. **`integrationCheck``ci`에 넣지 않았다.** 리뷰 §10의 계층은 `ci = check + architectureCheck
+ integrationCheck`지만, 이 저장소의 통합 레인 상당수는 컨테이너 런타임이 필요하고 이미
`integration-main.yml`(main push)과 nightly로 분리돼 있다. PR 게이트에 Docker를 요구하면 리뷰가
비판한 "로컬에서 돌릴 수 없는 check"가 된다.
4. **`verifyRuntimeModuleMembership`은 단순화하지 않았다.** 리뷰는 "현재보다 중복"이라 봤지만,
현재 구현은 선언된 멤버십이 아니라 **해석된 runtime closure**를 비교한다 — 다른 검증이 답하지
않는 질문이다. 중복이었던 85줄은 이미 이전 작업에서 제거돼 있었다.
5. **`springDependencyManagement`를 1.1.6 → 1.1.7로 올렸다.** 리뷰에 없는 항목이다. build-logic이
같은 플러그인을 적용해야 하는데, 루트는 Spring Boot 플러그인 때문에 이미 1.1.7로 해석하고
있었다(`buildEnvironment`가 `1.1.6 -> 1.1.7`로 표시). 카탈로그가 아무도 해석하지 않는 버전을
적고 있었고, 그대로 두면 convention plugin과 leaf가 서로 다른 버전을 쓰게 된다.
## 검증
```text
./gradlew -p build-logic test BUILD SUCCESSFUL (56 tests)
./gradlew verifyDependencyLocks BUILD SUCCESSFUL (62 leaf, 락 변화 없음)
./gradlew architectureCheck BUILD SUCCESSFUL
./gradlew configContractCheck BUILD SUCCESSFUL
./gradlew ci releaseCheck integrationCheck --dry-run BUILD SUCCESSFUL
./gradlew check --continue :sample-portfolio:test 3건만 실패 (아래)
./gradlew :app-bootstrap:test BUILD SUCCESSFUL
./gradlew :app-bootstrap:functionalTest BUILD SUCCESSFUL
python3 -c "yaml.safe_load(...)" workflow 20개 + composite action 파싱 OK
```
락 파일이 한 줄도 바뀌지 않았다는 것이 convention 이동의 핵심 근거다 — 해석되는 의존성 집합이
그대로라는 뜻이다.
## 이번 작업으로 드러난 기존 결함
셋 다 HEAD(d00c762)에서 동일하게 재현되며, 이번 변경이 원인이 아니다.
1. **`:sample-portfolio:test` 3건 실패.** `JpaLiveEventReplayAdapter`가 `@Repository`인데 생성자
3번째 파라미터 `java.time.Duration`을 만족시킬 빈이 없다. 샘플의 `@ComponentScan("dev.caskeleton")`
이 이 어댑터를 집어오고, `app-bootstrap`은 이 타입을 아예 참조하지 않는다. retention을 어디서
받을지(typed settings)는 설계 결정이라 이번 빌드 리팩터링에서 건드리지 않았다. **미해결.**
2. **`:adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph` — 한 번도 통과한 적 없음.**
`libs.*` 카탈로그 accessor(Provider)를 `String`으로 받는 클로저에 넘겨서 실행 즉시
`MissingMethodException`으로 죽었다. `check`가 이 태스크에 의존했지만 항상 앞선 실패가 먼저
빌드를 멈췄다. 고쳤고, 고치자 진짜 문제가 드러났다 — 카탈로그는 `jackson-core:3.0.2`를 적는데
Jackson BOM은 3.1.5로 해석한다. 버전 고정은 현재 상태 검증이므로 **모듈 존재** 검사로 바꿨다.
3. **`BuildVerificationPurityContractTest` 5건 실패.** public path snapshot의 입력이
`src/.env`에서 커밋된 `config/security.yml`로 옮겨졌는데(`src/.env*`는 gitignore라 CI 체크아웃에
없다) 픽스처는 계속 `.env`를 썼다. 스크립트가 맞고 테스트가 이전 계약을 설명하고 있었다. 고쳤다.