AI가 테스트를 빨리 짜준다. 그런데 통과를 봐도 안심이 안 된다.

이 글은 그 불안의 정체를 찾다가 나왔다. 결론부터 말하면 불안은 옳았고, 테스트를 더 짜는 것으로는 안 풀린다.

@Test
void 주문을_취소한다() {
    Order order = orderRepository.save(new Order(PENDING));

    mockMvc.perform(post("/orders/" + order.getId() + "/cancel"))
           .andExpect(status().isOk());
}

이 테스트는 통과한다. 그런데 서버가 아무것도 안 하고 200만 돌려줘도 통과한다. 그리고 배송 중인 주문을 취소하면 어떻게 되는지, 남의 주문을 취소하면 어떻게 되는지는 아무 말도 안 한다.

주문 상태 5개 × 요청자 3종류면 15칸인데 이 테스트는 1칸이다. 14칸이 비어 있었는데 그게 눈에 안 보였던 것뿐이다.


먼저: 이미 정해져 있어 다시 고르지 않은 것

계약을 쓰는 자리는 둘이다. 에이전트 운영 표준 원칙 8이 이슈 본문을 불변으로 정했으므로, 이슈는 이번 변경분만 적는다. “지금 이 API가 무엇을 보장하나”에는 이슈가 구조적으로 답 못 한다 — 변경 단위라서다. 그래서 저장소의 계약 코드가 이 API의 현재 계약 전체를 소유한다. 커밋과 코드의 관계와 같다. 이 글은 그 계약의 모양을 정하고, 저장소 쪽 모양은 원칙 9에 있다.

검증은 세 층으로 갈려 있다. 검증 표준이 형식은 Controller 어노테이션, 비즈니스 규칙은 Service, DB를 봐야 하는 것은 Domain으로 나눴다.

제약의 단일 출처는 어노테이션이다. Swagger 문서화 표준이 @Min·@Max 같은 검증 어노테이션을 제약의 유일한 출처로 삼았다. 이게 아래 원칙 5의 재료다.

생성물은 손으로 고치지 않는다. 이 저장소가 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 등

억지로 한 표에 우겨넣지 않는다. 축이 섞인 표는 빈칸이 무엇을 뜻하는지 알 수 없어져서, 표를 쓰는 이유가 사라진다.


원칙 4. 입력 공간은 동등 분할의 경계로만 센다

「입력 형식」 표는 다른 표와 성격이 다르다. 받을 수 있는 값이 사실상 무한이라 열거 자체가 안 된다.

그런데 동작이 갈리는 지점은 유한하다.

quantity:  ... -2  -1  │  0  │  1  ... 100  │  101  102 ...
                       ↑     ↑              ↑
                     여기서만 동작이 바뀐다

-1과 -2는 같은 취급을 받으므로 하나만 보면 된다. 같은 취급을 받는 값의 묶음이 동등 분할이고, 묶음의 경계에만 테스트를 둔다. 무한한 입력이 여섯 줄로 줄어든다.

필드 분류 값 기대
quantity null — 400
quantity 하한 미만 0 400
quantity 하한 1 200
quantity 상한 100 200
quantity 상한 초과 101 400
quantity 타입 오류 "abc" 400

묶음 안쪽을 더 찍지 않는다. 50을 테스트해도 1이 통과했다는 사실에 아무것도 더하지 않는다. 경계가 아닌 값을 늘리는 것은 테스트 수만 늘리고 증명은 안 늘린다.


원칙 5. 형식 검증의 행은 어노테이션에서 뽑고, 나머지는 사람이 쓴다

원칙 4의 표를 손으로 적을 필요가 없다. 경계가 이미 코드에 선언돼 있다.

public class CreateOrderRequest {
    @NotNull @Min(1) @Max(100)
    private Integer quantity;
}

@Min(1)이 0과 1을, @Max(100)이 100과 101을, @NotNull이 null 행을 만든다. 한 줄이 표의 여섯 행을 결정한다. Swagger 표준이 어노테이션을 제약의 단일 출처로 삼아뒀으므로 출처를 새로 만드는 것도 아니다.

다만 생성된 행은 순환적이다. @Max(100)에서 행을 뽑아 “101은 400”을 검증하면, 그건 Bean Validation이 동작한다는 것만 증명한다. 「100이 맞는 상한인가」는 증명되지 않는다.

그래서 생성이 주는 것은 증명이 아니라 목록이다.

