본문 바로가기

우아한테크코스

API 문서화에 RestDocs + Swagger UI 적용하기

API 문서 자동화를 해야 할 필요성을 느끼면서 알게 된 방법은 2가지가 있었다.

1. Swagger  2. RestDocs 

 

처음에는 사용이 간편한 Swagger를 사용했었다. 

build.gradle 에

 

 implementation 'org.springdoc:springdoc-openapi-ui:1.6.6'

 

위의 의존성 하나만 추가해주고 코드에

 

@Configuration
public class SwaggerConfig {

    @Bean
    public OpenAPI customOpenAPI() {
        return new OpenAPI();
    }
}

 

이 Config 클래스만 등록해주면 간편하게 API 문서가 자동화가 되는 것을 확인할 수 있었다.

하지만 API 가 어떤 동작을 하는 API 인지, URI의 쿼리 파라미터나 Path variable 이 어떤 걸 뜻하는지, request와 response의 필드들이 어떤 것을 의미하는지 나타내도록 커스텀하려면 Controller의 코드에 어노테이션을 잔뜩 추가할 수밖에 없었다.

 

그리고 다른 방법인 RestDocs 는 테스트 코드를 만들고, 그 테스트 코드가 성공을 해야 문서가 작성되고, 소스 코드를 침범하지 않는다는 점에서 장점이 있으나 설정이 복잡하고, 만들어지는 문서가 interactive 하지 않다는 단점이 있었다.

 

그래서 어떤 것을 사용해야 할까 고민하던 중 RestDocs와 Swagger를 같이 사용해서 두 방법의 장점만 뽑아서 사용할 수가 있다는 이야기를 듣고 해당 방법을 사용해 보기로 결정하였다. 

 

RestDocs와 SwaggerUI를 함께 사용하는 방법은 간단히 말하면 RestDocs로 만들어진. adoc 파일들을 

 id 'com.epages.restdocs-api-spec' 플러그인이 OpenApi3 스펙을 가진 .json 파일로 변화를 시켜주고, 해당 파일을 SwaggerUI (Swagger 랑 다름! Swagger를 사용하면 OpenApi 코드가 작성되고 SwaggerUI 가 그 코드를 읽어 시각화가 되는 것)가 읽어서 Swagger처럼 API 문서가 시각화가 되는 것이다. 

 

Java 17, Spring Boot 3.1.1 기준으로 설정 방법이다.

 

// restDocs Api 스펙 버전 추가
buildscript {
    ext {
        restdocsApiSpecVersion = '0.18.2'
    }
}

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.1'
    id 'io.spring.dependency-management' version '1.1.0'

	// openApi 스펙으로 변경해주는 플러그인들 추가
    id 'com.epages.restdocs-api-spec' version "${restdocsApiSpecVersion}"
    id 'org.hidetake.swagger.generator' version '2.18.2'
}

group = 'com.stampcrush'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    
    // swagger ui openapi 의존성 추가
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.4'

    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'

    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'com.mysql:mysql-connector-j'

    annotationProcessor 'org.projectlombok:lombok'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.rest-assured:rest-assured:5.3.1'
    testImplementation 'org.projectlombok:lombok'
    
    // restdocs-mockmvc 라이브러리 추가
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    testImplementation "com.epages:restdocs-api-spec-mockmvc:${restdocsApiSpecVersion}"

    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

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

// openapi3 문서 설정
openapi3 {
    setServer("http://localhost:8080")
    title = "스탬프크러쉬 API Docs"
    description = "스탬프크러쉬 API 명세서"
    version = "0.0.1"
    format = "json"

    outputDirectory = 'build/resources/main/static/docs'
}

// 처음에 outputDirectory 가 만들어져있지 않으면 문서가 생성되지 않기에 처음에 output directory 설정
task createOutputDirectory {
    doFirst {
        file(openapi3.outputDirectory).mkdirs()
    }
}

// swagger UI 가 시각화할 대상을 openapi3 로 설정
tasks.withType(GenerateSwaggerUI) {
    dependsOn 'openapi3'

    delete file('src/main/resources/static/docs/')
    copy {
        from "build/resources/main/static/docs"
        into "src/main/resources/static/docs/"

    }
}

// bootJar 가 실행되기 전에 실행될 태스크들 설정
bootJar {
    dependsOn 'createOutputDirectory', ':openapi3'
}

 

나는 RestDocs 테스트 코드를 MockMvc 를 사용해서 구현했기 때문에 MockMvc 의존성을 추가해 주었다. RestAssured를 사용해서 구현하려면 RestAssured 의존성을 구현해 주면 된다.

 

테스트 코드의 소스 코드는 아래와 같다.

 

package com.stampcrush.backend.api.docs;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.stampcrush.backend.application.manager.cafe.ManagerCafeCommandService;
import com.stampcrush.backend.common.KorNamingConverter;
import com.stampcrush.backend.entity.user.Owner;
import com.stampcrush.backend.entity.user.RegisterCustomer;
import com.stampcrush.backend.repository.user.OwnerRepository;
import com.stampcrush.backend.repository.user.RegisterCustomerRepository;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;

import java.util.Base64;

import static com.stampcrush.backend.fixture.CustomerFixture.REGISTER_CUSTOMER_GITCHAN;
import static com.stampcrush.backend.fixture.OwnerFixture.OWNER3;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

@KorNamingConverter
@AutoConfigureRestDocs
@AutoConfigureMockMvc
@WebMvcTest({
        ManagerCustomerCommandApiController.class,
        ManagerCafeCommandApiController.class,
        ManagerCafeCouponSettingCommandApiController.class,
})
@ExtendWith({RestDocumentationExtension.class})
public abstract class DocsControllerTest {

