[Spring][Docs] Spring REST Docs

3 분 소요

들어가며

디프만 11기 백엔드 개발자로 합류하면서 API 문서화를 Spring REST Docs로 진행하게 되었다. 따라서 기존에 사용하던 Swagger와 더불어 그 사용법과 원리를 기록하기 위한 포스팅이다.

출처

Spring REST Docs 적용 및 최적화 하기, 공식문서, 우아한형제들 기술 블로그

Swagger vs REST Docs

  • Swagger
    • 테스트 기반이 아니므로 실제 API와 다를 수 있으며 접근 안전성이 보장되지 않는다.
    • UI에서 바로 API 테스트가 가능한다.
    • 잘 구성되어 있는 화면 UI를 통해 가독성이 매우 좋다.
    • product 코드가 문서화를 위한 애노테이션에 오염되어 Controller 가독성이 조금은 떨어질 수 있다.
    • 애노테이션 기반으로 적용하기 매우 쉽다.
  • REST Docs
    • 테스트 기반 API 문서이므로 안전성이 보장된다.
    • 문서화를 위한 테스트 코드가 많이 소요된다.
    • product 코드에 영향이 없다.
  • 나는 다음과 같은 이유로 Swagger가 조금 더 낫다고 생각한다.
    • 어차피 Controller 단위 테스트를 진행하므로 REST Docs처럼 안정성을 보장받을 수 있는 점
    • 애노테이션 기반으로 적용하기 매우 쉽고 생산성이 향상된다는 점
    • API 문서는 백엔드뿐만 아니라 프론트 개발자분들과의 협업을 위해 필요하므로 간편한 UI 테스트 등을 지원하는 Swagger에 대한 프론트 개발자의 의견이 고려되어야 한다는 점
    • 문서화 코드에 오염된다는 부분은 어느 정도 긍정하나 Controller에는 URL 매핑을 위한 코드만 존재할 뿐 서비스 로직이 없어야 한다는 점 등을 고려할 때 문서화를 위한 애노테이션 2~3개가 붙는다는 이유로 가독성이 떨어진다는 것은 크게 공감하기 어려운 점
  • 위와 같은 이유로 Swagger에 한 표 던졌지만… 다른 팀원분들의 다수결에 따라 REST Docs로 진행하게 되었다. 둘 다 다룰 줄 안다면 시너지 효과를 기대해 볼 수 있겠다는 생각에 제대로 공부를 하게 되었다.

사용 코드

  • build.gradle
plugins {
    ...
	id "org.asciidoctor.jvm.convert" version "3.3.2"
}

configurations {
	asciidoctorExtensions
    ...
}

repositories {
	mavenCentral()
}

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

ext {
	snippetsDir = file('build/generated-snippets')
}

test {
	useJUnitPlatform()
	outputs.dir snippetsDir
}

asciidoctor.doFirst {
	delete file('src/main/resources/static/docs')
}

asciidoctor {
	inputs.dir snippetsDir
	dependsOn test
	configurations 'asciidoctorExtensions'
	sources {
		include("**/*.adoc")
	}
	baseDirFollowsSourceFile()
}

