제목, 태그, 카테고리로 검색

모든 글
약 12분 분량 프로젝트/타이미

Spring Boot 4 API Versioning과 Swagger UI의 충돌

목차

문제 상황

Spring Boot 4.0.1 + Spring Framework 7.0.2 환경에서 새롭게 도입된 API Versioning 기능을 사용하려고 했어요. 그런데 Swagger UI (/swagger-ui.html)에 접근하면 HTTP 400 에러가 발생했습니다.

InvalidApiVersionException: 400 BAD_REQUEST "Invalid API version: 'No path segment at index 1'."

Spring Boot 4에서 처음 도입된 기능이라 레퍼런스가 거의 없었어요. 구글링해도 대부분 Spring Boot 3 이하 버전 기준의 글들뿐이었습니다.


환경

  • Spring Boot 4.0.1
  • Spring Framework 7.0.2
  • springdoc-openapi-starter-webmvc-ui 3.0.1
  • Java 25

첫 번째 시도: SecurityConfig에 Swagger 경로 추가

처음에는 단순히 Spring Security에서 막는 건가 싶었어요. SecurityConfig에 Swagger 관련 경로를 permitAll로 추가해봤습니다.

security-config-swagger

결과: 실패

여전히 같은 에러가 발생했어요. 에러 메시지를 다시 보니 InvalidApiVersionException이라고 되어있더라고요. Security가 아니라 API Versioning 쪽 문제였어요.


두 번째 시도: WebConfig로 springdoc 패키지 제외

“Spring Boot 4 API versioning springdoc swagger”로 검색해봤어요. springdoc-openapi GitHub에서 이슈 #3163을 발견했습니다. 나랑 똑같은 문제를 겪고 있는 사람이 있었어요.

이슈에서 maintainer가 제안한 해결책은 WebMvcConfigureraddPathPrefix에서 springdoc을 제외하라는 거였어요.

WebConfig를 만들어서 springdoc 패키지를 API versioning에서 제외하려고 했습니다.

webconfig-exclude-springdoc

결과: 실패

addPathPrefix는 URL prefix만 관리하는 거지, API version parsing 자체를 제어하지 않았어요. 에러 메시지가 조금 바뀌긴 했지만 여전히 400 에러였습니다.

GitHub 이슈를 다시 읽어보니, addPathPrefix 외에 커스텀 ApiVersionParser도 언급되어 있었어요. Swagger UI 리소스(.html, .css, .js)에 대해서는 버전 파싱 자체를 건너뛰어야 한다고 했습니다.


세 번째 시도: 기존 ApiVersionConfig 수정

WebConfig파일을 삭제하고 다시 기존 파일을 수정하는 방향으로 갔어요.

api-version-config-original

여기서 configureApiVersioning으로 path segment versioning을 활성화하고 있었어요. 문제는 모든 요청에 대해 버전 파싱을 시도한다는 거예요. /swagger-ui.html 같은 요청도 path segment index 1에서 버전을 찾으려고 하니 당연히 실패합니다.


네 번째 시도: 커스텀 ApiVersionResolver로 Swagger 경로 제외 (블랙리스트 방식)

Spring Framework 공식 문서를 찾아봤어요. ApiVersionResolver라는 인터페이스가 있고, 요청에서 버전을 추출하는 역할을 한다고 되어있었습니다.

그리고 Dan Vega의 블로그에서 useVersionResolver()로 커스텀 resolver를 설정할 수 있다는 걸 알게 됐어요.

GitHub 이슈의 힌트와 조합해서, Swagger 경로에 대해서는 null을 반환하면 버전 파싱을 스킵할 수 있지 않을까 싶었습니다.

blacklist-version-resolver

결과: 부분 성공

Swagger UI 경로는 해결됐지만, 새로운 에러가 나왔어요.

InvalidApiVersionException: 400 BAD_REQUEST "Invalid API version: 'auth'."

블랙리스트 방식의 문제점이 드러났어요. /auth/login/google 같은 경로에서 auth를 버전으로 파싱하려다가 실패한 거예요. 제외해야 할 경로가 계속 늘어나면서 관리가 어려워졌습니다.


다섯 번째 시도: 화이트리스트 방식으로 전환

블랙리스트(제외할 경로 나열)보다 화이트리스트(API 경로만 버전 추출) 방식이 더 깔끔하다는 걸 깨달았어요.

/api/v{N}/... 패턴에 매칭되는 경로에서만 버전을 추출하고, 나머지는 모두 null을 반환하도록 변경했습니다.

whitelist-version-resolver

정규표현식 설명

^/api/v(\d+)/.+가 뭔지 궁금할 수 있어요.

