2023년 가을, 결제 API의 응답 스펙을 바꿔야 했습니다. 기존 앱은 amount 필드를 정수로 받고 있었는데, 신규 앱은 통화 코드와 함께 객체로 받아야 했죠. 똑같은 POST /payments인데 호출하는 클라이언트에 따라 다르게 동작해야 하는 상황. 그때 팀에서 가장 먼저 나온 말이 “그럼 /v2/payments 컨트롤러 새로 파면 되잖아요”였습니다. 저는 그 방식으로 1년을 버티다가 결국 다 갈아엎었습니다. 이 글은 그 삽질의 기록입니다.
증상: 컨트롤러가 두 배로 늘어나기 시작했다
당시 서비스는 일 평균 약 320만 건의 API 호출을 처리하고 있었고, 결제·주문·정산 도메인에 컨트롤러가 40개 남짓 있었습니다. 버전이 바뀔 때마다 OrderControllerV1, OrderControllerV2를 따로 만드는 관행이 있었는데, 문제는 v1과 v2가 공유하는 로직이 90%였다는 점입니다.
처음엔 견딜 만했습니다. 그런데 정산 정책이 분기마다 바뀌면서 같은 엔드포인트의 버전이 v4까지 늘어났고, 한 도메인에 거의 동일한 컨트롤러가 4개씩 쌓이기 시작했습니다. 버그를 하나 고치면 네 군데를 똑같이 고쳐야 했고, 한 번은 v2에만 패치가 누락돼 특정 가맹점 정산액이 틀어지는 사고도 났습니다. 운영 2년차에 컨트롤러 코드 라인이 처음의 2.7배가 되어 있더군요.
가설: URL 경로 분기 말고 다른 축이 필요하다
문제를 다시 정의했습니다. 제가 원한 건 “엔드포인트는 하나, 동작은 버전별로”였습니다. URL에 버전을 박는 방식(/v2/...)은 클라이언트 입장에선 명확하지만, 서버 코드 구조를 강제로 쪼개버리는 게 싫었습니다.
후보는 세 가지였습니다.
| 방식 | 장점 | 제가 본 단점 |
|---|---|---|
URL 경로 (/v2/orders) | 직관적, 캐싱·라우팅 쉬움 | 컨트롤러가 버전 수만큼 복제됨 |
쿼리 파라미터 (?version=2) | 구현 간단 | 캐시·로그에서 버전 추적 지저분함 |
헤더 기반 (X-API-Version: 2) | URL은 단일 유지, 메서드 단위 분기 가능 | 헤더 누락 시 기본값 처리 설계 필요 |
저는 헤더 기반으로 가되, 분기 로직을 컨트롤러 본문에 if (version == 2)로 흩뿌리는 건 피하고 싶었습니다. 그래서 메서드에 @ApiVersion(2)만 붙이면 스프링이 알아서 해당 버전 요청을 그 메서드로 보내주게 만들기로 했습니다.
첫 번째 삽질: 인터셉터로 풀려다 막힌 지점
처음엔 HandlerInterceptor에서 헤더를 읽어 분기하려 했습니다. 그런데 인터셉터는 이미 핸들러 매핑이 끝난 뒤에 동작합니다. 즉 어떤 메서드를 실행할지는 이미 정해진 상태라, “버전에 따라 다른 메서드를 고르는” 일 자체를 인터셉터로는 할 수 없었습니다. 반나절을 날리고 나서야 매핑 단계 자체에 개입해야 한다는 걸 깨달았습니다.
핵심은 RequestMappingHandlerMapping이었습니다. 스프링 MVC가 요청 URL과 메서드를 매칭할 때 사용하는 그 컴포넌트를 확장하면, “버전 헤더가 일치하는 메서드만 후보로 인정”하게 만들 수 있습니다.
원인 정리와 해결: @ApiVersion 어노테이션 만들기
먼저 어노테이션을 정의했습니다.
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiVersion {
int value();
}
다음으로 버전을 매칭 조건으로 다루는 RequestCondition을 만들었습니다. 이게 스프링에게 “이 메서드는 버전 N 요청에만 반응한다”고 알려주는 부분입니다.
public class ApiVersionCondition implements RequestCondition<ApiVersionCondition> {
private final int apiVersion;
public ApiVersionCondition(int apiVersion) {
this.apiVersion = apiVersion;
}
@Override
public ApiVersionCondition combine(ApiVersionCondition other) {
// 타입 레벨 + 메서드 레벨이 함께 있으면 메서드 쪽을 우선
return new ApiVersionCondition(other.apiVersion);
}
@Override
public ApiVersionCondition getMatchingCondition(HttpServletRequest request) {
int requested = resolveVersion(request); // 헤더 없으면 기본 1
return requested >= this.apiVersion ? this : null;
}
@Override
public int compareTo(ApiVersionCondition other, HttpServletRequest request) {
// 요청 버전 이하 중 가장 높은 버전이 선택되도록 내림차순
return other.apiVersion - this.apiVersion;
}
private int resolveVersion(HttpServletRequest request) {
String header = request.getHeader("X-API-Version");
return header == null ? 1 : Integer.parseInt(header);
}
}
여기서 getMatchingCondition의 requested >= apiVersion 조건이 제가 가장 신경 쓴 부분입니다. 클라이언트가 버전 3을 요청했는데 해당 엔드포인트에 v3 메서드가 없다면, v2나 v1으로 자연스럽게 폴백되도록 한 겁니다. compareTo에서 내림차순 정렬을 해줬기 때문에 “요청 버전 이하 중 가장 높은 버전”이 최종 선택됩니다.
마지막으로 이 조건을 핸들러 매핑에 등록합니다.
public class ApiVersionHandlerMapping extends RequestMappingHandlerMapping {
@Override
protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
return createCondition(handlerType.getAnnotation(ApiVersion.class));
}
@Override
protected RequestCondition<?> getCustomMethodCondition(Method method) {
return createCondition(method.getAnnotation(ApiVersion.class));
}
private ApiVersionCondition createCondition(ApiVersion apiVersion) {
return apiVersion == null ? null : new ApiVersionCondition(apiVersion.value());
}
}
그리고 설정에서 이 매핑을 기본 매핑보다 우선 등록합니다.
@Configuration
public class WebConfig implements WebMvcRegistrations {
@Override
public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
ApiVersionHandlerMapping mapping = new ApiVersionHandlerMapping();
mapping.setOrder(0);
return mapping;
}
}
이제 컨트롤러는 이렇게 됩니다. URL은 하나, 메서드만 버전별로 둡니다.
@RestController
@RequestMapping("/payments")
public class PaymentController {
@PostMapping
public PaymentResponse payV1(@RequestBody PaymentRequestV1 req) {
// 기존 정수 amount 처리
}
@PostMapping
@ApiVersion(2)
public PaymentResponseV2 payV2(@RequestBody PaymentRequestV2 req) {
// 통화 코드 포함 객체 처리
}
}
같은 POST /payments라도 X-API-Version: 2 헤더가 붙으면 payV2가, 헤더가 없으면 payV1이 실행됩니다. 컨트롤러를 통째로 복제할 필요가 사라졌습니다.
적용 후: 숫자로 본 변화
전환은 한 번에 하지 않았습니다. 가장 변경이 잦던 정산 도메인 8개 엔드포인트부터 옮기고, 2주간 운영 로그를 지켜본 뒤 나머지로 확대했습니다. 결과적으로 중복 컨트롤러 17개가 사라졌고, 버전 분기 관련 코드 라인이 약 40% 줄었습니다. 무엇보다 “v2에만 패치 누락” 같은 사고가 그 뒤로 한 번도 안 났습니다. 공유 로직이 한 메서드 체인 안에 있으니 고칠 곳이 한 군데뿐이니까요.
성능 걱정도 했는데, 매핑 단계에서 헤더 한 번 읽고 정수 비교하는 정도라 평균 응답시간에는 유의미한 변화가 없었습니다(p95 기준 측정 오차 범위 내). 매핑은 요청당 한 번이고 무거운 연산이 아니라서 그렇습니다.
다시 한다면 다르게 할 점
솔직히 후회되는 지점도 있습니다. 첫째, 헤더 누락 시 기본값을 1로 박은 것. 초기엔 편했지만, 나중에 “기본 버전을 올리고 싶다”는 요구가 나왔을 때 클라이언트별 영향 범위를 추적하기가 까다로웠습니다. 처음부터 기본 버전을 설정값으로 외부화했어야 했습니다.
둘째, 문서화. 같은 URL이 헤더에 따라 다르게 동작하는 구조는 강력하지만, 새로 합류한 동료에게는 “이 엔드포인트 v2 응답이 어디 정의돼 있냐”가 안 보입니다. Swagger에 버전을 노출하는 커스텀 작업을 처음부터 같이 했어야 운영이 편했을 겁니다.
그럼에도 다시 선택하라면 같은 방향으로 갑니다. 버전이 늘어날 미래가 보이는 API라면, 컨트롤러를 복제하는 대신 버전을 하나의 매칭 축으로 끌어올리는 편이 장기적으로 훨씬 덜 아픕니다. 어노테이션 하나로 “같은 API, 다른 버전 동작”을 표현할 수 있다는 건, 코드를 읽는 사람에게도 의도가 그대로 전달된다는 뜻이니까요.
스프링 MVC의 핸들러 매핑 확장 지점이 궁금하다면 Spring Framework 공식 문서의 RequestMappingHandlerMapping 항목을 직접 보시길 권합니다. 제가 글로 푼 것보다 확장 포인트의 의도가 더 정확히 적혀 있습니다.