이 글은 Spring Boot 버전을 4로 올리면서 새롭게 추가된 네이티브 API 버저닝을 도입하고, 그 과정에서 겪은 Swagger 연동 문제 해결에 대한 글입니다.
기존처럼 @RequestMapping("/api/v1/...")에 버전을 하드코딩하던 방식을 버리고, 프레임워크가 제공하는 네이티브 버저닝 설정인 usePathSegment(1)을 적용해 보았습니다. 하지만 기대와 달리 서버를 띄우고 API를 호출하자마자 마주한 것은 404 Not Found 에러였습니다.
심지어 Swagger(Springdoc) 문서에서는 경로에 뜬금없이 /api/1/auth가 찍혀 있는가 하면, {version}을 직접 입력하라는 파라미터 폼까지 등장하며 대혼란이 펼쳐졌습니다. 이러한 시행착오를 겪으며 알게 된 Spring의 내부 동작 원리와, 상황에 맞는 두 가지 실전 버저닝 전략 및 Swagger 설정법을 기록합니다.
핵심 동작 원리: Spring은 버전을 어떻게 인식할까?
Spring 버저닝의 핵심은 물리적 경로(Path)와 논리적 버전(Version) 조건의 분리입니다.
클라이언트가 /api/v1/auth를 요청하면, Spring은 이를 통째로 매핑 경로로 사용하지 않고 다음 세 단계를 거칩니다.
- 추출: 지정된 규칙(PathSegment 또는 Resolver)에 따라 URL에서 v1을 빼냅니다.
- 파싱: 파서(Parser)를 통해 v1을 숫자 1로 정규화합니다.
- 매칭: 버전을 제외한 나머지 경로(/api/auth 또는 /api/{version}/auth 패턴)를 컨트롤러에서 찾고, 해당 컨트롤러의 @RequestMapping(version="1") 조건이 일치하는지 최종 검증합니다.
이 원리를 바탕으로 두 가지 시나리오를 살펴보겠습니다.
시나리오 1: 표준 Path Segment 방식
가장 기본적이고 권장되는 방식입니다.
URL의 특정 위치(인덱스)가 항상 버전을 나타낸다고 프레임워크에 선언합니다.
1. ApiConfig 설정 (WebMvcConfigurer)
컨트롤러 코드를 깔끔하게 유지하기 위해 configurePathMatch를 활용하여 모든 REST API에 /api/v{version} 접두사를 자동으로 붙여줍니다.
그리고 usePathSegment(1) 설정으로 해당 자리가 버전임을 명시합니다.
ApiVersionConfig.java
Java
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.accept.ApiVersionParser;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerTypePredicate;
import org.springframework.web.servlet.config.annotation.ApiVersionConfigurer;
import org.springframework.web.servlet.config.annotation.PathMatchConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
// 지원 버전 제한 및 기본값 처리
.addSupportedVersions("1", "2")
.setDefaultVersion("1")
.setVersionRequired(false)
// Spring 라우팅 엔진이 두 번째 세그먼트(index 1)를 버전으로 사용하도록 지정
// (예: /api/v1/auth -> 경로 '/api/auth' + 버전 '1'로 분리)
.usePathSegment(1)
// 외부에서 들어오는 버전 문자열은 내부 표준 형식으로 정규화
.setVersionParser(new SimpleVersionParser());
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
// 모든 RestController에 공통으로 /api/v{version} 접두사 적용 (Swagger API 제외)
configurer.addPathPrefix(
"/api/v{version:(?:1|2)}",
HandlerTypePredicate.forAnnotation(RestController.class)
.and(HandlerTypePredicate.forBasePackage("org.springdoc").negate())
);
}
/**
* 버전 문자열을 내부에서 사용하는 표준 형식으로 변환하는 Parser.
*/
private static class SimpleVersionParser implements ApiVersionParser<String> {
@Override
public String parseVersion(String version) {
if (!StringUtils.hasText(version)) {
return null;
}
if (version.startsWith("v") || version.startsWith("V")) {
version = version.substring(1);
}
int dotIndex = version.indexOf('.');
return dotIndex == -1 ? version : version.substring(0, dotIndex);
}
}
}
2. Controller 적용
경로에 버전을 하드코딩하지 않고 속성으로만 관리합니다.
ApiConfig에서 이미 /api/v{version}을 붙여주기 때문에 여기서는 순수 도메인 경로만 적습니다.
AuthController.java
Java
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/auth", version = "1")
public class AuthController {
// 최종 매핑: /api/v1/auth/login
@PostMapping("/login")
public String login() {
return "v1 login";
}
@PostMapping(value = "/login", version = "2")
public String loginV2() {
return "v2 login";
}
}
3. Swagger 설정
Springdoc이 기본적으로 /api/v{version} 경로를 인식하지만, 도메인별로 정확하게 버전을 격리하기 위해서는 컨트롤러에 명시된 @RequestMapping(version="...") 속성을 읽어 필터링해 주는 과정이 추가로 필요합니다.
SwaggerConfig.java
Java
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Objects;
@Configuration
public class SwaggerConfig {
@Bean
public GroupedOpenApi authV1Api() {
return GroupedOpenApi.builder()
.group("auth-v1")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "1"))
.build();
}
@Bean
public GroupedOpenApi authV2Api() {
return GroupedOpenApi.builder()
.group("auth-v2")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "2"))
.build();
}
private boolean isVersionMatch(Method method, String version) {
// 1. 메서드 레벨 탐색 (@PostMapping 등에 version 속성이 명시된 경우 우선 적용)
RequestMapping methodMapping = AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
if (methodMapping != null && !methodMapping.version().isEmpty()) {
if (Objects.equals(methodMapping.version(), version)) {
return Objects.equals(methodMapping.version(), version);
}
return false;
}
// 2. 클래스 레벨 탐색 (메서드에 명시되지 않은 경우, 컨트롤러 클래스의 설정 확인)
RequestMapping classMapping = AnnotatedElementUtils.findMergedAnnotation(method.getDeclaringClass(), RequestMapping.class);
if (classMapping != null && Objects.equals(classMapping.version(), version)) {
return Objects.equals(classMapping.version(), version);
}
// 3. 둘 다 없는 경우 매칭 실패
return false;
}
}
시나리오 2: Custom Resolver 방식 (예외 API가 있는 경우)
프로젝트 내에 /health나 Webhook처럼 버전 규칙을 따르지 않는 예외 API가 섞여 있다면 usePathSegment(1) 설정은 치명적인 IndexOutOfBounds 오류를 발생시킵니다. 경로가 짧아 1번 인덱스가 없기 때문입니다. 이럴 때는 직접 경로 길이를 검증하는 Resolver를 작성해야 합니다.
1. 커스텀 어노테이션
버전 검사에서 제외할 API를 표시하기 위한 어노테이션을 만듭니다.
NonVersionApi.java
Java
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface NonVersionApi {
// 이 어노테이션이 붙은 컨트롤러는 /api/{version} 접두사 규칙에서 제외됩니다.
}
2. ApiConfig 설정
usePathSegment 대신 커스텀 useVersionResolver를 적용합니다.
ApiVersionConfig.java
Java
import jakarta.annotation.Nullable;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.accept.ApiVersionResolver;
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.addSupportedVersions("1", "2")
.setDefaultVersion("1")
.setVersionRequired(false)
// 경로의 구조를 확인한 뒤에만 버전을 파싱하도록 유연한 리졸버 사용
.useVersionResolver(new ApiPathVersionResolver())
.setVersionParser(new SimpleVersionParser());
}
@Override
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.addPathPrefix(
"/api/v{version:(?:1|2)}",
HandlerTypePredicate.forAnnotation(RestController.class)
.and(HandlerTypePredicate.forBasePackage("org.springdoc").negate())
.and(HandlerTypePredicate.forAnnotation(NonVersionApi.class).negate()) // 예외 API 제외
);
}
/**
* 경로의 구조를 확인하여 버전을 파싱하는 유연한 리졸버
*/
private static class ApiPathVersionResolver implements ApiVersionResolver {
@Override
public @Nullable String resolveVersion(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null || !uri.startsWith("/api/v")) {
return null; // 버전 규칙을 따르지 않는 API는 무시
}
int start = 6; // "/api/v" 이후 시작 지점
int end = uri.indexOf('/', start);
if (end != -1) {
return uri.substring(start, end);
}
return null;
}
}
// ... (생략)
}
3. Controller 적용
버전이 없는 API를 추가 작성합니다.
HealthController.java
Java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@NonVersionApi // 커스텀 어노테이션
@RestController
public class HealthController {
@GetMapping("/health") // 버전 검사 없이 통과
public String health() {
return "OK";
}
}
4. Swagger 설정 (고도화)
커스텀 ApiVersionResolver를 사용하게 되면 Swagger URL에 /api/v{version}이 표시됩니다.
기본 usePathSegment()를 사용할 때는 Springdoc(OpenAPI)이 내부적으로 버전 세그먼트를 인지하여 경로를 자동 완성해 주지만, 커스텀 ApiVersionResolver를 도입하고 addPathPrefix로 물리적인 /api/v{version} 경로를 등록하게 되면, Springdoc은 이를 단순한 경로 변수(Path Variable)로 취급하여 문서에 날것 그대로 노출합니다.
이 경우에는 리플렉션을 활용한 필터링과 함께, 경로 변수를 실제 버전 문자열로 치환해 주는 OpenApiCustomizer 작업이 필요합니다.
SwaggerConfig.java
Java
import io.swagger.v3.oas.models.Paths;
import org.springdoc.core.customizers.OpenApiCustomizer;
@Configuration
public class SwaggerConfig {
@Bean
public GroupedOpenApi v1Api() {
return GroupedOpenApi.builder()
.group("기타")
.pathsToMatch("/health")
.addOpenApiCustomizer(openApiCustomizer(null))
.build();
}
@Bean
public GroupedOpenApi authV1Api() {
return GroupedOpenApi.builder()
.group("auth-v1")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "1"))
.addOpenApiCustomizer(openApiCustomizer("1"))
.build();
}
@Bean
public GroupedOpenApi authV2Api() {
return GroupedOpenApi.builder()
.group("auth-v2")
.pathsToMatch("/api/v*/auth/**")
.addOpenApiMethodFilter(method -> isVersionMatch(method, "2"))
.addOpenApiCustomizer(openApiCustomizer("2"))
.build();
}
private OpenApiCustomizer openApiCustomizer(@Nullable String version) {
return openApi -> {
if (openApi.getPaths() == null) {
return;
}
if (version != null) {
Paths newPaths = new Paths();
openApi.getPaths().forEach((path, pathItem) -> {
String newPath = path.replaceAll("/api/(v)?\\{version[^}]*}", "/api/v" + version);
newPaths.addPathItem(newPath, pathItem);
});
openApi.setPaths(newPaths);
}
};
}
// (참고: isVersionMatch 메서드는 시나리오 1과 동일하게 작성합니다.)
}
마무리
Spring의 새로운 버저닝 메커니즘은 단순한 문자열 치환이 아닙니다. 프레임워크가 물리적 URL(/{version})을 가상의 논리적 경로와 버전 정보로 철저하게 분해하고, 이를 다시 컨트롤러의 매핑 조건과 맞춰보는 고도의 라우팅 엔진입니다.
초반 설정에는 다소 시행착오가 따를 수 있지만, 이 원리를 이해하고 나면 컨트롤러 코드를 훨씬 깨끗하게 유지하면서도 견고한 버전 관리가 가능해집니다. 이 글이 Spring Boot 환경에서 API 버저닝을 도입하려는 분들께 작은 이정표가 되기를 바랍니다.
참고 자료
Spring Framework 7.0 Release Notes (API 버저닝 관련)