부분의미
^문자열 시작
/api/v리터럴 문자 /api/v
(\d+)숫자 1개 이상 캡처 (그룹1)
/리터럴 /
.+아무 문자 1개 이상

매칭 예시:

경로매칭?캡처된 버전
/api/v1/usersO1
/api/v2/auth/loginO2
/swagger-ui.htmlX-
/api/v1X(/ 뒤에 뭔가 있어야 함)

결과: 실패

MissingApiVersionException: 400 BAD_REQUEST "API version is required."

null을 반환해도 DefaultApiVersionStrategy가 버전이 필수라고 판단해서 예외를 던지고 있었어요.


여섯 번째 시도: setVersionRequired(false) 추가

다시 Spring Framework 문서와 Piotr’s TechBlog를 찾아봤어요.

By default, a version is required when API versioning is enabled, and MissingApiVersionException is raised resulting in a 400 response if not present. You can make it optional…

아, 기본적으로 버전이 필수로 설정되어 있었어요. null을 반환해도 “버전 없음”으로 처리되니까 예외가 발생하는 거였습니다.

ApiVersionConfigurersetVersionRequired(false)를 추가해서 버전이 없는 요청도 허용하도록 했어요.

결과: Swagger UI 로드 성공, 하지만 API 목록이 비어있음

No operations defined in spec!

Swagger가 버전별 API를 제대로 인식하지 못하고 있었어요.


일곱 번째 시도: OpenApiConfig에 GroupedOpenApi 설정

springdoc이 @RequestMapping(version = "1.0") 어노테이션을 인식해서 버전별로 API를 그룹화하도록 설정해야 했어요.

grouped-openapi-config

결과: 성공!

드디어 Swagger UI에서 API v1 / API v2 그룹을 선택할 수 있게 됐어요.


여덟 번째 시도 (최종): 공식 예제 스타일 ApiVersionParser 적용

처음에는 addSupportedVersions("1", "2") 형태로 사용했는데, Spring Framework 공식 예제를 보니 시맨틱 버저닝("1.0", "2.0")을 사용하고 있었어요.

공식 스타일에 맞춰서 SimpleVersionParser를 추가했습니다. 이 파서는 v11.0, 11.0으로 변환해줘요.

simple-version-parser

컨트롤러의 버전 어노테이션도 "1.0" 형태로 변경:

controller-version-annotation

결과 : 성공


최종 코드

ApiVersionConfig.java

final-api-version-config

OpenApiConfig.java (버전별 그룹 설정)

final-openapi-config

컨트롤러 예시

controller-example


핵심 포인트 정리

설정역할
ApiPathVersionResolver/api/v{N}/... 패턴에서만 버전 추출 (화이트리스트 방식)
SimpleVersionParserv11.0 변환 (공식 예제 스타일)
setVersionRequired(false)Swagger 등 버전 없는 경로 허용
addPathPrefix + negate()springdoc 패키지는 prefix에서 제외
GroupedOpenApiSwagger에서 v1/v2 API 그룹 선택 가능

블랙리스트 vs 화이트리스트

이 문제를 해결하는 방법은 크게 두 가지가 있어요.

블랙리스트 방식 (springdoc 이슈에서 제안)

springdoc GitHub 이슈 #3163에서 제안된 방식이에요. 제외할 경로를 하나씩 나열합니다.

public class ApiVersionParser implements org.springframework.web.accept.ApiVersionParser {
@Override
public Comparable parseVersion(String version) {
if("api-docs".equals(version) || "swagger-ui-bundle.js".equals(version))
return null;
return version;
}
}

단점: Swagger, actuator, 에러 페이지 등 제외할 경로가 계속 늘어나요.

화이트리스트 방식 (내가 선택한 방식)

공식 문서나 다른 블로그에서 명시적으로 권장하는 방식은 아니지만, /api/v{N}/... 패턴만 버전 추출하는 방식이 더 깔끔해요.

private static final Pattern VERSION_PATTERN = Pattern.compile("^/api/v(\\d+)/.+");

장점: 새로운 경로가 추가돼도 수정할 필요가 없어요.

참고: Spring Framework 공식 문서에는 화이트리스트/블랙리스트 필터링에 대한 명시적인 가이드가 없다. 커스텀 ApiVersionResolver를 사용할 수 있다는 것만 언급되어 있다.


