본문 바로가기
코비의 개발일지

Spring REST Doc 적용기

by 규난 2023. 4. 2.
728x90

현재 사내에서 api 문서를 postman으로 관리를 하고 있는데 곧 외부망을 내부망으로 교체할 예정이라 외부망을 사용하지 못할 시에 postman으로 api를 문서화하지 못하기 때문에 다른 방식으로 api를 문서화할 수 있는 방법을 찾아야 했고 같은 팀의 대리님이 문서 작업을 자동화할 수 있는 방법을 찾아보시다가 RESTful 서비스에 대한 문서 생성을 도와주는 Spring REST Doc이라는 라이브러리를 알게 되었고 직접 사용해 보신 후 팀 내에 도입하기로 결정하였습니다.

Spring REST Doc이란?

RESTful 서비스에 대한 정확하고 읽기 쉬운 문서를 생성해주는 자바 라이브러리 입니다.

Asciidoctor를 사용하여 필요에 맞게 스타일이 지정된 HTML, PDF 등을 생성해줍니다.

Spring MVC test framework, Spring webflux WebTestClient, REST Assured 5로 작성된 테스트코드를 기반으로 문서를 생성해주고, 테스트코드가 성공하지 못하면 문서를 생성해주지 않기 때문에 문서의 정확성을 보장하는데 도움이 됩니다.

 

프로젝트 구성

buildscript {
    ext {
        queryDslVersion = "5.0.0"
        snippetsDir = file('build/generated-snippets') // 1
    }
}
plugins {
    // ...
    id 'org.asciidoctor.jvm.convert' version '3.3.2' // 2
}

// ...

configurations {
    asciidoctorExtensions
    compileOnly {
        extendsFrom annotationProcessor
    }
}

// 3
asciidoctor {
    configurations 'asciidoctorExtensions'
    inputs.dir snippetsDir
    dependsOn test
}

repositories {
	mavenCentral()
}

dependencies {
    // ...
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
    asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
}

test {
    outputs.dir snippetsDir // 4
    useJUnitPlatform()
}

// 5
task copyDocument(type: Copy) {
    dependsOn asciidoctor

    from file("build/docs/asciidoc/")
    into file("src/main/resources/static/docs")
}

// 6
bootJar {
    dependsOn copyDocument
}
  1. 생성된 snippet 저장할 위치를 정의
  2. gradle7 부터 사용하는 플러그인으로 asciidoc 파일 변환, build 디렉토리에 복사하는 플러그인
  3. asciidoctor Task 사용할 인풋 디렉토리를 build/generated-snippets 지정. dependsOn test 문서가 작성되기 전에 테스트가 실행
  4. 테스트 Task 결과 아웃풋 디렉토리를 위에서 정의한 snippetsDir 지정
  5. asciidoctor Task 생성한 build/docs/asciidoc파일을 src/main/resources/static/docs 복사
  6. bootJar 실행시 copyDocument 먼저 실행

 

bootJar 실행시 test -> asciidoctor -> copyDocument -> bulid 순서로 진행이 됩니다.

 

테스트 코드 작성

package com.test.web.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.test.common.config.WebMvcConfig;
import com.test.common.config.security.WebSecurityConfig;
import com.test.web.exam.controller.SpringRestDocTestController;
import com.test.web.exam.dto.UserDto;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.restdocs.payload.JsonFieldType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static com.test.common.util.ApiDocumentUtils.getDocumentRequest;
import static com.test.common.util.ApiDocumentUtils.getDocumentResponse;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(
        controllers = SpringRestDocTestController.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebSecurityConfig.class),
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebMvcConfig.class)
        }
)
// 1
@ExtendWith(RestDocumentationExtension.class) 
public class SpringRestDocTestControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;

    @BeforeEach
    public void setUp(RestDocumentationContextProvider restDocumentation) {
        SpringRestDocTestController springRestDocTestController = new SpringRestDocTestController();
        
        // 2
        mockMvc = MockMvcBuilders
                .standaloneSetup(springRestDocTestController)
                .apply(documentationConfiguration(restDocumentation).uris()
                        .withScheme("https")
                        .withHost("test.com")
                        .and()
                        .operationPreprocessors()
                        .withRequestDefaults(modifyUris().removePort())
                )
                .build();
    }

    @Test
    @DisplayName("spring rest doc test")
    public void restDocTest() throws Exception {
        UserDto.Request request = UserDto.Request.builder()
                .name("name")
                .build();

        mockMvc.perform(post("/api/user")
                        .contextPath("/api")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request))
                )
                .andExpect(status().isOk())
                .andDo(print())
                .andDo(document("createUser",
                        getDocumentRequest(), // 3
                        getDocumentResponse(), // 4
                        requestFields( // 5
                                fieldWithPath("name").type(JsonFieldType.STRING).description("사용자 이름")
                        ),
                        responseFields( // 6
                                fieldWithPath("name").type(JsonFieldType.STRING).description("사용자 이름")
                        ))
                );
    }
}

package com.test.common.util;

import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;

import java.net.URISyntaxException;

import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;

public interface ApiDocumentUtils {

    static OperationRequestPreprocessor getDocumentRequest() throws URISyntaxException {
        return preprocessRequest(prettyPrint());
    }

