[Spring] restdocs + swagger 같이 사용하기

Dev_ch·2023년 8월 25일
14
post-thumbnail

전편 : [Spring] Spring RestDocs 적용하기

처음에는 분명 RestDocs가 혁신적인 API 문서라고 느꼈는데, 갈수록 의문이 들기시작했다.

  1. ascciidoc으로 만들어진 문서조각을 우리가 직접 합쳐줘야했다.
  2. swagger 처럼 직접 테스트 해볼 수 없고 가독성이 클라이언트에게 친화적이지 않다.
  3. 디자인이 별로다.

분명 단점들이 존재했다. 그렇다고 swagger를 사용하기엔 프로덕트 코드에 어노테이션들이 덕지덕지 붙어있는 건 싫어서 더 나은 방법이 없을까 고민하던 찰나.

정말 극한의 효율과 클라이언트에게 친화적인 API 문서를 만들 수 있겠다는 생각이 들었고, 그 이유는 바로 restdocs + swagger를 합쳐서 사용하는 것 이였다. docs로 만들어진 문서를 swagger로 변환하여 사용하면...(혁신이다)

정말 극한의 문서를 만들고 싶은 여러분께 극한의 API 문서를 자동화해서 만드는 법을 이번 포스팅에서 다루어보겠다.

개념

restdocs + swagger 문서를 제작하는 로직은 아래와 같다.

  1. 기존처럼 테스트 코드를 통해 docs 문서를 생성
  2. docs 문서를 OpenAPI3 스펙으로 변환
  3. 만들어진 OpenAPI3 스펙을 SwaggerUI로 생성
  4. 생성된 SwaggerUI를 static 패키지에 복사 및 정적리소스로 배포

로직은 아주 간단하다. 사실 구현 자체도 어렵지 않은데, 필자의 경우 정말 많은 삽질을 하였다. 여러 포스팅이 있었지만 제대로 되지 않거나, build 과정에서 패키지가 충돌하는 오류가 발생해 내 머리에도 오류가 발생할 뻔 했지만 코드를 새로 짜면서 gradle과 코드를 구성하여 성공하였다.

코드를 보면서 구현하면 간단하기에 천천히 따라와보자 (발생했던 Exception도 포스팅에 작성해보겠다)

구현

1. build.gradle

// 1. Import 추가
import com.sun.security.ntlm.Server
import org.hidetake.gradle.swagger.generator.GenerateSwaggerUI
import org.springframework.boot.gradle.tasks.bundling.BootJar

// 2. buildscript 추가
buildscript {
    ext {
        restdocsApiSpecVersion = '0.17.1'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.2'
    id 'io.spring.dependency-management' version '1.1.2'
    // 3. openAPI 플러그인 추가
    id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
	// 4. swaggerUI 플러그인 추가
    id 'org.hidetake.swagger.generator' version '2.18.2'
}

group = 'study.project'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
}

// 5. 생성된 API 스펙이 어느 위치에 있는지 지정
swaggerSources {
    sample {
        setInputFile(file("${project.buildDir}/api-spec/openapi3.yaml"))
    }
}

// 6. openapi3 스펙 생성시 설정 정보
openapi3 {
    servers = [
            { url = "http://배포중인 주소" },
            { url = "http://localhost:8080" }
    ]
    title = "API 문서"
    description = "RestDocsWithSwagger Docs"
    version = "0.0.1"
    format = "yaml"
}

dependencies {
	.
    .
    .
    
    // 7. RestDocs 추가
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    // 8. openAPI3 추가
    testImplementation 'com.epages:restdocs-api-spec-mockmvc:' + restdocsApiSpecVersion
	// 9. SwaggerUI 추가
    swaggerUI 'org.webjars:swagger-ui:4.11.1'
}

tasks.named('test') {
    useJUnitPlatform()
}

// 10. openapi3가 먼저 실행 - doFrist를 통한 Header 설정 (글에서 자세하게 설명)
tasks.withType(GenerateSwaggerUI) {
    dependsOn 'openapi3'
    doFirst {
        def swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml")

        def securitySchemesContent =  "  securitySchemes:\n" +  \
                                      "    APIKey:\n" +  \
                                      "      type: apiKey\n" +  \
                                      "      name: Authorization\n" +  \
                                      "      in: header\n" + \
                                      "security:\n" +
                                      "  - APIKey: []  # Apply the security scheme here"

        swaggerUIFile.append securitySchemesContent
    }
}

// 11. 생성된 openapi3 스펙을 기반으로 SwaggerUISample 생성 및 static/docs 패키지에 복사
bootJar {
    dependsOn generateSwaggerUISample
    from("${generateSwaggerUISample.outputDir}") {
        into 'static/docs'
    }
}