삽질하면서 배운 것들

  1. 블랙리스트보다 화이트리스트: Swagger, actuator, 에러 페이지 등 제외할 경로가 계속 늘어나요. /api/v{N}/... 패턴만 버전 추출하는 화이트리스트 방식이 훨씬 깔끔합니다.

  2. addPathPrefix만으로는 부족해요: URL prefix 설정과 API version parsing은 완전히 별개의 기능이에요. prefix를 제외해도 version parsing은 여전히 모든 요청에 적용돼요.

  3. 커스텀 ApiVersionResolver가 핵심: API 경로에서만 버전을 추출하고 나머지는 null을 반환해서 버전 파싱 자체를 우회해야 해요.

  4. setVersionRequired(false) 필수: 이게 제일 찾기 어려웠어요. null을 반환해도 기본 설정상 버전이 필수라서 MissingApiVersionException이 발생합니다.

  5. GroupedOpenApi로 버전별 API 문서 분리: addOpenApiMethodFilter에서 @RequestMapping(version = "X.0")을 체크해서 버전별로 API를 그룹화해요.

  6. 공식 예제 스타일 따르기: "1" 대신 "1.0" 시맨틱 버저닝을 사용하고, SimpleVersionParser로 변환하는 게 표준적인 방법이에요.

  7. Spring Boot 4 + springdoc은 아직 불안정해요: 이건 나만 겪는 문제가 아니라 알려진 호환성 이슈예요. springdoc 쪽에서 fix가 나올 수도 있지만, 당분간은 이런 workaround가 필요합니다.


참고 자료

The Problem

In a Spring Boot 4.0.1 + Spring Framework 7.0.2 environment, I tried to use the newly introduced API Versioning feature. However, accessing Swagger UI (/swagger-ui.html) resulted in an HTTP 400 error.

InvalidApiVersionException: 400 BAD_REQUEST "Invalid API version: 'No path segment at index 1'."

Since this was a feature first introduced in Spring Boot 4, there was almost no reference material available. Searching online only turned up articles written for Spring Boot 3 and earlier.


Environment

  • Spring Boot 4.0.1
  • Spring Framework 7.0.2
  • springdoc-openapi-starter-webmvc-ui 3.0.1
  • Java 25

First Attempt: Adding Swagger Paths to SecurityConfig

At first, I thought it might simply be blocked by Spring Security. I tried adding Swagger-related paths as permitAll in the SecurityConfig.

security-config-swagger

Result: Failed

The same error persisted. Looking at the error message again, it said InvalidApiVersionException. It was an API Versioning issue, not a Security issue.


Second Attempt: Excluding the springdoc Package via WebConfig

I searched for “Spring Boot 4 API versioning springdoc swagger.” I found issue #3163 on the springdoc-openapi GitHub. Someone was experiencing the exact same problem as me.

The maintainer’s suggested solution in the issue was to exclude springdoc from WebMvcConfigurer’s addPathPrefix.

I created a WebConfig to try excluding the springdoc package from API versioning.

webconfig-exclude-springdoc

Result: Failed

addPathPrefix only manages URL prefixes; it does not control API version parsing itself. The error message changed slightly but the 400 error persisted.

Re-reading the GitHub issue, I noticed that in addition to addPathPrefix, a custom ApiVersionParser was also mentioned. It said that version parsing itself should be skipped for Swagger UI resources (.html, .css, .js).


Third Attempt: Modifying the Existing ApiVersionConfig

I deleted the WebConfig file and went back to modifying the existing file.

api-version-config-original

Path segment versioning was being enabled via configureApiVersioning. The problem was that it attempted version parsing on every request. A request like /swagger-ui.html would naturally fail when trying to find a version at path segment index 1.


Fourth Attempt: Custom ApiVersionResolver to Exclude Swagger Paths (Blacklist Approach)

I looked at the Spring Framework official documentation. There was an interface called ApiVersionResolver responsible for extracting the version from a request.

From Dan Vega’s blog, I learned that a custom resolver could be configured using useVersionResolver().

Combining this with hints from the GitHub issue, I thought that returning null for Swagger paths might skip version parsing.

blacklist-version-resolver

Result: Partial Success

The Swagger UI path was resolved, but a new error appeared.

InvalidApiVersionException: 400 BAD_REQUEST "Invalid API version: 'auth'."

The problem with the blacklist approach became apparent. It tried to parse auth as a version from paths like /auth/login/google. The list of paths to exclude kept growing, making it unmanageable.


Fifth Attempt: Switching to a Whitelist Approach

I realized that a whitelist (extract version only from API paths) approach was cleaner than a blacklist (listing paths to exclude).

I changed it to extract the version only from paths matching the /api/v{N}/... pattern and return null for everything else.

whitelist-version-resolver

Regex Explanation

You might be wondering what ^/api/v(\d+)/.+ means.