    static OperationResponsePreprocessor getDocumentResponse() {
        return preprocessResponse(prettyPrint());
    }

}
  1. JUnit 5 환경에 RestDocumentationExtension을 확장하여 REST API 요청 및 응답에 대한 문서를 생성해 주기 위해 적용
  2. withScheme, withHost를 사용하여 문서상의 기본 uri를 적용 및 removePort()를 사용하여 포트 제거
  3. 문서상의 request를 예쁘게 출력하기 위해 적용
  4. 문서상의 response를 예쁘게 출력하기 위해 적용
  5. @RequestBody로 받은 오브젝트를 문서상의 출력해 주기 위해 사용
  6. 응답 값으로 받은 데이터를 문서상의 출력해 주기 위해 사용

 

5번 같은 경우에는 requestParameters, pathParameters, requestParts 등 여러 가지 static method를 통해 query string, path variable, multipart/form-data 등을 문서상의 출력이 가능합니다. 

 

@WebMvcTest로 테스트 진행시 @Controller, @ControllerAdvice, Spring Security와  WebMvcConfigurer에 관련된 설정 그리고 @JsonComponent, Converter/GenericConverter, Filter, Validator, HandlerMethodArgumentResolver 등 즉, 프레젠테이션 계층의 빈들만 컴포넌트 스캔 대상이 됩니다. (@RestController와 @RestControllerAdvice 어노테이션은 @Controller와 @ControllerAdvice를 포함하고 있기 때문에 컴포넌트 스캔 대상이 됩니다.)

사내 프로젝트에는 Spring Security가 적용되어 있고 WebMvcConfigurer를 상속한 WebMvcConfig가 있기 때문에 저 두 개의 오브젝트가 컴포넌트 스캔 대상이 되는데 제가 테스트하는데 필요 없는 오브젝트들까지 빈들로 등록해 줘야 하는 번거로움이 있고 오직 Controller만 단위 테스트를 하기 위해서 test 진행 시 spring boot가 실행될 때 component scan 대상에서 제외시켜주었습니다.

@WebMvcTest(
        controllers = SpringRestDocTestControllerTest.class,
        excludeFilters = {
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebSecurityConfig.class),
                @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = WebMvcConfig.class)
        }
)

이렇게 테스트 코드를 작성 후 테스트가 성공하게 되면 아까 build.gradle에서 지정했던 build/generated-snippets 경로에 테스트 결과에 대한 문서 조각들이 생성이 됩니다.

테스트로 만들어진 조각 파일들을 이용하여 문서를 만드는 방법은

src/docs/asciidoc 디렉토리를 생성 후 index.adoc 파일을 만듭니다.

그리고 main/resources/static/docs 디렉토리를 만들어 줍니다.

그다음 index.adoc 파일에 Asciidoc 문법을 사용하여 문서를 작성 후 

= User API Guide
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:
:operation-curl-request-title: Example curl
:operation-http-request-title: Example request
:operation-http-response-title: Example response

[[user]]
== User

[[createUser]]
=== Create User
`POST` 사용자 생성

operation::createUser[snippets='curl-request,http-request,request-fields,request-body,http-response,response-fields,response-body']

bootJar를 통해 빌드를 하게 되면 위에서 언급했던 test -> asciidoctor -> copyDocument -> bulid 순서로 진행이 되고 빌드 성공 시 main/resources/static/docs에 index.html 파일이 생성된 것을 볼 수 있습니다.

index.html을 열어 보시면 저희가 테스트 코드에서 작성한 것들이 문서에 잘 담겨있는 것을 확인할 수 있습니다.

단위 테스트에 필요성과 장점에 대해서 구글링을 하다 보면 왜 단위 테스트를 해야 하고 어떤 장점이 있는지에 대해서 많이 나오긴 하는데 저는 익숙하지 않아서인지 항상 개발하면서 단위 테스트에 대한 필요성과 장점을 많이 느끼지 못하였습니다. 이번에 사내에 Spring REST Doc을 적용하면서 전체적인 단위 테스트의 작성은 아니지만 controller에 대한 단위 테스트는 꼭 작성하게 되었는데 이번 기회에 단위 테스트를 작성하면 어떤 장점이 있고 왜 필요한지에 대한 생각을 많이 하면서 느낀 점을 추후에 이 포스트에 업데이트를 해야겠습니다.

 

테스트 코드를 작성하면서 느낀 점

Spring REST Doc을 적용하면서 컨트롤러 단위 테스트를 작성하고 있는데 컨트롤러 로직이 간단하고 성공 케이스에 대해서만 단위 테스트를 작성하고 있어서 그런지 단위 테스트에 대한 장점을 딱히 느끼지 못하였습니다.

 

많은 개발자들과 협업 시에 공통으로 많이 쓰는 메소드나 오브젝트가 있을 경우에는 이것들을 쓰는 코드들은 테스트 코드를 작성함으로써 메소드의 반환 타입이나 내부 로직 그리고 오브젝트가 변경되었을 경우 영향받는 곳을  빠르게 찾을 수 있다는 장점은 있을 것 같은데 저의 경험상 아직까지는 이런 경우는 발생하지 않아서인지 저의 생각을 확정 지을 수는 없을 것 같습니다.

 

그래서 내린 결론은 컨트롤러 테스트 같은 경우에는 parameter에 validation과 예외 테스트, 서비스에 비즈니스 로직 테스트 같은 경우는 목 객체를 사용하여 의존 객체를 대체한 후 성공과 예외 테스트, 레포지토리 테스트 같은 경우에는 실제 데이터베이스나 인 메모리 데이터베이스(예를 들어 H2 DB)를 사용하여 데이터베이스에 대한 CRUD 테스트가 진행되어야 한다는 생각을 하게 되었는데 이 부분은 테스트에 대해서 공부를 더 해보면서 잘 못된 생각이 있으면 바로잡아야 할 거 같습니다.

728x90