생성이 하는 일   "이 여섯 가지를 물어봐야 한다"
사람이 할 일     "100 이 맞나" · "0 개 주문을 허용해야 하나"

빠뜨리지 않게 해주는 것이지 답을 주는 것이 아니다. 그래도 값이 있는 이유는 지금 문제가 「뭘 안 봤는지 모르겠다」이기 때문이다.

그리고 생성되는 건 세 층 중 한 층뿐이다.

층 예 행을 생성하나
형식 @Min(1), @NotNull 생성한다
비즈니스 규칙 “배송 후엔 취소 불가” 사람이 쓴다
DB 확인 “이미 가입된 이메일” 사람이 쓴다

표가 생성 구역과 수기 구역으로 갈리는 것이 흠이 아니다. 어디까지 기계가 봐줬는지가 눈에 보이는 것이 이득이다.


원칙 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);

마지막 줄이 없으면 아무것도 증명되지 않는다. cancel() 안을 통째로 지워도 앞의 두 줄은 통과한다.

이 규칙이 왜 필요한지는 뮤테이션 관점에서 분명해진다. 코드를 일부러 망가뜨렸을 때 우는 테스트만 무언가를 지키고 있는 것인데, 상태 코드만 보는 테스트는 무엇을 망가뜨려도 울지 않는다.

실패 칸도 같다. 409를 기대하는 칸이면 상태가 안 바뀌었는지까지 확인한다. 409를 주면서 뒤로는 취소해버리는 구현이 통과하면 안 된다.


원칙 7. 색칠된 표는 생성물이다

표가 있으면 “어디가 통과했나”를 색으로 보고 싶어진다. 그 색을 손으로 칠하면 안 된다.

손으로 칠하면 코드가 바뀌어도 색이 남는다. 그러면 그 표는 권위 있어 보이면서 거짓말을 하고, 없는 것보다 나쁘다. 이 저장소가 색인에 대해 이미 내린 판단과 같다 — 없으면 원본을 읽지만, 틀리면 틀린 것을 읽고 확신한다.

계약 enum (@Cell)                    ─┐
                                       ├──→ TestExecutionListener ──→ 색칠된 HTML
테스트 실행 결과 (@Contract로 연결)   ─┘

색은 넷이다. 둘로는 부족하다.

색 뜻
초록 계약 있고 테스트 통과
빨강 계약 있고 테스트 실패
노랑 계약 있는데 테스트가 없다
회색 그 칸(actor×state)에 계약 자체가 없다

“계약 없는 테스트”라는 다섯 번째 경우는 원칙 9의 설계로 애초에 못 만든다. 테스트가 계약 enum 값을 가리키게 강제되므로, 없는 계약을 가리키는 순간 컴파일이 막는다.

노랑이 이 표준의 핵심이다. “계약에는 적었는데 테스트가 없는 칸” — 그게 처음의 불안이었고, 이제 사람의 성실함이 아니라 기계가 잡는다. @Max(100)을 새로 붙이는 순간 노랑 행 두 개가 저절로 생기고, 테스트를 짤 때까지 노랑으로 남는다.

초록/빨강만 칠하면 노랑 칸은 그냥 비어 보인다. 안 짠 건지 안 봐도 되는 건지 구별이 안 되고, 그러면 원래 문제로 돌아간다.

다만 위 그림대로 만든 노랑은 이 뜻을 지키지 못했고, 계산 방식은 뒤에 바뀌었다. 리스너는 실행을 관찰하는 물건이라, 그림의 구조에서 노랑은 「테스트가 없다」가 아니라 「이번 실행에서 그 계약을 가리킨 테스트가 안 끝났다」였다. 그래서 --tests로 일부만 돌리면 안 돌린 계약이 전부 노랑으로 찍혀 부풀어 오른다. 계약 커버리지 게이트 표준 원칙 1이 노랑을 실행 결과가 아니라 클래스패스 정적 스캔(@Contract가 붙은 메서드가 있는가)으로 세게 바꿨고, 원칙 3이 그 때문에 빈 자리 — 테스트는 있는데 이번 실행에서 안 돈 계약 — 를 파랑으로 갈라 색이 다섯이 됐다. 노랑의 뜻은 그대로이고, 그 뜻대로 세는 방법만 바뀐 것이다.

출력은 HTML로 한다. 색칠에 라이브러리가 필요 없고, CI 아티팩트로 떨어뜨리면 브라우저로 바로 열리고, 생성물이라 커밋하지 않아도 된다. 엑셀로 뽑는 안은 apache-poi 같은 의존이 붙고 git diff가 안 되는데, 생성물이라 diff가 필요 없다는 점을 고려해도 HTML보다 나은 게 없었다.