    protected static final Long CAFE_ID = 1L;
    protected static final Owner OWNER = OWNER3;
    protected static final RegisterCustomer CUSTOMER = REGISTER_CUSTOMER_GITCHAN;

    protected static String OWNER_BASIC_HEADER;
    protected static String CUSTOMER_BASIC_HEADER;

    protected MockMvc mockMvc;

    @Autowired
    protected WebApplicationContext ctx;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected ManagerCafeFindService managerCafeFindService;

    @MockBean
    protected OwnerRepository ownerRepository;

    @MockBean
    protected RegisterCustomerRepository customerRepository;

    @MockBean
    protected ManagerCafeCommandService managerCafeCommandService;

    @MockBean
    public AuthTokensGenerator authTokensGenerator;

    @BeforeAll
    static void setUpAuth() {
        OWNER_BASIC_HEADER = "Basic " + Base64.getEncoder().encodeToString((OWNER.getLoginId() + ":" + OWNER.getEncryptedPassword()).getBytes());
        CUSTOMER_BASIC_HEADER = "Basic " + Base64.getEncoder().encodeToString((CUSTOMER.getLoginId() + ":" + CUSTOMER.getEncryptedPassword()).getBytes());
    }

    @BeforeEach
    void setUp(final RestDocumentationContextProvider restDocumentation) {
        mockMvc = MockMvcBuilders.webAppContextSetup(ctx)
                .apply(documentationConfiguration(restDocumentation))
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .alwaysDo(print())
                .build();
    }
}

 

이렇게 DocsControllerTest 라는 최상위 클래스를 두고 

 

package com.stampcrush.backend.api.docs.manager.cafe;

import com.epages.restdocs.apispec.ResourceSnippetParameters;
import com.epages.restdocs.apispec.Schema;
import com.stampcrush.backend.api.docs.DocsControllerTest;
import com.stampcrush.backend.api.manager.cafe.request.CafeCreateRequest;
import com.stampcrush.backend.api.manager.cafe.request.CafeUpdateRequest;
import com.stampcrush.backend.application.manager.cafe.dto.CafeCreateDto;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders;

import java.time.LocalTime;
import java.util.Optional;

import static com.epages.restdocs.apispec.MockMvcRestDocumentationWrapper.document;
import static com.epages.restdocs.apispec.ResourceDocumentation.resource;
import static org.mockito.Mockito.when;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

public class ManagerCafeCommandApiDocsControllerTest extends DocsControllerTest {

    @Test
    void 카페_상세_정보_변경() throws Exception {
        // given
        when(ownerRepository.findByLoginId(OWNER.getLoginId())).thenReturn(Optional.of(OWNER));
        CafeUpdateRequest request = new CafeUpdateRequest("안녕하세요", LocalTime.NOON, LocalTime.MIDNIGHT, "01012345678", "imageUrl");

        // when, then
        mockMvc.perform(RestDocumentationRequestBuilders.patch("/api/admin/cafes/{cafeId}", CAFE_ID)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request))
                        .header(HttpHeaders.AUTHORIZATION, OWNER_BASIC_HEADER))
                .andDo(document("manager/cafe/update-cafe",
                                preprocessRequest(prettyPrint()),
                                preprocessResponse(prettyPrint()),
                                resource(
                                        ResourceSnippetParameters.builder()
                                                .tag("사장 모드")
                                                .description("카페 상세 정보 업데이트")
                                                .requestHeaders(headerWithName("Authorization").description("임시(Basic)"))
                                                .requestFields(
                                                        fieldWithPath("openTime").description("오픈 시간"),
                                                        fieldWithPath("closeTime").description("마감 시간"),
                                                        fieldWithPath("telephoneNumber").description("전화번호"),
                                                        fieldWithPath("cafeImageUrl").description("카페 이미지 URL"),
                                                        fieldWithPath("introduction").description("카페 소개글")
                                                )
                                                .requestSchema(Schema.schema("CafeUpdateRequest"))
                                                .build()
                                )
                        )
                )
                .andExpect(status().isOk());
    }
}

 

이런 식으로 테스트 하나하나씩 작성하면 된다!

혹시 동작이 되지 않는다면 import 되는 의존성들을 다시 잘 확인해봐야 한다.

 

테스트 코드가 성공을 하고 build 를 하면 build 디렉토리의 generated-snippets에. adoc 파일이 생성되는 것과 build.gradle 에 설정해 놨듯이 openapi3.json 파일이 build/resources/main/static/docs 디렉토리에 생성되는 것을 볼 수 있다. 

 

 

그리고 어플리케이션을 실행시키면 src/main/resources/static/docs 위치로 해당 json 파일이 복사가 되고 SwaggerUI 가 해당 위치에 있는 파일을 읽게 해야 할 필요가 있다. 

 

그래서 application.yml 에 아래 설정들을 추가해 주면 된다.

 

springdoc:
  swagger-ui:
    url: /docs/openapi3.json
    path: /api/swagger-ui/index.html

 

url 은 swaggerUI 가 기본으로 읽어올 대상의 위치를 지정해 주는 설정이고 path는 swaggerUI의 경로를 설정해 준다. 위의 경우는 localhost:8080/api/swagger-ui/index.html로 접근하면 swaggerUI 가 동작되도록 설정해 둔 것이다.

 

최종적으로 스탬프크러쉬의 API 를 예쁘게 문서화할 수 있었다 🥳

 

 

 

 

참고 글

https://thalals.tistory.com/433