문제의 시작

Swagger 문제는 보통 문서화 도구의 문제처럼 보이지만, 실제로는 framework version과 routing 전략, security 설정이 한꺼번에 드러나는 경우가 많다. 이 글은 SpringFox가 Spring Boot 특정 버전에서 parse error를 내던 상황을 따라가며, 단순한 dependency 추가 너머의 호환성 지점을 정리한 기록이다.

상세한 API documentation을 위해 swagger를 설정했다. 협업과 소통을 위해 pre-documentation에 쓸 수 있었지만 그때는 notion으로 API를 정리하기로 해서 기능 구현이 마무리된 지금 시점에 도입했다.

Spring Boot와 연동하는데, 버전에 따라 configuration에 차이가 있고 조금 까다롭다.

구현하면서 확인한 흐름

implementation 'io.springfox:springfox-boot-starter:3.0.0'
implementation 'io.springfox:springfox-swagger-ui:3.0.0'

먼저 dependency를 추가한다. Spring Boot에 Swagger/OpenAPI를 붙이는 방법은 여러 가지가 있고, 이 글에서는 Springfox를 사용했다.

결과를 어떻게 검증했는가

Swagger SpringFox Parse Error 이미지 01

이후 Docket bean을 만들어줘야 되는데, Baeldung에서 가져온 공식문서이다. 스프링 부트 버전에 따라 configuration 차이가 생기는 부분인데, 지금 프로젝트에 쓰고 있는 2.7.x 버전에서 다음과 같은 에러가 비슷하게 뜬다.

00:50:54.956 [main] ERROR SpringApplication[reportFailure:830] - Application run failed
org.springframework.context.ApplicationContextException: Failed to start bean 'documentationPluginsBootstrapper'; nested exception is java.lang.NullPointerException: Cannot invoke "org.springframework.web.servlet.mvc.condition.PatternsRequestCondition.getPatterns()" because "this.condition" is null
    at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:181)
    at org.springframework.context.support.DefaultLifecycleProcessor.access$200(DefaultLifecycleProcessor.java:54)
    at org.springframework.context.support.DefaultLifecycleProcessor$LifecycleGroup.start(DefaultLifecycleProcessor.java:356)
    at java.base/java.lang.Iterable.forEach(Iterable.java:75)
    at org.springframework.context.support.DefaultLifecycleProcessor.startBeans(DefaultLifecycleProcessor.java:155)
    at org.springframework.context.support.DefaultLifecycleProcessor.onRefresh(DefaultLifecycleProcessor.java:123)
    at org.springframework.context.support.AbstractApplicationContext.finishRefresh(AbstractApplicationContext.java:935)
    at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:586)
    at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:145)
    at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:740)
    at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:415)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:303)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1312)
    at org.springframework.boot.SpringApplication.run(SpringApplication.java:1301)
    at com.atlas.psp.AtlasRouterApplication.main(AtlasRouterApplication.java:53)
Caused by: java.lang.NullPointerException: Cannot invoke "org.springframework.web.servlet.mvc.condition.PatternsRequestCondition.getPatterns()" because "this.condition" is null
    at springfox.documentation.spring.web.WebMvcPatternsRequestConditionWrapper.getPatterns(WebMvcPatternsRequestConditionWrapper.java:56)
    at springfox.documentation.RequestHandler.sortedPaths(RequestHandler.java:113)
    at springfox.documentation.spi.service.contexts.Orderings.lambda$byPatternsCondition$3(Orderings.java:89)
    at java.base/java.util.Comparator.lambda$comparing$77a9974f$1(Comparator.java:473)
    at java.base/java.util.TimSort.countRunAndMakeAscending(TimSort.java:355)
    at java.base/java.util.TimSort.sort(TimSort.java:234)
    at java.base/java.util.Arrays.sort(Arrays.java:1307)
    at java.base/java.util.ArrayList.sort(ArrayList.java:1721)
    at java.base/java.util.stream.SortedOps$RefSortingSink.end(SortedOps.java:392)
    at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:258)
    at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:258)
    at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:258)
    at java.base/java.util.stream.Sink$ChainedReference.end(Sink.java:258)
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:510)
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
    at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
    at springfox.documentation.spring.web.plugins.WebMvcRequestHandlerProvider.requestHandlers(WebMvcRequestHandlerProvider.java:81)
    at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
    at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1625)
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
    at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
    at springfox.documentation.spring.web.plugins.AbstractDocumentationPluginsBootstrapper.withDefaults(AbstractDocumentationPluginsBootstrapper.java:107)
    at springfox.documentation.spring.web.plugins.AbstractDocumentationPluginsBootstrapper.buildContext(AbstractDocumentationPluginsBootstrapper.java:91)
    at springfox.documentation.spring.web.plugins.AbstractDocumentationPluginsBootstrapper.bootstrapDocumentationPlugins(AbstractDocumentationPluginsBootstrapper.java:82)
    at springfox.documentation.spring.web.plugins.DocumentationPluginsBootstrapper.start(DocumentationPluginsBootstrapper.java:100)
    at org.springframework.context.support.DefaultLifecycleProcessor.doStart(DefaultLifecycleProcessor.java:178)
    ... 14 common frames omitted