원칙 8. 칸과 테스트는 타입 세이프 계약으로 잇는다

생성기가 “어느 테스트가 어느 칸인지”를 알아야 한다.

정해야 했던 건 이거였다 — 그 연결을 무엇으로 표현하는가.

안 A — 문자열 태그. @Tag("C3"). 얻는 것은 JUnit이 이미 태그를 XML로 뱉어준다는 점이다. 생성기가 새로 만들 게 없다. 버린 이유는 오타가 빌드를 통과한다는 것이다. @Tag("C03")이나 @Tag("c3")으로 적어도 컴파일은 된다. 계약과 테스트의 동기화를 사람이 오타 없이 적는 것에 의존하게 되는데, 그건 이 표준이 애초에 없애려던 바로 그 종류의 실패다.

안 B — 타입 세이프 애노테이션. 계약을 문자열이 아니라 enum으로 선언하고, 테스트는 그 enum 값을 가리키는 애노테이션을 붙인다.

@Test
@Contract(CompetitorContract.S409_CANCEL_ADMIN_SHIPPED)
void 관리자가_배송중_경쟁사를_취소하면_409_이고_상태가_안_바뀐다() { ... }

없는 상수를 적으면 컴파일이 막는다. 짝을 사람이 맞추는 게 아니라 컴파일러가 강제한다.

골랐다 — 안 B. 매핑 파일을 따로 두지 않는 이유는 안 A와 같다 — 짝을 손으로 적는 곳이 생기면 그 줄을 빼먹었을 때 다시 조용히 통과한다. standards-pairing.rb를 만들면서 매핑 테이블 안을 버린 것과 같은 판단이다. 다만 안 A도 매핑 파일은 안 뒀는데, 문자열은 그 자체가 오타에 열려 있어 강제력이 없었다. enum은 값 자체가 유일한 진짜 계약 목록이라 오타를 낼 수 있는 자리가 없다.

대가는 애노테이션이 특정 enum 타입에 묶인다는 것이다. 자바 애노테이션은 제네릭을 못 받아 @Contract의 값 타입을 하나의 enum으로 고정해야 한다. 계약 enum이 도메인마다 따로 있으니 @Contract 애노테이션도 도메인마다 하나씩 필요하다. 문자열로 두면 이 문제가 없지만 그러면 안 A로 돌아간다.


원칙 9. 계약은 저장소의 enum이 소유하고, 번호가 아니라 이름으로 가리킨다

「먼저」 절에서 이슈는 변경분만, 저장소는 현재 전체를 담기로 했다. 저장소 쪽 계약을 무엇으로 표현하는가가 남는다.

public enum CompetitorContract {

    @Cell(endpoint = "GET /api/v1/admin/competitors/{competitorNo}", actor = "관리자", state = "없는 번호", expect = 404)
    S404_GET_ADMIN_MISSING,

    @Cell(endpoint = "DELETE /api/v1/admin/competitors/{competitorNo}", actor = "관리자", state = "있는 경쟁사", expect = 200)
    S200_DELETE_ADMIN_EXISTING,
    // ...
}

enum 상수 하나가 칸 하나다. actor·state가 표의 두 축이고, expect가 칸에 찍히는 값이다. 상수 전체를 훑으면 표가 나온다 — 표를 따로 그릴 필요가 없다.

정해야 했던 건 상수 이름, 즉 계약을 무엇으로 식별하는가였다.

안 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를 지우면 배송중을 쓰는 상수가 하나도 안 남고, 그 열이 표에서 통째로 사라진다. 회색조차 안 뜬다. 배송중이라는 상태를 다룬 적이 있다는 사실 자체가 표에서 지워진다.

반대로 S403_CANCEL_ADMIN_SHIPPED가 있었다면 열이 살아남아 본인×배송중이 회색으로 남는다. 같은 삭제인데 흔적이 남을지가 무관한 다른 계약의 존재에 달려 있다.

정해야 했던 건 이거였다 — 표의 축을 어디서 얻는가.

안 A — 지금대로 계약 상수에서 모은다. 얻는 것은 따로 선언할 것이 없다는 점이다. 계약을 적으면 표가 저절로 나온다. 버린 이유는 회색이 신호이기를 그만둔다는 것이다. 회색이 없는 게 “다 덮었다”인지 “축이 사라졌다”인지 구별이 안 되면, 원칙 7이 색을 넷으로 늘린 이유가 무너진다.

