API 테스트 계약표 표준 원칙 7이 색을 넷으로 늘리면서 이렇게 적었다.
노랑이 이 표준의 핵심이다. “계약에는 적었는데 테스트가 없는 칸” — 그게 처음의 불안이었고, 이제 사람의 성실함이 아니라 기계가 잡는다.
그런데 기계가 잡기만 한다. 노랑이 떠도 빌드는 초록이고, 표는 build/ 아래 HTML로 떨어질 뿐이다. 열린 항목 장부에 이 질문이 그대로 남아 있었다 — “노랑이 뜨면 CI를 막을 것인가.”
막는 쪽으로 가려고 실제 프로젝트를 열어보니, 그 전에 답해야 할 것이 둘 있었다.
먼저: 이미 정해져 있어 다시 고르지 않은 것
계약의 모양과 색의 뜻은 끝났다. 계약은 저장소의 {도메인}Contract enum이 소유하고(원칙 9), 테스트는 @Contract(enum 값)으로 잇고(원칙 8), 색은 초록·빨강·노랑·회색 넷이다(원칙 7). 이 글은 그 색 하나(노랑)를 무엇으로 계산하고, 떴을 때 무엇을 할지만 다룬다.
표를 손으로 칠하지 않는다는 것도 끝났다. 원칙 7이 이미 정했다. 그래서 아래 원칙 3에서 표를 고칠 때도 색칠 규칙만 바꾸고 사람이 개입할 자리를 만들지 않는다.
원칙 1. 노랑은 실행 결과가 아니라 정적 스캔으로 센다
막기로 정하기 전에, 지금 노랑이 무엇으로 계산되는지를 먼저 봐야 했다.
// ContractReportListener — 테스트가 끝날 때마다 결과를 모은다
executionFinished(...) → results.put(contract, status)
// 색칠할 때
backgroundOf(null) → UNTESTED // 기록이 없으면 노랑
노랑은 “테스트가 없다”가 아니라 “이번 실행에서 그 계약을 가리킨 테스트가 안 끝났다”였다. 리스너는 실행을 관찰하는 물건이라 그것 말고는 알 수가 없다.
그래서 --tests로 일부만 돌리면 안 돌린 계약이 전부 노랑으로 찍힌다. results.isEmpty()면 표를 안 쓰는 방어가 하나 있지만, 그건 계약 테스트를 하나도 안 돌렸을 때만 걸린다. 하나라도 걸치면 나머지가 통째로 노랑이 된다.
무엇을 돌렸느냐에 따라 답이 바뀌는 것은 게이트가 아니다. 개발자가 테스트 하나만 돌려보는 순간 빌드가 빨개지면, 그 게이트는 하루 만에 꺼진다.
안 A — 실행 결과를 쓰되 “전체 실행일 때만” 게이트를 켠다. 얻는 것은 리스너를 안 고쳐도 된다는 점이다. 버린 이유는 “전체 실행”을 판별할 방법이 미덥지 않다는 것이다. 태그로 거르거나 클래스 하나만 돌리는 것도 전부 정당한 실행인데, 그 각각이 전체인지 아닌지를 리스너가 알 방법이 없다. 결국 CI에서만 켜는 환경 변수로 가게 되고, 그러면 로컬에서는 아무도 안 보는 검사가 된다.
골랐다 — 안 B, 클래스패스를 훑어 @Contract가 붙은 메서드를 센다.
// 실행과 무관하다. 어느 테스트를 돌렸든 답이 같다.
Set<CompetitorContract> tested = ContractCoverage.testedContracts();
안 A가 못 지켜주는 것은 답의 안정성이다. 정적 스캔은 “테스트가 존재하는가”를 묻고, 그 답은 무엇을 돌렸는지와 상관없다.
클래스를 전부 로드하지 않는다. ASM 기반 메타데이터로 @Contract를 단 메서드가 있는 클래스만 먼저 거른 뒤, 그 몇 개만 리플렉션으로 읽는다. 애노테이션 값이 enum이라 값을 타입 그대로 얻으려면 결국 로드가 필요한데, 전부 로드하면 테스트와 무관한 클래스의 초기화까지 끌고 온다.
대가는 스캔이 컴파일된 결과를 본다는 것이다. 소스에 @Contract를 적어도 컴파일 전에는 안 보인다. 실무에서 테스트를 돌리는 시점에는 이미 컴파일이 끝나 있으므로 문제가 되지 않았다.
원칙 2. 못 짜는 계약은 이유를 적어 유예하고, 이유 없는 노랑은 빌드를 막는다
정의를 고치고 세어보니 16개 중 8개가 노랑이었다. 그런데 그 8개를 들여다보니 전부 이미 문서화돼 있었다.
여기서 검증할 수 없는 계약이 둘 있다. 401 계약은 standalone MockMvc에 Security 필터가 붙지 않아 인증을 거치지 않는다. 삭제된 경쟁사 계약은 없는 번호와 실패 경로가 같아 UseCase를 스텁으로 두면 둘을 가를 수 없다. 표에서 노랑으로 남는다.
—
CompetitorContractTestJavadoc
노랑 8개는 잊은 것이 아니라 정한 것이었다. 그대로 막으면 의도적으로 남긴 8개 때문에 빌드가 즉시 빨개진다.
여기서 노랑이 실은 두 종류였다는 게 드러난다.
| 노랑 | 대응 |
|---|---|
| 잊어서 안 짠 것 | 막아야 한다 |
| 지금 하네스로 못 짜는 것 | 막으면 개발이 멈춘다 |
둘은 대응이 정반대인데 색이 같다. 원칙 7이 초록·빨강만으로는 부족하다며 노랑을 만든 것과 똑같은 모양이 노랑 안에서 한 겹 더 나온 것이다.
안 A — 하네스를 고쳐 8개를 다 짜고 게이트를 켠다. 얻는 것은 유예라는 개념 자체가 필요 없다는 점이다. 버린 이유는 이 항목이 묻는 질문이 아니라는 것이다. 장부의 질문은 “막을 것인가”이지 “어떻게 테스트할 것인가”가 아니다. 하네스 개선을 게이트의 선행 조건으로 묶으면 둘 다 안 끝난다. 그리고 하네스를 고쳐도 다음에 또 못 짜는 계약이 나오면 같은 자리에서 다시 막힌다 — 구조가 아니라 이번 8개만 푸는 것이다.
안 B — 게이트에 면제 목록(baseline)을 둔다. 지금 노랑인 것들을 파일에 적어두고 그것만 빼준다. 얻는 것은 도입이 즉시 가능하다는 점이다. 버린 이유는 짝을 손으로 적는 곳이 생긴다는 것이다. 이 저장소가 standards-pairing.rb에서 매핑 테이블 안을 버린 것과 같은 판단이고, 무엇보다 목록에는 왜 면제인지가 안 남는다.
골랐다 — 안 C, 계약 옆에 유예 표시를 달고 이유를 필수로 받는다.
@Pending("standalone MockMvc에 Security 필터가 붙지 않아 인증을 거치지 않는다")
@Cell(endpoint = "GET /api/v1/admin/competitors", actor = "비로그인", state = "무관", expect = 401)
S401_LIST_ANONYMOUS_ANY,
안 B가 못 지켜주는 것은 이유가 계약 옆에 붙어 있다는 것이다. 별도 목록은 계약과 떨어져 살아서 한쪽만 고치면 어긋나는데, 표시는 계약 상수에 붙어 있으므로 계약을 지우면 같이 사라진다.
그리고 이 결정은 원래 있던 것을 옮긴 것이기도 하다. “못 짠다”는 판단은 이미 Javadoc 산문에 있었는데, 산문이라 기계가 못 읽어서 게이트가 볼 수 없었다. 계약표 표준 원칙 1이 검증하지 않기로 한 칸에 −를 적게 한 것과 같은 모양이다 — 잊은 것과 정한 것을 가르려면 정한 것을 적을 자리가 있어야 한다.
이유를 필수로 받는 이유는 표시가 면제권이 되지 않게 하려는 것이다. 이유 없이 달 수 있으면 테스트를 짜기 싫을 때마다 붙게 되고, 그 순간 이 표시는 노랑을 숨기는 도구가 된다. 애노테이션 값에 기본값을 주지 않는 것만으로 컴파일러가 이걸 강제한다.
대가는 유예가 늘어나는 것을 기계가 못 막는다는 것이다. 이유를 적기만 하면 통과하므로, 이유가 성의 없어지는 것은 리뷰가 봐야 한다. 그래도 이유 없는 면제 목록보다 나은 이유는 리뷰가 볼 것이 코드에 남기 때문이다.
유예가 낡으면 그것도 실패다
유예를 빼주기만 하면 표시가 영원히 남는다. 하네스가 좋아져 테스트를 짤 수 있게 돼도 아무도 떼지 않고, 그러면 결국 노랑을 쌓아두는 것과 같아진다.
그래서 게이트는 양방향이다.
| 상태 | 결과 |
|---|---|
| 테스트 있음 | 통과 |
| 테스트 없음 + 유예 있음 | 통과 |
| 테스트 없음 + 유예 없음 | 실패 — 짜거나, 못 짜는 이유를 적는다 |
| 테스트 있음 + 유예 남음 | 실패 — 유예를 뗀다 |
라우트-계약 정합성 표준에서 양방향을 본 것과 같은 이유다. 한 방향만 보면 반대쪽이 조용히 쌓인다.
원칙 3. 게이트와 표는 같은 정의를 쓴다
노랑의 정의를 바꿨으면 표도 같이 바뀌어야 한다. 게이트는 정적 스캔으로 판정하는데 표는 실행 결과로 칠하면, 같은 칸을 두고 둘이 다른 말을 한다.
그래서 리스너의 노랑도 정적 스캔이 정하게 바꿨고, 그러자 자리 하나가 비었다 — 테스트는 있는데 이번 실행에서 안 돈 계약이다. 예전에는 이것도 노랑이었지만 이제 노랑은 “테스트가 없다”만 뜻한다.
| 색 | 뜻 |
|---|---|
| 초록 | 테스트 있고 통과 |
| 빨강 | 테스트 있고 실패 |
| 노랑 | 테스트가 없다 (유예면 이유가 함께 찍힌다) |
| 파랑 | 테스트는 있는데 이번 실행에서 안 돌렸다 |
| 회색 | 그 칸에 계약 자체가 없다 |
색이 하나 늘었지만 늘어난 게 아니라 갈라진 것이다. 예전 노랑이 두 가지를 뜻하고 있었고, 그걸 못 가른 것이 애초에 게이트를 못 만든 이유였다.
빌드를 막는 것은 표가 아니라 테스트다. 표는 사람이 보는 것이라 안 보면 그만이고, 리스너는 리포트를 쓸 뿐 종료 코드에 영향을 주지 않는다. 게이트는 별도의 테스트 클래스가 맡는다 — 그래야 gradlew test가 그냥 실패한다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
| 노랑을 무엇으로 세나 | 클래스패스 정적 스캔 | 실행 결과로 세면 무엇을 돌렸느냐에 따라 답이 바뀐다 |
| 노랑이 뜨면 | 빌드를 막는다 | 안 막으면 표를 안 보는 순간 쌓인 채 잊힌다 |
| 못 짜는 계약은 | 이유를 적어 유예한다 | 잊은 노랑과 정한 노랑은 대응이 정반대다 |
| 유예에 이유가 필수인가 | 그렇다 | 이유 없이 달 수 있으면 노랑을 숨기는 도구가 된다 |
| 면제 목록 파일은 | 안 쓴다 | 계약과 떨어져 살면 어긋나고, 왜 면제인지가 안 남는다 |
| 유예가 낡으면 | 그것도 실패다 | 안 그러면 표시가 영원히 남아 노랑을 쌓는다 |
| 표와 게이트의 관계 | 같은 정의를 쓴다 | 다르면 같은 칸을 두고 둘이 다른 말을 한다 |
| 막는 주체는 | 표가 아니라 테스트 | 리포트는 종료 코드에 영향을 주지 않는다 |
이 표준을 정하기까지
시작은 장부의 #31이었다. “노랑이 뜨면 CI를 막을 것인가.” 바로 앞에 라우트-계약 정합성 표준에서 “어긋나면 빌드를 막는다”를 정한 참이라, 같은 판단을 여기도 적용하면 끝나는 줄 알았다.
먼저 물은 것이 “지금 노랑을 어떻게 측정하고 있나”였다. 막는 것을 정하기 전에 무엇을 막을지부터 봐야 했는데, 이게 이 표준의 방향을 통째로 바꿨다. 리스너 코드를 읽어보니 노랑은 “테스트가 없다”가 아니라 “이번 실행에서 안 돌았다”였다. 리스너는 실행을 관찰하는 물건이라 그것밖에 알 수 없었던 것이다. 그 신호로는 막을 수 없다는 게 분명해졌고, “막을 것인가”를 답하기 전에 “노랑이 무엇인가”를 다시 정해야 한다는 순서가 나왔다.
정의를 고치고 세어보니 두 번째 발견이 나왔다. 노랑이 8개인데, 그 8개가 전부 CompetitorContractTest의 Javadoc에 이유와 함께 적혀 있었다. 잊은 게 아니라 정한 것이었다. 그대로 막으면 의도적으로 남긴 것 때문에 빌드가 빨개진다는 뜻이라, 여기서 “막는다/안 막는다”의 이분법이 깨졌다.
그 순간 노랑 안에서 색이 한 겹 더 갈렸다. 원칙 7이 초록·빨강만으로는 “계약 있는데 테스트 없음”이 안 보인다며 노랑을 만들었는데, 똑같은 논리가 노랑 안에서 반복된 것이다 — 잊은 노랑과 못 짜는 노랑은 대응이 정반대인데 색이 같았다. 같은 실수를 같은 글이 한 겹 아래에서 다시 하고 있었다.
해법도 같은 글에 이미 있었다. 원칙 1이 검증하지 않기로 한 칸에 −를 적게 한 것 — “다 채우는 것이 목표가 아니다. −는 안 본다는 결정이고, 그 결정을 적을 자리가 생기는 것이 이 표의 값이다.” 유예 표시는 그것과 같은 장치를 노랑에 적용한 것이다. 그리고 실제로 적을 내용은 이미 있었다 — Javadoc 산문에 있던 것을 기계가 읽는 자리로 옮긴 것뿐이다.
면제 목록 파일 안은 빨리 버려졌다. standards-pairing.rb를 만들면서 매핑 테이블을 버린 것과 같은 근거였고, 여기엔 이유가 하나 더 있었다 — 목록에는 왜 면제인지가 안 남는다. 이유가 남지 않으면 6개월 뒤에 그 줄이 아직 유효한지 판단할 수 없고, 그러면 아무도 못 지우는 줄이 된다.
낡은 유예를 실패로 잡는 규칙은 바로 앞 표준에서 배운 것이다. 유예를 빼주기만 하면 표시가 영원히 남는다는 걸 라우트-계약 정합성의 양방향 검사가 이미 가르쳐줬다. 한 방향만 보면 반대쪽이 조용히 쌓인다.
마지막으로 게이트가 진짜 우는지 확인했다. 원칙 6(“상태 코드만 보는 테스트는 무엇을 망가뜨려도 울지 않는다”)을 게이트 자신에게 적용한 것이다. 유예를 하나 떼보고, 테스트가 있는 계약에 유예를 달아보고, 둘 다 실패하는 것과 원복하면 통과하는 것을 확인했다. 검사를 만들었으면 그 검사도 뮤테이션을 통과해야 한다 — 안 그러면 초록을 보고 안심하는 자리가 하나 더 생길 뿐이다.
정리
- 노랑은 실행 결과가 아니라 정적 스캔으로 센다. 무엇을 돌렸느냐에 따라 답이 바뀌면 게이트가 아니다
- 이유 없는 노랑은 빌드를 막는다. 안 막으면 표를 안 보는 순간 쌓인 채 잊힌다
- 못 짜는 계약은 이유를 적어 유예한다. 잊은 노랑과 정한 노랑은 대응이 정반대다
- 유예의 이유는 필수다. 이유 없이 달 수 있으면 노랑을 숨기는 도구가 된다
- 면제 목록 파일을 두지 않는다. 계약과 떨어져 살면 어긋나고, 왜 면제인지가 안 남는다
- 유예가 낡으면 그것도 실패다. 안 그러면 표시가 영원히 남는다
- 표와 게이트는 같은 정의를 쓴다. 다르면 같은 칸을 두고 둘이 다른 말을 한다
- 게이트도 뮤테이션을 통과해야 한다. 안 우는 검사는 초록을 보고 안심하는 자리를 하나 더 만든다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/contract-coverage-gate-standard/SKILL.md
---
name: contract-coverage-gate-standard
description: 테스트 없는 계약으로 빌드를 막고 못 짜는 계약을 이유와 함께 유예하는 규칙. 계약 커버리지 게이트를 만들 때, 노랑을 처리할 때, 계약에 유예를 달거나 뗄 때 반드시 적용한다.
---
# 계약 커버리지 게이트 표준
API 테스트 계약표 표준이 노랑(계약은 있는데 테스트가 없다)을 드러내지만 막지는 않는다. 표는 사람이 보는 것이라 안 보면 그만이다. 이 표준이 노랑을 빌드 실패로 바꾼다.
## 노랑을 정적으로 센다
- 노랑을 `TestExecutionListener`의 실행 결과로 세지 않는다. 거기서 얻는 것은 "테스트가 없다"가 아니라 **"이번 실행에서 안 돌았다"**이다.
- **클래스패스를 훑어 `@Contract`가 붙은 메서드를 센다.** 어느 테스트를 돌렸든 답이 같아야 게이트다.
- 클래스를 전부 로드하지 않는다. ASM 메타데이터로 `@Contract`를 단 클래스만 거른 뒤 그것만 리플렉션으로 읽는다.
- "전체 실행일 때만 게이트를 켠다"는 방식을 쓰지 않는다. 전체인지 아닌지를 판별할 방법이 없고, 결국 CI에서만 도는 검사가 된다.
## 게이트
- 게이트는 리스너가 아니라 **별도 테스트 클래스**가 맡는다. 리포트는 종료 코드에 영향을 주지 않는다.
- 스프링 컨텍스트를 띄우지 않는다. 클래스패스만 읽으므로 띄울 이유가 없다.
- **양방향을 다 검사한다.**
| 상태 | 결과 |
|---|---|
| 테스트 있음 | 통과 |
| 테스트 없음 + `@Pending` | 통과 |
| 테스트 없음 + 유예 없음 | 실패 |
| 테스트 있음 + `@Pending` 남음 | 실패 (낡은 유예) |
- 실패 메시지에 **어느 계약인지 상수 이름을 찍는다.** 개수만 알려주면 찾는 일을 사람에게 넘긴다.
- 게이트를 만들었으면 **뮤테이션으로 우는지 확인한다.** 유예를 하나 떼보고, 테스트가 있는 계약에 유예를 달아보고, 둘 다 실패하는 것을 본다.
## 유예
- 지금 테스트 하네스로 못 짜는 계약은 계약 상수에 `@Pending("이유")`를 단다.
- **이유는 필수다.** 애노테이션에 기본값을 주지 않아 컴파일러가 강제하게 한다. 이유 없이 달 수 있으면 노랑을 숨기는 도구가 된다.
- 이유는 **무엇이 없어서 못 짜는지**를 적는다. "나중에"나 "TODO"는 이유가 아니다.
- **별도 면제 목록 파일을 두지 않는다.** 계약과 떨어져 살면 한쪽만 고쳤을 때 어긋나고, 목록에는 왜 면제인지가 안 남는다.
- 하네스가 좋아져 테스트를 짤 수 있게 되면 **유예를 뗀다.** 안 떼면 게이트가 실패로 잡는다.
- 유예는 리뷰 대상이다. 늘어나는 것은 기계가 못 막는다.
## 표
- 게이트와 표는 **같은 정의**를 쓴다. 게이트가 정적 스캔으로 판정하면 표의 노랑도 정적 스캔이 정한다.
- 색은 다섯이다.
- **초록** — 테스트 있고 통과
- **빨강** — 테스트 있고 실패
- **노랑** — 테스트가 없다 (유예면 이유를 함께 찍는다)
- **파랑** — 테스트는 있는데 이번 실행에서 안 돌렸다
- **회색** — 그 칸에 계약 자체가 없다
- 유예된 칸에는 **이유를 함께 찍는다.** 색만으로는 잊은 노랑과 구별이 안 된다.
GitHub Copilot — .github/instructions/contract-coverage-gate-standard.instructions.md
---
description: 테스트 없는 계약으로 빌드를 막고 못 짜는 계약을 이유와 함께 유예하는 규칙
applyTo: "**/*Contract*.java, **/*CoverageTest.java, **/Pending.java"
---
# 계약 커버리지 게이트
- 노랑(계약은 있는데 테스트가 없다)은 빌드를 막는다. 리포트만 찍고 통과시키지 않는다.
- 노랑을 `TestExecutionListener`의 실행 결과로 세지 않는다. 그것은 "이번 실행에서 안 돌았다"이지 "테스트가 없다"가 아니다.
- 클래스패스를 훑어 `@Contract`가 붙은 메서드를 센다. ASM 메타데이터로 후보를 거른 뒤 그것만 로드한다.
- 게이트는 리스너가 아니라 별도 테스트 클래스가 맡는다. 스프링 컨텍스트를 띄우지 않는다.
- 양방향을 검사한다 — 테스트 없고 유예도 없으면 실패, 테스트가 생겼는데 유예가 남아 있어도 실패.
- 실패 메시지에 어느 계약인지 상수 이름을 찍는다.
- 못 짜는 계약은 `@Pending("이유")`로 유예한다. 이유는 필수이고 기본값을 주지 않는다.
- 이유는 무엇이 없어서 못 짜는지를 적는다. "나중에"나 "TODO"는 이유가 아니다.
- 별도 면제 목록 파일을 두지 않는다. 유예는 계약 상수 옆에 붙는다.
- 하네스가 좋아지면 유예를 뗀다. 안 떼면 게이트가 잡는다.
- 표의 노랑도 게이트와 같은 정의(정적 스캔)로 칠한다. 색은 초록·빨강·노랑·파랑(안 돌림)·회색 다섯이다.
- 유예된 칸에는 이유를 함께 찍는다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기