Spring REST Docs 는 테스트 코드를 통해 API 명세서 작성을 도와주는 라이브러리이다. 필자는 빌드, 문서 작성 등 restdocs 의 전반적인 이해가 어려워 관련 강의를 들은 지 1년 만에 두 번째 시도 끝에 실무에 적용할 수 있게 되었다. (감격..😭)
먼저 최종 산출물을 확인하고 빌드하는 법, 문서 작성하는 법 순으로 설명해보고자 한다.
기술 스택
- spring boot 2.7.3
- gradle 7.5
- java 11
- JUnit5
① 최종 산출물 - API 명세서 (index.html)
http://127.0.0.1:8080/esonline/docs/index.html
이것이 최종 API 명세서인 index.html
이다. 문서를 파일이나 타 프로그램에서 관리하는 게 아닌 API URL 로 바로 접근할 수 있다는게 장점처럼 느껴졌다.
index.adoc
에서 필자의 입맛대로 문서를 작성하며 테스트가 성공하면 생성되는는 스니펫(snippet:토막) 문서를 index.adoc
에 포함시켜 하나의 완성된 문서를 도출하는 구조이다.
🔍 .adoc ? 그게 뭐야?
♣ asciidoc: 마크업 언어 (≒ markdown)
♣ .adoc: asciidoc 으로 작성된 파일 (≒ .md)
♣ Spring REST Docs: asciidoc를 주 언어로 사용
♣ Asciidoctor: asciidoc를 html 로 변환해주는 컨버터
② index.html 는 어떻게 만들어지는가? : 빌드하기
gradle task 를 실행하면, Asciidoctor가 index.adoc
와 include 된 스니펫 문서들을 합쳐 디폴트 경로인 build/asciidoc
에 html 파일로 변환한다. 하지만 build/asciidoc
에 있다고 해서 누군가 알아줄거라고 생각하면 오산이다. jar 나 war 로 패키징할거라면 resource 디렉토리인 build/resources/main/static/docs
에, 로컬에서 확인하려면 src/main/resources/static/docs
에 그들이 인식할 수 있는 알맞은 위치에 복사해 넣어줘야한다.
구조도를 그리자면 이런 과정을 거친다.
이제 실 구현체인 build.gradle 를 확인해보자!
plugins {
id 'org.springframework.boot' version '2.7.3'
id 'io.spring.dependency-management' version '1.0.13.RELEASE'
id 'java'
id 'org.asciidoctor.jvm.convert' version '3.3.2' // [1]
}
group = 'dev.elsboo'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
asciidoctorExt // [2]
}
repositories {
mavenCentral()
}
dependencies {
// spring
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
implementation 'org.springframework.boot:spring-boot-starter-web'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // [3]
testImplementation group: 'org.springframework.restdocs', name: 'spring-restdocs-mockmvc', version: '2.0.6.RELEASE' // [4]
}
tasks.named('test') {
useJUnitPlatform()
}
asciidoctor { // [5]
//inputs.dir snippetsDir
configurations 'asciidoctorExt' // [6]
dependsOn test // [7]
}
task copyDocument(type: Copy) { // [8]
dependsOn asciidoctor // [9]
from(file("${asciidoctor.outputDir}")) // [10]
into(file("src/main/resources/static/docs")) // [11]
}
build { // [12]
dependsOn copyDocument // [13]
}
bootJar { // [14]
dependsOn asciidoctor // [15]
copy { // [16]
from "${asciidoctor.outputDir}" // [17]
into 'build/resources/main/static/docs' // [18]
}
}
다음은 Spring Rest Docs 를 위해 추가한 코드 설명이다.
[1] : asciidoc 언어를 html 로 변환해주는 asciidoctor 플러그인을 추가한다.
[2] : 스니펫 문서를 메인 문서에 보다 간단하게 포함시킬 수 있는 방식을 제공하는 asciidoctorExt 를 선언한다.
[3] : asciidoctorExt 용 디펜던시를 추가한다. 사실 asciidoctorExt 를 사용하지 않아도 동작에는 지장이 없다. 하지만 스니펫 디폴트 경로 등 spring-restdocs 전용으로 사용이 용이하도록 추가해주는 것이다.
[4] : API mock 테스트할 때 asciidoc 문서를 생성해주는 디펜던시를 추가한다.
[5] : asciidoctor task
- [6] : asciidoctorExt 사용을 선언한다.
- [7] : asciidoctor task 실행 전에 test task 를 먼저 실행해 스니펫 문서를 미리 만들어둔다.
[8] : copyDocument task - html 복사 해주는 task
- [9] : copyDocument task 실행 전에 asciidoctor task 를 먼저 실행해 html 문서를 만들어둔다.
- [10] 소스 & [11] 타깃 - asciidoctor는 html 파일을 디폴트 output 경로인
${asciidoctor.outputDir}
에 생성한다. 이 파일을 로컬에서 실행 시http:localhost:8080/docs/index.html
로 접근할 수 있도록 html 파일을 복사한다. ${asciidoctor.outputDir}
의 디폴트 경로는 필자같은 경우는build\docs\asciidoc
이다. 공식 문서와 경로가 다르니 각자 확인해보자
[12] : build task
- [13] : build task 실행 전에 copyDocument task 를 먼저 실행해 원하는 위치에 html 문서를 복사한다.
[14] : bootJar task
- [15]: bootJar task 실행 전에 asciidoctor task 를 먼저 실행해 원하는 위치에 html 문서를 만들어둔다.
- [16]: jar 만들 때 html 도 resources 에 포함될 수 있도록 인식 가능한 경로에 html 문서를 복사한다.
- [17] 소스 & [18] 타깃 - asciidoctor 가 생성해준
{asciidoctor.outputDir}
의 html 를 jar 의 리소스로 엮일 수 있도록build/resources/main/static/docs
로 html 파일을 복사한다.
③ 스니펫 코드를 만들어보자
build 에서 보았던 generated-snippets 을 기억할 것 이다. 이들은 테스트 코드를 실행하면서 나온 산출물이다.
테스트 코드를 살펴보자
@Test
public void analyzer() throws Exception {
NoriAnalyzerRequestVo noriAnalyzerRequestVo = createVo();
mockMvc.perform(
MockMvcRequestBuilders.get("/analyze/nori")
.header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON)
.param("decompoundMode", "compound")
.param("userDictionaryRules", "가곡역 가곡 역")
.param("discardPunctuation", "true")
.param("noriPartOfSpeech", "NN")
.param("inputText", "아버지가 가방에 들어가고 싶다.")
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.jsonPath("$.analyzedTextInfoList", Matchers.hasSize(Matchers.not(0))))
.andDo(document("analyze-nori",
requestHeaders(
headerWithName(HttpHeaders.ACCEPT).description("accept header")
),
requestParameters(
parameterWithName("decompoundMode").description("복합 명사 옵션").attributes(
key("available").value("none,discard,mixed")
),
parameterWithName("userDictionaryRules").description("유저 사전").optional(),
parameterWithName("discardPunctuation").description("기호 제거 여부"),
parameterWithName("noriPartOfSpeech").description("stop tags").attributes(
key("available").value("E,IC,J,MAG,MM,NA,NR,SC,SE,SF,SH,SL")
),
parameterWithName("inputText").description("입력 텍스트").optional()
),
responseFields(
fieldWithPath("analyzedTextInfoList[0].analyzedText").description("분석 완료된 텍스트"),
fieldWithPath("analyzedTextInfoList[0].startOffset").description("텀 시작 위치"),
fieldWithPath("analyzedTextInfoList[0].endOffset").description("텀 끝 위치")
)
))
;
}
이 중 스니펫 코드 생성은 아래 부분이다. 제공하는 다양한 스니펫 중 request-headers, request-parameters, response-fields 를 생성하고 있다.
.andDo(
document( // [1]
"analyze-nori", // [2]
// request-headers.adoc
requestHeaders( // [3]
headerWithName(HttpHeaders.ACCEPT).description("accept header") // [4]
),
// request-parameters.adoc
requestParameters( // [5]
parameterWithName("decompoundMode").description("복합 명사 옵션").attributes(
key("available").value("none,discard,mixed") // [6]
),
parameterWithName("userDictionaryRules").description("유저 사전").optional(),
parameterWithName("discardPunctuation").description("기호 제거 여부"),
parameterWithName("noriPartOfSpeech").description("stop tags").attributes(
key("available").value("E,IC,J,MAG,MM,NAL")
),
parameterWithName("inputText").description("입력 텍스트").optional()
),
// response-fields.adoc
responseFields( // [7]
fieldWithPath("analyzedTextInfoList[0].analyzedText").description("분석 완료된 텍스트"), // [8]
fieldWithPath("analyzedTextInfoList[0].startOffset").description("텀 시작 위치"),
fieldWithPath("analyzedTextInfoList[0].endOffset").description("텀 끝 위치")
)
))
[1]: 스니펫 생성 메소드
[2]: 스니펫 디렉토리명 지정. build\generated-snippets\analyze-nori
하위에 스니펫이 생성된다.
[3]: request-headers.adoc 생성 부분
- [4]: 테이블에 들어갈 데이터 생성
[5]: request-parameters.adoc 생성 부분
- [6]: 추가로 설명할 데이터를 넣어줄 때 attribute() 로 지정한다.
[7]: response-fields.adoc 생성 부분
- [8]: analyzedTextInfoList 는 List 이므로 첫 번째 요소로 문서를 가져온다. 만약 리스트 결과가 없으면 익셉션을 뱉으므로
.andExpect()
로 리스트 결과가 존재함을 확인해주는 것이 좋다.
생성된 산출물을 확인해보자
기본적으로 생성되는 6개의 스니펫을 제외하고 테스트 코드로 작성된 3개의 스니펫이 추가로 생성되었다.
테이블을 커스터마이징했기 때문에 기본 모양새와 다르다.
💡 필자가 헷갈렸던 스니펫 차이
request-parameter vs. request-fields?
● request-fields : 요청 body 에 들어가는 필드
● request-parameter: URL 에 들어가는 파라미터
response-fields vs. response-body ?
● response-fields : 산출물이 파라미터 설명 테이블
●response-body: 산출물이 body 내용
스니펫 커스터마이징
그런데 스니펫 테이블이 맘에 안 든다. 이 스니펫 템플릿을 커스터마이징하려면 어떻게 할까?
공식 문서(Customizing the Output) 에 방법이 나와 있다.
- 디폴트 템플릿 을 참고하여 .snippet 파일을 커스터마이징하고
- 파일 경로는
src/test/resources/org/springframework/restdocs/templates/asciidoctor
에 위치한 후 - 테스트 코드에서
.attribute()
를 통해 값을 넣어주면 된다
💡 .snippet 파일?
스니펫 문서의 형태를 묘사하며 mustache 와 asciidoctor 의 조합이다. 테스트 코드에 작성한 속성값은 mustache 로 제어하고 테이블 등 문서 형태는 asciidoctor로 제어한다.
④ 메인 문서 index.adoc 을 만들어보자
문서 위치
index.adoc
은 src/docs/asciidoc/
에 두었다. 디폴트 소스 경로이기 때문이다.
그리고 이 글에서는 편의를 위해 메인 .adoc 파일명을 index 라고 했지만 이름은 변경해도 무방하다!
메인 문서 코드를 살펴보자
index.adoc
= ElasticSearch Analyzer Test Online API Guide
서혜인 <catsarah3333@gmail.com>
:sectnums:
:doctype: book
:source-highlighter: rouge
:toc: left
:toclevels: 5
:sectlinks:
:sectanchors:
:operation-request-parameters-title: Request Parameter
:operation-response-fields-title: Response Parameter
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response
[[api]]
= API Reference
[[analyzer]]
== /analyzer
Return *analyzed text* by *input analyzer*. +
지정한 형태소 분석기에 따른 분석 결과를 반환한다.
[[analzer-nori]]
=== /analyzer/nori
`GET` 요청을 사용하여 노리 형태소 분석 결과를 반환한다.
operation::analyze-nori[snippets='http-request,http-response,request-parameters,response-fields']
내가 만들고 싶은 API 명세서를 마크다운 작성하듯이 asciidoc 으로 작성하면된다. 신택스는 마크다운과 어느 정도 비슷하고 index.adoc 에 있는 마크다운과의 차이점만 설명해놓겠다.
https://docs.asciidoctor.org/asciidoc/latest/syntax-quick-reference/
=, ==, ===
헤더 지정. 마크다운의 #, ##, ## 와 같다.
:sectnums:
테이블 목차에 번호 뎁스 표시:doctype: book
문서 구조 지정:source-highlighter: rouge
소스 코드 하이라이팅:toc: left
Table Of Contents. 테이블 목차 생성 위치 지정:toclevels: 5
테이블 목차 몇 뎁스까지 보여줄건지 지정:sectlinks:
앵커 기능 지원:sectanchors:
헤더에 마우스 hovering 시 앵커 아이콘 생김:operation-request-parameters-title: Request Parameter
스니펫 제목 변경
[[api]]
앵커 URL명
스니펫 문서 포함시키기
그리고 주목할 부분은 이 부분이다.
operation::analyze-nori[snippets='http-request,http-response,request-parameters,response-fields']
operation
커맨드는 스니펫 문서를 include 하는 역할이다. operation
에 대한 설명이 asciidoc 문서를 뒤져봐도 없는 이유는 build.gradle
에서 정의한 asciidoctorExt 가 제공하는 기능이기 때문이다!
asciidoctorExt 는 다음과 같은 장점이 있다.
- 스니펫 문서 생성 경로를 디폴트로
/build/generated-snippets
로 지정해준다. - 스니펫 제목을 디폴트로 지정해준다.
만약 asciidoctorExt 를 사용하지 않는다면 스니펫을 include 할 때
ifndef::snippets[]
:snippets: ./build/generated-snippets
endif::[]
include::{snippets}/analyze-nori/request-parameters.adoc[]
include::{snippets}/analyze-nori/response-fields.adoc[]
include::{snippets}/analyze-nori/http-request.adoc[]
include::{snippets}/analyze-nori/http-response.adoc[]
이런 식으로 스니펫 경로를 직접 지정해줘야하며
타이틀도 수동으로 지정해줘야한다.
느낀점
◆ 스니펫에 쓰일 API 정보는 비즈니스 코드에서 읽어오는게 아니라 실행하는 테스트에서 가져온다. 그러므로 검증할 request parameter 는 전부 테스트 코드에 명시되어야한다. (테스트 코드에 없는 request parameter 는 스니펫 문서에도 노출되지 않는단 뜻이다!)
◆ 다만, 응답 데이터에 대해서는 동기화가 깨졌는지 여부를 알 수 있다. response 엔 존재하나 스니펫 코드로 설명이 없거나, 그 반대인 경우도 테스트가 실패하기 때문이다.
◆ 문서가 정교해질수록 spring rest docs 를 쓰든 swagger를 쓰든 문서 코드 작성량은 많아질 수 밖에 없다. 문서 작성 부분이 테스트 안이냐, 비즈니스 로직 안이냐의 차이이다.
◆ 그렇기 때문에 가독성이나 유지보수 측면에서는 spring rest docs 가 괜찮다고 생각한다. 비즈니스 코드와 분리되어 있기 때문에 복잡해 보이지 않고 코드 수정 시에도 swagger 에 비해 큰 영향을 받지 않기 때문이다.
◆ swagger에 비해 restdocs는 문서 작성 느낌이 강하고 수정 자유도가 높다. 스니펫 테이블도 컬럼 추가나 수정이 가능하고 변경이력 같은 부분도 자유자재로 작성할 수 있다.
◆ 하지만 스니펫 템플릿을 수정할 땐 mustache 랑 asciidoc 섞어서 사용하기 때문에 처음 작성하는 경우 신택스 찾아보는게 귀찮다..
단순 문서 작성이나 swagger 보다 러닝커브가 높긴 하다. 하지만 API 명세서와 API 를 한 곳에서 관리한다는 점, 문서 작성 코드와 비즈니스 코드가 분리된다는 점, 특히 API 요청/응답 부분은 스니펫 문서 생성 전 검증을 해주기 때문에 동기화 가능한 점 덕분에 사용할 가치가 있구나 라고 느꼈다.
레퍼런스
https://velog.io/@max9106/Spring-Spring-rest-docs를-이용한-문서화
https://godekdls.github.io/Spring%20REST%20Docs/customizingrequestsandresponses/
https://techblog.woowahan.com/2597/
https://realforceman.tistory.com/70
https://docs.spring.io/spring-restdocs/docs/2.0.5.RELEASE/reference/html5/#introduction
https://docs.asciidoctor.org/asciidoc/latest/
https://jaehun2841.github.io/2019/08/04/2019-08-04-spring-rest-docs/#코드-설명
Spring REST Docs 이해
글 읽어주셔서 언제나 감사합니다. 좋은 피드백, 개선 피드백 너무나도 환영합니다.
'SearchDeveloper > SpringBoot' 카테고리의 다른 글
[JPA] Multi Datasource 설정하기 (1) | 2022.11.06 |
---|---|
[JPA] Querydsl 시작하기 (0) | 2022.10.21 |
[JPA] Querydsl 에서 Fetch Join 적용 안되는 이유 (3) | 2022.10.12 |
스프링부트 jar 구성 및 실행 원리 (4) | 2022.09.08 |
Spring Data Mongo save 시 DuplicateKeyException 나는 이유 및 해결 (0) | 2022.08.09 |