Swagger(feat. Springdoc OpenAPI)

공병주(Chris)·2023년 2월 9일
2
post-thumbnail

2023 글로벌미디어학부 졸업 작품 프로젝트 Dandi 에서 api 문서를 위해 Swagger를 사용하기로 결정했습니다.

Swagger와 Restdocs

보통 Swagger와 많이 비교되는 것이 Restdocs입니다. 과거 우테코 프로젝트에서는 Restdocs를 사용했습니다.

Swagger 장단점

장점

  • Swagger로 생성된 문서에는 api call을 할 수 있는 기능이 존재
  • 테스트를 작성하지 않고 어노테이션으로 문서를 간단하게 생성 가능

단점

  • 프로덕션 코드(controller와 dto)에 문서화를 위한 어노테이션들이 작성해야함

Restdocs 장단점

장점

  • 프로덕션 코드에 문서화와 관련된 코드를 작성하지 않아도 됨
  • 테스트 코드가 통과해야 문서를 작성할 수 있기 때문에 테스트를 강제하는 효과

단점

  • Restdocs 문서를 생성하기 위한 테스트 코드를 꾸준한 관리 필요
  • 반드시, 테스트 코드를 통해서만 문서 생성 가능
    테스트를 강제할 수 있는 것도 장점이지만, 만약 Api Call을 통한 인수테스트를 진행하는 상황에서 Controller Layer Test 로 Restdocs 생성을 한다면 중복되는 테스트가 존재

Swagger 채택

이번 프로젝트는 아래와 같은 이유로 Swagger를 채택했습니다.

  1. 문서 상에 Api Call을 할 수 있다. 이런 이유로, iOS 개발자가 개발할 때 편리한 Swagger를 원한다. 또한, 나도 간단한 Api Call QA에서 Swagger 문서를 사용할 수 있을 것이다.
  2. 이전 프로젝트에서 Restdocs를 사용해보았으니 Swagger를 경험해보고 싶다.
  3. 이번 프로젝트에서 Api Call을 통한 인수테스트를 진행하는데, Restdocs 생성을 위한 Controller Test와 중복되는 테스트가 많이 존재한다.

Springdoc-openapi vs Springfox

Springdoc-openapi과 Springfox는 Swagger를 통한 API 문서 구성을 도와주는 library입니다.

따라서, 둘 중에 하나를 선택해야 했기 때문에 둘의 차이를 알아보았습니다.

https://springdoc.org/#migrating-from-springfox

Springdoc의 공식문서 Differentiation to Springfox project 에 아래와 같은 내용이 있습니다.

  • OAS 3 was released in July 2017, and there was no release of springfox to support OAS 3. springfox covers for the moment only swagger 2 integration with Spring Boot. The latest release date is June 2018. So, in terms of maintenance there is a big lack of support lately.
  • We decided to move forward and share the library that we already used on our internal projects, with the community.
  • The biggest difference with springfox, is that we integrate new features not covered by springfox:
  • The integration between Spring Boot and OpenAPI 3 standard.
  • We rely on on swagger-annotations and swagger-ui only official libraries.
  • We support new features on Spring 5, like spring-webflux with annotated and functional style.
  • We do our best to answer all the questions and address all issues or enhancement requests

Springdoc 문서에 아래와 같이 Springfox와의 차이점을 설명합니다.

https://stackoverflow.com/questions/72479827/are-there-any-advantages-of-using-migrating-to-springdoc-openapi-from-springfox

작성일 기준 8일 전을 기준으로, Springfox보다 Springdoc을 추천하는 근거들이 있습니다.

Springdoc이 Springfox보다 좋은 점은 꾸준히 관리되고 업데이트 된다는 점입니다. 공식 문서 FAQ도 상당히 잘 되었습니다. SpringBoot 3가 출시되었습니다. Springfox는 SpringBoot 3에 대한 support를 하지 않고 할지도 명확하지 않다고 합니다. 물론 저는 2.7.7 버전을 사용하고 있지만, 추후에 3 점대로 올릴 수도 있다는 가능성도 열어두었습니다.

따라서, Springdoc-openapi을 선택했습니다.

Springdoc-openapi 의존성