이게 스프링부트 2.6 출시되면서 PathPatternParser에 변경이 있었다고 하니, application.yaml에 이걸 추가해줘야한다.

spring.mvc.pathmatch.matching-strategy=ant_path_matcher

그리고 SpringFoxConfig에 다음과 같은 Bean도 추가해줘야한다.

@Bean
public WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping(WebEndpointsSupplier webEndpointsSupplier,
        ServletEndpointsSupplier servletEndpointsSupplier, ControllerEndpointsSupplier controllerEndpointsSupplier,
        EndpointMediaTypes endpointMediaTypes, CorsEndpointProperties corsProperties,
        WebEndpointProperties webEndpointProperties, Environment environment) {
    List<ExposableEndpoint<?>> allEndpoints = new ArrayList();
    Collection<ExposableWebEndpoint> webEndpoints = webEndpointsSupplier.getEndpoints();
    allEndpoints.addAll(webEndpoints);
    allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
    allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
    String basePath = webEndpointProperties.getBasePath();
    EndpointMapping endpointMapping = new EndpointMapping(basePath);
    boolean shouldRegisterLinksMapping = this.shouldRegisterLinksMapping(webEndpointProperties, environment,
            basePath);
    return new WebMvcEndpointHandlerMapping(endpointMapping, webEndpoints, endpointMediaTypes,
            corsProperties.toCorsConfiguration(), new EndpointLinksResolver(allEndpoints, basePath),
            shouldRegisterLinksMapping, null);
}

private boolean shouldRegisterLinksMapping(WebEndpointProperties webEndpointProperties, Environment environment,
        String basePath) {
    return webEndpointProperties.getDiscovery().isEnabled() && (StringUtils.hasText(basePath)
        || ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT));
}

다 이전에 언급한 PathPatternParser때문에 생기는 문제인데, Swagger가 api enpoint와 상세내용을 긁어오면서 문제가 생긴다. 대량의 URI path pattern이 있는 HTTP URL path에 사용하기 위해 디자인되었다고 한다.

이후 Spring Security가 설정되어 있다면 antMatchers에 Swagger 관련 endpoint들을 추가해준다.

.antMatchers("/auth/signup","/profile", "/health","/swagger-ui/**", "/v3/api-docs", "/v2/api-docs").permitAll()

그러면 v2/api-docs 로 가면

Swagger SpringFox Parse Error 이미지 02

이런식으로 뜨는데 swagger-ui 로 가야 저 JSON들이 시각화되어 나타난다.

Swagger SpringFox Parse Error 이미지 03

디버깅에서 남은 기준

이 문제의 핵심은 Swagger 자체가 아니라 version boundary를 의심하는 태도였다. library가 framework 내부 동작에 기대고 있다면, minor upgrade에서도 routing, path matching, security whitelist가 함께 흔들릴 수 있다. 이런 문제는 error log를 끝까지 읽고 release change를 확인해야 빨리 좁혀진다.

이런식으로 api endpoint들이 모두 수집되는데, 여기서 직접 request를 날려볼 수도 있다.