Pull Request 015. API 문서화 적용하기 - Spring Rest Docs
API 문서화를 위해서는 크게 두 가지 방법이 있다. 하나는 이번 포스팅에서 서술할 Spring Rest Docs를 사용하는 것이고, 하나는 Swagger를 사용하는 것이다.
두 방법의 가장 큰 차이는 '문서화를 위한 코드를 어디에 작성할 것인가?'에 있다. Swagger는 프로덕션 코드에 문서화를 위한 코드를 작성한다. Spring Rest Docs는 테스트 코드에 문서화를 위한 코드를 작성한다.
이번 프로젝트에는 Spring Rest Docs를 적용해볼 예정이다. 애초에 TDD 구현 방법을 연습하기 위해 모든 경우에 테스트 코드를 먼저 작성할 예정이고, 기왕 테스트 코드를 작성한 김에 테스트 코드에 문서화 코드까지 적용시키는게 낫겠다고 생각했기 때문이다. 또한 많은 분들이 Swagger의 UI만을 이용해 Swagger + Spring Rest Docs를 사용하는 방법을 올려놔주셨던데, 이것은 프로젝트가 어느정도 정리가 되면 진행해보고자 한다.
Spring Rest Docs가 문서를 작성하기 위해서는 테스트를 통과해야 한다. 테스트를 통과하지 못하면, 문서 생성 작업을 시작하지 않는다. 테스트를 통과하는 것에 더해, 작성한 API 스펙 정보 역시 구현한 코드와 동일해야한다. 즉, 적절히 테스트와 기능을 구현하고, 구현한 기능에 맞춰 정확하게 API 스펙 정보를 작성하지 않으면 아예 문서 자체가 생성되지 않는다. 사실 둘 중 하나만 틀려도 테스트가 실패하기 때문에 무엇을 잘못했는지를 쉽게 파악할 수 있다는 장점도 있다.
일단 테스트를 통과하면, 테스트 코드에 작성한 API 스펙 정보 코드를 기반으로 API문서 스니펫이 .adoc 확장자 파일로 생성된다. 이후 생성된 스니펫들을 모아 하나의 API문서로 만들며, 작성된 문서를 HTML파일로 만들면 우리가 눈으로 확인할 수 있는 문서가 완성된다.
Spring Rest Docs를 사용하기 위해서는 먼저 gradle.build 파일부터 건드려야 한다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.17'
id 'io.spring.dependency-management' version '1.1.3'
id "org.asciidoctor.jvm.convert" version "3.3.2" // 1
}
group = 'tdd'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '11'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
ext { //2
set('snippetsDir', file("build/generated-snippets"))
}
configurations { //3
asciidoctorExtensions
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation 'org.springframework.restdocs:spring-restdocs-core:2.0.7.RELEASE' // 4
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // 5
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 6
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'com.google.code.gson:gson:2.10.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// testImplementation 'org.springframework.security:spring-security-test'
testImplementation "org.assertj:assertj-core:3.20.2"
}
tasks.named('test') {
outputs.dir snippetsDir // 7
useJUnitPlatform()
}
tasks.named('asciidoctor') { // 8
configurations "asciidoctorExtensions"
inputs.dir snippetsDir
dependsOn test
}
task copyDocument(type: Copy) { // 9
dependsOn asciidoctor
from file("${asciidoctor.outputDir}")
into file("src/main/resources/static/docs")
}
build { // 10
dependsOn copyDocument
}
bootJar { // 11
dependsOn copyDocument
from("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
위에서부터 주석 번호가 달린 부분들이 Spring Rest Docs 적용을 위해 새롭게 작성한 부분이다. 하나하나 살펴보자.
- .adoc 확장자를 가지는 AsciiDoc 문서를 생성해주는 Asciidoctor를 사용하기 위한 플러그인을 추가한다.
- ext 변수의 set() 메서드를 이용해 API문서 스니펫이 생성될 경로를 지정한다.
- AsciiDoctor에서 사용되는 의존 그룹을 지정한다. :asciidoctor task가 실행되면 이 부분에서 지정한 'asciidoctorExtensions'라는 그룹을 지정한다.
- spring-restdocs-core 라이브러리를 추가한다.
- spring-restdocs-mockmvc 라이브러리를 추가한다.
- 5번만 해도 4번이 되는 걸로 알고있는데, 왜인지 안되더라. 그래서 4번을 따로 추가했다.
- spring-restdocs-asciidoctor 라이브러리를 추가한다. 3에서 지정한 asciidoctorExtensions 그룹에 해당 라리브러리가 포함된다.
- :test task 실행 시, API문서 생성스니펫 디렉토리 경로를 설정한다.
- :asciidoctor task 실행 시, Asciidoctor 기능을 사용하기 위해 :asciidoctor task에 asciidoctorExtenstions를 설정한다.
- 9번은 :build task가 실행되기 전에 실행된다. :copyDocument task가 수행되면 index.html파일이 'src/main/resources/static/docs' 에 copy 되며, copy된 index.html 파일은 API 문서를 파일 형태로 외부에 제공하기 위한 용도로 사용할 수 있다.
- :build 가 실행되기 전에 :copyDocument가 먼저 실행될 수 있도록 설정한다.
- :bootJar는 애플리케이션 실행파일을 설정하는 task다. 해당 설정을 통해 :bootJar 실행 전에 :copyDocument가 실행되도록 하며 Asciidoctor 실행으로 생성되는 index.html 파일을 jar파일 안에 추가해준다.
- index.html파일을 추가해줌으로서 추후 애플리케이션을 실행하고 `domain + /docs/index.html` 의 형태로 API 문서를 확인할 수 있다.
이렇게 build.gradle 설정을 마치면 src/docs/asciidoc/ 의 경로로 디렉토리를 만들어주고 해당 디렉토리 내에 index.adoc 파일을 생성해주어야 한다. 이제 Spring Rest Docs를 테스트 코드에 적용해보자.
@Test
@DisplayName("정상적인 게시글 등록 요청 테스트")
void postFreeBoardTest() throws Exception {
... 테스트를 위한 given 단계 코드들 ... (앞선 포스팅에서 확인하세요!)
// when // then
mockMvc.perform(
post("/free-board") // 1
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(content)
).andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value(testPost.getTitle()))
.andExpect(jsonPath("$.content").value(testPost.getContent()))
.andDo(document( // 2
"post-free-board", // 3
getRequestPreProcessor(), // 4
getResponsePreProcessor(), // 5
requestFields( // 6
List.of(
fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
fieldWithPath("content").type(JsonFieldType.STRING).description("내용")
)
),
responseFields( // 7
List.of(
fieldWithPath("boardId").type(JsonFieldType.NUMBER).description("게시글 식별자"), // (5)
fieldWithPath("title").type(JsonFieldType.STRING).description("제목"),
fieldWithPath("content").type(JsonFieldType.STRING).description("내용")
)
)
));
}
자유 게시글 등록 테스트 코드를 가져와봤다. 역시 주석으로 번호를 달아놓은 부분을 보면 된다.
- 엥? 이건 전이랑 똑같은데? 싶지만, 의존하는 함수가 다르다. 처음 적용했을 때 이 부분 때문에 애먹었다. 기존에는 `org.springframework.test.web.servlet.request.MockMvcRequestBuilders;` 라이브러리의 HTTP 메서드들을 사용했다. 그런데 해당 라이브러리의 함수를 사용하면 문서가 생성되지 않는다. 대신 `org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders` 라이브러리의 HTTP 메서드들을 사용해야 한다.
- document()메서드는 API 스펙 정보를 전달받아 실질적 문서화 작업을 수행하는 메서드이다.
- "post-free-board"는 API 문서 스니펫의 식별자 역할을 한다. 문서 스니펫은 post-free-board 디렉토리 하위에 생성된다.
- 문서 스니펫을 생성하기 이전에 request에 해당하는 문서 영역을 전처리한다.
- 문서 스니펫을 생성하기 이전에 response에 해당하는 문서 영역을 전처리한다.
- 4, 5에 사용되는 메서드들은 따로 구현해야한다. 잠시 후에 기술한다.
- 문서로 표현될 Request Body를 의미한다. List에 담기는 객체들은 FieldDescriptor로, 이들은 Request Body를 JSON포맷으로 표현했을 때 하나의 프로퍼티를 의미한다. type(JsonFieldType.STRING)은 JSON 프로퍼티의 값이 문자열임을 의미한다.
- 6번과 마찬가지로 Response Body를 의미한다.
4,5 번에 사용된 메서드들은 아래와 같이 작성되어있다.
import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.*;
public interface ApiDocumentUtils {
static OperationRequestPreprocessor getRequestPreProcessor() {
return preprocessRequest(prettyPrint());
}
static OperationResponsePreprocessor getResponsePreProcessor() {
return preprocessResponse(prettyPrint());
}
}
pretteyPrint()라고 적혀있듯이, 예쁘게 표현해준다. 직접 사용해보면, JSON key : value 쌍마다 줄바꿈을 해주는 것을 확인할 수 있다.
아 그리고 PathVariable이 적용되는 경우에는 아래와 같이 코드를 추가하자. 보면 알 수 있겠지만, RequestParameter가 추가되면 비슷하게 사용해주면 될 것이다. 이는 추후에 검색 기능을 구현하면서 추가할 예정이다.
document("put-free-board",
getRequestPreProcessor(),
getResponsePreProcessor(),
pathParameters( // 추가된 부분
parameterWithName("free-board-id").description("자유 게시글 식별자")
),
이제 비어있는 index.adoc 파일을 채워 문서 형식을 갖춰준다.
= Grooming Zone
:sectnums:
:toc: left
:toclevels: 4
:toc-title: Table of Contents
:source-highlighter: prettify
ⓒ JKROH <shworud1995@naver.com / shworud29@gmail.comn>
***
== FreeBoardController
=== 자유 게시글 등록
.curl-request
include::{snippets}/post-free-board/curl-request.adoc[]
.http-request
include::{snippets}/post-free-board/http-request.adoc[]
.request-fields
include::{snippets}/post-free-board/request-fields.adoc[]
.http-response
include::{snippets}/post-free-board/http-response.adoc[]
.response-headers
include::{snippets}/post-free-board/response-headers.adoc[]
=== 자유 게시글 수정
.curl-request
include::{snippets}/put-free-board/curl-request.adoc[]
.http-request
include::{snippets}/put-free-board/http-request.adoc[]
.path-parameters
include::{snippets}/put-free-board/path-parameters.adoc[]
.request-fields
include::{snippets}/put-free-board/request-fields.adoc[]
.http-response
include::{snippets}/put-free-board/http-response.adoc[]
.response-fields
include::{snippets}/put-member/response-fields.adoc[]
=== 자유 게시글 조회
.curl-request
include::{snippets}/get-free-board/curl-request.adoc[]
.http-request
include::{snippets}/get-free-board/http-request.adoc[]
.path-parameters
include::{snippets}/get-free-board/path-parameters.adoc[]
.request-fields
include::{snippets}/get-free-board/request-fields.adoc[]
.http-response
include::{snippets}/get-free-board/http-response.adoc[]
.response-fields
include::{snippets}/get-member/response-fields.adoc[]
=== 자유 게시글 삭제
.curl-request
include::{snippets}/delete-free-board/curl-request.adoc[]
.http-request
include::{snippets}/delete-free-board/http-request.adoc[]
.path-parameters
include::{snippets}/delete-free-board/path-parameters.adoc[]
.http-response
include::{snippets}/delete-free-board/http-response.adoc[]
이 부분은 과감히 스킵하겠다.
이제 프로젝트를 build 하면 src/main/resources/static/docs 디렉토리에 index.html 파일이 생성된다.
전체 코드는 링크에서 확인할 수 있다.