implementation group: 'org.springdoc', name: 'springdoc-openapi-ui', version: '1.6.14'

For spring-boot v3 support, make sure you use springdoc-openapi v2

저는 SpringBoot 2.7.7을 사용하기 때문에 v1 중에 공식 문서에 나와있는 1.6.14를 사용했습니다.

springdoc-openapi endpoints

공식 문서에 친절하게 아래의 endpoints를 제시해줍니다.

  1. REST API that holdes the OpenAPI definition:
    http://serverName:managementPort/actuator/openapi
  2. An Endpoint, that routes to the swagger-ui:
    http://serverName:managementPort/actuator/swagger-ui

yml 설정

springdoc:
  version: v0.0.1
  packagesToScan: dandi.dandi
  pathsToMatch: /api
  swagger-ui:
    path: /api-docs
  api-docs:
    enabled: true
    groups:
      enabled: false

version은 제가 @Value로 받아쓰기 위한 값입니다.

springdoc.swagger-ui.path 경로로 접근하면 /swagger-ui/index.html 로 Redirect 됩니다.

springdoc.api-docs.groups.enabled는 Default가 true인데, 일단 false로 지정해주었습니다. 추후에 api들이 많아지면 grouping 하는 설정을 진행할 때 true로 활성화 할 예정입니다. 바로 아래 링크를 참고하시면 groups에 대해 이해하실 수 있을 겁니다.

https://happy-jjang-a.tistory.com/165

다른 설정들은 부가적인 설명 없이 이해할 수 있을 겁니다. 궁금한 점이 있으면 아래 링크의 springdoc-openapi core propertiesswagger-ui properties를 참조하시면 좋을 것 같습니다.

https://springdoc.org/#springdoc-openapi-core-properties

함께 보면 좋을 설정

springdoc.cache.disabled (Default는 false)

To disable the springdoc-openapi cache of the calculated OpenAPI.

springdoc.show-actuator (Default는 false)

To display the actuator endpoints.

404 발생시

springdoc.api-docs.enabled=false

springdoc.api-docs를 false로 설정하고 swagger-ui에 접근했을 때, 404가 응답되는 것을 겪었는데요.

공식 문서를 보니 아래와 같은 설명이 있었습니다.

With this property, all the springdoc-openapi auto-configuration beans are disabled:

아래와 같이 /v3/api-docs로 접근할 수 있는 값에 대한 허용 여부인지 알고 false로 두었는데요. false로 두면 springdoc-openapi bean들이 활성화되지 않습니다. 문서를 잘 읽는 필요성을 또 한번 느꼈습니다.

{
"openapi":"3.0.1",
	"info": {
		"title":"Dandi API",
		"description":"Global Media 2023 Graduation Exhibition Team Dandi",
		"termsOfService":"Team Dandi","version":"v0.0.1"
	},
	"servers":[{
		"url":"http://localhost:8080",
		"description":"Generated server url"
	}],
	"paths":{},
	"components":{}
}

@Configuration

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI openAPI(@Value("${springdoc.open-api-version}") String openApiVersion) {
        Info info = new Info()
                .title("Dandi API")
                .version(openApiVersion)
                .description("Global Media 2023 Graduation Exhibition Team Dandi")
                .termsOfService("Team Dandi");

        return new OpenAPI()
                .components(new Components())
                .info(info);
    }
}

대부분의 값들을 yml에서 설정해서 Bean에 주입하기 때문에 크게 설정할 값이 많지 않았습니다.

Open Api Docs 생성하기

Swagger 2에서 Swagger 3로 업데이트 되면서 아래와 같은 변경이 있습니다.

  • @Api → @Tag
  • @ApiIgnore → @Parameter(hidden = true) or @Operation(hidden = true) or @Hidden
  • @ApiImplicitParam → @Parameter
  • @ApiImplicitParams → @Parameters
  • @ApiModel → @Schema
  • @ApiModelProperty(hidden = true) → @Schema(accessMode = READ_ONLY)
  • @ApiModelProperty → @Schema
  • @ApiOperation(value = "foo", notes = "bar") → @Operation(summary = "foo", description = "bar")
  • @ApiParam → @Parameter
  • @ApiResponse(code = 404, message = "foo") → @ApiResponse(responseCode = "404", description = "foo")

