Java/Spring & Spring Boot

Spring Rest Docs (API 문서화)

마손리 2023. 5. 3. 21:28

API 문서화란 클라이언트가 REST API 백엔드 애플리케이션에 요청을 전송하기 위해서 알아야 되는 요청 정보(요청 URL(또는 URI), request body, query parameter 등)를 문서로 잘 정리하는 것을 의미한다.

 

API 자동 문서화의 방법으로 SwaggerSpring Rest Docs를 사용한다.

 

둘의 특징은 다음과 같으며 이번 포스트에서는 Spring Rest Docs를 사용한다.

  • Swagger의 API 문서화 방식
    • 애터네이션 기반의 API 문서화 방식
    • 애플리케이션 코드에 문서화를 위한 애너테이션들이 포함된다.
    • 가독성 및 유지 보수성이 떨어진다.
    • API 문서와 API 코드 간의 정보 불일치 문제가 발생할 수 있다.
    • API 툴로써의 기능을 활용할 수 있다.
  • Spring Rest Docs의 API 문서화 방식
    • 테스트 코드 기반의 API 문서화 방식
    • 애플리케이션 코드에 문서화를 위한 정보들이 포함되지 않는다.
    • 테스트 케이스의 실행이 “passed”여야 API 문서가 생성된다.
    • 테스트 케이스를 반드시 작성해야 된다.
    • API 툴로써의 기능은 제공하지 않는다.

 

Spring Rest Docs 사용 순서

Spring Rest Docs가 개발자를 도와 API문서를 자동으로 생성해주지만 이를 위해 개발자가 해야할 작업이 있다.

 

  1. 초기 설정
    1. build.gradle 설정
    2. index.adoc 파일 생성
  2. 테스트 코드 작성
    1. 슬라이스 테스트 코드 작성 : Spring Rest Docs는 Controller의 슬라이스 테스트와 밀접한 관련이 있기에 Controller에 대한 슬라이스 테스트 코드를 먼저 작성한다.
    2. API 스펙 정보 코드 작성 : 슬라이스 테스트 코드 다음에 Controller에 정의되어 있는 API 스펙 정보(Request Body, Response Body, Query Parameter 등)를 코드로 작성한다.
  3. test 태스크(task) 실행 및 API 문서 스니펫(.adoc파일) 생성
    1. 작성된 슬라이스 테스트 코드를 실행 시켜 테스트를 통과할시 위에서 작성된 API 스펙 정보 코드를 기반으로 API 문서 스니펫(snippet)이 자동 생성된다. (스니펫이란 문서의 부분 조각들)
  4. API 문서 생성 (Asciidoc 작성)
    1. Asciidoc을 작성하여 위에서 생성된 스니펫을 모아 하나의 API 문서로 생성한다. (Asciidoc이란 기술 문서 작성을 위해 설계된 마크업 언어)
  5. API 문서를 HTML로 변환

 

여기서 개발자가 해야될 작업은 다음과 같다.

  1. 초기 설정
  2. 테스트코드 작성 및 테스트 실행
  3. Asciidoc을 작성하여 자동으로 생성된 스니펫들을 모아 API 문서 생성

 

 

1.  초기 설정 (build.gradle 설정 및 index.adoc 파일 생성)

 

build.gradle 설정

plugins {
	id 'org.springframework.boot' version '2.7.1'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id "org.asciidoctor.jvm.convert" version "3.3.2"    // (1)
	id 'java'
}

group = 'com.codestates'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}

// (2)
ext {
	set('snippetsDir', file("build/generated-snippets"))
}

// (3)
configurations {
	asciidoctorExtensions
}

dependencies {
        // (4)
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
  
        // (5) 
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.mapstruct:mapstruct:1.5.1.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
	implementation 'org.springframework.boot:spring-boot-starter-mail'

	implementation 'com.google.code.gson:gson'
}

// (6)
tasks.named('test') {
	outputs.dir snippetsDir
	useJUnitPlatform()
}

// (7)
tasks.named('asciidoctor') {
	configurations "asciidoctorExtensions"
	inputs.dir snippetsDir
	dependsOn test
}

// (8)
task copyDocument(type: Copy) {
	dependsOn asciidoctor            // (8-1)
	from file("${asciidoctor.outputDir}")   // (8-2)
	into file("src/main/resources/static/docs")   // (8-3)
}

build {
	dependsOn copyDocument  // (9)
}