골랐다 — 안 B, 축을 계약 밖의 enum으로 선언한다.

enum Actor { 본인, 타인, 관리자 }

@Cell(actor = Actor.본인, state = OrderStatus.SHIPPED, expect = 409)
S409_CANCEL_OWNER_SHIPPED,

축이 계약 밖에 있으므로 계약을 전부 지워도 행과 열은 남고, 그 자리가 회색이 된다. state는 대개 도메인 enum이 이미 있어 새로 만들 것도 없다.

덤으로 같은 표준 안의 비대칭 하나가 없어진다. 원칙 8이 @Tag("C3")을 버린 근거는 오타가 빌드를 통과한다는 것이었는데, @Cell(actor = "본인")은 아직 문자열이었다. "본인 "이나 "OWNER"로 적어도 컴파일이 통과하고, 그 순간 축 값이 하나 더 생겨 표가 조용히 갈라진다. 안 B는 그 자리도 컴파일러에게 넘긴다.

대가는 원칙 8이 이미 진 것과 같은 모양이다 — @Cell이 특정 도메인 enum에 묶여 도메인마다 하나씩 필요해진다. 자바 애노테이션은 제네릭을 못 받는다.


원칙 11. 계약을 없앤 이유는 enum이 아니라 이슈와 커밋이 기록한다

원칙 10이 지운 자리가 회색으로 남는 데까지 풀었다. 남은 건 그 칸이 왜 없어졌는지다.

안 A — enum에 폐기 사유를 남긴다. ErrorCode 표준 원칙 6이 쓴 모양이다.

@Deprecated  // 2026-08-18 배송중 취소를 허용하기로 함
@Cell(actor = Actor.본인, state = OrderStatus.SHIPPED, expect = 409)
S409_CANCEL_OWNER_SHIPPED,

얻는 것은 코드만 보고 이유를 안다는 점이다. 트래커로 나갈 필요가 없다.

버린 이유는 두 겹이다. 첫째, ErrorCode가 상수를 안 지운 근거 둘이 여기엔 다 없다. 하나는 번호 재사용 사고인데 원칙 9가 번호를 없앴고, 하나는 “9자리는 클라이언트가 분기하는 공개 계약”인데 계약 enum은 테스트만 본다. 근거가 사라졌는데 결론만 가져오면 모양만 베끼는 것이다. 둘째, enum이 「현재 계약 전체」라는 뜻이 깨진다. 죽은 상수가 표에 섞여 나오고, 생성기가 그걸 거르면 남긴 의미가 절반 사라진다.

골랐다 — 안 B, enum에는 아무것도 안 적는다. 계약을 없애는 것도 기능 변경이라 이슈가 돌고, 그 본문은 불변이다(에이전트 운영 표준 원칙 8). 커밋도 남는다. 안 A가 못 지켜주는 것은 같은 사실이 한 곳에만 사는 것이다 — enum에도 적으면 두 곳에 살고, 한쪽만 고치면 어긋난다.

되짚을 입구는 원칙 10이 이미 만들어뒀다. 회색 칸이 본인 × 배송중이라는 검색어를 그대로 준다. 축이 남지 않았다면 이 안은 성립하지 않았다.

대가는 코드만 봐서는 모른다는 것이다. 이유를 알려면 트래커나 git log로 나가야 한다. 그래도 택한 이유는 두 벌이 어긋나는 실패가 조용하기 때문이다 — 밖으로 나가는 건 번거롭지만 눈에 보인다.


판단 기준 정리

