18 KiB
검증 표면 축소 설계 — 스켈레톤을 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. 채택하는 판단 기준
리뷰의 핵심 원칙을 이 저장소의 결정 규칙으로 승격한다.
- 현재 상태(Current State)가 아니라 불변조건(Invariant)을 검증한다. "모듈이 N개다", "필드가 정확히 이 집합이다", "문서에 적힌 수가 레지스트리와 같다"는 현재 상태다. "ID가 중복되지 않는다", "domain이 framework를 참조하지 않는다"는 불변조건이다.
- 검증기를 검증하지 않는다. validator를 mutation해서 validator가 실패하는지 보는 task는 스켈레톤의 기본 빌드 정책이 아니다.
- 자동으로 구성할 수 있는 것은 검증으로 강제하지 않는다. convention plugin으로 주입한다.
- 로컬
check는 로컬이어야 한다. leaf의check는 그 leaf만 검사한다. - 릴리스 불변조건을 일반 개발 빌드에 강제하지 않는다.
- 문서 drift는 빌드 실패 사유가 아니다. 커스텀 Markdown/Java 파서를 유지하지 않는다.
- GitHub Actions = CI + artifact 생산, Argo CD = CD. CI에 클러스터 배포 자격증명을 넣지 않는다.
- Template maintainer용 검증과 Template consumer용 검증을 분리한다.
이 기준은 기존의 D8 결정("quality 블록을 convention plugin으로 빼지 않는다")을 대체한다.
D8의 3번 근거(build-logic이 플러그인 버전을 두 번 선언하게 된다)는 이미 무효다 —
build-logic/settings.gradle이 메인 빌드의 libs.versions.toml을 읽고 있으므로 버전은 한 곳에 있다.
2. 목표 task 계층
:<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
.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 경계
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. 명시적 위험
- leaf 62개에
plugins {}블록을 추가한다. 적용 순서가 바뀌므로 leaf별check로 확인한다. - dependency locking이 STRICT라, 어떤 leaf의 configuration에 의존성이 추가되면 락 파일이 깨진다.
따라서 convention 이동은 해석되는 의존성 집합을 바꾸지 않는 범위로 제한한다.
ca.spring-config는 이미 processor를 선언한 leaf만 opt-in한다. - 삭제하는 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 대조 제거.
리뷰와 다르게 결정한 것
ci-quality-gates.yml을ci.yml로 개명하지 않았다. 리뷰 §25는 워크플로 이름 정리를 제안했지만, 같은 절에서 "중요한 것은 workflow 개수가 아니라 공통 setup을 복사하지 않는 것"이라고 했다. 개명은 문서·테스트 20여 곳을 건드리고 얻는 것이 이름뿐이다.fileserver-release.yml만 개명했다 — 그건 이름이 틀렸기 때문이다(릴리스하지 않는다).ca.spring-config는 opt-in이다. 리뷰 §14는 convention이 자동으로 processor를 넣는 그림을 보여 주지만, 모든 configuration이 STRICT로 락되어 있어서 지금 선언하지 않은 leaf에 넣으면 락이 깨진다. 지금 선언한 15개 leaf가 명시적으로 적용한다.integrationCheck를ci에 넣지 않았다. 리뷰 §10의 계층은 `ci = check + architectureCheck- integrationCheck
지만, 이 저장소의 통합 레인 상당수는 컨테이너 런타임이 필요하고 이미integration-main.yml`(main push)과 nightly로 분리돼 있다. PR 게이트에 Docker를 요구하면 리뷰가 비판한 "로컬에서 돌릴 수 없는 check"가 된다.
- integrationCheck
verifyRuntimeModuleMembership은 단순화하지 않았다. 리뷰는 "현재보다 중복"이라 봤지만, 현재 구현은 선언된 멤버십이 아니라 해석된 runtime closure를 비교한다 — 다른 검증이 답하지 않는 질문이다. 중복이었던 85줄은 이미 이전 작업에서 제거돼 있었다.springDependencyManagement를 1.1.6 → 1.1.7로 올렸다. 리뷰에 없는 항목이다. build-logic이 같은 플러그인을 적용해야 하는데, 루트는 Spring Boot 플러그인 때문에 이미 1.1.7로 해석하고 있었다(buildEnvironment가1.1.6 -> 1.1.7로 표시). 카탈로그가 아무도 해석하지 않는 버전을 적고 있었고, 그대로 두면 convention plugin과 leaf가 서로 다른 버전을 쓰게 된다.
검증
./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)에서 동일하게 재현되며, 이번 변경이 원인이 아니다.
:sample-portfolio:test3건 실패.JpaLiveEventReplayAdapter가@Repository인데 생성자 3번째 파라미터java.time.Duration을 만족시킬 빈이 없다. 샘플의@ComponentScan("dev.caskeleton")이 이 어댑터를 집어오고,app-bootstrap은 이 타입을 아예 참조하지 않는다. retention을 어디서 받을지(typed settings)는 설계 결정이라 이번 빌드 리팩터링에서 건드리지 않았다. 미해결.:adapter:outbound:messaging:verifyJsonSchemaRuntimeGraph— 한 번도 통과한 적 없음.libs.*카탈로그 accessor(Provider)를String으로 받는 클로저에 넘겨서 실행 즉시MissingMethodException으로 죽었다.check가 이 태스크에 의존했지만 항상 앞선 실패가 먼저 빌드를 멈췄다. 고쳤고, 고치자 진짜 문제가 드러났다 — 카탈로그는jackson-core:3.0.2를 적는데 Jackson BOM은 3.1.5로 해석한다. 버전 고정은 현재 상태 검증이므로 모듈 존재 검사로 바꿨다.BuildVerificationPurityContractTest5건 실패. public path snapshot의 입력이src/.env에서 커밋된config/security.yml로 옮겨졌는데(src/.env*는 gitignore라 CI 체크아웃에 없다) 픽스처는 계속.env를 썼다. 스크립트가 맞고 테스트가 이전 계약을 설명하고 있었다. 고쳤다.