API 테스트 계약표 표준이 계약을 저장소의 enum이 소유하게 만들었다. @Contract 애노테이션 덕에 없는 계약을 가리키는 테스트는 컴파일이 막고, TestExecutionListener가 계약과 테스트 실행 결과를 맞춰 색을 칠한다.
그런데 그 표준을 다 지켜도 안 잡히는 어긋남이 하나 남는다.
// CompetitorContract.java — 계약은 여전히 여기 있다
@Cell(endpoint = "DELETE /api/v1/admin/competitors/{competitorNo}", actor = "관리자", state = "있는 경쟁사", expect = 200)
S200_DELETE_ADMIN_EXISTING,
// CompetitorApiController.java — 그런데 이 메서드를 누가 지웠다
// @DeleteMapping("/{competitorNo}")
// public ResponseEntity<...> delete(...) { ... }
계약 enum은 그대로 있고, 계약을 가리키는 테스트도 그대로 통과한다(실제로는 컴파일도 안 될 테니 테스트를 같이 지웠을 것이다). 컨트롤러에서 라우트가 사라졌다는 사실 자체를 아무도 검사하지 않는다. 반대 방향도 마찬가지다 — 새 엔드포인트를 추가하고 계약을 깜빡해도 초록·빨강·노랑·회색 어느 색도 그걸 말해주지 않는다. 넷 다 “계약이 있다”는 걸 전제로 갈리는 색이기 때문이다.
먼저: 이미 정해져 있어 다시 고르지 않은 것
계약의 모양과 소유는 이미 끝났다. API 테스트 계약표 표준 원칙 9가 계약을 저장소의 {도메인}Contract enum이 소유하게 했고, 원칙 8이 @Cell(endpoint=...)로 라우트 문자열을 이미 계약 안에 박아뒀다. 이 글은 그 endpoint 문자열이 거짓말을 하지 않는지만 다룬다.
색칠 파이프라인도 안 건드린다. ContractReportListener는 “계약과 실행된 테스트”만 맞춰 본다. 이 글이 여는 것은 “계약과 실제 라우트”라는 별개의 축이라, 넷째 색을 새로 만들지 않고 빌드 성공/실패라는 다른 신호를 쓴다 — 아래 원칙 2에서 이유를 적는다.
원칙 1. 계약 표기 방식은 도메인마다 반복한다
이 검사가 성립하려면 먼저 계약이 실제로 기계가 읽을 수 있는 형태여야 한다. 그런데 지금 이걸 만족하는 도메인은 CompetitorContract 하나뿐이다. 나머지 도메인(auth, review, content 등)은 @Tag("C3") 스타일의 문자열 태그만 쓰고, 여기엔 애초에 비교할 라우트 문자열이 없다.
Contract 애노테이션의 Javadoc이 이 갈림길을 이미 적어뒀다.
/**
* <b>한계</b> — 자바 어노테이션은 제네릭을 받지 못해 값의 타입을 특정 enum으로
* 고정해야 한다. 그래서 계약표가 늘면 이 어노테이션도 표마다 하나씩 필요하다.
*/
public @interface Contract {
CompetitorContract value();
}
안 A — 도메인마다 @XxxContract 애노테이션과 enum, 리스너를 반복한다. 지금 Competitor가 하는 그대로를 도메인 수만큼 복제한다. 얻는 것은 API 테스트 계약표 표준의 핵심 가치 — 컴파일러가 계약-테스트 동기화를 강제한다는 것 — 을 그대로 지킨다는 점이다. 대가는 도메인마다 파일 세 벌(애노테이션·enum·리스너)이 늘어나는 보일러플레이트다.
안 B — 전부 @Tag 문자열로 통일한다. 이미 대부분의 도메인이 이 방식이라 새로 만들 것이 없다. 버린 이유는 이 표준이 막으려던 실패 자체가 돌아온다는 것이다 — 오타 난 태그가 빌드를 통과한다. API 테스트 계약표 표준 원칙 8이 정확히 이 이유로 문자열 태그를 버렸는데, 그 판단을 라우트 검사 하나 편해지자고 뒤집는 건 앞뒤가 안 맞는다.
안 C — 큰 enum 하나에 모든 도메인의 계약을 몰아넣는다. @Cell에 이미 endpoint가 있어 한 enum 안에서도 여러 엔드포인트를 표로 가르는 게 가능하니, 이론적으로는 도메인도 그렇게 가를 수 있다. 버린 이유는 파일 하나가 전체 API를 다 알게 된다는 것이다. 계약을 도메인 경계 없이 한곳에 두면, 그 도메인을 안 건드리는 작업도 이 파일을 스치게 되고, 리뷰 범위가 계약 enum 크기만큼 항상 넓어진다.
골랐다 — 안 A. Contract.java의 Javadoc이 이미 이 방향을 예견해뒀고, 컴파일 강제를 포기하는 안 B는 표준 자체를 접는 것과 같다. 보일러플레이트는 실재하는 대가이지만, 도메인이 실제로 늘어날 때 생성기(도메인 이름만 주면 세 파일을 찍어내는 스크립트)로 완화할 문제이지 지금 방식을 버릴 이유는 아니다.
원칙 2. 어긋나면 빌드를 막는다
라우트-계약 어긋남을 발견했을 때 리포트만 찍을지, 빌드를 실패시킬지를 정해야 했다.
안 A — 리포트만 찍고 통과시킨다. ContractReportListener가 이미 하는 것과 같은 모양이라 자연스럽다. 버린 이유는 노랑과 같은 운명을 겪는다는 것이다 — 계약표의 노랑(계약은 있는데 테스트가 없다)도 지금 CI를 안 막고 있고, 그게 열린 항목 장부의 #31로 아직 안 정해져 있다. 강제하지 않는 신호는 리포트를 보는 습관이 없으면 그냥 쌓인다.
골랐다 — 안 B, 빌드를 막는다. 지금은 Competitor 도메인 하나뿐이라 파급이 작다. 지금 막지 않으면 도메인이 늘어난 뒤에도 계속 경고만 하는 채로 굳어질 가능성이 높다 — 강제 규칙을 나중에 추가하는 것보다 처음부터 강제인 채로 도메인을 넓히는 편이 되돌리기 쉽다. 대가는 컨트롤러를 리팩터링할 때 계약도 같은 커밋에서 같이 고쳐야 한다는 것인데, 그건 애초에 이 표준이 요구하려는 바로 그 규율이다.
원칙 3. 라우트는 문서가 아니라 스프링이 실제로 등록한 핸들러에서 읽는다
라우트 목록을 어디서 가져올지도 갈렸다.
안 A — springdoc이 만드는 OpenAPI 문서(/v3/api-docs)를 파싱한다. 이미 의존성으로 붙어 있어 새로 추가할 것이 없다. 버린 이유는 문서화용 가공을 한 번 더 거친다는 것이다. springdoc은 사람이 읽을 문서를 만드는 게 목적이라 경로 표기나 그룹핑이 @Cell(endpoint=...) 문자열과 정확히 같다는 보장이 없고, 어긋나면 그게 진짜 라우트 문제인지 문서화 레이어의 가공 때문인지부터 가려야 한다.
골랐다 — 안 B, RequestMappingHandlerMapping을 스프링 컨텍스트에서 직접 조회한다. springdoc도 내부적으로 이 클래스를 감싼 것뿐이라, 가공 없는 원본에서 바로 비교하는 쪽이 어긋남의 원인을 하나로 좁힌다. getPatternValues()가 뽑는 경로 문자열이 컨트롤러의 @GetMapping/@PostMapping 애노테이션 값 그대로라, @Cell(endpoint = "GET /api/v1/admin/competitors/{competitorNo}")와 문자 그대로 비교할 수 있다.
@IntegrationTest
class RouteContractConsistencyTest {
@Autowired
private RequestMappingHandlerMapping requestMappingHandlerMapping;
@Test
void 컨트롤러의_라우트와_계약표의_엔드포인트가_일치한다() {
Set<String> actualRoutes = competitorRoutes();
Set<String> declaredContracts = declaredContractEndpoints();
Set<String> routesWithoutContract = new TreeSet<>(actualRoutes);
routesWithoutContract.removeAll(declaredContracts);
Set<String> contractsWithoutRoute = new TreeSet<>(declaredContracts);
contractsWithoutRoute.removeAll(actualRoutes);
assertThat(routesWithoutContract).as("컨트롤러에는 있는데 계약에 없는 라우트").isEmpty();
assertThat(contractsWithoutRoute).as("계약에는 있는데 컨트롤러에 없는 라우트").isEmpty();
}
}
도메인은 경로 접두사가 아니라 컨트롤러 타입으로 가른다. handlerMethod.getBeanType() == CompetitorApiController.class로 거른다. 접두사(/api/v1/admin/competitors)로 걸렀다면 다른 컨트롤러가 우연히 같은 접두사로 시작하는 경로를 추가해도 조용히 섞여 들어온다 — 타입은 그럴 여지가 없다.
대칭으로 양방향을 다 본다. 라우트에 없는 계약(지운 엔드포인트의 계약 잔재)과 계약에 없는 라우트(계약을 깜빡한 새 엔드포인트) 둘 다 같은 실패다. 한쪽만 보면 standards-pairing.rb가 이미 겪은 실수를 반복한다 — 「규칙은 있는데 글이 없다」만 잡고 반대는 놓쳤던 것과 같은 모양이다.
판단 기준 정리
| 질문 | 답 | 왜 |
|---|---|---|
| 계약 표기를 도메인마다 어떻게 넓히나 | 애노테이션·enum·리스너를 반복(안 A) | 문자열 통일(안 B)은 이 표준이 막으려던 실패를 되살린다 |
| 어긋나면 어떻게 하나 | 빌드를 막는다 | 지금 안 막으면 도메인이 늘어도 계속 경고만 하는 채로 굳는다 |
| 라우트를 어디서 읽나 | RequestMappingHandlerMapping 직접 조회 |
springdoc은 문서화 가공을 한 번 더 거쳐 원인이 흐려진다 |
| 도메인을 어떻게 가르나 | 컨트롤러 타입 | 경로 접두사는 다른 컨트롤러가 우연히 겹칠 수 있다 |
| 어느 방향을 검사하나 | 양방향 | 지운 라우트의 계약 잔재와 깜빡한 계약이 같은 실패다 |
이 표준을 정하기까지
시작은 블로그의 열린 항목 장부였다. #29 “계약 파일과 실제 라우트가 어긋나는 것을 아무도 검사하지 않는다”가 API 테스트 계약표 표준을 닫으면서 바로 뒤따라 열린 자리였고, 설명에 이미 “라우트를 스캔하는 방법이 프로젝트마다 달라 확인이 필요하다”고 적혀 있었다. 실측 없이는 안이 안 세워지는 종류의 항목이라 아리맘 프로젝트로 갔다.
가서 보니 라우트 스캔 자체는 이미 풀려 있었다. springdoc-openapi가 의존성으로 돌고 있어서, “어떻게 라우트를 뽑나”는 고민할 필요가 없었다. 진짜 열려 있던 건 다른 것이었다 — Competitor 도메인만 계약이 enum으로 기계가 읽을 수 있고, 나머지 도메인은 @Tag 문자열이라 비교할 라우트 문자열 자체가 없었다. 스캔 방법이 아니라 비교할 재료가 있는 도메인이 하나뿐이라는 게 진짜 제약이었다.
그래서 범위를 먼저 좁히자고 제안했다가 뒤집혔다. “Competitor 도메인에만 한정하고 나머지는 범위 밖으로 명시하자”는 제안에 “건드려도 좋으니 해보자”는 답이 왔고, 그 순간 쟁점이 “언제 검사를 만드나”에서 “계약 표기를 어떻게 도메인 수만큼 넓히나”로 바뀌었다. Contract.java의 Javadoc이 이 갈림길을 이미 예견해둔 걸 그때 다시 읽었다 — “계약표가 늘면 이 어노테이션도 표마다 하나씩 필요하다.”
라우트 소스를 고를 때는 “이미 있는 걸 써야 하지 않나”가 먼저 나왔다. springdoc이 이미 돌고 있으니 그 JSON을 파싱하는 게 자연스러워 보였다. 그런데 그건 사람이 읽을 문서를 만드는 레이어라는 걸 다시 짚었다 — springdoc 자체가 RequestMappingHandlerMapping을 감싼 것뿐이니, 감싸는 층을 하나 더 만들 이유가 없었다.
설계가 세 갈래로 정해진 뒤에는 실제로 만들어보는 것으로 넘어갔다. 이 항목 자체가 “실제로 API 하나에 만들어봐야 답이 나온다”는 전제를 달고 있었으니, 설계만으로 닫을 수 없었다. 아리맘에 RouteContractConsistencyTest를 실제로 작성했고 — 개발 환경에 Java와 Docker가 없어 한 번은 사람이 직접 설치해야 했다 — 돌려보니 getPatternValues()가 뽑는 문자열이 @Cell(endpoint=...)와 정확히 일치했다. 가정이 아니라 그린 빌드로 확인됐다.
정리
- 라우트-계약 어긋남은 계약-테스트 어긋남과 다른 축이다. 계약표의 네 가지 색 중 어느 것도 “라우트 자체가 사라졌다”를 말해주지 않는다
- 계약 표기를 도메인마다 반복한다. 문자열 태그로 통일하면 이 표준이 막으려던 실패(오타가 빌드를 통과한다)가 되살아난다
- 어긋나면 빌드를 막는다. 지금 안 막으면 도메인이 늘어난 뒤에도 경고만 하는 채로 굳는다
- 라우트는
RequestMappingHandlerMapping에서 직접 읽는다. springdoc은 문서화 가공을 한 번 더 거쳐 원인을 흐린다 - 도메인은 컨트롤러 타입으로 가른다. 경로 접두사는 다른 컨트롤러가 우연히 겹칠 여지가 있다
- 양방향을 다 본다. 지운 라우트의 계약 잔재와 깜빡한 새 라우트의 계약 누락은 같은 실패다
AI 코드 어시스턴트에 바로 적용하기
Claude Code — .claude/skills/route-contract-consistency-standard/SKILL.md
---
name: route-contract-consistency-standard
description: 컨트롤러의 실제 라우트와 계약 enum의 endpoint가 어긋나지 않는지 검사하는 규칙. 계약 enum을 새 도메인에 확장할 때, 라우트를 추가·삭제할 때, 계약-라우트 정합성 테스트를 짤 때 반드시 적용한다.
---
# 라우트-계약 정합성 표준
API 테스트 계약표 표준이 계약-테스트 어긋남은 잡지만, 계약-라우트 어긋남(컨트롤러에서 라우트를 지웠는데 계약이 남거나, 새 라우트에 계약을 안 붙인 경우)은 아무 신호도 안 준다. 이 표준이 그 자리를 채운다.
## 계약 표기를 도메인마다 반복한다
- 새 도메인에 계약을 기계가 읽을 수 있게 확장하려면 `{도메인}Contract` enum, `@{도메인}Contract` 애노테이션, 리스너 세 파일을 그 도메인용으로 만든다. Competitor 도메인의 `Contract`·`Cell`·`CompetitorContract`·`ContractReportListener`가 모양이다.
- `@Tag` 같은 문자열 태그로 통일하지 않는다. 오타가 컴파일을 통과해 계약-테스트 동기화가 다시 사람 손에 맡겨진다.
- 여러 도메인의 계약을 enum 하나에 몰아넣지 않는다. 도메인 경계 없는 파일이 되어 관련 없는 작업도 이 파일을 스치게 된다.
## 라우트-계약 정합성 테스트를 만든다
- 도메인마다 `RouteContractConsistencyTest` 하나를 만든다. `@IntegrationTest`를 써서 기존 통합 테스트와 같은 스프링 컨텍스트를 공유한다.
- `RequestMappingHandlerMapping`을 오토와이어해서 실제 라우트를 읽는다. springdoc의 OpenAPI 문서(`/v3/api-docs`)를 파싱하지 않는다 — 문서화 가공을 한 번 더 거쳐 어긋남의 원인이 흐려진다.
- 라우트를 도메인으로 거를 때는 경로 접두사가 아니라 **컨트롤러 타입**(`handlerMethod.getBeanType()`)으로 가른다. 접두사는 다른 컨트롤러가 우연히 겹칠 수 있다.
- 라우트 문자열은 `"METHOD /path"` 형태로 만들어 계약의 `@Cell(endpoint=...)` 문자열과 그대로 비교한다.
- **양방향을 다 검사한다.** 컨트롤러에는 있는데 계약에 없는 라우트, 계약에는 있는데 컨트롤러에 없는 라우트 둘 다 실패로 본다. 한쪽만 보지 않는다.
- 어긋나면 **테스트를 실패시켜 빌드를 막는다.** 리포트만 찍고 통과시키지 않는다.
```java
@IntegrationTest
class RouteContractConsistencyTest {
@Autowired
private RequestMappingHandlerMapping requestMappingHandlerMapping;
@Test
void 컨트롤러의_라우트와_계약표의_엔드포인트가_일치한다() {
Set<String> actualRoutes = competitorRoutes(); // 컨트롤러 타입으로 거른 실제 라우트
Set<String> declaredContracts = declaredContractEndpoints(); // @Cell(endpoint=) 전체
Set<String> routesWithoutContract = new TreeSet<>(actualRoutes);
routesWithoutContract.removeAll(declaredContracts);
Set<String> contractsWithoutRoute = new TreeSet<>(declaredContracts);
contractsWithoutRoute.removeAll(actualRoutes);
assertThat(routesWithoutContract).as("컨트롤러에는 있는데 계약에 없는 라우트").isEmpty();
assertThat(contractsWithoutRoute).as("계약에는 있는데 컨트롤러에 없는 라우트").isEmpty();
}
}
```
GitHub Copilot — .github/instructions/route-contract-consistency-standard.instructions.md
---
description: 컨트롤러의 실제 라우트와 계약 enum의 endpoint가 어긋나지 않는지 검사하는 규칙
applyTo: "**/*Contract*.java, **/*ContractTest.java, **/RouteContractConsistencyTest.java"
---
# 라우트-계약 정합성
- 계약-테스트 어긋남(API 테스트 계약표 표준)과 계약-라우트 어긋남은 다른 축이다. 라우트를 지우거나 추가할 때는 별도로 확인한다.
- 새 도메인에 계약을 확장할 때는 `{도메인}Contract` enum·`@{도메인}Contract` 애노테이션·리스너 세 파일을 그 도메인용으로 만든다. 문자열 태그로 통일하지 않는다.
- 여러 도메인의 계약을 enum 하나에 몰아넣지 않는다.
- 도메인마다 `RouteContractConsistencyTest`를 만든다. `@IntegrationTest`로 기존 컨텍스트를 공유한다.
- 라우트는 `RequestMappingHandlerMapping`에서 직접 읽는다. springdoc의 OpenAPI 문서를 파싱하지 않는다.
- 라우트를 도메인으로 거를 때는 컨트롤러 타입으로 가른다. 경로 접두사로 거르지 않는다.
- 라우트 문자열은 `"METHOD /path"`로 만들어 `@Cell(endpoint=...)`와 그대로 비교한다.
- 양방향을 다 검사한다 — 라우트에 없는 계약, 계약에 없는 라우트 둘 다 실패다.
- 어긋나면 테스트를 실패시켜 빌드를 막는다. 리포트만 찍고 통과시키지 않는다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기