PartMeaning
^Start of string
/api/vLiteral characters /api/v
(\d+)Capture one or more digits (group 1)
/Literal /
.+One or more of any character

Matching examples:

PathMatches?Captured Version
/api/v1/usersYes1
/api/v2/auth/loginYes2
/swagger-ui.htmlNo-
/api/v1No(needs something after /)

Result: Failed

MissingApiVersionException: 400 BAD_REQUEST "API version is required."

Even when returning null, the DefaultApiVersionStrategy determined that a version was required and threw an exception.


Sixth Attempt: Adding setVersionRequired(false)

I went back to the Spring Framework documentation and Piotr’s TechBlog.

By default, a version is required when API versioning is enabled, and MissingApiVersionException is raised resulting in a 400 response if not present. You can make it optional…

Ah, so the version is set to required by default. Even when returning null, it gets treated as “no version,” triggering the exception.

I added setVersionRequired(false) to the ApiVersionConfigurer to allow requests without a version.

Result: Swagger UI loaded successfully, but the API list was empty

No operations defined in spec!

Swagger was not properly recognizing versioned APIs.


Seventh Attempt: GroupedOpenApi Configuration in OpenApiConfig

I needed to configure springdoc to recognize @RequestMapping(version = "1.0") annotations and group APIs by version.

grouped-openapi-config

Result: Success!

Finally, I could select API v1 / API v2 groups in Swagger UI.


Eighth Attempt (Final): Applying the Official Example-Style ApiVersionParser

Initially I used addSupportedVersions("1", "2"), but looking at the Spring Framework official examples, they used semantic versioning ("1.0", "2.0").

I added a SimpleVersionParser to match the official style. This parser converts v1 to 1.0 and 1 to 1.0.

simple-version-parser

The version annotations in controllers were also changed to the "1.0" format:

controller-version-annotation

Result: Success


Final Code

ApiVersionConfig.java

final-api-version-config

OpenApiConfig.java (Version Group Configuration)

final-openapi-config

Controller Example

controller-example


Key Points Summary

SettingRole
ApiPathVersionResolverExtracts version only from /api/v{N}/... patterns (whitelist approach)
SimpleVersionParserConverts v1 to 1.0 (official example style)
setVersionRequired(false)Allows paths without a version, such as Swagger
addPathPrefix + negate()Excludes springdoc package from the prefix
GroupedOpenApiEnables v1/v2 API group selection in Swagger

Blacklist vs Whitelist

There are two main approaches to solving this problem.

Blacklist Approach (Suggested in the springdoc Issue)

This is the approach suggested in springdoc GitHub issue #3163. Paths to exclude are listed one by one.

public class ApiVersionParser implements org.springframework.web.accept.ApiVersionParser {
@Override
public Comparable parseVersion(String version) {
if("api-docs".equals(version) || "swagger-ui-bundle.js".equals(version))
return null;
return version;
}
}

Drawback: The list of paths to exclude (Swagger, actuator, error pages, etc.) keeps growing.

Whitelist Approach (My Choice)

While not explicitly recommended in official documentation or other blogs, the approach of extracting versions only from /api/v{N}/... patterns is cleaner.

private static final Pattern VERSION_PATTERN = Pattern.compile("^/api/v(\\d+)/.+");

Advantage: No modifications needed when new paths are added.

Note: The Spring Framework official documentation does not provide explicit guidance on whitelist/blacklist filtering. It only mentions that a custom ApiVersionResolver can be used.


Lessons Learned from the Troubleshooting

  1. Whitelist over blacklist: Paths to exclude (Swagger, actuator, error pages, etc.) keep growing. A whitelist approach that extracts versions only from /api/v{N}/... patterns is much cleaner.

  2. addPathPrefix alone is not enough: URL prefix configuration and API version parsing are completely separate features. Even if you exclude the prefix, version parsing still applies to all requests.

  3. Custom ApiVersionResolver is the key: You need to extract versions only from API paths and return null for everything else to bypass version parsing entirely.

  4. setVersionRequired(false) is essential: This was the hardest to find. Even when returning null, the default setting treats the version as required, causing MissingApiVersionException.

  5. Separate API documentation by version with GroupedOpenApi: Use addOpenApiMethodFilter to check @RequestMapping(version = "X.0") and group APIs by version.

  6. Follow the official example style: Use "1.0" semantic versioning instead of "1", and convert with SimpleVersionParser for the standard approach.

  7. Spring Boot 4 + springdoc is still unstable: This is not a problem unique to me; it is a known compatibility issue. A fix may come from the springdoc side, but for now this kind of workaround is necessary.


References

Author
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글