AI가 테스트를 빨리 짜준다. 그런데 통과를 봐도 안심이 안 된다.
이 글은 그 불안의 정체를 찾다가 나왔다. 결론부터 말하면 불안은 옳았고, 테스트를 더 짜는 것으로는 안 풀린다.
@Test
void 주문을_취소한다() {
Order order = orderRepository.save(new Order(PENDING));
mockMvc.perform(post("/orders/" + order.getId() + "/cancel"))
.andExpect(status().isOk());
}
한 줄씩 따라가면, @Test는 이 메서드가 테스트 코드라는 표시다 — 이 표시가 붙은 메서드를 빌드 도구가 찾아 자동으로 실행한다. Order order = orderRepository.save(new Order(PENDING))는 상태가 PENDING(결제 대기)인 주문 객체를 하나 만들어 Repository를 통해 DB에 저장하고, DB가 매긴 ID까지 채워진 객체를 돌려받는 줄이다. mockMvc.perform(post("/orders/" + order.getId() + "/cancel"))은 실제 서버를 띄우지 않고도 POST /orders/{id}/cancel로 가짜 HTTP 요청을 보내는 줄이다. .andExpect(status().isOk())는 그 응답의 HTTP 상태 코드가 200(성공)인지만 확인한다.
이 테스트는 통과한다. 그런데 서버가 아무것도 안 하고 200만 돌려줘도 통과한다. 그리고 배송 중인 주문을 취소하면 어떻게 되는지, 남의 주문을 취소하면 어떻게 되는지는 아무 말도 안 한다.
주문 상태 5개 × 요청자 3종류면 15칸인데 이 테스트는 1칸이다. 14칸이 비어 있었는데 그게 눈에 안 보였던 것뿐이다.
먼저: 이미 정해져 있어 다시 고르지 않은 것
계약을 쓰는 자리는 둘이다. 에이전트 운영 표준 원칙 8이 이슈 본문을 불변으로 정했으므로, 이슈는 이번 변경분만 적는다. “지금 이 API가 무엇을 보장하나”에는 이슈가 구조적으로 답 못 한다 — 변경 단위라서다. 그래서 저장소의 계약 코드가 이 API의 현재 계약 전체를 소유한다. 커밋과 코드의 관계와 같다. 이 글은 그 계약의 모양을 정하고, 저장소 쪽 모양은 원칙 9에 있다.
검증은 세 층으로 갈려 있다. 검증 표준이 형식은 Controller 어노테이션, 비즈니스 규칙은 Service, DB를 봐야 하는 것은 Domain으로 나눴다.
제약의 단일 출처는 어노테이션이다. Swagger 문서화 표준이 @Min·@Max 같은 검증 어노테이션을 제약의 유일한 출처로 삼았다. 이게 아래 원칙 5에서 400 행과 맞대어 보는 상대다.
생성물은 손으로 고치지 않는다. 이 저장소가 always-on.rb·agents-skills.rb·ai-map.rb에서 세 번 쓴 패턴이고, 근거도 같다 — 원본이 하나면 어긋날 수가 없다.
원칙 1. 계약은 나열이 아니라 표로 적는다
나열은 빠진 것을 못 보여준다. 계약이 다섯 줄 적혀 있으면 그게 다섯 개로 충분한 건지 여섯 번째를 잊은 건지 구별할 방법이 없다.
안 A — 지금처럼 C1~Cn을 목록으로 적는다. 쓰기 쉽고 형식이 자유롭다. 버린 이유는 완전성을 사람의 기억력에 맡기는 것이다. 축이 둘만 돼도 조합이 십수 개인데, 그걸 빠짐없이 떠올리는 건 매번 성공할 수 없는 종류의 일이다.
안 B — 개수 하한을 정한다. “계약이 최소 다섯 개는 있어야 한다” 같은 규칙. 버린 이유는 그 숫자가 지어낸 값이라는 것이다. 다섯 개를 적어도 그게 맞는 다섯 개인지 아무도 모르고, 채우려고 의미 없는 계약을 쓰게 된다.
골랐다 — 안 C, 셀 수 있는 축을 잡아 표로 적는다.
| | PENDING | CONFIRMED | SHIPPED | DELIVERED | CANCELLED |
|--------|---------|-----------|---------|-----------|-----------|
| 본인 | C1 200 | C2 200 | C3 409 | − | C4 409 |
| 관리자 | C5 200 | C6 200 | C7 200 | − | C8 409 |
| 남 | C9 403 | − | − | − | − |
안 A와 안 B가 못 지켜주는 것은 빈칸이 보이는 것이다. 15칸 중 9칸을 채웠고 6칸이 −인 게 한눈에 들어온다.
다 채우는 것이 목표가 아니다. −는 “안 본다”는 결정이고, 그 결정을 적을 자리가 생기는 것이 이 표의 값이다. 잊은 것과 정한 것이 갈리는 순간 불안의 성질이 바뀐다.
대가는 축을 잘못 고르면 그 축 밖은 여전히 안 보인다는 것이다. 상태×역할 표를 다 채워도 “동시에 두 번 눌렀을 때”는 거기 없다. 그러니 표가 주는 것은 완전성이 아니라 고른 축 안에서의 완전성이고, 불안은 사라지는 게 아니라 「뭘 안 봤지」에서 「축을 잘 골랐나」로 옮겨간다. 후자는 남에게 보여주고 물어볼 수 있는 질문이라 그게 진보다.
원칙 2. 성공과 실패는 축이 아니라 칸의 값이다
표를 「정상 케이스 표」와 「예외 케이스 표」로 가르고 싶어진다. 가르면 안 된다.
성공/실패를 축으로 쓰려면 어떤 조합이 성공인지 미리 알아야 표를 그릴 수 있는데, 그게 바로 지금 정하려는 것이다. 순서가 뒤집힌다.
칸의 값이 곧 기대 응답이다
C1 200 성공
C3 409 실패 — 배송 후엔 취소 불가
C9 403 실패 — 남의 주문
같은 격자 안에 둘이 함께 있어야 대비가 보인다. 본인 × SHIPPED가 409인데 관리자 × SHIPPED가 200이라는 사실은 두 칸이 나란히 있어야 읽힌다. 표를 가르면 그 비교가 사라지고, 두 표 사이의 일관성은 아무도 검사하지 않는다.
원칙 3. 축의 성격이 다르면 표를 나눈다
성공/실패로는 안 나누지만, 나눠야 할 때가 따로 있다.
같은 취소 API인데 이 실패들은 상태×역할 격자에 안 들어간다.
잘못된 JSON 400 상태와 무관하다
없는 주문 ID 404 상태가 존재하지 않는다
결제 취소 API 타임아웃 504 외부 사정이다
동시에 두 번 눌렀다 ? 축이 「횟수」다
축이 다르면 표가 다르다. API 하나에 표가 서넛 나온다.
| 표 | 축 | 나오는 응답 |
|---|---|---|
| 업무 규칙 | 도메인 상태 × 요청자 | 200 · 409 · 403 |
| 입력 형식 | 필드 × 동등 분할 | 400 |
| 외부 연동 | 상대의 실패 종류 | 502 · 503 · 504 |
| 동시성 | 호출 횟수 · 순서 | 409 등 |
억지로 한 표에 우겨넣지 않는다. 축이 섞인 표는 빈칸이 무엇을 뜻하는지 알 수 없어져서, 표를 쓰는 이유가 사라진다.
400 행은 업무 규칙 계약과 같은 계약 enum에 쓰지만, 표는 여전히 따로 그린다(2026-10-21). 원칙 5가 400 행을 같은 enum에 손으로 쓰게 바꾸면서, 400 행만 「어느 필드의 어느 제약을 어겼나」(Violation)를 하나 더 들게 했다. 결과 표를 그리는 리스너는 Violation이 붙은 행을 업무 규칙 표에 섞지 않고, 엔드포인트마다 「입력 형식 표」(행: 필드, 열: 위반 종류)를 따로 그린다. 적는 곳은 하나로 합쳤어도 그려지는 표는 축마다 갈리므로, 이 원칙은 그대로 지켜진다. 아직 어느 프로젝트의 리스너도 이렇게 그리지 않는다 — 원칙 5의 게이트와 함께 하기로 한 약속이다.
원칙 4. 입력 공간은 동등 분할의 경계로만 센다
「입력 형식」 표는 다른 표와 성격이 다르다. 받을 수 있는 값이 사실상 무한이라 열거 자체가 안 된다.
그런데 동작이 갈리는 지점은 유한하다.
quantity: ... -1 0 │ 1 2 ... 99 100 │ 101 102 ...
↑ ↑
0과 1 사이 100과 101 사이
여기서만 동작이 바뀐다 (400 ↔ 200)
@Min(1) · @Max(100)이면 경계는 둘이다 — 0│1과 100│101. 0과 -1은 둘 다 400이라 같은 취급을 받으므로 하나만 보면 된다. 같은 취급을 받는 값의 묶음이 동등 분할이고, 묶음의 경계 양쪽에만 테스트를 둔다. 경계 둘의 양쪽 값 넷(0 · 1 · 100 · 101)에 null과 타입 오류를 더하면, 무한한 입력이 여섯 줄로 줄어든다.
| 필드 | 분류 | 값 | 기대 |
|---|---|---|---|
quantity |
null | — | 400 |
quantity |
하한 미만 | 0 |
400 |
quantity |
하한 | 1 |
200 |
quantity |
상한 | 100 |
200 |
quantity |
상한 초과 | 101 |
400 |
quantity |
타입 오류 | "abc" |
400 |
묶음 안쪽을 더 찍지 않는다. 50을 테스트해도 1이 통과했다는 사실에 아무것도 더하지 않는다. 경계가 아닌 값을 늘리는 것은 테스트 수만 늘리고 증명은 안 늘린다.
원칙 5. 형식 검증의 400 행은 사람이 쓰고, 게이트가 검증 어노테이션과 양방향으로 대조한다
원칙 4의 경계는 이미 코드에 선언돼 있다.
public class CreateOrderRequest {
@NotNull @Min(1) @Max(100)
private Integer quantity;
}
@NotNull은 「비우면 안 된다」, @Min(1)은 「1보다 작으면 안 된다」, @Max(100)은 「100보다 크면 안 된다」는 선언이다. 원칙 4의 표에서 400이 나오는 줄 중 null · 0 · 101 셋이 어노테이션 하나에 하나씩 대응한다. 타입 오류("abc") 줄은 어노테이션에서 나오지 않아 아래 대조의 대상이 아니다.
처음엔 이 어노테이션에서 행을 뽑기로 했다. Swagger 표준이 어노테이션을 제약의 단일 출처로 삼아뒀으니 출처를 새로 만들 것도 없었다. 그런데 뽑는 도구는 끝내 만들지 않았다. 만들기 전에 「도구가 무엇을 내놓느냐」부터 막혔고, 2026-10-21에 셋을 놓고 골랐다.
안 A — 생성기가 어노테이션을 읽어 테스트 코드를 뽑는다. 얻는 것은 사람이 쓸 것이 하나도 없다는 점이다. 버린 이유는 그 테스트가 어노테이션을 다시 말할 뿐이라는 것이다. @Size(max = 50)에서 「51자를 넣으면 400」을 뽑으면, 누가 실수로 max = 500으로 고쳐도 생성기는 「501자를 넣으면 400」을 새로 뽑고 테스트는 계속 초록이다. 어노테이션이 틀리면 테스트도 같이 틀린다. Khorikov가 「테스트가 운영 코드의 규칙을 복제하면 검증이 아니다」(『Unit Testing』 11.3)라고 한 병과 같다.
안 B — 사람이 행 목록만 채운다. Kaner가 2001년에 쓴 입력 필드 매트릭스가 이 모양이다 — 필드마다 줄을 세우고 사람이 테스트를 돌려 칸을 칠한다. 얻는 것은 「100이 맞나」를 사람이 판단할 자리가 행마다 있다는 점이다. 버린 이유는 맞대어 볼 상대가 없어 게이트로 못 쓴다는 것이다. 어노테이션을 하나 새로 붙이고 행을 안 적어도 아무도 모른다. Kaner의 표는 일을 남에게 넘기는 도구였지 빌드를 막는 도구가 아니었다.
골랐다 — 안 C, 사람이 400 행을 계약 enum에 손으로 쓰고, 게이트가 검증 어노테이션과 양방향으로 대조한다. 이 표준을 실제로 쓰는 프로젝트의 돌봄 계약표에 이미 이렇게 쓴 줄이 하나 있다.
S400_CARE_CONTRACT_CREATE_ADMIN_NO_SERVICE_HOURS(Endpoint.CREATE, Actor.관리자, CareContractState.서비스_시간이_빔, 400),
한 줄씩 보면, 상수 이름이 「400 · 계약 만들기 · 관리자 · 서비스 시간 없음」을 읽히게 적고, 괄호 안 넷이 엔드포인트 · 부르는 사람 · 상황 · 기대 응답이다(모양은 원칙 9 · 12에서 다룬다). 이 칸을 가리키는 테스트는 실제로 서비스 시간을 공백으로 보내고 400이 오는지, 그리고 계약이 안 생겼는지를 본다.
그런데 이 줄만으로는 기계가 맞대어 볼 수 없다. 상황 이름 서비스_시간이_빔은 사람에게는 serviceHours의 @NotBlank로 읽히지만 게이트에게는 그냥 이름이다. 그래서 400 행만 생성자의 다섯째 인자로 「어느 필드의 어느 제약을 어겼나」를 하나 더 받는다(2026-10-21).
S400_CREATE_ADMIN_NO_SERVICE_HOURS(Endpoint.CREATE, Actor.관리자, CareContractState.서비스_시간이_빔, 400,
Violation.of("serviceHours", NotBlank.class)),
Violation.of(...)의 첫 값은 요청 DTO의 필드 이름이고, 둘째 값은 그 필드에 붙은 제약 어노테이션의 타입이다(NotBlank.class는 @NotBlank 어노테이션 자체를 가리키는 값이다). 계약 enum은 이것을 Cell에 함께 담는다(상수 이름에서 CARE_CONTRACT_가 빠진 이유는 원칙 13의 완성 예시에 있다). 이름을 문자열로 적는 "serviceHours"는 오타가 날 수 있지만, 바로 아래 대조가 그 오타를 「어노테이션 없는 행」으로 잡는다.
게이트가 볼 것은 두 방향이다. 게이트는 엔드포인트를 처리하는 컨트롤러 메서드에서 @Valid @RequestBody가 붙은 요청 DTO를 찾아 그 필드들의 (필드, 제약 타입) 묶음을 모두 뽑고, 400 행들의 Violation 묶음과 맞대어 본다.
DTO에서 뽑은 (필드, 제약)마다 같은 Violation을 든 400 행이 있는가 — 없으면 빌드 실패 (행을 빠뜨렸다)
400 행의 Violation마다 DTO에 그 (필드, 제약)이 있는가 — 없으면 빌드 실패 (어노테이션이 사라졌거나 행이 틀렸다)
안 A와 안 B가 못 지켜주는 것은 어노테이션과 행이 따로 진술되고, 기계가 둘을 맞대어 본다는 것이다. 행은 사람이 「이 값을 보내면 400이어야 한다」고 따로 적은 문장이라, 어노테이션이 사라지면 행이 남아 게이트가 울고, 테스트는 실제로 위반 값을 보내 400을 확인한다. 안 A처럼 어노테이션을 베낀 것이 아니고, 안 B처럼 빠져도 조용한 것도 아니다.
다만 이 게이트는 아직 어느 프로젝트에도 없다. 그 프로젝트의 400 행에도 Violation 인자가 아직 없다. 표기와 대조 방법을 정한 것이지 돌려 본 것이 아니라, 구현되기 전까지 이 대조는 하기로 한 약속이다.
@Pattern과 커스텀 검증도 같은 길로 풀린다. @Min(1)은 경계가 0으로 자명하지만, 정규식이나 @SortKey 같은 커스텀 검증은 위반 값을 자동으로 계산할 수 없다. 안 C에서는 위반 예시를 사람이 행에 적으므로 그 계산이 필요 없다. 원칙 4의 경계는 사람이 위반 값을 고를 때의 기준으로 남는다 — @Max(100)이면 101을 보낸다.
조합은 만들지 않는다. 한 행에 위반은 하나이고 나머지 필드는 정상값이다. 필드 5개에 위반 3종이면, 필드마다 고를 수 있는 것이 위반 셋과 정상값 하나로 넷이라 곱집합은 4⁵ = 1024이지만 행은 5 × 3 = 15다. 행 하나가 어노테이션 하나를 가리켜야 게이트가 둘을 맞댈 수 있고, 위반 둘을 한 행에 넣으면 400이 어느 쪽 때문인지 모른다. Myers가 1979년에 「동등 분할은 입력의 조합을 탐색하지 않는다」고 짚은 약점은 그대로 안고 간다.
출력 쪽 경계는 여기 넣지 않는다. 빈 목록 · 최대 페이지 크기 같은 응답의 경계는 목록 요청 계약 표준과 페이징 전략 표준이 맡는다.
대가는 셋이다. 행을 사람이 써야 한다 — 어노테이션 하나에 행 하나, 테스트 하나다. 「100이 맞는 상한인가」는 여전히 증명되지 않는다 — 게이트는 어노테이션과 행이 서로 짝이 맞는지만 보고, 100이라는 값이 업무에 맞는지는 행을 쓰는 사람이 판단한다. 그리고 400 행은 상황 이름과 Violation을 함께 적어야 한다 — 사람이 읽을 이름과 기계가 맞댈 (필드, 제약)이 같은 행에 두 번 적힌다. 둘이 어긋나도(이름은 서비스 시간인데 Violation은 다른 필드) 게이트는 Violation만 보므로 잡지 못한다.
세 층으로 보면 이렇다.
| 층 | 예 | 행은 누가 쓰나 | 기계가 하는 일 |
|---|---|---|---|
| 형식 | @Min(1), @NotNull |
사람 | 어노테이션과 양방향 대조 (게이트는 아직 구현 전) |
| 비즈니스 규칙 | “배송 후엔 취소 불가” | 사람 | 없음 |
| DB 확인 | “이미 가입된 이메일” | 사람 | 없음 |
형식 층만 맞대어 볼 상대(어노테이션)가 코드에 있다. 나머지 두 층은 비즈니스 규칙이 Service 코드 안의 if로, DB를 봐야 하는 규칙이 Domain 코드 안으로 흩어져 있어 대조할 선언이 없다.
원칙 6. 테스트는 상태 코드가 아니라 결과를 확인한다
표를 아무리 잘 그려도 칸을 채우는 테스트가 비어 있으면 소용없다.
// 이렇게 하지 않는다 — 서버가 아무것도 안 해도 통과한다
mockMvc.perform(post("/orders/1/cancel"))
.andExpect(status().isOk());
// 이렇게 한다
mockMvc.perform(post("/orders/1/cancel"))
.andExpect(status().isOk());
assertThat(orderRepository.findById(1L).getStatus())
.isEqualTo(CANCELLED);
마지막 두 줄을 따라가면, orderRepository.findById(1L)은 Repository로 DB에서 ID가 1인 주문을 다시 조회하는 줄이고, .getStatus()는 그 주문 객체에서 상태값을 꺼낸다. assertThat(...).isEqualTo(CANCELLED)는 「이 값이 CANCELLED(취소됨)와 같아야 맞다」고 단정하는 줄이다 — 꺼낸 상태값이 실제로 CANCELLED가 아니면 이 줄에서 테스트가 실패로 처리된다.
마지막 줄이 없으면 아무것도 증명되지 않는다. cancel() 안을 통째로 지워도 앞의 두 줄은 통과한다.
이 규칙이 왜 필요한지는 뮤테이션 관점에서 분명해진다. 코드를 일부러 망가뜨렸을 때 우는 테스트만 무언가를 지키고 있는 것인데, 상태 코드만 보는 테스트는 무엇을 망가뜨려도 울지 않는다.
실패 칸도 같다. 409를 기대하는 칸이면 상태가 안 바뀌었는지까지 확인한다. 409를 주면서 뒤로는 취소해버리는 구현이 통과하면 안 된다.
이 원칙은 테스트를 쓸 때 지키는 것이다. 다 쓴 테스트가 실제로 망가뜨림에 우는지 재는 일은 원칙 14가 맡는다.
원칙 7. 색칠된 표는 생성물이다
표가 있으면 “어디가 통과했나”를 색으로 보고 싶어진다. 그 색을 손으로 칠하면 안 된다.
손으로 칠하면 코드가 바뀌어도 색이 남는다. 그러면 그 표는 권위 있어 보이면서 거짓말을 하고, 없는 것보다 나쁘다. 이 저장소가 색인에 대해 이미 내린 판단과 같다 — 없으면 원본을 읽지만, 틀리면 틀린 것을 읽고 확신한다.
계약 enum (칸마다 Cell) ─┐
├──→ TestExecutionListener ──→ 색칠된 HTML
테스트 실행 결과 (Verify로 연결) ─┘
TestExecutionListener는 테스트가 끝날 때마다 끼어들어 “이 테스트가 어느 계약을 가리켰고 통과했는지”를 모아 HTML로 색칠해 주는 스프링의 확장 지점이다. 그림의 Cell과 Verify는 원칙 8 · 12에서 다룬다 — 이 원칙을 처음 쓸 때는 @Cell과 @Contract라는 애노테이션이었고, 2026-10-21에 이름과 모양이 바뀌었다.
색은 넷이다. 둘로는 부족하다.
| 색 | 뜻 |
|---|---|
| 초록 | 계약 있고 테스트 통과 |
| 빨강 | 계약 있고 테스트 실패 |
| 노랑 | 계약 있는데 테스트가 없다 |
| 회색 | 그 칸(actor×state)에 계약 자체가 없다 |
“계약 없는 테스트”라는 다섯 번째 경우는 원칙 8의 설계로 애초에 못 만든다. 테스트에 붙이는 Verify가 계약 enum 값만 받으므로, 없는 계약을 가리키는 순간 컴파일이 막는다.
노랑이 이 표준의 핵심이다. “계약에는 적었는데 테스트가 없는 칸” — 그게 처음의 불안이었고, 이제 사람의 성실함이 아니라 기계가 잡는다. 계약 상수를 하나 더하는 순간 그 칸은 노랑으로 뜨고, 테스트를 짤 때까지 노랑으로 남는다.
초록/빨강만 칠하면 노랑 칸은 그냥 비어 보인다. 안 짠 건지 안 봐도 되는 건지 구별이 안 되고, 그러면 원래 문제로 돌아간다.
다만 위 그림대로 만든 노랑은 이 뜻을 지키지 못했고, 계산 방식은 뒤에 바뀌었다. 리스너는 실행을 관찰하는 물건이라, 그림의 구조에서 노랑은 「테스트가 없다」가 아니라 「이번 실행에서 그 계약을 가리킨 테스트가 안 끝났다」였다. 그래서 --tests로 일부만 돌리면 안 돌린 계약이 전부 노랑으로 찍혀 부풀어 오른다. 계약 커버리지 게이트 표준 원칙 1이 노랑을 실행 결과가 아니라 클래스패스 정적 스캔(그 계약을 가리키는 Verify가 붙은 메서드가 있는가)으로 세게 바꿨고, 원칙 3이 그 때문에 빈 자리 — 테스트는 있는데 이번 실행에서 안 돈 계약 — 를 파랑으로 갈라 색이 다섯이 됐다. 노랑의 뜻은 그대로이고, 그 뜻대로 세는 방법만 바뀐 것이다.
출력은 HTML로 한다. 색칠에 라이브러리가 필요 없고, CI가 빌드하면서 만들어 내는 부산물(아티팩트)로 떨어뜨리면 브라우저로 바로 열리고, 생성물이라 커밋하지 않아도 된다. 엑셀로 뽑는 안은 apache-poi 같은 의존이 붙고 git diff가 안 되는데, 생성물이라 diff가 필요 없다는 점을 고려해도 HTML보다 나은 게 없었다.
표가 수십 장이 되면 요약 한 장을 얹는다(2026-10-21). 처음에는 API 하나의 표만 정했다. 그런데 API가 50개면 HTML이 50장이고, 노랑이 어디 있는지 한눈에 볼 층이 없었다. 그 프로젝트가 먼저 답을 냈다 — 같은 리스너가 index.html 한 장을 더 만들어, 계약표마다 한 줄씩 계약 · 통과 · 실패 · 테스트 없음 · 유예를 세고 각 표로 링크한다. 유예는 계약 상수에 @Pending("이유")를 단 것이다 — 이유를 적고 테스트 없이 게이트를 통과시키는 표시다. 그대로 표준으로 올렸다.
다섯 값을 모두 보여 주지만, 여기서 지켜볼 값은 노랑(테스트 없음)이 아니라 유예다. 유예 없는 노랑은 커버리지 게이트가 빌드를 막아서 애초에 쌓일 수 없다. 반대로 유예는 게이트가 이유를 보고 일부러 통과시키므로, 조용히 쌓이는 쪽은 그쪽이다. 그래서 「노랑이 있으면 CI를 실패시킬까」라는 질문은 따로 답할 필요가 없었다 — 게이트가 이미 그 일을 한다.
원칙 8. 칸과 테스트는 타입 세이프 계약으로 잇는다
생성기가 “어느 테스트가 어느 칸인지”를 알아야 한다.
정해야 했던 건 이거였다 — 그 연결을 무엇으로 표현하는가.
안 A — 문자열 태그. @Tag("C3"). 얻는 것은 JUnit이 이미 태그를 XML로 뱉어준다는 점이다. 생성기가 새로 만들 게 없다. 버린 이유는 오타가 빌드를 통과한다는 것이다. @Tag("C03")이나 @Tag("c3")으로 적어도 컴파일은 된다. 계약과 테스트의 동기화를 사람이 오타 없이 적는 것에 의존하게 되는데, 그건 이 표준이 애초에 없애려던 바로 그 종류의 실패다.
안 B — 타입 세이프 애노테이션. 계약을 문자열이 아니라 enum으로 선언하고, 테스트는 그 enum 값을 가리키는 애노테이션을 붙인다.
@Test
@CompetitorContract.Verify(CompetitorContract.S404_GET_ADMIN_MISSING)
void 관리자가_없는_번호의_경쟁사를_조회하면_404다() { ... }
한 줄씩 따라가면, @CompetitorContract.Verify(...)는 이 표준을 위해 직접 만든 애노테이션이고, CompetitorContract enum 안에 선언돼 있어서 이름 앞에 enum 이름이 붙는다. 괄호 안에는 CompetitorContract라는 enum에 미리 선언된 상수 중 하나(S404_GET_ADMIN_MISSING 같은 값)만 적을 수 있다. 그 enum에 없는 이름을 적으면 자바 컴파일러가 “그런 값은 없다”며 그 자리에서 빌드를 막는다 — 그래서 이 테스트가 “관리자 × 없는 번호” 칸(원칙 9의 enum에 있는 상수)을 가리킨다는 연결이 끊어질 수 없다.
없는 상수를 적으면 컴파일이 막는다. 짝을 사람이 맞추는 게 아니라 컴파일러가 강제한다.
골랐다 — 안 B. 매핑 파일을 따로 두지 않는 이유는 안 A와 같다 — 짝을 손으로 적는 곳이 생기면 그 줄을 빼먹었을 때 다시 조용히 통과한다. standards-pairing.rb를 만들면서 매핑 테이블 안을 버린 것과 같은 판단이다. 다만 안 A도 매핑 파일은 안 뒀는데, 문자열은 그 자체가 오타에 열려 있어 강제력이 없었다. enum은 값 자체가 유일한 진짜 계약 목록이라 오타를 낼 수 있는 자리가 없다.
대가는 애노테이션이 특정 enum 타입에 묶인다는 것이다. 자바 애노테이션은 제네릭을 못 받아 값 타입을 하나의 enum으로 고정해야 한다. 계약 enum이 도메인마다 따로 있으니 이 애노테이션도 도메인마다 하나씩 필요하다. 문자열로 두면 이 문제가 없지만 그러면 안 A로 돌아간다.
처음엔 이 애노테이션을 @Contract라는 이름으로 계약 enum 밖에 따로 두었다. 2026-10-21에 그 프로젝트가 쓰던 모양을 표준으로 올리면서(원칙 12) 계약 enum 안에 Verify로 중첩하게 바꿨다. 도메인마다 하나씩 필요한 것은 그대로지만, 밖에 두면 CompetitorContractRef · InquiryContractRef처럼 표마다 새 이름을 지어야 하고 안에 두면 표 이름이 그대로 이름이 된다. 그리고 게이트와 리스너는 이 애노테이션의 타입을 모른다 — 「value()가 계약표의 상수를 돌려주는 애노테이션」이면 무엇이든 받으므로 표가 늘어도 수집 코드를 고치지 않는다.
원칙 9. 계약은 저장소의 enum이 소유하고, 번호가 아니라 이름으로 가리킨다
「먼저」 절에서 이슈는 변경분만, 저장소는 현재 전체를 담기로 했다. 저장소 쪽 계약을 무엇으로 표현하는가가 남는다.
public enum CompetitorContract implements ContractTable {
S404_GET_ADMIN_MISSING("GET /api/v1/admin/competitors/{competitorNo}", Actor.관리자, CompetitorState.없는_번호, 404),
S200_DELETE_ADMIN_EXISTING("DELETE /api/v1/admin/competitors/{competitorNo}", Actor.관리자, CompetitorState.있는_경쟁사, 200),
// ...
}
한 줄씩 보면, 상수마다 괄호 안에 값 넷을 적는다 — 순서대로 endpoint · actor · state · expect다(이 값을 받는 생성자와 implements ContractTable은 원칙 12에서 다룬다). endpoint는 이 계약이 가리키는 API 주소이고, 그 안의 GET·DELETE는 그 주소로 “무엇을 하고 싶은지”를 나타내는 HTTP 메서드다 — GET은 조회, DELETE는 삭제를 요청한다는 뜻이다. enum 상수 하나가 칸 하나다. actor·state가 표의 두 축이고, expect가 칸에 찍히는 값이다. 축 값을 "관리자" 같은 문자열이 아니라 Actor.관리자 같은 enum 상수로 적는 이유는 원칙 10에서 다룬다. 상수 전체를 훑으면 표가 나온다 — 표를 따로 그릴 필요가 없다.
정해야 했던 건 상수 이름, 즉 계약을 무엇으로 식별하는가였다.
안 A — 좌표. 업무규칙/취소/관리자/배송중처럼 표의 축 값을 그대로 이어 붙인다. 얻는 것은 이름만 봐도 표의 어느 칸인지 읽힌다는 점이다. 버린 이유는 축 이름이 바뀌면 식별자가 깨진다는 것이다. state의 표기를 “배송중”에서 “SHIPPED”로 바꾸면 그 순간 이름을 참조하던 모든 곳이 같이 깨진다.
안 B — 파일을 번호의 원본으로 두고 이슈가 이어 쓴다. ErrorCode 수명주기 표준과 같은 모양이다 — 저장소가 C1부터 순번을 매기고 이슈는 그 번호를 인용만 한다. 얻는 것은 짧고 안정적인 식별자다. 버린 이유는 이슈가 파일에 의존하게 된다는 것이다. 이슈를 쓰는 시점에 저장소의 다음 번호를 확인해야 하고, 두 세션이 동시에 계약을 추가하면 같은 번호를 잡을 수 있다 — 스키마 마이그레이션 표준이 파일명을 일련번호에서 타임스탬프로 바꾼 이유와 같은 종류의 충돌이다.
골랐다 — 안 C, 의미론적 enum 이름. 번호를 아예 안 매긴다. S{상태코드}_{ENDPOINT}_{ACTOR}_{STATE} — S404_GET_ADMIN_MISSING. 상태 코드를 앞에 두면 나열했을 때 같은 응답끼리 묶인다. 안 A의 “읽힌다”는 살리면서 안 A의 약점(축 이름 변경에 취약함)은 없다 — enum 상수 이름은 표기 문자열이 아니라 식별자라 원본 한글 축 이름이 바뀌어도 상수 이름은 그대로 둘 수 있다. 안 B의 “짧다”는 포기했지만, 안 B가 겪는 이슈-파일 의존과 번호 충돌도 같이 사라졌다.
계약을 지우면 그 상수를 지운다. 새로 추가하면 새 상수를 끝에 붙인다. 끼워 넣거나 뒷번호를 밀 일이 없다 — 번호가 없으니 밀 것도 없다.
계약 enum이 어느 패키지에 사는지는 패키지 구조 표준 원칙 7이 든다 — 그 도메인의 테스트 패키지다. 공용 contract/ 에 모으면 도메인 폴더가 비어 보여 무테스트로 집계된다. 실제로 그렇게 세다가 셋이 0으로 나왔다. 자리 규칙을 여기 다시 적지 않는 이유는 두 벌이 되면 한쪽만 고쳤을 때 어긋나기 때문이다.
원칙 10. 표의 축은 계약 상수가 아니라 별도 enum이 선언한다
원칙 9는 “계약을 지우면 그 상수를 지운다”로 끝난다. 그런데 지운 뒤의 표를 그려보지는 않았다.
이 원칙을 정하기 전에는 축(actor · state)을 계약 상수에 붙인 @Cell 애노테이션의 문자열 값에서 모았다. 그래서 상수를 지우면 축 값이 같이 없어질 수 있다.
// 이 원칙 전의 모양 — state 축 {결제완료, 배송중}은 상수들에서 모아진 것이다
@Cell(actor = "본인", state = "결제완료", expect = 200) S200_CANCEL_OWNER_PAID,
@Cell(actor = "본인", state = "배송중", expect = 409) S409_CANCEL_OWNER_SHIPPED,
@Cell(actor = "관리자", state = "결제완료", expect = 200) S200_CANCEL_ADMIN_PAID,
S409_CANCEL_OWNER_SHIPPED를 지우면 배송중을 쓰는 상수가 하나도 안 남고, 그 행이 표에서 통째로 사라진다(리스너가 그리는 표는 상태가 행, 부르는 사람이 열이다 — 원칙 13의 완성 예시). 회색조차 안 뜬다. 배송중이라는 상태를 다룬 적이 있다는 사실 자체가 표에서 지워진다.
반대로 S403_CANCEL_ADMIN_SHIPPED가 있었다면 행이 살아남아 본인×배송중이 회색으로 남는다. 같은 삭제인데 흔적이 남을지가 무관한 다른 계약의 존재에 달려 있다.
정해야 했던 건 이거였다 — 표의 축을 어디서 얻는가.
안 A — 지금대로 계약 상수에서 모은다. 얻는 것은 따로 선언할 것이 없다는 점이다. 계약을 적으면 표가 저절로 나온다. 버린 이유는 회색이 신호이기를 그만둔다는 것이다. 회색이 없는 게 “다 덮었다”인지 “축이 사라졌다”인지 구별이 안 되면, 원칙 7이 색을 넷으로 늘린 이유가 무너진다.
골랐다 — 안 B, 축을 계약 밖의 enum으로 선언한다.
enum Actor { 본인, 타인, 관리자 }
enum OrderCancelState { 결제완료, 배송중 }
S409_CANCEL_OWNER_SHIPPED(Endpoint.CANCEL, Actor.본인, OrderCancelState.배송중, 409),
축 값은 계약 상수가 아니라 선언에서 나온다 — 상태는 상황 enum 전체, 부르는 사람은 그 엔드포인트가 선언한 Actor 목록이다. 축이 계약 밖에 있으므로 계약을 전부 지워도 행과 열은 남고, 그 자리가 회색이 된다. 이 원칙을 정할 때는 열도 「Actor enum 전체」였는데, Actor를 서비스에 하나로 두자(원칙 13) 값이 스물둘이라 모든 표에 열이 스물둘씩 생겼다. 그래서 2026-10-21에 열만 엔드포인트의 선언으로 좁혔다 — 좁힌 열도 계약 상수가 아니라 선언에서 나오므로 이 원칙이 막으려던 문제는 그대로 막힌다(원칙 13). 400 행의 상황은 업무 규칙 표가 아니라 입력 형식 표로 간다(원칙 3). 처음에는 state에 도메인 enum(OrderStatus.SHIPPED)을 그대로 쓰면 새로 만들 것도 없다고 봤는데, 그걸로는 「없는 번호」 같은 칸을 못 적어서 2026-10-21에 도메인마다 「상황」 enum을 따로 두기로 했다 — 원칙 13.
덤으로 같은 표준 안의 비대칭 하나가 없어진다. 원칙 8이 @Tag("C3")을 버린 근거는 오타가 빌드를 통과한다는 것이었는데, @Cell(actor = "본인")은 아직 문자열이었다. "본인 "이나 "OWNER"로 적어도 컴파일이 통과하고, 그 순간 축 값이 하나 더 생겨 표가 조용히 갈라진다. 안 B는 그 자리도 컴파일러에게 넘긴다.
이 원칙을 정할 때는 축 값을 여전히 @Cell 애노테이션에 적었고, 그래서 대가가 원칙 8과 같은 모양이었다 — @Cell이 특정 도메인 enum에 묶여 도메인마다 하나씩 필요해진다. 자바 애노테이션은 제네릭을 못 받는다. 이 대가는 2026-10-21에 축을 받는 그릇을 애노테이션에서 생성자로 바꾸면서 없어졌다 — 원칙 12.
원칙 11. 계약을 없앤 이유는 enum이 아니라 이슈와 커밋이 기록한다
원칙 10이 지운 자리가 회색으로 남는 데까지 풀었다. 남은 건 그 칸이 왜 없어졌는지다.
안 A — enum에 폐기 사유를 남긴다. ErrorCode 표준 원칙 6이 쓴 모양이다.
@Deprecated // 2026-08-18 배송중 취소를 허용하기로 함
S409_CANCEL_OWNER_SHIPPED(Endpoint.CANCEL, Actor.본인, OrderCancelState.배송중, 409),
얻는 것은 코드만 보고 이유를 안다는 점이다. 트래커로 나갈 필요가 없다.
버린 이유는 두 겹이다. 첫째, ErrorCode가 상수를 안 지운 근거 둘이 여기엔 다 없다. 하나는 번호 재사용 사고인데 원칙 9가 번호를 없앴고, 하나는 “9자리는 클라이언트가 분기하는 공개 계약”인데 계약 enum은 테스트만 본다. 근거가 사라졌는데 결론만 가져오면 모양만 베끼는 것이다. 둘째, enum이 「현재 계약 전체」라는 뜻이 깨진다. 죽은 상수가 표에 섞여 나오고, 생성기가 그걸 거르면 남긴 의미가 절반 사라진다.
골랐다 — 안 B, enum에는 아무것도 안 적는다. 계약을 없애는 것도 기능 변경이라 이슈가 돌고, 그 본문은 불변이다(에이전트 운영 표준 원칙 8). 커밋도 남는다. 안 A가 못 지켜주는 것은 같은 사실이 한 곳에만 사는 것이다 — enum에도 적으면 두 곳에 살고, 한쪽만 고치면 어긋난다.
되짚을 입구는 원칙 10이 이미 만들어뒀다. 회색 칸이 본인 × 배송중이라는 검색어를 그대로 준다. 축이 남지 않았다면 이 안은 성립하지 않았다.
대가는 코드만 봐서는 모른다는 것이다. 이유를 알려면 트래커나 git log로 나가야 한다. 그래도 택한 이유는 두 벌이 어긋나는 실패가 조용하기 때문이다 — 밖으로 나가는 건 번거롭지만 눈에 보인다.
원칙 12. 축은 애노테이션이 아니라 계약 enum의 생성자가 받는다
원칙 10은 축을 계약 밖의 enum으로 빼면서 대가 하나를 적었다 — @Cell이 도메인 enum에 묶여 도메인마다 하나씩 필요해진다. 그 대가를 안고 그 모양으로 쓰고 있는 프로젝트(두 벌이 된 적은 아직 없다)와 피해 간 프로젝트가 하나씩 있었다(2026-10-20).
정해야 했던 건 이거였다 — 축 enum의 값을 어떤 그릇에 담는가.
안 A — @Cell 애노테이션에 도메인 enum 타입으로 받는다. 이 표준을 쓰는 또 하나의 프로젝트인 farm-predict가 이 모양이다.
// user 도메인 전용 @Cell
public @interface Cell {
UserEndpoint endpoint();
UserActor actor();
UserTargetState state();
int expect();
}
// UserContract — 상수 위에 @Cell 을 붙인다
@Cell(endpoint = DETAIL, actor = 관리자, state = 없음, expect = 404)
S404_DETAIL_관리자_없음,
한 줄씩 보면, public @interface Cell은 애노테이션을 선언하는 문법이고, 안의 네 줄은 이 애노테이션이 받을 값의 이름과 타입이다. state()의 타입이 UserTargetState로 박혀 있어서 다른 도메인의 상태를 적으면 컴파일이 막는다. 아래 두 줄은 그 애노테이션을 계약 상수 하나에 붙인 모습이다.
얻는 것은 그 컴파일 보호와, 축 값이 상수 바로 위에 붙어 표처럼 읽힌다는 점이다. 버린 이유는 표를 그리는 기계까지 도메인마다 한 벌이 된다는 것이다. 자바 애노테이션은 제네릭을 못 받으니 @Cell의 속성 타입을 도메인 enum 하나로 고정해야 하고, 그 @Cell을 읽는 리스너도 그 타입을 직접 안다. farm-predict의 리스너 이름이 UserContractReportListener인 이유다. 도메인이 둘이 되면 @Cell · 테스트용 애노테이션 · 리스너를 통째로 복제해야 하고, 표가 도메인마다 다른 기계에서 나오니 API 수십 개의 표를 한곳에 모아 볼 방법도 없다. farm-predict는 아직 user 도메인 하나뿐이라 실제로 두 벌이 된 적은 없다.
골랐다 — 안 B, 계약 enum의 생성자가 축을 받아 Cell 값 객체에 담고, 모든 계약 enum이 ContractTable 인터페이스를 구현한다. 그 프로젝트가 이 모양이다(커밋 4374c1f · f19d45a · d8a85cb).
// 공용 — 서비스에 하나
public final class Cell {
private final String endpoint;
private final Actor actor;
private final Enum<?> state;
private final int expect;
public static Cell of(String endpoint, Actor actor, Enum<?> state, int expect) { ... }
// endpoint() · actor() · state() · expect()
}
public interface ContractTable {
String name(); // enum 이 이미 갖고 있다
Cell cell(); // 계약 enum 이 생성자로 받아 둔 칸
default Pending pending() { ... } // 상수에 붙은 @Pending 을 읽는다
}
// 도메인마다 — 계약 enum
public enum CompetitorContract implements ContractTable {
S404_GET_ADMIN_MISSING("GET /api/v1/admin/competitors/{competitorNo}", Actor.관리자, CompetitorState.없는_번호, 404);
private final Cell cell;
CompetitorContract(String endpoint, Actor actor, CompetitorState state, int expect) {
this.cell = Cell.of(endpoint, actor, state, expect);
}
@Override
public Cell cell() {
return cell;
}
}
한 줄씩 보면, Cell은 칸 하나의 네 값(엔드포인트 · 부르는 사람 · 상태 · 기대 응답)을 담기만 하는 클래스이고, Cell.of(...)는 그것을 만드는 정적 팩터리 메서드다. state의 타입이 Enum<?>인 것은 「어느 enum이든」이라는 뜻이라, 도메인마다 다른 상태 enum을 Cell 하나가 받을 수 있다. ContractTable은 계약 enum이 공통으로 갖출 세 메서드를 정한 인터페이스다. 계약 enum CompetitorContract는 상수마다 괄호 안의 넷을 생성자로 받아 Cell로 묶어 들고 있다가 cell()로 돌려준다. enum 상수의 괄호는 그 enum의 생성자를 부르는 문법이다.
안 A가 못 지켜주는 것은 기계가 도메인 수와 상관없이 한 벌이라는 것이다. 리스너 · 게이트 · 라우트 검사는 ContractTable만 알면 되고, 클래스패스에서 ContractTable을 구현한 enum을 찾아 전부 돈다. 새 도메인을 붙여도 기계 쪽은 한 줄도 안 고친다. 그리고 안 A의 장점인 컴파일 보호는 잃지 않는다. Cell.of는 Enum<?>를 받지만, 계약 enum의 생성자가 state를 CompetitorState 타입으로 받으므로 다른 도메인의 상태(InquiryState.xxx)를 적으면 생성자 호출에서 컴파일이 막힌다. 이 문제를 처음 꺼낼 때는 「Enum<?>라 다른 도메인 상태가 통과할 수 있다」고 추론했는데, 생성자를 읽어 보니 그 구멍은 생성자 매개변수 타입이 막고 있었다.
무엇을 하나로 두고 무엇을 도메인마다 두는지의 기준은 「도메인 지식을 담는가」다.
하나로 둔다 Cell · ContractTable · Actor · @Pending · 결과 표 리스너 · 게이트 · 라우트 대조(RouteContracts)
도메인마다 둔다 계약 enum (생성자 · cell() · 안에 중첩한 Endpoint · Verify) · 상황 enum · 라우트 검사 테스트 한 줄
계약 목록과 그것을 가리키는 애노테이션은 도메인 지식이라 나누고, 표를 그리고 세고 맞대어 보는 방법은 도메인 지식이 아니라 나누지 않는다. Actor가 왜 하나로 가는지는 원칙 13에서 다룬다.
대가는 셋이다. 테스트가 계약을 가리키는 애노테이션(Verify)은 여전히 도메인마다 하나다 — 원칙 8의 대가는 안 B에서도 그대로다. 계약 enum마다 생성자와 cell() 열 줄 남짓이 똑같이 반복된다. 그리고 Cell.of를 계약 enum의 생성자 밖에서 바로 부르면 Enum<?>라 타입 보호가 없다. 생성자의 state 매개변수를 그 도메인의 상황 enum 타입으로 받는 것이 이 보호의 전부라, 그 타입을 Enum<?>로 넓히는 순간 안 A가 지켜 주던 것이 사라진다.
원칙 13. Actor는 서비스에 하나, 상태는 도메인마다 「상황」 enum으로 둔다
원칙 10은 축을 계약 밖 enum으로 뺐지만 그 enum을 몇 개 두고 무엇으로 채우는지는 정하지 않았다. 규칙에는 「state는 도메인 enum을 그대로 쓴다. actor는 없으면 만든다」 한 줄이 있었고, 그대로 따르려니 둘 다 막혔다.
상태부터 막혔다. 경쟁사 계약표에는 「없는 번호」와 「삭제된 경쟁사」 칸이 있다. 그런데 이건 경쟁사가 가질 수 있는 상태가 아니라 요청이 마주치는 상황이다. 도메인의 상태 enum에는 그런 값이 없고, 있어서도 안 된다 — 테스트 칸 하나를 적으려고 운영 코드의 enum에 없는_번호를 더하면 도메인이 오염된다.
안 A — 도메인 enum을 그대로 쓴다. 얻는 것은 새로 만들 것이 없다는 점이다. 버린 이유는 위의 「없는 번호」를 적을 수 없다는 것이다. 적으려면 도메인을 오염시키거나, 그 칸을 표 밖으로 빼야 한다.
골랐다 — 안 B, 도메인마다 「상황」 enum을 따로 둔다. CompetitorState.없는_번호, CareContractState.서비스_시간이_빔처럼 그 칸에서 요청이 마주치는 상황을 이름으로 적는다. 대상이 아직 없는 등록 엔드포인트라면 올바른_요청처럼 요청의 상태가 축이 되고, 원칙 5의 400 행도 이 자리에 위반 하나를 이름으로 적는다. 안 A가 못 지켜주는 것은 도메인에 없는 상황도 칸이 된다는 것이다. 대가는 도메인 enum과 이름이 비슷한 enum이 테스트 쪽에 하나 더 생긴다는 것이고, 도메인 상태가 늘어도 상황 enum이 저절로 따라 늘지 않는다.
부르는 사람은 반대로 하나로 간다. farm-predict는 UserActor처럼 도메인마다 두었고, 그 프로젝트는 Actor 하나를 서비스 전체가 쓴다. 고른 것은 하나다. 부르는 사람의 종류는 도메인 지식이 아니라 그 서비스의 인증 구조라서 도메인이 늘어도 늘지 않는다. 그 프로젝트의 Actor는 값이 스무 개 남짓인데, 갈리는 자리별로 묶인다 — 세션이 가르는 자리(관리자 · 비로그인 · 역할별 관리자), 자원의 주인이 가르는 자리(본인 · 타인), 어디서 왔는가가 가르는 자리(사내망 직결 · 프록시 경유), 누구 몫으로 세는가가 가르는 자리(전달 주소). 마지막 둘은 이 서비스에만 있는 값이라 도메인 지식처럼 보이지만, 갈리는 것은 그 창구 앞의 인증 체인(필터 체인)이지 업무 규칙이 아니다. 도메인마다 두면 같은 「관리자」가 도메인 수만큼 따로 선언되고, 한쪽만 이름을 바꾸는 날 표끼리 말이 달라진다. 대가는 Actor에 값을 더하려면 서비스 공용 파일을 고쳐야 한다는 것이고, 그래서 값은 인증 구조가 새로 갈리는 자리에서만 더한다.
Actor를 하나로 두자 원칙 10과 부딪혔다(2026-10-21). 원칙 10은 축 enum의 값을 계약이 없어도 전부 행 · 열로 남기게 했다. 둘을 같이 지키면 그 프로젝트의 계약표 28장 모두에 열이 스물둘씩 생긴다. 「계약 만들기」 표라면 실제로 응답이 갈리는 부르는 사람은 관리자 · 비로그인 · 담당자 셋인데, 나머지 열아홉 열(사내망 직결 · 전달 주소 · 자동 로그인 쿠키 …)이 통째로 회색이다. 회색은 「이 칸을 안 보기로 했다」는 신호인데, 그 엔드포인트와 상관도 없는 열이 회색이면 신호가 묻혀 표를 읽을 수 없다. 주니어 독자 검토가 원칙 10과 이 원칙을 나란히 읽고 이것을 짚었다.
정해야 했던 건 이거였다 — 결과 표의 열(부르는 사람)을 무엇으로 그리는가.
안 C — Actor 전체를 열로 그린다. 원칙 10을 글자 그대로 지키는 모양이다. 얻는 것은 따로 선언할 것이 없고, 계약을 지워도 열이 절대 안 사라진다는 점이다. 버린 이유는 위의 스물두 열이다. Actor에 값이 하나 늘 때마다 그 값과 상관없는 표 28장에 회색 열이 하나씩 붙는다.
안 D — 계약에 쓰인 Actor만 열로 그린다. 그 프로젝트의 결과 표 리스너가 지금 이렇게 그린다 — 엔드포인트의 계약 상수들을 훑어 나온 actor만 모아 열을 세운다(상태 행도 같은 방식으로 모은다). 얻는 것은 열이 딱 필요한 만큼이라는 점이다. 버린 이유는 원칙 10 위반이라는 것이다. S403_CREATE_STAFF_WORKING_CAREGIVER가 담당자를 쓰는 마지막 계약이면, 그것을 지우는 순간 담당자 열이 표에서 통째로 사라지고 회색조차 안 남는다 — 원칙 10이 행에 대해 막은 바로 그 실패가 열에서 다시 난다.
골랐다 — 안 E, 엔드포인트 enum의 상수마다 자기에게 해당하는 Actor 목록을 선언하고, 결과 표의 열은 그 목록으로 그린다.
private enum Endpoint {
CREATE("POST /api/v1/admin/mothers/{motherNo}/care-contracts", Actor.관리자, Actor.비로그인, Actor.담당자);
// ...
}
한 줄씩 보면, 괄호 안 첫 값이 주소이고, 그 뒤가 「이 엔드포인트 앞에서 응답이 갈리는 부르는 사람」의 목록이다. 결과 표는 이 목록의 순서대로 열을 세운다. 안 C가 못 지켜주는 것은 그 엔드포인트와 상관없는 열이 안 생긴다는 것이고, 안 D가 못 지켜주는 것은 열이 계약 상수가 아니라 선언에서 나온다는 것이다 — 담당자를 쓰는 마지막 계약을 지워도 목록에 Actor.담당자가 남아 있으므로 열은 남고 그 칸들이 회색이 된다. 원칙 10이 막으려던 문제는 그대로 막힌다.
목록 밖의 Actor를 쓴 계약 상수는 표에 그릴 열이 없어 조용히 빠진다. 그래서 계약 상수의 actor가 그 엔드포인트가 선언한 목록에 없으면 실패하는 검사를 둔다. 이 검사는 아직 어느 프로젝트에도 없다 — 하기로 한 약속이다. 그 프로젝트의 리스너도 아직 안 D 방식이라, 엔드포인트에 목록을 적고 리스너가 그 목록으로 열을 그리게 바꾸는 일은 그 프로젝트 몫이다.
대가는 둘이다. 엔드포인트마다 Actor 목록을 한 번 더 적어야 한다 — 계약 상수에 이미 적힌 부르는 사람을 엔드포인트에 다시 적는 셈이다. 그리고 목록에서 Actor를 빼면 열은 사라진다. 다만 그건 계약 상수를 지우다 따라 사라지는 것이 아니라 선언을 고친 것이라 diff에 그 줄이 보인다. 원칙 10이 막은 것은 「무관한 삭제에 흔적이 달려 있는 것」이지 「열을 일부러 빼는 것」이 아니다.
규칙대로 쓴 완성 예시
이 문제를 꺼낸 2026-10-20에는 규칙이 요구하는 네 값(엔드포인트 · 부르는 사람 · 상황 · 기대 응답)을 전부 갖춘 예시가 이 글에 하나도 없었다. 그 프로젝트의 돌봄 계약표에서 「계약 만들기」 엔드포인트만 남겨 줄였다. 그 프로젝트와 다른 곳이 넷이다. 그 프로젝트는 계약 enum을 공용 contract/ 패키지에 두지만, 여기서는 패키지 구조 표준 원칙 7대로 도메인의 테스트 패키지에 둔다. 그 프로젝트는 표가 한 패키지에 모여 있어 상수 이름에 도메인 접두사(CARE_CONTRACT_)를 붙이지만, 계약 enum이 이미 도메인마다 하나라 여기서는 원칙 9의 이름 규칙 S{상태코드}_{ENDPOINT}_{ACTOR}_{STATE} 그대로 뺀다. 400 행의 Violation 인자(원칙 5)는 그 프로젝트에 아직 없다. 그리고 그 프로젝트의 Endpoint는 주소 문자열만 든 클래스라, 위의 Actor 목록도 아직 없다.
// 돌봄 계약 도메인의 테스트 패키지
public enum CareContractState {
일하는_관리사,
없는_산모,
그만둔_관리사,
서비스_시간이_빔
}
public enum CareContractContract implements ContractTable {
S201_CREATE_ADMIN_WORKING_CAREGIVER(Endpoint.CREATE, Actor.관리자, CareContractState.일하는_관리사, 201),
S404_CREATE_ADMIN_MISSING_MOTHER(Endpoint.CREATE, Actor.관리자, CareContractState.없는_산모, 404),
S409_CREATE_ADMIN_RESIGNED_CAREGIVER(Endpoint.CREATE, Actor.관리자, CareContractState.그만둔_관리사, 409),
S400_CREATE_ADMIN_NO_SERVICE_HOURS(Endpoint.CREATE, Actor.관리자, CareContractState.서비스_시간이_빔, 400,
Violation.of("serviceHours", NotBlank.class)),
S401_CREATE_ANONYMOUS_WORKING_CAREGIVER(Endpoint.CREATE, Actor.비로그인, CareContractState.일하는_관리사, 401),
S403_CREATE_STAFF_WORKING_CAREGIVER(Endpoint.CREATE, Actor.담당자, CareContractState.일하는_관리사, 403);
private final Cell cell;
CareContractContract(Endpoint endpoint, Actor actor, CareContractState state, int expect) {
this.cell = Cell.of(endpoint.route, endpoint.actors, actor, state, expect);
}
// 400 행만 쓴다 — 어느 필드의 어느 제약을 어겼는지를 함께 받는다
CareContractContract(Endpoint endpoint, Actor actor, CareContractState state, int expect, Violation violation) {
this.cell = Cell.of(endpoint.route, endpoint.actors, actor, state, expect, violation);
}
@Override
public Cell cell() {
return cell;
}
/** 주소는 컨트롤러의 실제 라우트와 글자 그대로 맞아야 한다 — 라우트-계약 정합성 테스트가 본다. */
private enum Endpoint {
CREATE("POST /api/v1/admin/mothers/{motherNo}/care-contracts", Actor.관리자, Actor.비로그인, Actor.담당자);
private final String route;
private final List<Actor> actors; // 결과 표의 열. 이 순서대로 그린다
Endpoint(String route, Actor... actors) {
this.route = route;
this.actors = List.of(actors);
}
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Verify {
CareContractContract value();
}
}
위에서부터 따라가면, CareContractState는 이 표의 세로축이다 — 요청이 마주치는 상황 넷이고, 그중 없는_산모와 서비스_시간이_빔은 도메인의 상태가 아니다. CareContractContract의 상수 여섯이 칸 여섯이다. 괄호 안 첫 값 Endpoint.CREATE는 아래 Endpoint enum의 상수다. 주소와 그 엔드포인트의 Actor 목록(관리자 · 비로그인 · 담당자)을 거기 한 번만 적어 두고, 생성자가 둘을 꺼내 Cell에 함께 담는다 — 원칙 12의 Cell에 「엔드포인트가 선언한 Actor 목록」이 하나 더 붙은 것이다. Actor... actors는 값을 몇 개든 받는 매개변수이고, List.of(actors)가 그것을 바꿀 수 없는 목록으로 굳힌다. 원칙 9 · 12의 예시는 축 이야기를 하려고 주소 문자열을 바로 적었지만, 규칙대로 쓰면 이 엔드포인트 enum을 거친다. 둘째 값은 서비스 공용 Actor, 셋째는 이 도메인의 상황, 넷째는 기대 응답이다. 400 행만 다섯째 값 Violation을 더 적고, 그래서 생성자가 둘이다. 생성자의 state 매개변수가 CareContractState라서 다른 도메인의 상황을 적으면 컴파일이 막는다. 맨 아래 Verify는 테스트가 이 표의 칸을 가리킬 때 쓰는 애노테이션이다 — @Retention(RetentionPolicy.RUNTIME)은 실행 중에도 이 표시를 읽을 수 있게 남기라는 뜻이고, @Target(ElementType.METHOD)는 메서드에만 붙일 수 있다는 뜻이다.
칸 하나를 채우는 테스트는 이렇다.
@Test
@CareContractContract.Verify(CareContractContract.S400_CREATE_ADMIN_NO_SERVICE_HOURS)
void blankServiceHours() throws Exception {
Map<String, Object> body = contractBody(caregiverNo);
body.put("serviceHours", " ");
fixture.postContract(motherNo, body)
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[?(@.field == 'serviceHours')]").exists());
assertNoContracts(motherNo);
}
한 줄씩 보면, contractBody(...)는 정상 요청 본문을 만들고, body.put("serviceHours", " ")가 그중 서비스 시간 하나만 공백으로 바꾼다 — 원칙 5의 「한 행에 위반 하나, 나머지는 정상값」이다. .andExpect(status().isBadRequest())가 400을, 그다음 줄이 오류 목록에 serviceHours 칸이 있는지를 본다. 마지막 assertNoContracts(motherNo)가 계약이 실제로 안 생겼는지 확인한다 — 원칙 6의 「실패 칸에서는 상태가 안 바뀐 것까지 본다」다.
리스너는 이 enum을 받아 엔드포인트마다 업무 규칙 표를 그린다. 열은 Endpoint.CREATE가 선언한 세 Actor이고 그 순서 그대로다. 행은 상황 enum이다. Violation이 붙은 400 행은 여기 섞지 않는다(원칙 3).
| 관리자 | 비로그인 | 담당자 | |
|---|---|---|---|
| 일하는 관리사 | 201 | 401 | 403 |
| 없는 산모 | 404 | ||
| 그만둔 관리사 | 409 |
빈칸이 회색이다. 축 enum의 밑줄은 표에서 공백으로 그린다.
400 행은 같은 엔드포인트의 입력 형식 표로 따로 그린다. 행이 필드, 열이 위반 종류다.
@NotBlank |
|
|---|---|
serviceHours |
400 |
서비스_시간이_빔은 이 표의 serviceHours × @NotBlank 칸이 대신하므로 업무 규칙 표에는 행으로 세우지 않는다. 그 400 행을 지워도 흔적이 조용히 사라지지는 않는다 — DTO에 @NotBlank가 남아 있는 한 원칙 5의 게이트가 「행을 빠뜨렸다」로 빌드를 막는다. 입력 형식 표를 따로 그리는 리스너도 그 게이트처럼 아직 구현 전이다.
원칙 14. 계약 칸은 초록만으로 충족되지 않고, 운영 코드를 망가뜨려도 안 죽는 칸은 목록으로 뽑는다
원칙 6은 「코드를 일부러 망가뜨렸을 때 우는 테스트만 무언가를 지키고 있다」고 적었다. 그런데 실제로 망가뜨려 본 칸은 하나도 없었다.
그 프로젝트에서 계약표의 축을 문자열에서 enum 생성자로 갈아엎었을 때, 도메인 셋의 33칸이 전부 초록으로 남았다. 구현을 흔들었는데 안 깨졌다는 것은 리팩터링에 견딘다는 증거이고 진짜 성과다. 하지만 회귀(고친 뒤 예전에 되던 동작이 다시 깨지는 것)를 막는다는 증거는 한 건도 아니다. 칸이 초록인 이유는 둘 중 하나다 — 계약이 실제로 지켜지고 있거나, 그 칸이 애초에 빨개질 수 없게 쓰였거나. 지금 게이트는 둘을 못 가른다. Meszaros는 이 두 번째를 『xUnit Test Patterns』에서 Neverfail Test라 부르고, 테스트를 읽어서도 돌려서도 안 보이는 프로젝트 냄새로 분류했다.
둘을 가르는 길은 하나다 — 운영 코드를 일부러 망가뜨려 보고 그 칸의 테스트가 죽는지 본다. 이것이 뮤테이션 테스트다. Myers가 1979년에 오류를 일부러 심어 몇 개를 잡는지 보자고 한 것을 도구가 자동으로 해 준다.
정해야 했던 건 이거였다 — 그 결과를 어떤 형태로 내놓는가.
안 A — 뮤테이션으로 빌드를 막는 게이트를 하나 더 둔다. 얻는 것은 강제력이다. 버린 이유는 게이트가 둘이 되고 빌드가 느려지는데, 막을 기준으로 쓸 숫자가 아래 안 B의 점수뿐이고 그 점수는 흐려져 기준이 되지 못한다는 것이다.
안 B — 전체 뮤테이션 점수에 임계값을 건다. 「뮤턴트의 80%가 죽어야 한다」 같은 숫자다. 얻는 것은 숫자 하나로 추세를 본다는 점이다. 버린 이유는 둘이다. 계약과 무관한 코드가 점수에 섞여 들어와 뜻이 흐려진다. 그리고 400 · 403 칸은 컨트롤러를 망가뜨리면 당연히 죽어서 정보가 없다. 컨트롤러의 입력 검증이나 권한 검사를 한 줄만 망가뜨려도 그 요청에 바로 성공 응답(200 · 201)이 나와 칸이 빨개지기 때문이다. 당연히 죽는 칸이 점수를 끌어올리면 정작 안 죽는 칸이 그 아래 묻힌다.
골랐다 — 안 C, 칸 단위로 「망가뜨려도 안 죽는 칸」 목록을 뽑아 보고한다. 빌드는 막지 않는다. 안 B가 못 지켜주는 것은 신호가 칸에 붙는다는 것이다. 점수가 아니라 「S200_UPDATE_ADMIN_EXISTING은 무엇을 망가뜨려도 안 죽었다」가 나오므로, 그 칸의 테스트를 고치면 된다. 칸과 뮤턴트를 잇는 재료는 이미 반쯤 있다. 일부러 망가뜨린 코드 한 벌을 뮤턴트라 부르고, 그것을 자동으로 만들어 테스트를 돌려 보는 Java 도구가 PIT다. PIT는 뮤턴트마다 그것을 죽인 테스트 이름을 남기고, 테스트는 Verify로 칸을 가리킨다. 둘을 이으면 칸마다 그것이 죽인 뮤턴트가 나온다. 한 칸을 가리키는 테스트가 여럿이면 합쳐서 본다.
설정만 바꾸는 계약은 자연히 빠진다. 로그 경로나 볼륨 같은 설정 계약은 망가뜨릴 운영 코드가 없고, 애초에 「설정 — 배포 때 확인」으로 표시해 테스트를 짜지 않는다(계약 커버리지 게이트 표준 원칙 5).
게이트 표준의 「뮤테이션으로 우는지 확인한다」와는 다른 것이다. 그쪽은 게이트 자신을 망가뜨려 게이트가 우는지 보는 것이고, 이쪽은 운영 코드를 망가뜨려 계약 칸의 테스트가 우는지 보는 것이다.
대가는 보고라서 안 보면 그만이라는 것이다. 게이트 표준 원칙 3이 결과 표를 두고 「표는 사람이 보는 것이라 안 보면 그만」이라 적은 것과 같은 대가다. 그리고 아직 한 번도 돌리지 않았다. 도입 전에 그 프로젝트의 경쟁사 계약의 8칸(S200 다섯 · S201 둘 · S400 하나)에서 PIT를 한 번 돌려 목록이 어떤 모양으로 나오는지부터 확정한다. 그 시험 실행은 그 프로젝트 쪽 일이고, 결과가 나오기 전까지 이 원칙은 하기로 한 약속이지 재 본 사실이 아니다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
| 계약을 어떻게 적나 | 나열이 아니라 표 | 나열은 빠진 것을 못 보여준다 |
| 성공/실패를 축으로? | 아니다. 칸의 값이다 | 축으로 쓰면 답을 미리 알아야 표가 그려진다 |
| 표를 언제 나누나 | 축의 성격이 다를 때 | 축이 섞이면 빈칸의 뜻이 사라진다 |
| 무한한 입력은 | 동등 분할의 경계만 | 묶음 안쪽은 증명을 안 늘린다 |
| 형식 검증 400 행은 | 사람이 계약 enum에 Violation과 함께 쓰고, 게이트가 검증 어노테이션과 양방향 대조(게이트는 아직 구현 전) |
생성하면 어노테이션을 베낀 동어반복이고, 사람만 쓰면 빠져도 모른다 |
| 400 행의 조합은 | 만들지 않는다. 한 행에 위반 하나 | 행 하나가 어노테이션 하나를 가리켜야 대조된다 |
| 테스트는 무엇을 보나 | 상태 코드 + 결과 | 코드만 보면 무엇을 망가뜨려도 안 운다 |
| 색칠은 누가 | 생성기(TestExecutionListener). 노랑은 정적 스캔으로 센다(게이트 표준) |
손으로 칠하면 코드가 바뀌어도 남는다. 실행 결과로 세면 --tests 일부 실행에서 노랑이 부푼다 |
| 색이 몇 개 | 넷. 게이트 표준에서 파랑(테스트 있는데 이번에 안 돌림)이 갈라져 다섯 | 「계약 있는데 테스트 없음」이 보여야 한다 |
| 칸과 테스트를 잇는 법 | 계약 enum 안에 중첩한 Verify(enum 값) |
문자열 태그는 오타가 컴파일을 통과한다 |
| 계약은 어디 사나 | 저장소의 enum. 이슈는 변경분만 | 이슈는 변경 단위라 「현재 전체」에 못 답한다 |
| 계약 식별자는 | 의미론적 이름. 번호 없음 | 좌표는 축 이름 변경에 깨지고, 번호는 동시 추가가 부딪힌다 |
| 표의 축은 어디서 오나 | 계약 밖의 선언. 상태는 상황 enum 전체, 부르는 사람은 그 엔드포인트가 선언한 Actor 목록 |
상수에서 모으면 마지막 계약을 지울 때 축이 통째로 사라진다 |
| 지운 이유는 어디 적나 | 안 적는다. 커밋과 이슈가 답한다 | 두 곳에 살면 한쪽만 고쳐도 어긋난다 |
| 축을 어떤 그릇에 담나 | 계약 enum의 생성자 → Cell 값 객체, 계약 enum은 ContractTable 구현 |
애노테이션은 제네릭을 못 받아 기계까지 도메인마다 한 벌이 된다 |
| 부르는 사람 축은 몇 개 | 서비스에 Actor 하나 |
인증 구조라 도메인이 늘어도 안 는다 |
| 결과 표의 열은 (2026-10-21) | 엔드포인트 enum 상수가 선언한 Actor 목록. 목록 밖 actor를 쓴 계약은 검사가 막는다(아직 구현 전) |
Actor 전체면 스물두 열이 대부분 회색이고, 계약에서 모으면 마지막 계약을 지울 때 열이 사라진다 |
| 상태 축은 | 도메인마다 「상황」 enum | 「없는 번호」는 도메인 상태가 아니라 요청이 마주치는 상황이다 |
| 초록이면 충족인가 | 아니다. 망가뜨려도 안 죽는 칸을 목록으로 뽑는다(보고) | 점수 임계값은 무관한 코드와 당연히 죽는 칸에 흐려진다 |
이 표준을 정하기까지
시작은 「통과해도 안심이 안 된다」였다. AI가 테스트를 빨리 짜주는데 그 통과가 못 미덥다는 것이었고, 처음엔 그게 E2E 비용 문제인 줄 알았다.
첫 갈림길에서 불안이 둘로 갈렸다. 하나는 “이 테스트가 진짜 뭘 잡긴 하나”(단위 쪽, 순환)이고 하나는 “이거 말고 안 본 게 있을 것 같다”(E2E 쪽, 불완전)였다. 둘은 처방이 다르다 — 앞은 뮤테이션으로 깨지고 뒤는 안 깨진다. 섞어 다루면 둘 다 안 풀린다는 걸 거기서 알았다.
그리고 뒤쪽 불안은 옳다는 걸 인정하고 나서 길이 났다. 사용자 여정의 공간은 무한하고 검증한 것은 유한하니 “안 본 게 있다”는 언제나 참이다. 더 짜서 그 느낌을 없애려는 시도는 원리적으로 실패한다. 목표를 「불안을 없앤다」에서 「불안이 붙는 자리를 옮긴다」로 바꾼 것이 이 표준의 방향을 정했다.
형식을 바꾸자는 결론은 이 저장소가 이미 한 번 발견한 것이었다. 에이전트 운영 표준이 이슈 본문을 자유 산문이 아니라 고정된 절로 만들면서 이렇게 적었다 — “빠진 것은 빈칸으로 보이지 않는다.” 그때는 이슈의 절 구조에 적용했고, 이번엔 계약의 축에 한 번 더 적용한 것이다.
입력 공간에서 뜻밖의 수확이 있었다. 무한한 입력을 어떻게 세느냐가 막혔는데, 동등 분할로 경계만 찍으면 유한해지고 그 경계가 이미 @Min·@Max에 선언돼 있었다. Swagger 표준이 어노테이션을 단일 출처로 삼아둔 덕에 새 출처를 만들 필요도 없었다. 표의 행을 생성할 수 있다는 게 이 대화에서 제일 실용적인 발견이었다.
다만 그 생성이 순환적이라는 것도 같이 봤다. 어노테이션에서 뽑은 행으로 어노테이션을 검증하는 것이라 「100이 맞는 상한인가」는 답하지 못한다. 그래서 생성물을 증명이 아니라 목록으로 격하시켜 적었다 — 이걸 안 적으면 6개월 뒤에 “형식 검증은 자동으로 다 된다”고 오해한다. 이 생성은 끝내 만들어지지 않았고, 2026-10-21에 사람이 쓰고 기계가 대조하는 쪽으로 바뀌었다(아래).
마지막에 색이 둘에서 넷으로 늘었다. 처음엔 통과/실패만 칠하려 했는데, 그러면 「계약은 있는데 테스트가 없는 칸」이 그냥 빈칸으로 보인다. 안 짠 건지 안 봐도 되는 건지 구별이 안 되니 원래 문제로 되돌아간다. 노랑을 넣는 순간 이 표가 대시보드가 아니라 검사 도구가 됐다.
이 글을 쓸 때는 여기서 멈췄다. 계약 번호(C1~Cn)와 문자열 태그(@Tag)로 표를 다 채웠다고 봤다. 그런데 실제로 그 프로젝트에 API 하나를 붙여보니 둘 다 오래 못 갔다. @Tag("C3")는 오타를 잡아주지 않았고, 매번 이슈로 가서 다음 번호가 몇인지 확인하는 것도 번거로웠다.
해법은 「값을 문자열이 아니라 타입으로 만든다」는, 이 저장소가 이미 여러 번 써먹은 수였다. Contract 애노테이션의 값 타입을 String이 아니라 계약 enum으로 두자 두 문제가 한 번에 풀렸다 — 오타는 컴파일 에러가 됐고, “다음 번호가 몇인가”라는 질문 자체가 사라졌다. 번호가 없으니 물을 것도 없어진 것이다. 처음엔 “번호가 없으면 안 읽히지 않을까”가 걱정이었는데, S404_GET_ADMIN_MISSING처럼 상태 코드와 축을 이름에 그대로 담으니 오히려 C7보다 더 읽혔다.
그리고 원칙 7의 회색 정의가 저절로 좁아졌다. “계약 없는 테스트”라는 경우의 수를 처리해야 했는데, enum 강제 덕분에 그 경우가 애초에 존재할 수 없게 됐다. 회색은 이제 “그 칸에 계약이 없다”는 뜻 하나로 정리됐다. 규칙을 새로 정한 게 아니라, 설계를 바꾸니 정할 필요가 있던 규칙이 사라졌다.
마지막으로 계약을 지웠을 때의 표를 그려봤다. 원칙 9가 “지우면 그 상수를 지운다”로 끝나 있었는데, 지운 뒤가 어떻게 보이는지는 안 봤던 것이다. 그려보니 축이 @Cell 값에서 모아지는 탓에, 그 축 값을 쓰던 마지막 계약을 지우면 행이 통째로 사라졌다. 회색이 남을지 아닐지가 무관한 다른 계약에 달려 있었고, 그러면 회색이 신호이기를 그만둔다. 축을 계약 밖으로 빼자 그게 사라졌고, 덤으로 @Cell에 남아 있던 문자열 축 값까지 타입이 됐다 — 원칙 8이 @Tag에 적용한 판단을 같은 글 안에서 반쪽만 쓰고 있었던 셈이다. 다만 원칙 9의 예시와 뒤에 쓴 두 글의 예시는 한동안 문자열 축으로 남아 있었고, 2026-10-20에 enum으로 바로잡았다.
노랑의 계산은 이 글 밖에서 한 번 더 바뀌었다. 노랑으로 빌드를 막으려고 계약 커버리지 게이트 표준을 정하면서 리스너 코드를 읽어보니, 원칙 7의 그림대로 만든 노랑은 「테스트가 없다」가 아니라 「이번 실행에서 안 돌았다」였다. 원칙 7을 쓸 때는 색의 뜻만 정하고 그 뜻을 무엇으로 세는지는 따져보지 않았던 것이다. 게이트 표준이 정적 스캔으로 바꿨고, 원칙 7에 그 사실과 링크를 달았다.
두 프로젝트의 축을 나란히 놓자 그릇이 달랐다(2026-10-20). 예시를 enum으로 고치려고 farm-predict와 그 프로젝트의 실제 축을 대조했는데, 둘 다 축은 enum이었지만 farm-predict는 @Cell 애노테이션에, 그 프로젝트는 계약 enum의 생성자와 Cell 값 객체에 담고 있었다. 원칙 10이 「대가」로 적어 둔 「@Cell이 도메인마다 하나씩」을 그 프로젝트는 이미 피해 가 있었고, 그 덕에 리스너 · 게이트 · 라우트 검사가 계약표 28개를 한 벌로 돌고 있었다. 2026-10-21에 그 프로젝트 쪽을 표준으로 올렸다(원칙 12). 걱정했던 「다른 도메인 상태가 컴파일을 통과한다」는 생성자 매개변수 타입이 막고 있었다.
그 대조에서 규칙대로 쓴 완성 예시가 하나도 없다는 것도 드러났다. 그때는 원칙 9의 예시가 enum으로 고쳐지기 전이라, 원칙 9를 베끼면 문자열 축이, 원칙 10을 베끼면 endpoint 없는 모양이 나왔고, 「없는 번호」 같은 칸은 「state는 도메인 enum을 그대로」라는 규칙으로는 적을 수조차 없었다. 그 프로젝트가 이미 답을 써 두고 있었다 — Actor는 서비스에 하나, 상태는 도메인마다 상황으로. 그것을 원칙 13으로 옮기고 돌봄 계약표를 줄여 완성 예시로 실었다.
Actor를 하나로 두자 열이 스물둘이 됐다(2026-10-21). 원칙 10은 축 값을 계약이 없어도 전부 남기라 했고, 원칙 13은 Actor를 서비스에 하나로 뒀다. 각각은 맞았는데 둘을 같이 지키니 그 프로젝트에서는 모든 계약표에 열이 스물둘씩 생기고 그 대부분이 회색이었다 — 주니어 독자 검토가 두 원칙을 나란히 읽고 짚었다. Actor 전체를 열로 그리는 안은 그 스물두 열 때문에, 그 프로젝트의 리스너처럼 계약에 쓰인 Actor만 모으는 안은 마지막 계약을 지우면 열이 사라져 원칙 10을 어겨서 버렸다. 남은 것은 열도 선언에서 나오게 하되, 그 선언을 엔드포인트 단위로 좁히는 것이었다. 엔드포인트 enum의 상수가 자기 Actor 목록을 들고, 목록 밖 actor를 쓴 계약은 검사가 막기로 했다. 검사와 리스너 변경은 아직 안 했다.
입력 형식 계약은 생성기 없이 풀렸다(2026-10-21). 원칙 5가 「어노테이션에서 뽑는다」고 해 놓고 뽑는 도구를 만들지 않아 400 계약을 못 쓰고 있었다. 도구를 만들기 전에 「무엇을 뽑나 — 테스트 코드인가, 행 목록인가」에서 막혔는데, 조사하다 보니 이 표에는 선조가 있었다. Kaner의 2001년 『Lessons Learned in Software Testing』에 필드 × 표준 테스트로 짠 입력 필드 매트릭스가 있고, Myers의 1979년 『The Art of Software Testing』에 동등 분할 · 경계값 분석과 「조합은 천문학적이다」라는 경고가 있다. 셋이 받치는 자리가 달랐다 — Kaner의 표는 사람이 행을 채우는 안 B의 모양이고, Myers의 경계값 분석은 안 A가 자동으로 뽑으려던 바로 그 경계이며, Khorikov는 그 자동 생성이 운영 코드를 베낀 동어반복이 된다며 안 A를 반대하는 근거다. 답은 안 B에 맞대어 볼 상대를 붙이는 데 있었다. 사람이 행을 쓰고(Kaner), 기계는 행을 만들지 않고 어노테이션과 맞대어 보기만 한다. 그러자 @Pattern의 경계를 어떻게 자동으로 뽑느냐는 질문이 따로 풀 필요 없이 사라졌다 — 위반 예시를 사람이 적으니까. 남은 것은 게이트가 행과 어노테이션을 맞댈 표기였고, 같은 날 400 행만 Violation.of(필드, 제약 타입)을 하나 더 받게 정했다. 게이트 구현은 그다음 일이다.
마지막으로 초록이 무엇의 증거인지를 다시 물었다. 축을 통째로 갈아엎었는데 33칸이 전부 초록이었다. 그건 리팩터링에 견딘다는 증거였지 망가뜨리면 운다는 증거가 아니었다. 원칙 6이 「뮤테이션 관점」을 말해 놓고 실제로 망가뜨려 본 칸이 없었던 것이다. 게이트나 점수 임계값이 아니라 「안 죽는 칸」 목록으로 정했고(원칙 14), 그 목록의 모양은 그 프로젝트에서 한 번 돌려 본 뒤에 확정한다.
정리
- 계약은 나열이 아니라 표로 적는다. 나열은 빠진 것을 보여주지 못한다
- 다 채우는 것이 목표가 아니다.
−는 “안 본다”는 결정이고, 그 결정을 적을 자리가 이 표의 값이다 - 성공과 실패는 축이 아니라 칸의 값이다. 가르면 두 표 사이의 일관성을 아무도 안 본다
- 축의 성격이 다르면 표를 나눈다. 업무 규칙 · 입력 형식 · 외부 연동 · 동시성
- 무한한 입력은 동등 분할의 경계로만 센다. 묶음 안쪽은 증명을 안 늘린다
- 형식 검증의 400 행은 사람이
Violation.of(필드, 제약 타입)과 함께 쓰고, 게이트가 검증 어노테이션과 양방향으로 대조한다. 생성하면 동어반복이고, 사람만 쓰면 빠져도 모른다. 한 행에 위반 하나다 — 게이트는 아직 어느 프로젝트에도 없다 - 테스트는 상태 코드가 아니라 결과를 확인한다. 실패 칸에서는 상태가 안 바뀐 것까지 본다
- 색칠된 표는 생성물이다. 손으로 칠하면 코드가 바뀌어도 색이 남아 거짓말을 한다
- 색은 넷이고 노랑이 핵심이다. 계약은 있는데 테스트가 없는 칸이 기계로 드러나야 한다. 노랑은 실행 결과가 아니라 정적 스캔으로 센다 — 게이트 표준에서 바뀌었다
- 칸과 테스트는 계약 enum 안에 중첩한
Verify(enum 값)으로 잇는다. 문자열 태그는 오타가 컴파일을 통과한다 - 계약은 저장소의 enum이 소유한다. 이슈는 이번 변경분만 적는다 — 커밋과 코드의 관계와 같다
- 계약 식별자는 번호가 아니라 의미론적 이름이다. 좌표는 축 이름이 바뀌면 깨지고, 순번은 동시에 추가하면 부딪힌다
- 표의 축은 계약 밖의 선언에서 나온다. 상태는 상황 enum 전체, 부르는 사람은 그 엔드포인트가 선언한
Actor목록이다. 상수에서 모으면 마지막 계약을 지울 때 축이 통째로 사라져 회색조차 안 남는다 - 왜 지웠는지는 enum에 적지 않는다. 이슈 본문과 커밋이 이미 불변으로 기록한다
- 축은 계약 enum의 생성자가 받아
Cell에 담고, 계약 enum은ContractTable을 구현한다. 표를 그리고 세고 맞대는 기계가 도메인 수와 상관없이 한 벌이 된다 Actor는 서비스에 하나, 상태는 도메인마다 「상황」 enum이다. 부르는 사람은 인증 구조이고, 「없는 번호」는 도메인 상태가 아니다- 결과 표의 열은 엔드포인트 enum이 선언한
Actor목록으로 그린다.Actor전체면 스물두 열이 회색이고, 계약에서 모으면 열이 사라진다. 목록 밖actor를 막는 검사는 아직 구현 전이다 - 초록만으로 계약 칸이 충족되지 않는다. 망가뜨려도 안 죽는 칸을 목록으로 뽑아 보고한다 — 아직 한 번도 돌리지 않았다
자신만의 철학을 만들어가는 중입니다.
댓글남기기