주석을 통해 간단하게 설명했지만, 해당 build를 이해하는 것이 중요하기에 하나씩 살펴보자.

  1. buildscript 추가
    • 버전을 동일하게 맞춰주기 위해 script를 통해 api-spec 버전을 맞춰준다 (필수는 아님)
  2. openAPI 플러그인 추가
    • Spring Rest Docs의 결과물을 openAPI3 스펙으로 변환하기 위한 플러그인
  3. swaggerUI 플러그인 추가
    • openAPI3 스펙 기반으로 SwaggerUI 생성
  4. 생성된 API 스펙이 어느 위치에 있는지 지정
    • openAPI3 스펙을 SwaggerUI로 생성하기 위해선 해당 스펙이 어디에 위치해 있는지 지정해줘야 하는데, 해당 구문에서 위치를 지정해준다.
  5. openapi3 스펙 생성시 설정 정보
    • servers =
      [{ url = "http://배포중인 주소" },
      { url = "http://localhost:8080" }]
      : 요청을 보낼 서버 주소이다. List 형태로 여러 server를 지정해줄 수 있다.
    • title = "API 문서" : API 문서 이름
    • description = "RestDocsWithSwagger Docs" : API 상세정보
    • version = "0.0.1" : API 버전
    • format = "yaml" : 생성될 openAPI3 스펙 파일 타입 (포스팅에선 yaml)
  6. RestDocs 추가
  7. openAPI3 추가
  8. SwaggerUI 추가
  9. openapi3가 먼저 실행 - doFrist를 통한 Header 설정 (글에서 자세하게 설명)

    🔍 해당 부분을 통해 openAPI3가 먼저 생성되고, 생성된 스펙에 구문을 추가하여 Authorization에 토큰을 추가할 수 있게 한다.

기존 docs에서 requestHeader()로 작성한 테스트코드를 Header로 인식을 하여 입력칸이 생기기는 하나, 실제로 보내지진 않는다. 결국 Authorization과 같은 헤더 타입에 토큰을 넣기 위해선 스펙안에 토큰을 넣는 코드를 따로 정의 해주어야 한다.

    doFirst {
        def swaggerUIFile = file("${openapi3.outputDirectory}/openapi3.yaml")

        def securitySchemesContent =  "  securitySchemes:\n" +  \
                                      "    APIKey:\n" +  \
                                      "      type: apiKey\n" +  \
                                      "      name: Authorization\n" +  \
                                      "      in: header\n" + \
                                      "security:\n" +
                                      "  - APIKey: []"

        swaggerUIFile.append securitySchemesContent
    }

해당 로직의 과정 중에서 doFrist 메서드를 통해 생성된 openAPI3 스펙에 마지막 구문에 헤더 관련 설정을 추가해준다. 이는 openAPI3 스펙에 준수하여 모든 API에 Authorization 헤더에 토큰을 넣을 수 있게 설정해준다.

docs + swagger를 구현을 했지만 헤더에 토큰을 넣지 못하는 상황이 발생해 수많은 삽질을 통해 해당 방법을 터득했다,, 정작 swagger를 사용하는데 API를 요청해보지 못하는 건 큰 리스크이기에 해결이 꼭 필요했다.

⚠️ 참고로 doLast로 하면 해당 구문을 추가하기 전에 SwaggerUI가 생성되기에 적용이 안되니 꼭 doFirst 메서드를 사용하자.

  1. 생성된 openapi3 스펙을 기반으로 SwaggerUISample 생성 및 static/docs 패키지에 복사
    • 간단하지만 중요하다. bootJar이 실행되기전 generateSwaggerUISample 을 실행하여 생성되어있는 openAPI3 스펙을 SwaggerUI로 생성하여 main/resources/static/docs에 복사한다. 참고로 복사가 된다 한들 local 에서는 보이지 않으니 주의하자.
    • 해당 패키지에 복사하는 이유는 정적리소스를 주소로 접근하기 위해서이다.

문서를 자동화하는 만큼 build.gradle 에서 정의해야하는 스크립트들이 중요하니 해당 부분만 잘해도 완성한 것 이나 다름없다.

2. 테스트 코드 작성

저번 포스팅에서 다루었던 restdocs 테스트 코드를 그대로 사용해도 된다.

  • docs 테스트 코드 작성시 document (변경전)
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
  • docs + swagger 테스트 코드 작성시 document(변경후)
import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document;

위와 같이 기존 docs에서 import 구문을 변경하는 것 만으로 docs+swagger를 적용할 수 있지만, swagger의 정보와 설정을 변경하기 위해서 아래와 같이 테스트 코드를 리팩터링 할 수 있다.

