# 검증 표면 축소 설계 — 스켈레톤을 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 ::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 :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`를 썼다. 스크립트가 맞고 테스트가 이전 계약을 설명하고 있었다. 고쳤다.