Controller

@Tag(name = "인증")
@RestController
public class AuthController {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @Operation(summary = "Apple ID로 로그인/회원가입")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "로그인", headers = @Header(name = AUTHORIZATION, description = "Access Token")),
            @ApiResponse(responseCode = "201", description = "회원가입 후 로그인", headers = @Header(name = AUTHORIZATION, description = "Access Token")),
            @ApiResponse(responseCode = "401", description = "유효하지 않은 Apple Id Token")
    })
    @PostMapping("/login/oauth/apple")
    public ResponseEntity<Void> login(@Parameter(description = "Apple IdToken") @RequestBody LoginRequest loginRequest) {
        LoginResponse loginResponse = authService.getAccessToken(loginRequest);
        if (loginResponse.isNewUser()) {
            return ResponseEntity.status(HttpStatus.CREATED)
                    .header(HttpHeaders.AUTHORIZATION, loginResponse.getToken())
                    .build();
        }
        return ResponseEntity.ok()
                .header(HttpHeaders.AUTHORIZATION, loginResponse.getToken())
                .build();
    }
}

@Tag, @Operation, @ApiResponses, @ApiResponse, @Parameter 만 보시면 됩니다!

어노테이션들에 대한 부가 설명은 하지 않겠습니다.

DTO

public class LoginRequest {

    @Schema(example = "1g2u5h1jh12j12jk23h")
    private String idToken;

    public LoginRequest() {
    }

    public String getIdToken() {
        return idToken;
    }
}

프로덕션 코드와 문서를 위한 코드 분리하기

위에서 설정한 방식에서는 프로덕션 코드와 문서를 위한 코드, 두 개의 관심사가 한 곳에 함께 있다는 문제가 있습니다. Controller에 메서드가 많아지면 가독성이 더 떨어질 것으로 예상합니다.(물론 Controller를 분리하는 방법도 있겠습니다만..)

따라서, 아래와 같이 추상화 기술을 이용해서 문서를 위한 코드와 프로덕션 코드를 분리했습니다.

다만, Controller에만 적용했습니다. DTO는 api가 많아짐에 따라 Request, Response로 2배씩 늘어날텐데, Controller와 같이 분리를 한다면 클래스들이 너무 많아질 것이라고 생각했습니다. 또한, DTO는 필드 생성자 getter만 존재하기 때문에 가독성을 크게 해치지 않을 것이라고 예상했습니다.

Api Docs를 위한 인터페이스

@Tag(name = "인증")
public interface AuthControllerDocs {

    @Operation(summary = "Apple ID로 로그인/회원가입")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "200", description = "로그인", headers = @Header(name = AUTHORIZATION, description = "Access Token")),
            @ApiResponse(responseCode = "201", description = "회원가입 후 로그인", headers = @Header(name = AUTHORIZATION, description = "Access Token")),
            @ApiResponse(responseCode = "401", description = "유효하지 않은 Apple Id Token")
    })
    ResponseEntity<Void> login(@Parameter(description = "사용자 id") LoginRequest loginRequest);
}

Controller

@RestController
public class AuthController implements AuthControllerDocs {

    private final AuthService authService;

    public AuthController(AuthService authService) {
        this.authService = authService;
    }

    @PostMapping("/login/oauth/apple")
    public ResponseEntity<Void> login(@RequestBody LoginRequest loginRequest) {
        LoginResponse loginResponse = authService.getAccessToken(loginRequest);
        if (loginResponse.isNewUser()) {
            return ResponseEntity.status(HttpStatus.CREATED)
                    .header(HttpHeaders.AUTHORIZATION, loginResponse.getToken())
                    .build();
        }
        return ResponseEntity.ok()
                .header(HttpHeaders.AUTHORIZATION, loginResponse.getToken())
                .build();
    }
}

개발을 하다보면 api가 많아질텐데요.

다음엔 api가 많아짐에 따라, group을 적용해야한다면 적용하는 포스팅으로 돌아오겠습니다~

참고자료

https://springdoc.org/#getting-started

https://www.baeldung.com/spring-rest-openapi-documentation

0개의 댓글