// (10)
bootJar {
	dependsOn copyDocument    // (10-1)
	from ("${asciidoctor.outputDir}") {  // (10-2)
		into 'static/docs'     // (10-3)
	}
}
  1. (1)에서는 .adoc 파일 확장자를 가지는 AsciiDoc 문서를 생성해 주는 Asciidoctor를 사용하기 위한 플러그인을 추가한다.
  2. (2)에서는 ext 변수의 set() 메서드를 이용해서 API 문서 스니펫이 생성될 경로를 지정한다.
  3. (3)에서는 AsciiDoctor에서 사용되는 의존 그룹을 지정하고 있다. :asciidoctor task가 실행되면 내부적으로 (3)에서 지정한 ‘asciidoctorExtensions’라는 그룹을 지정한다.
  4. (4)에서 'org.springframework.restdocs:spring-restdocs-mockmvc'를 추가함으로써 spring-restdocs-core와 spring-restdocs-mockmvc 의존 라이브러리가 추가된다.
  5. (5)에서 spring-restdocs-asciidoctor 의존 라이브러리를 추가한다. (3)에서 지정한 asciidoctorExtensions 그룹에 의존 라이브러리가 포함이 된다.
  6. (6)에서는 :test task 실행 시, API 문서 생성 스니펫 디렉토리 경로를 설정한다.
  7. (7)에서는 :asciidoctor task 실행 시, Asciidoctor 기능을 사용하기 위해 :asciidoctor task에 asciidoctorExtensions을 설정한다.
  8. (8)은 :build task 실행 전에 실행되는 task이다. :copyDocument task가 수행되면 index.html 파일이 src/main/resources/static/docs 에 copy 되며, copy 된 index.html 파일은 API 문서를 파일 형태로 외부에 제공하기 위한 용도로 사용할 수 있다.
    1. (8-1)에서는 :asciidoctor task가 실행된 후에 task가 실행되도록 의존성을 설정.
    2. (8-2)에서는 "build/docs/asciidoc/" 경로에 생성되는 index.adoc를 copy한 후,
    3. (8-3)의 "src/main/resources/static/docs" 경로로 index.html을 추가해 준다.
  9. (9)에서는 :build task가 실행되기 전에 :copyDocument task가 먼저 수행되도록 한다.
  10. (10)에서는 애플리케이션 실행 파일이 생성하는 :bootJar task 설정이다.
    1. (10-1)에서는 :bootJar task 실행 전에 :copyDocument task가 실행되도록 의존성을 설정한다.
    2. (10-2)와 (10-3)에서는 Asciidoctor 실행으로 생성되는 index.html 파일을 jar 파일 안에 추가해 준다. jar 파일에 index.html을 추가해 줌으로써 웹 브라우저에서 접속(http://localhost:8080/docs/index.html) 후, API 문서를 확인할 수 있다.

(8)에서 copy되는 index.html은 외부에 제공하기 위한 용도이고, (10)에서는 index.html을 애플리케이션 실행 파일인 jar 파일에 포함해서 웹 브라우저에서 API 문서를 확인하기 위한 용도이다.

 

 

 index.adoc 파일 생성

build.gradle 설정을 완료 한 뒤, 나중에 테스트 이후 생성될 API 문서 스니펫들을 사용하여 최종 API 문서로 만들어 주는 템플릿 문서 (index.adoc)를 생성해야한다. 

  • Gradle 기반 프로젝트에서는 아래 경로에 해당하는 디렉토리를 생성해 주어야 한다.
    • src/docs/asciidoc/
  • 다음으로 src/docs/asciidoc/ 디렉토리 내에 비어있는 템플릿 문서(index.adoc)를 생성해 준다.

 

 

2.  테스트 코드 및 API 정보 코드 작성

이전 포스트와 같이 API 계층에 대한 슬라이스 테스트 코드를 작성해준 뒤 Spring Rest Docs을 이용한 API 정보 코드를 작성해 주어야 한다.

 

테스트코드의 기본구조

@EnableJpaAuditing
@SpringBootApplication
public class JavaProjectApplication {
	public static void main(String[] args) {
		SpringApplication.run(Section3Week3HomeworkJunitApplication.class, args);
	}

}
@WebMvcTest(MemberController.class)   // (1)
@MockBean(JpaMetamodelMappingContext.class)   // (2)
@AutoConfigureRestDocs    // (3)
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc;  
    @MockBean 
    private MemberService memberService;
    @Test
    public void postMemberTest() throws Exception {
        // given, 테스트를 위한 given 작성
		
        // when, 테스트를 위한 when 작성
        ResultActions actions =
                mockMvc.perform(
                );

        // then
        actions
                .andExpect(// (4) response에 대한 기대 값 검증)
                .andDo(document(
                        // (5) API 문서 스펙 정보 추가
                ));
    }
}
  1. 이전 포스트의 테스트 코드와는 달리 (1)에서 @SpringBootTest 애너테이션을 사용하지 않고, @WebMvcTest 애너테이션을 사용했다. @WebMvcTest 애너테이션은 Controller를 테스트하기 위한 전용 애너테이션이다. @WebMvcTest 애너테이션의 괄호 안에는 테스트 대상 Controller 클래스를 지정한다.
    • @SpringBootTest 에너테이션을 사용하더라도 Spring Rest Docs의 사용방식에 변함은 없다.
  2. (2)JPA에서 사용하는 Bean(Entity 등) 들을 Mock 객체로 주입해 주는 설정이다. Spring Boot 기반의 테스트는 항상 최상위 패키지 경로에 있는 xxxxxxxApplication 클래스를 찾아서 실행한다.
    • 첫번째 코드와 같이 @EnableJpaAuditing 에너테이션을 해당 어플리케이션의 최상위 클래스(ex. JavaProjectApplication)에 추가하여야 @MockBean 에너테이션을 사용할 수 있다.
  3. (3)에서는 Spring Rest Docs(API 문서 생성에 필요)에 대한 자동 구성을 위해 @AutoConfigureRestDocs를 추가해 준다.
  4. 이전 포스트의 슬라이스 테스트와 같이 테스트를 위한 given, when, then을 작성해준다.
  5. 마지막으로 Spring Rest Docs api 메서드(ex. document())들을 사용하여 API 문서에 필요한 Snippet을 생성을 위한 코드를 작성 해준다.

 

여기까지가 Spring Rest Docs를 사용하기 위한 기본 구조 였으며 이제부터 실제 예제 코드를 살펴 보자.

 

 

실제 예제 코드

// (1) 테스트와 API 문서화에 사용되는 Api들의 static 메서드들
import static com.codestates.util.ApiDocumentUtils.getRequestPreProcessor;
import static com.codestates.util.ApiDocumentUtils.getResponsePreProcessor;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.BDDMockito.given;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName;
import static org.springframework.restdocs.request.RequestDocumentation.pathParameters;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(MemberController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
public class MemberControllerRestDocsTest {
    @Autowired
    private MockMvc mockMvc;
    @MockBean
    private MemberService memberService;
    @MockBean
    private MemberMapper mapper;
    @Autowired
    private Gson gson;

    @Test
    public void patchMemberTest() throws Exception {
        // given
        long memberId = 1L;
        MemberDto.Patch patch = new MemberDto.Patch(memberId, "Member1", "010-1111-1111", Member.MemberStatus.MEMBER_ACTIVE);
        String content = gson.toJson(patch);

        MemberDto.Response responseDto =
                new MemberDto.Response(1L,
                        "member1@test1.com",
                        "Member1",
                        "010-1111-1111",
                        Member.MemberStatus.MEMBER_ACTIVE,
                        new Stamp());

        given(mapper.memberPatchToMember(Mockito.any(MemberDto.Patch.class))).willReturn(new Member());
        given(memberService.updateMember(Mockito.any(Member.class))).willReturn(new Member());
        given(mapper.memberToMemberResponse(Mockito.any(Member.class))).willReturn(responseDto);

        // when
        ResultActions actions =
                mockMvc.perform(
                            patch("/v11/members/{member-id}", memberId)
                                .accept(MediaType.APPLICATION_JSON)
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(content)
                );

        // then
        actions
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.memberId").value(patch.getMemberId()))
                .andExpect(jsonPath("$.data.name").value(patch.getName()))
                .andExpect(jsonPath("$.data.phone").value(patch.getPhone()))
                .andExpect(jsonPath("$.data.memberStatus").value(patch.getMemberStatus().getStatus()))
                .andDo(document("patch-member", // (2) Spring Rest Docs를 사용
                        getRequestPreProcessor(), // (3) preprocessRequest(prettyPrint())
                        getResponsePreProcessor(), // preprocessResponse(prettyPrint())
                        pathParameters( // (4)
                                parameterWithName("member-id").description("회원 식별자")
                        ),
                        requestFields( // (5)
                                List.of( // (5-1)
                                        fieldWithPath("memberId").type(JsonFieldType.NUMBER).description("회원 식별자").ignored(),
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("이름").optional(),
                                        fieldWithPath("phone").type(JsonFieldType.STRING).description("휴대폰 번호").optional(),
                                        fieldWithPath("memberStatus").type(JsonFieldType.STRING).description("회원 상태: MEMBER_ACTIVE / MEMBER_SLEEP / MEMBER_QUIT").optional()
                                )
                        ),
                        responseFields( // (6)
                                List.of(
                                        fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 데이터"),
                                        fieldWithPath("data.memberId").type(JsonFieldType.NUMBER).description("회원 식별자"),
                                        fieldWithPath("data.email").type(JsonFieldType.STRING).description("이메일"),
                                        fieldWithPath("data.name").type(JsonFieldType.STRING).description("이름"),
                                        fieldWithPath("data.phone").type(JsonFieldType.STRING).description("휴대폰 번호"),
                                        fieldWithPath("data.memberStatus").type(JsonFieldType.STRING).description("회원 상태: 활동중 / 휴면 상태 / 탈퇴 상태"),
                                        fieldWithPath("data.stamp").type(JsonFieldType.NUMBER).description("스탬프 갯수")
                                )
                        )
                ));
    }
}
  1. (1)번은 해당 테스트와 Spring Rest Docs에 관련된 static 메서드들이다.
    1. 이전포스트에서는 patch와 post같은 static 메서드들 Spring Rest Docs를 사용하기 위해선 RestDocumentationRequestBuilders 클래스의 메서드들을 호출해줘야 한다. 
      (이전 포스트와 같이 MockMvcRequestBuilders 클래스에서 호출할 경우 테스트 실패)
  2. (2)의 document(…) 메서드는 API 스펙 정보를 전달받아서 실질적인 문서화 작업을 수행하는 RestDocumentationResultHandler 클래스에서 가장 핵심 기능을 하는 메서드이다.
    • document() 메서드의 첫 번째 파라미터인 "patch-member" API 문서 스니펫의 식별자 역할을 한다.
    • 테스트에 모두 통과하면 build.gradle의 ext에서 설정한 폴더인 build/generated-snippets에 "patch-member"라는 폴더에 API 문서의 스니펫들이 생성된다. 
  3. (3)번의 getRequestPreProcessor()와 getResponsePreProcessor()는 그저 preprocessRequest(prettyPrint())preprocessResponse(prettyPrint())을 리턴하는 스태틱 메서드로 편의성을 위해 따로 만들어 주었다.
    • preprocessRequest(prettyPrint())와 preprocessResponse(prettyPrint())는 권장사항일 뿐이므로 생략해 주어도 된다. (하지만 사용하는 것을 권장)
    • preprocessRequest(prettyPrint())와 preprocessResponse(prettyPrint())을 사용하면 나중에 스니펫 생성시 JSON 포멧의 request body와 response body가 가독성 좋게 표현이 된다.
    • preprocessRequest(prettyPrint())와 preprocessResponse(prettyPrint()) 사용시 순서를 지켜주지 않으면 컴파일 에러를 발생한다. (사용 순서 주의)
  4. (4)번의 pathParameters()는 URI에서 사용되는 변수들을 표현해준다. (Controller의 @PathVarialbe로 들어오는 파라미터 변수) 해당 코드에서는 회원 ID에 속한다.
  5. (5)번의 requestFields()를 사용하면 request body테이블로 문서화하여 표현한 스니펫이 생성된다. 
    파라미터로 전달되는 List의 원소인 FieldDescriptor 객체가 request body에 포함된 데이터를 표현한다.
    • (5-1)은 request body를 JSON 포맷으로 표현했을 때, 하나의 프로퍼티를 의미하는 FieldDescriptor이다. type(JsonFieldType.NUMBER)은 JSON 프로퍼티의 값이 숫자형을 의미하며 OBJECT, ARRAY등의 타입을 명시해줄 수 있다.
    • (5-1)의 ignored()와 optional()등으로 해당 입력값들의 정보를 제공
  6. (6)번의 responseField()는 (5)번과 마찬가지로 respose body를 테이블로 문서화하여 표현한다.

 

 

코드를 작성한뒤 테스트를 실행하여 테스트에 모두 통과한다면  build.gradle과 document()에서 설정한 폴더로 그림과 같이 스니펫들이 자동으로 생성된다.

설정한 폴더에 자동으로 생성된 스니펫들
request-fields.adoc, (5)번 requestFields에서 작성한 정보들이 자동으로 테이블로 표현이 된다.

 

기타

preprocessRequest(prettyPrint()) 사용하지 않았을 경우

// preprocessRequest(prettyPrint())를 사용하지 않은 경우
{"memberId":1,"name":"Member1","phone":"010-1111-1111","memberStatus":"MEMBER_ACTIVE"}

// preprocessRequest(prettyPrint())를 사용한 경우
{
  "memberId" : 1,
  "name" : "Member1",
  "phone" : "010-1111-1111",
  "memberStatus" : "MEMBER_ACTIVE"
}

(3)번의 preprocessRequest(prettyPrint())와 preprocessResponse(prettyPrint())를 사용여부에 따른 스니펫의 표현 차이이다. 왠만하면 사용하도록 하자

 

 

생성된 snippet과 테스트코드 비교

생성된 스니펫들과 테스트코드를 비교해 살펴보면 Spring Rest Docs로 작성한 코드인 pathParameters(), requestFields() responseFields()의 스니펫들만 테이블로 표현이 되었다.

 

또한 request-field 스니펫을 살펴보면 memberId가 없는데 코드에서 memberId의 필드에 .ignored()를 사용하여 request body에 포함이 안되도록 정보를 주고있다.

(해당 어플리케이션에서는 memberId를 path parameter로 변수를 받아주고있다. 또한 MemberDto.Patch에 memberId가 필요하므로 requestFields()에 memberId의 필드를 넣어주지 않으면 테스트에서 실패하기 때문에 꼭 필드를 작성하고 .ignored() 메서드를 붙여주자)

 

 

3. Asciidoc markup 작성

이제 마지막 단계이다.

 

코드 작성 이전 생성한 src/docs/asciidoc/index.adoc에 만들어진 스니펫을 이용한 Asciidoc markup을 작성하면 API문서가 작성되고 빌드를 해주면 서버에서 해당 문서를 열람할 수 있다.

 

src/docs/asciidoc/index.adoc에 Asciidoc 작성

= 커피 주문 애플리케이션
:sectnums:
:toc: left
:toclevels: 4
:toc-title: Table of Contents
:source-highlighter: prettify

Mason Lee <lts890303@gmail.com>

v1.0.0, 2023.05.04

***
== MemberController
=== 회원 정보 수정
.curl-request
include::{snippets}/patch-member/curl-request.adoc[]

.http-request
include::{snippets}/patch-member/http-request.adoc[]

.path-parameters
include::{snippets}/patch-member/path-parameters.adoc[]

.request-fields
include::{snippets}/patch-member/request-fields.adoc[]

.http-response
include::{snippets}/patch-member/http-response.adoc[]

.response-fields
include::{snippets}/patch-member/response-fields.adoc[]

 

위의 코드를 index.adoc에 그림의 왼쪽과 같이 작성하면 오른쪽처럼 API문서가 표현된다.

 

 

빌드

모든 작업 완료 후 Gradle에서 build를 해주면 src/main/resources/static.docs에 index.html 문서가 생성되며 

서버 가동 이후 브라우저를 이용해 "localhost:8080/docs/index.html"에 접속하면 해당 API문서를 확인할 수 있다.

 

 

 

보완 사항

여기까지가 Spring Rest Docs를 이용하여 API 문서 자동 생성의 기본적인 방법이다.

하지만 몇가지 보완해야 될 것들이 남아있다.

  1. Request body의 optional 필드에 optional에 대한 정보 누락
  2. 유효성 검증에 대한 정보 누락

위의 사항은 다음 포스트에서 보완함.

 

 

예시 코드 깃헙

https://github.com/Mason3144/Java_test_practiceAndExamples

 

 

마무리

@SpringBootTest vs @WebMvcTest

이전 포스트에서는 @SpringBootTest + @AutoConfigureMockMvc 애너테이션으로 Controller의 테스트를 진행했었다.

 

@SpringBootTest와 @WebMvcTest의 차이점은?

  • @SpringBootTest 
    @AutoConfigureMockMvc와 함께 사용하여 Controller를 테스트.
    자동으로 프로젝트에서 사용하는 전체 Bean을 ApplicationContext에 등록하여 사용.
    그렇기에 테스트 환경을 구성하는 것은 편리하지만 실행 속도가 상대적으로 느림.

  • @WebMvcTest
    Controller 테스트에 필요한 Bean만 ApplicationContext에 등록.
    실행 속도가 상대적으로 빠름.
    하지만 Controller에서 의존하고 있는 객체가 있다면 @MockBean을 이용하여 해당 객체를 Mock 객체로 사용하여 의존성을 일일이 제거 해주어야 함.

 

 

결과적으로 @SpringBootTest는 데이터베이스까지 요청 프로세스가 이어지는 통합 테스트에 주로 사용되고, @WebMvcTestController를 위한 슬라이스 테스트에 주로 사용된다.

 

 

 

'Java > Spring & Spring Boot' 카테고리의 다른 글

Spring Rest Docs Custom  (0) 2023.05.07
Slice test  (0) 2023.05.01
Unit test (단위 테스트)와 테스트 종류  (0) 2023.05.01
DB N+1 문제해결(JPQL의 join fetch 사용)  (0) 2023.04.29
이벤트리스너와 비동기  (2) 2023.04.28