최근에 Swagger를 붙여서 API 문서화를 할 일이 있었다. 예전에도 별 일 없이 한 번에 잘 되었기에 이번에도 바로 되겠거니 했지만.. http://localhost:8080/swagger-ui/index.html 페이지에 들어가자마자 500 상태코드와 함께 "Failed to load API definition" 이라는 에러를 만나게 되었다.
프로젝트 환경은 다음과 같다.
| 버전 | |
| Java | 21 |
| Spring Boot | 3.5.6 |
| Build | Gradle |
| org.springdoc:springdoc-openapi-starter-webmvc-ui | 2.8.9 |
에러 메시지는 다음과 같다. NoSuchMethodError 라는 에러 타입이 나오는데, 이 오류는 Spring Boot 3.x.x 환경에서 springdoc-openapi 라이브러리 버전이 Spring 프레임워크의 내부 메서드 구조와 맞지 않아 발생하는 오류라고 한다.
jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoSuchMethodError: 'void org.springframework.web.method.ControllerAdviceBean.<init>(java.lang.Object)'
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1104)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:110)
....
여러 블로그들을 헤매며 찾아본 해결법들은 다음과 같다.
현재 스프링 부트 3.5.6 버전 대와 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.x.x' 에서 의존성 버전이 불일치하기 때문에 호환되는 springdoc-api 버전으로 대체해야 한다.
springdoc-openapi 라이브러리가 실행되는 과정에서 ControllerAdviceBean 클래스의 특정 생성자
(ControllerAdviceBean)를 호출하려고 시도했으나, 현재 로드된 org.springframework.web 모듈에는 해당 생성자가
존재하지 않기 때문에 NoSuchMethodError 에러가 나오게 된다.
ControllerAdviceBean 이라고 하니 뭔가 이와 관련이 있을 듯 하여, ControllerAdvice 어노테이션이 붙은 클래스가 있는지 찾아보았다.
그러하다.. 전역 예외 처리하는 클래스에 @RestControllerAdvice 어노테이션이 붙어 있었다.
@RestControllerAdvice
public class GlobalExceptionHandler {
/** 도메인 예외 */
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException e) {
return ResponseEntity.status(e.getStatus())
.body(new ErrorResponse(e.getErrorCode()));
}
....
}
스웨거 500 에러의 자세한 원인을 확인해보니 @RestControllerAdvice가 Swagger가 사용하는 Spring MVC의 내부 컴포넌트(ControllerAdviceBean)를 로드하는 과정에서 의존성 관련 충돌을 유발할 수 있다고 한다.
처음에는 springdoc-openapi 의 버전을 Spring Boot 버전대와 맞게끔 조절하면 해결되겠거니 하면서 열심히 버전 가챠를 돌렸지만 헛수고였다. 그러다가 SpringDoc 설정이라는 것을 뒤늦게 발견했다.
핵심은 Swagger 문서 생성 과정에서 전역 예외 처리(@ControllerAdvice) 빈을 무시하도록 설정하는 것이었다.
위 SpringDoc 설정은 application.yml 파일에서 할 수 있다.
auto-response-codes를 false로 설정하였는데, 이를 통해 SpringDoc이 ControllerAdviceBean을 분석할 때 발생하는 충돌을 방지하게 된다. 좀 더 자세히 말하자면, 이 설정을 통해 Swagger가 기본적으로 응답 코드를 추론하는 기능을 비활성화하여@RestControllerAdvice 빈을 분석하는 과정을 건너뛰게 한다.
spring:
springdoc:
# ControllerAdvice 빈을 포함하여 문서화할지 여부를 설정
auto-response-codes: false
이외에도 Swagger 관련된 여러 SpringDoc 설정 옵션들이 있겠지만, 일단 이것만 설정하고 세부적인 건 나중에 추가적으로 설정하기로 했다.
위와 같은 해결책으로 Swagger 페이지가 아래와 같이 정상적으로 동작하게 되었다.

SwaggerConfig는 다음과 같이 구성하였다.
package com.delivery.justonebite.global.config;
/**
* Swagger/OpenAPI 구성
* - API info(제목/설명/버전) 정의
* - JWT Bearer 인증 스키마를 전체 API에 적용 (Swagger UI에서 Authorize 사용 가능)
* - @Profile("!prod")로 운영 서버에서는 문서 자동 비활성화
*
* swagger 페이지 : //http://localhost:8080/swagger-ui/index.html
*/
@OpenAPIDefinition(
info = @Info(
title = "AI 활용 배달 주문 관리 플랫폼 API 명세서",
description = "배달 주문 관리 프로젝트에 사용되는 API 명세서입니다.",
version = "v1"
)
)
@Configuration
public class SwaggerConfig {
private static final String SECURITY_SCHEME_NAME = "Authorization";
@Bean
@Profile("!prod") // profile 적용(운영에서 비활성화)
public OpenAPI openAPI() {
// Security 설정 : 모든 API에 Bearer 토큰 적용
SecurityRequirement securityRequirement = new SecurityRequirement().addList(SECURITY_SCHEME_NAME);
Components components = new Components()
.addSecuritySchemes(SECURITY_SCHEME_NAME,
new SecurityScheme()
.name(SECURITY_SCHEME_NAME)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
);
return new OpenAPI()
.addSecurityItem(securityRequirement)
.components(components);
}
}
컨트롤러 쪽의 Swagger 태그는 다음과 같이 설정했다. 좀 길다 싶지만, 각각의 메서드마다 예외 사항을 표시하기 위해 해당하는 모든 예외 코드들을 명시하였다.
/**
* @Tag: API를 그룹화하는 데 사용 (Controller 레벨)
* @Operation : 각 API 메서드에 대한 설명 추가하는 어노테이션 (설명, 요약)
* @Parameter: 메서드의 인자(경로 변수, 쿼리 파라미터)에 대한 설명을 정의
* @RequestBody: 요청 본문의 DTO 구조를 문서화 (@Schema와 함께 사용)
* @ApiResponse: HTTP 응답 코드(200, 201, 400 등)별 상세 설명과 반환될 DTO 구조를 정의
* @PreAuthorize("hasRole('CUSTOMER')") : Spring Security 인증 토큰 및 사용자 역할 검증
*/
@Operation(
summary = "주문 생성 요청",
description = "사용자(CUSTOMER)가 주문을 요청합니다. 해당 API 요청 권한은 CUSTOMER만 가능합니다.",
responses = {
@ApiResponse(
responseCode = "201",
description = "주문 생성에 성공하였습니다."
),
@ApiResponse(
responseCode = "404",
description = "주문할 가게 정보가 존재하지 않습니다.",
content = @Content(mediaType = "application/json")
),
@ApiResponse(
responseCode = "400",
description = "요청한 총 금액이 서버의 총 금액과 일치하지 않습니다.",
content = @Content(mediaType = "application/json")
)
}
)
@PreAuthorize("hasRole('CUSTOMER')")
@PostMapping
public ResponseEntity<Void> createOrder(@Valid @RequestBody CreateOrderRequest request,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
orderService.createOrder(request, userDetails.getUser());
return ResponseEntity.status(HttpStatus.CREATED).build();
}