질문 답 왜
계약을 어떻게 적나 나열이 아니라 표 나열은 빠진 것을 못 보여준다
성공/실패를 축으로? 아니다. 칸의 값이다 축으로 쓰면 답을 미리 알아야 표가 그려진다
표를 언제 나누나 축의 성격이 다를 때 축이 섞이면 빈칸의 뜻이 사라진다
무한한 입력은 동등 분할의 경계만 묶음 안쪽은 증명을 안 늘린다
형식 검증 행은 어노테이션에서 생성 경계가 이미 선언돼 있다
생성된 행이 증명인가 아니다. 목록이다 어노테이션대로 도는지만 본다
테스트는 무엇을 보나 상태 코드 + 결과 코드만 보면 무엇을 망가뜨려도 안 운다
색칠은 누가 생성기(TestExecutionListener). 노랑은 정적 스캔으로 센다(게이트 표준) 손으로 칠하면 코드가 바뀌어도 남는다. 실행 결과로 세면 --tests 일부 실행에서 노랑이 부푼다
색이 몇 개 넷. 게이트 표준에서 파랑(테스트 있는데 이번에 안 돌림)이 갈라져 다섯 「계약 있는데 테스트 없음」이 보여야 한다
칸과 테스트를 잇는 법 @Contract(enum 값) 문자열 태그는 오타가 컴파일을 통과한다
계약은 어디 사나 저장소의 enum. 이슈는 변경분만 이슈는 변경 단위라 「현재 전체」에 못 답한다
계약 식별자는 의미론적 이름. 번호 없음 좌표는 축 이름 변경에 깨지고, 번호는 동시 추가가 부딪힌다
표의 축은 어디서 오나 계약 밖의 enum 선언 상수에서 모으면 마지막 계약을 지울 때 축이 통째로 사라진다
지운 이유는 어디 적나 안 적는다. 커밋과 이슈가 답한다 두 곳에 살면 한쪽만 고쳐도 어긋난다

이 표준을 정하기까지

시작은 「통과해도 안심이 안 된다」였다. AI가 테스트를 빨리 짜주는데 그 통과가 못 미덥다는 것이었고, 처음엔 그게 E2E 비용 문제인 줄 알았다.

첫 갈림길에서 불안이 둘로 갈렸다. 하나는 “이 테스트가 진짜 뭘 잡긴 하나”(단위 쪽, 순환)이고 하나는 “이거 말고 안 본 게 있을 것 같다”(E2E 쪽, 불완전)였다. 둘은 처방이 다르다 — 앞은 뮤테이션으로 깨지고 뒤는 안 깨진다. 섞어 다루면 둘 다 안 풀린다는 걸 거기서 알았다.

그리고 뒤쪽 불안은 옳다는 걸 인정하고 나서 길이 났다. 사용자 여정의 공간은 무한하고 검증한 것은 유한하니 “안 본 게 있다”는 언제나 참이다. 더 짜서 그 느낌을 없애려는 시도는 원리적으로 실패한다. 목표를 「불안을 없앤다」에서 「불안이 붙는 자리를 옮긴다」로 바꾼 것이 이 표준의 방향을 정했다.

형식을 바꾸자는 결론은 이 저장소가 이미 한 번 발견한 것이었다. 에이전트 운영 표준이 이슈 본문을 자유 산문이 아니라 고정된 절로 만들면서 이렇게 적었다 — “빠진 것은 빈칸으로 보이지 않는다.” 그때는 이슈의 절 구조에 적용했고, 이번엔 계약의 축에 한 번 더 적용한 것이다.

입력 공간에서 뜻밖의 수확이 있었다. 무한한 입력을 어떻게 세느냐가 막혔는데, 동등 분할로 경계만 찍으면 유한해지고 그 경계가 이미 @Min·@Max에 선언돼 있었다. Swagger 표준이 어노테이션을 단일 출처로 삼아둔 덕에 새 출처를 만들 필요도 없었다. 표의 행을 생성할 수 있다는 게 이 대화에서 제일 실용적인 발견이었다.

다만 그 생성이 순환적이라는 것도 같이 봤다. 어노테이션에서 뽑은 행으로 어노테이션을 검증하는 것이라 「100이 맞는 상한인가」는 답하지 못한다. 그래서 생성물을 증명이 아니라 목록으로 격하시켜 적었다 — 이걸 안 적으면 6개월 뒤에 “형식 검증은 자동으로 다 된다”고 오해한다.

마지막에 색이 둘에서 넷으로 늘었다. 처음엔 통과/실패만 칠하려 했는데, 그러면 「계약은 있는데 테스트가 없는 칸」이 그냥 빈칸으로 보인다. 안 짠 건지 안 봐도 되는 건지 구별이 안 되니 원래 문제로 되돌아간다. 노랑을 넣는 순간 이 표가 대시보드가 아니라 검사 도구가 됐다.

이 글을 쓸 때는 여기서 멈췄다. 계약 번호(C1~Cn)와 문자열 태그(@Tag)로 표를 다 채웠다고 봤다. 그런데 실제로 아리맘 프로젝트에 API 하나를 붙여보니 둘 다 오래 못 갔다. @Tag("C3")는 오타를 잡아주지 않았고, 매번 이슈로 가서 다음 번호가 몇인지 확인하는 것도 번거로웠다.