task copyDocument(type: Copy) {
	dependsOn asciidoctor

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

build {
	dependsOn copyDocument
}

bootJar {
	dependsOn copyDocument

	from file('src/main/resources/static/docs')
	into file('build/resources/main/static/docs')
}
  • RestDocsConfig
@TestConfiguration
public class RestDocsConfig {

    @Bean
    public RestDocumentationResultHandler write() {
        return MockMvcRestDocumentation.document(
                "{class-name}/{method-name}",
                Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
                Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
        );
    }
}
  • RestDocsSupport
public interface RestDocsSupport {

    public static Attribute field(String key, String value) {
        return new Attribute(key, value);
    }
}
  • TestControllerTest
@WebMvcTest(TestController.class)
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
class TestControllerTest {

    MockMvc mockMvc;
    ObjectMapper objectMapper = new ObjectMapper();

    @Autowired
    RestDocumentationResultHandler restDocs;

    @BeforeEach
    void setUp(WebApplicationContext context, RestDocumentationContextProvider provider) {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
                .apply(MockMvcRestDocumentation.documentationConfiguration(provider))
                .alwaysDo(MockMvcResultHandlers.print())
                .alwaysDo(restDocs)
                .addFilter(new CharacterEncodingFilter("UTF-8", true))
                .build();
    }

    @Test
    @DisplayName("getAccount")
    public void getAccount() throws Exception {
        mockMvc.perform(get("/{id}", 1L).contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andDo(
                        restDocs.document(
                                pathParameters(
                                        parameterWithName("id").description("Account id")
                                ),
                                responseFields(
                                        fieldWithPath("id").description("id"),
                                        fieldWithPath("name").description("name"),
                                        fieldWithPath("email").description("email")
                                ))
                );
    }

    @Test
    @DisplayName("getAccountList")
    public void getAccountList() throws Exception {
        mockMvc.perform(get("/accounts").param("size", "10").param("page", "0"))
                .andExpect(status().isOk())
                .andDo(
                        restDocs.document(
                                requestParameters(
                                        parameterWithName("size").optional().description("size"),
                                        parameterWithName("page").optional().description("page")
                                ),
                                responseFields(
                                        fieldWithPath("data").description("데이터"),
                                        fieldWithPath("data.list[0].id").description("id"),
                                        fieldWithPath("data.list[0].email").description("email"),
                                        fieldWithPath("data.list[0].name").description("name")
                                )
                        )
                );
    }

    @Test
    @DisplayName("createAccount")
    public void createAccount() throws Exception {
        AccountDTO dto = AccountDTO.builder().name("yhw").email("yhwjjang1995@naver.com").build();
        String json = objectMapper.writeValueAsString(dto);

        mockMvc.perform(post("/").contentType(MediaType.APPLICATION_JSON).content(json))
                .andExpect(status().isCreated())
                .andDo(
                        restDocs.document(
                                requestFields(
                                        fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하")),
                                        fieldWithPath("email").description("email").attributes(field("constraints", "길이 30 이하"))
                                )
                        )
                );
    }
}
  • index.adoc
= REST Docs 문서 만들기
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

[[Account-API]]
== Account API

=== Account 단일 조회
* link:Account-단일-조회.html[Account 단일 조회 API, window=_blank]

=== Account 모두 조회
* link:Account-모두-조회.html[Account 모두 조회 API, window=_blank]

=== Account 생성
* link:Account-생성.html[Account 생성 조회 API, window=_blank]
  • Account-단일-조회.adoc: 나머지 조각 adoc 파일도 비슷한 형태
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toclevels: 2
:sectlinks:

[[Account-단일-조회]]
=== Account 단일 조회
include::{snippets}/test-controller-test/get-account/http-request.adoc[]
include::{snippets}/test-controller-test/get-account/http-response.adoc[]
include::{snippets}/test-controller-test/get-account/path-parameters.adoc[]
include::{snippets}/test-controller-test/get-account/response-body.adoc[]
include::{snippets}/test-controller-test/get-account/response-fields.adoc[]

멀티 모듈 프로젝트와 관계

  • 이번 프로젝트는 MSA 구조로 진행하게 되었다. 이와 같은 구조에서 어떻게 하면 깔끔한 API 문서를 제공할 수 있을까 고민해 보았다.
  • 각 기능 모듈에서 각자 Controller 단위 테스트 진행 후 .html 파일을 생성한 후에 jar 파일로 패키징을 하고 이후 app 모듈에서 index.adoc 파일을 하나만 유지한 채 각 기능 모듈의 .html 링크만 거는 방식을 생각해 보았다. 그 이유는 하나의 app 모듈 같은 패키지에서 하나의 index.adoc 파일로 여러 명이 같이 테스트를 진행한다면 다음과 같은 문제가 발생할 수 있을 것 같기 때문이다.
    • 기능별로 모듈을 나눴다면 결국 API 문서도 모듈 단위로 제공되어야 하는 점
    • 기능 모듈 API 문서가 존재하지 않는다면 인수인계받은 개발자가 특정 기능 모듈을 사용하려고 할 때 결국 의존성을 뒤져서 API 스펙을 확인해야 한다는 점
    • 하나의 index.adoc 파일로 여러 명이 한 번에 작업을 한다면 conflict이 발생할 수 있는 점
    • 각 개발자가 같은 app 모듈 패키지에서 테스트를 진행한다면 커스텀한 snippet이 겹치거나 충돌될 수 있는 점
    • 기능 모듈에서는 단위 테스트를 진행하고 필요한 기능을 갖춘 하나의 app 모듈에서는 통합 테스트가 진행되는 것이 적절하다고 생각되는 점
    • 하나의 app 모듈에서 테스트를 진행하게 된다면 힘들게 기능별로 모듈을 나눴는데 문서화를 위해서 굳이 또 Controller 테스트만 합쳐야 된다는 점
    • 특정 기능 모듈이 다른 app 모듈에서 중복 사용된다면 결국 테스트 코드를 중복해서 계속 작성해야 하는 점
    • 프로젝트 구조가 커진다면 결국 문서 분리화를 통한 가독성 확보가 필수 불가결 한 점
  • 하지만 이번 프로젝트에서는 규모가 크지는 않을 것 같고 다른 팀원분들의 의견에 따라 하나의 app 모듈에서 하나의 index.adoc 파일에서 함께 작업하기로 했다.

고찰

  • 이번 프로젝트에서 사용한 Spring REST Docs를 통해 Swagger와는 다른 장점을 볼 수 있었다.
  • 코딩을 하면서 공통된 API 스펙의 경우 따로 공통화 시키는 작업이 필요할 수 있을 것 같다.

댓글남기기