@DisplayName("소셜 로그인 API")
    @Test
    void socialLogin() throws Exception {
        // given
        // 생략..

        // when // then
        mockMvc.perform(
                        RestDocumentationRequestBuilders.post("/auth/signin")
                                .param("code", "JKWHNF2CA78acSW6AUw7cvxWsxzaAWVNKR34SAA0AZ")
                                .param("platform", "KAKAO")
                )
                .andDo(print())
                .andExpect(status().isOk())
                .andDo(document("socialLogin",
                        preprocessRequest(prettyPrint()),
                        preprocessResponse(prettyPrint()),
                        resource(ResourceSnippetParameters.builder()
                                .tag("User API")
                                .summary("소셜 로그인 API")
                                .formParameters(
                                        parameterWithName("code").description("발급받은 인가코드"),
                                        parameterWithName("platform").description("플랫폼 : 'GOOGLE' / 'KAKAO' "))
                                .responseFields(
                                        fieldWithPath("code").type(NUMBER).description("상태 코드"),
                                        fieldWithPath("message").type(STRING).description("상태 메세지"),
                                        fieldWithPath("data.userId").type(NUMBER).description("유저 ID"),
                                        fieldWithPath("data.email").type(STRING).description("유저 이메일"),
                                        fieldWithPath("data.nickName").type(STRING).description("유저 닉네임"),
                                        fieldWithPath("data.profileImageUrl").type(STRING).description("유저 프로필 이미지"),
                                        fieldWithPath("data.accessToken").type(STRING).description("액세스 토큰"),
                                        fieldWithPath("data.refreshToken").type(STRING).description("리프레쉬 토큰"))
                                .requestSchema(Schema.schema("FormParameter-socialLogin"))
                                .responseSchema(Schema.schema("UserResponse.Login"))
                                .build())));

restdocs 작성에 대해서는 전편에 자세하게 다루고 있으니 전편을 참고하도록 하자 ➡️ 전편 보러가기
원래의 경우 request, response에 관한 부분을 document 내부에서 바로 작성해주었지만, openAPI3 스펙을 자세하게 정의하기 위해 resource로 한번 감싼 이후에 tag, summary와 같은 옵션들을 사용해 커스텀 해줄 수 있다.

  1. tag : tag가 같으면 해당 API들을 그룹화 할 수 있다.
  2. summary : 해당 API 이름
  3. requestSchema, responseSchema : 스케마 이름 정의

이정도만 해도 openAPI3 스펙을 더 깔끔하게 만들 수 있다.

3. 실행해보기

프로젝트 루트 경로에서 아래 커맨드를 통해 빌드를 해주고 서버를 켜보도록 하자.

./gradlew build
cd build/libs
java -jar {생성된 jar 파일}

그리고 localhost:8080/docs/index.html로 접근하게 되면...!

restdocs와 swagger의 합작품이 탄생된다.. 광기와 극한의 효율을 추구하는 사람이 만들어버린 괴물같은 API 문서(?)다.

사용법은 swagger와 동일하며 Authorization에 JWT와 같은 토큰이 필요하다면 requestHeaders로 테스트 문서를 작성했다면, 입력칸이 하나 생기는데 해당 입력칸은 건드리지 않고 API 우측상단에 있는 자물쇠버튼을 클릭해서 토큰을 넣어주면 된다.

자물쇠를 누르면 아래와 같은 창이 뜨는데

만약 Bearer 토큰이라면 Bearer {JWT Token} 과 같이 넣어주면 된다.

ex) Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTY5Mjk2OTMzNH0.RkW_m_hucGooYnRHSjtRSfEJ4Us3Bjl4IReD2NzFBGHVpBCUxxjwJOza1huhxjvsTMEiIIcKizUwvXqjV2wdzA

이러면 진짜 끄읏-!


4. 삽질기

  1. restdocs + swagger 사용하는 방법이 몇가지 있는데, 해당 방법을 찾기 전까지 스펙은 생성이 되는데 index가 안만들어진다는 등 문제가 있었음! 하지만 이 문제는 비교적 간단하게 해결
  1. build.gradle 삽질 : 해당 삽질이 정말 힘들었습니다.. 다른 블로그 포스팅에서 스크립트를 구성한대로 작성해보았으나 이미 생성된 패키지에 패키지를 추가하는 과정에서 충돌이 일어나 Exception이 발생해 해당 스크립트를 최대한 살려보려고 노력했으나 실패(...) 그래서 스크립트를 제가 새로 짜보았고 해결 하였습니다 🥹
    (왜 나만안돼..)
  1. 헤더에 토큰이 들어가지 않는 문제 : 해당 삽질도 꽤 어려웠습니다.. reuqestHeaders로 문서를 작성했는데 인식도 하고 입력창도 생기지만 정작 입력창에 토큰을 넣고 요청을 보내면 header에 토큰이 담겨지지 않는 문제가 발생하여서 많이 검색해보았는데 해결방법이 딱히 나오지 않았습니다..
    그래서 openAPI3 스펙 자체에 헤더를 요청할 수 있는 컴포넌트와 API에 헤더를 적용할 수 있는 코드를 직접 넣어주는 방식으로 해결하였습니다 🥲