해법은 「값을 문자열이 아니라 타입으로 만든다」는, 이 저장소가 이미 여러 번 써먹은 수였다. Contract 애노테이션의 값 타입을 String이 아니라 계약 enum으로 두자 두 문제가 한 번에 풀렸다 — 오타는 컴파일 에러가 됐고, “다음 번호가 몇인가”라는 질문 자체가 사라졌다. 번호가 없으니 물을 것도 없어진 것이다. 처음엔 “번호가 없으면 안 읽히지 않을까”가 걱정이었는데, S404_GET_ADMIN_MISSING처럼 상태 코드와 축을 이름에 그대로 담으니 오히려 C7보다 더 읽혔다.

그리고 원칙 7의 회색 정의가 저절로 좁아졌다. “계약 없는 테스트”라는 경우의 수를 처리해야 했는데, enum 강제 덕분에 그 경우가 애초에 존재할 수 없게 됐다. 회색은 이제 “그 칸에 계약이 없다”는 뜻 하나로 정리됐다. 규칙을 새로 정한 게 아니라, 설계를 바꾸니 정할 필요가 있던 규칙이 사라졌다.

마지막으로 계약을 지웠을 때의 표를 그려봤다. 원칙 9가 “지우면 그 상수를 지운다”로 끝나 있었는데, 지운 뒤가 어떻게 보이는지는 안 봤던 것이다. 그려보니 축이 @Cell 값에서 모아지는 탓에, 그 축 값을 쓰던 마지막 계약을 지우면 행이 통째로 사라졌다. 회색이 남을지 아닐지가 무관한 다른 계약에 달려 있었고, 그러면 회색이 신호이기를 그만둔다. 축을 계약 밖으로 빼자 그게 사라졌고, 덤으로 @Cell에 남아 있던 문자열 축 값까지 타입이 됐다 — 원칙 8이 @Tag에 적용한 판단을 같은 글 안에서 반쪽만 쓰고 있었던 셈이다.

노랑의 계산은 이 글 밖에서 한 번 더 바뀌었다. 노랑으로 빌드를 막으려고 계약 커버리지 게이트 표준을 정하면서 리스너 코드를 읽어보니, 원칙 7의 그림대로 만든 노랑은 「테스트가 없다」가 아니라 「이번 실행에서 안 돌았다」였다. 원칙 7을 쓸 때는 색의 뜻만 정하고 그 뜻을 무엇으로 세는지는 따져보지 않았던 것이다. 게이트 표준이 정적 스캔으로 바꿨고, 원칙 7에 그 사실과 링크를 달았다.


정리

  • 계약은 나열이 아니라 표로 적는다. 나열은 빠진 것을 보여주지 못한다
  • 다 채우는 것이 목표가 아니다. −는 “안 본다”는 결정이고, 그 결정을 적을 자리가 이 표의 값이다
  • 성공과 실패는 축이 아니라 칸의 값이다. 가르면 두 표 사이의 일관성을 아무도 안 본다
  • 축의 성격이 다르면 표를 나눈다. 업무 규칙 · 입력 형식 · 외부 연동 · 동시성
  • 무한한 입력은 동등 분할의 경계로만 센다. 묶음 안쪽은 증명을 안 늘린다
  • 형식 검증의 행은 어노테이션에서 생성한다. 다만 그건 증명이 아니라 목록이다
  • 테스트는 상태 코드가 아니라 결과를 확인한다. 실패 칸에서는 상태가 안 바뀐 것까지 본다
  • 색칠된 표는 생성물이다. 손으로 칠하면 코드가 바뀌어도 색이 남아 거짓말을 한다
  • 색은 넷이고 노랑이 핵심이다. 계약은 있는데 테스트가 없는 칸이 기계로 드러나야 한다. 노랑은 실행 결과가 아니라 정적 스캔으로 센다 — 게이트 표준에서 바뀌었다
  • 칸과 테스트는 @Contract(enum 값)으로 잇는다. 문자열 태그는 오타가 컴파일을 통과한다
  • 계약은 저장소의 enum이 소유한다. 이슈는 이번 변경분만 적는다 — 커밋과 코드의 관계와 같다
  • 계약 식별자는 번호가 아니라 의미론적 이름이다. 좌표는 축 이름이 바뀌면 깨지고, 순번은 동시에 추가하면 부딪힌다
  • 표의 축은 계약 밖의 enum이 선언한다. 상수에서 모으면 마지막 계약을 지울 때 축이 통째로 사라져 회색조차 안 남는다
  • 왜 지웠는지는 enum에 적지 않는다. 이슈 본문과 커밋이 이미 불변으로 기록한다

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기