사실 2번의 경우 애초에 문서를 생성하지를 못하다보니 문제가 컸었고, 3번의 경우 문서는 구현이 됐는데 restdocs + swagger를 적용한 이유가 restdocs의 안정성과 swagger의 클라이언트 친화적인 문서를 만들기 위해서였는데 swagger의 장점인 API 요청을 못한다는 것은 적용의 의미가 사라지기 때문에 어떻게든 헤더에 토큰을 넣기 위해 발버둥 치다가 성공했습니다 ...🥹

삽질도 많이 했고 결국 성공도 했습니다.. 극한의 API 문서를 만드는데 성공했기에 API 문서를 약간 정복해버린 느낌이 드네요,, restdocs와 swagger 장점만 뽑아서 합쳐놓은 문서이기에 아마 API 문서는 이렇게 정착할 것 같습니다.

restdocs은 테스트를 기반으로 하다보니 API 문서의 신뢰도가 높았지만 조각을 직접 넣어줘야 한다는 점, API를 요청해볼 수 없다는 점, 디자인이 좋지 않다는 점,, 그리고 swagger의 경우 프로덕트 코드에 어노테이션이 많이 붙는다는 점 이러한 단점들을 다 없애고 장점만 남겼다는게 왤케 뿌듯한지 모르겠습니다..

아무튼 여러분들도 restdocs + swagger 꼭 쓰세요 🙇‍♂️


참조
https://github.com/ePages-de/restdocs-api-spec
https://thalals.tistory.com/433
https://jwkim96.tistory.com/274

profile
내가 몰입하는 과정을 담은 곳

3개의 댓글

comment-user-thumbnail
2023년 12월 11일

덕분에 좋은 내용 잘 보고 갑니다.
정말 감사합니다.

답글 달기
comment-user-thumbnail
2024년 6월 28일

큰 도움 받았습니다. 감사합니다.
혹시 지금도 requestHeader값을 지정해 줄 수 없는걸까요?ㅠ 공식 문서 찾아보고 있는데 아직 찾지 못해서요

답글 달기
comment-user-thumbnail
2024년 9월 21일

restdocs-api-spec의 인증 부분을 추가하기 위해 검색하던 중 선생님의 글을 읽게 되었습니다.
gradle의 securitySchemesContent 부분 잘 읽었습니다. 감사합니다.

글에서 말씀하신대로 적용해도 전혀 문제가 없습니다만,
restdocs-api-spec 문서를 좀 더 파서 정식적으로 써보자는 생각으로 연구한 결과 알려드립니다.

문서 발췌 부분

문서에서는 헤더만 잘 입력하면 자동으로 생성해준다고 되어 있습니다.
https://github.com/ePages-de/restdocs-api-spec?tab=readme-ov-file#security-definitions-in-openapi

restdocs-api-spec inspects the AUTHORIZATION header of a request for a JWT token. Also the a HTTP basic authorization header is discovered and documented. If such a token is found the scopes are extracted and added to the resource.json snippet.

restdocs-api-spec 관련 코드

코드를 보면 실제로 JWT 토큰을 디코딩까지 해보고 openapi security 관련 로직을 실행합니다.
JwtSecurityHandler.kt
SecurityRequirementsHandler.kt
ResourceSnippet.kt
SecuritySchemeGenerator.kt

실제 적용 방법

JWT로 예를들면,
테스트 코드의 헤더에
헤더명을 HttpHeaders.AUTHORIZATION 으로 입력하고,
헤더값을 Bearer 를 적은 뒤 실제 디코딩 가능한 JWT 토큰을 입력해주면 문서에 Authorize 버튼이 생성됩니다.
(jwt.io 사이트에서 예제로 나와있는 JWT를 사용해도 무방합니다.)

 RestDocumentationRequestBuilders.post("/v1/foo/bar")
    .header(HttpHeaders.AUTHORIZATION, "Bearer {디코딩 가능한 JWT 토큰을 입력}")
    .content(reqDtoJson)
    .contentType(MediaType.APPLICATION_JSON)


헤더값 앞부분만 Bearer 대신 Basic 을 적어주시면 베이직 토큰 인증도 가능합니다.

추가사항 - Oauth

Oauth 관련 인증은 아래 링크를 참고하시면 될 것 같습니다.
https://github.com/ePages-de/restdocs-api-spec?tab=readme-ov-file#openapi-301-1

답글 달기