기존에 프로젝트를 진행하면서 API문서를 레포지토리 Wiki에 작성하고 있었다. 하지만 API가 변경될 때마다 직접 Wiki를 수정해주어야 했고, 심지어는 중간에 잘못된 내용이 들어가는 문제도 발생하였다. 그래서 자동으로 API 문서를 만들어주는 도구를 사용해야겠다고 생각했고 그 후보군에 Swagger와 Spring Rest Docs 두가지가 들어왔다.
사용하기 전에 먼저 두 도구에 대해 조사해보니 확연한 차이가 있었다.
간단히 두 도구에 대한 차이점을 말하자면
처음에는 작성하기 더 쉽다는 Swagger를 선택하려 했다. 하지만 운영코드에 문서화 코드가 들어간다는 점이 마음에 들지 않았다. 몇몇 블로그에서 예시 코드들을 봤는데 운영코드에 Swagger 관련 애노테이션들이 덕지덕지 붙어있는 모습을 보고 마음을 돌렸다.
Spring Rest Docs는 테스트를 통과해야만 문서를 작성할 수 있고, 테스트 코드에 문서화 관련 코드를 적어야하기 때문에 작업량이 많다. 하지만 테스트를 꼭 통과해야한다는 점이 API문서의 신뢰도를 높여준다는 특징 때문에 Spring Rest Docs를 사용하기로 마음먹었다.
처음으로 Spring Rest Docs를 사용하면서 삽질을 굉장히 많이 했다. 그 과정에서 배운점이나, 나뿐만 아니라 다른 사람들도 흔하게 겪을 수 있는 오류에 대해서 정리해보려 한다.
이곳에 나오지 않은 추가적인 내용은 Spring Rest Docs 공식문서를 참고하자.
plugins {
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
configurations {
asciidoctorExt
}
dependencies {
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
ext {
snippetsDir = file('build/generated-snippets')
}
test {
outputs.dir snippetsDir
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
전부 다 공식문서에 나와있는 내용이고, 플러그인과 설정부분은 넘어가고 그 다음부터 설명해보자.
의존관계 추가 부분을 먼저 살펴보면
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
asciidoctor 확장 라이브러리를 의존관계로 추가한다.
테스트 코드가 통과되면 테스트 코드에 포함되어 있는 문서화 코드를 바탕으로 .adoc
파일을 만들어준다.
.adoc
파일을 자동으로 만들어주고.adoc
파일에서 매크로를 사용하여 스니펫 조각들을 연동하고테스트가 통과되었을 때 만들어지는 .adoc
파일들의 위치는
ext {
snippetsDir = file('build/generated-snippets')
}
이 부분에서 설정된다. 여기서에서 만들어진 .adoc
파일은 build/generated-snippets
에 위치하게 된다.
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
WebTestClient
를 사용하려면 sprint-restdocs-webtestclient
를 추가하면 된다.REST Assured
를 사용하려면 spring-restdocs-restassured
를 추가하면 된다.asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
asciidoctor 작업을 구성하는 부분이다.
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
IDE에서 src/main/resources/static/docs 에서 완성된 html문서를 위치시키는 방법이다.
// static/docs 폴더 비우기
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// asccidoctor 작업 이후 생성된 HTML 파일을 static/docs 로 copy
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
// build 의 의존작업 명시
build {
dependsOn copyDocument
}
@AutoConfigureRestDocs
MockMvc를 이용해서 테스트할 때 테스트 코드에 @AutoConfigureRestDocs
애노테이션을 붙여주어야 한다. 테스트 클래스에 적용해서 Spring Rest Docs를 사용하는데 필요한 것들을 자동으로 설정해주는데 사용하는 애노테이션이다.
서블릿 웹 애플리케이션에서 MockMvc
, WebTestClient
, REST Assured
기반의 환경을 설정해준다. 만약 애노테이션을 붙이지 않으면 다음 에러가 발생한다.
java.lang.IllegalStateException: REST Docs configuration not found. Did you forget to apply a MockMvcRestDocumentationConfigurer when building the MockMvc instance?
내가 Rest Docs를 적용했던 사례들을 몇가지 가져오면서 어떤식으로 적용했는지 설명해보겠다.
단일 퀴즈 조회 컨트롤러 메서드는 다음과 같다.
@GetMapping("/{quizId}")
public ResponseEntity<SingleQuizServiceResponse> findSingleQuiz(
@PathVariable("quizId") Long quizId
) {
//...
return ResponseEntity
.ok()
.body(result);
}
간단하게 살펴보면
경로 변수(Path Variable)이 존재한다.
또 객체를 응답으로 전달하고있다. 응답으로 객체를 전달하면 JSON으로 변환되어 HTTP 응답메세지 바디에 담긴다.
public class SingleQuizServiceResponse {
private Long quizId;
private String title;
private String description;
private String thumbnailData;
private int likeCount;
private int playTime;
private List<QuestionServiceDto> questions;
//...
}
public class QuestionServiceDto {
private Long id;
private String content;
private int number;
private String answer;
private String attachmentData;
private String attachmentType;
private List<ChoiceServiceDto> choices;
//...
}
public class ChoiceServiceDto {
private int number;
private String content;
//...
}
단순한 형태가 아니라 계층형구조의 다소 복잡한 JSON을 반환하는 경우이다.
mvc.perform(
RestDocumentationRequestBuilders.get("/api/v1/quiz/{quizId}", testQuizId)
)
.andExpect(status().isOk())
.andDo(
document(
"단일 퀴즈 조회",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
HeaderDocumentation.responseHeaders(
),
pathParameters(
parameterWithName("quizId").description("퀴즈 ID")
),
responseFields(
fieldWithPath("quizId").description("퀴즈 ID").type(NUMBER),
fieldWithPath("title").description("퀴즈 제목").type(STRING),
fieldWithPath("description").description("퀴즈 설명").type(STRING),
fieldWithPath("thumbnailData").optional().description("퀴즈 썸네일이미지 데이터").type(STRING),
fieldWithPath("likeCount").description("퀴즈 좋아요 수").type(NUMBER),
fieldWithPath("playTime").description("퀴즈 플레이 횟수").type(NUMBER),
fieldWithPath("questions").description("문제 목록"),
fieldWithPath("questions[].id").description("문제 ID").type(NUMBER),
fieldWithPath("questions[].content").description("문제 내용").type(STRING),
fieldWithPath("questions[].number").description("문제 번호").type(NUMBER),
fieldWithPath("questions[].answer").description("문제 정답").type(STRING),
fieldWithPath("questions[].attachmentData").optional().description("문제 첨부파일 데이터").type(STRING),
fieldWithPath("questions[].attachmentType").optional().description("문제 첨부파일 타입").type(STRING),
fieldWithPath("questions[].choices[].number").description("선택지 번호").type(NUMBER),
fieldWithPath("questions[].choices[].content").description("선택지 내용").type(STRING)
)
)
);
한줄 한줄 자세히 살펴보자.
원래 MockMvc를 이용해서 테스트할 때는 perform 메서드 안에 요청정보를 넘겨주면서 요청 URI를 MockHttpServletRequestBuilder
의 메서드로 전달한다. 하지만 이곳에서는 RestDocumentationRequestBuilders
를 사용하고 있다.
RestDocumentationRequestBuilders
는 MockMvcRequestBuilders
를 확장한 클래스로, REST Docs의 문서화 기능을 추가적으로 제공하는 클래스이다.
MockMvcRequestBuilders
와 함께 사용되면서 API의 경로변수를 문서화 하는데 특화되어 있다. 문서에 경로변수를 포함하기 위해서는 꼭 RestDocumentationRequestBuilders
를 사용해서 URI, HTTP Method를 표현해야한다. 만약 기존처럼 MockHttpServletRequestBuilder
를 사용하면 다음 에러를 맞이하게 된다.
java.lang.IllegalArgumentException: urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?
그냥 테스트 코드를 작성할 때도 볼수 있는 익숙한 코드이다. 응답으로 HTTP 200 OK 상태코드가 올것을 기대한다.
MockMvcRestDocumentation클래스의 document 메서드는 Spring Rest Docs를 작성할 때 가장 중요한 부분이다. 이 내부에 담기는 정보를 바탕으로 테스트가 성공했을 때 .adoc
문서들이 생성된다.
첫번째 매개변수로는 문서의 식별자를 전달한다. 여기서 적은 이름이 관련된 .adoc 문서가 담기는 폴더 이름이 된다.
두번째와 세번째 매개변수로 전처리기를 전달할 수 있다.
OperationRequestProcessor
OperationResponseProcessor
preProcessorRequest
, preProcessorResponse
를 호출해서 전달하였다.이후로는 스니펫과 관련된 부분이 나온다.
pathParameters(
parameterWithName("quizId").description("퀴즈 ID")
),
경로변수에 대한 정보를 표현하는 스니펫이다. 나는 RequestDocumentation
클래스의 parameterWithName
만 사용해서 ParameterDiscriptor
만 전달해주었는데 List, Map으로도 속성을 전달할 수 있는 것 같다.
description
메서드로 연결해서 설명을 추가할 수 있다.
responseFields(
fieldWithPath("quizId").description("퀴즈 ID").type(NUMBER),
fieldWithPath("title").description("퀴즈 제목").type(STRING),
fieldWithPath("description").description("퀴즈 설명").type(STRING),
fieldWithPath("thumbnailData").optional().description("퀴즈 썸네일이미지 데이터").type(STRING),
fieldWithPath("likeCount").description("퀴즈 좋아요 수").type(NUMBER),
fieldWithPath("playTime").description("퀴즈 플레이 횟수").type(NUMBER),
subsectionWithPath("questions").description("문제 목록"),
fieldWithPath("questions[].id").description("문제 ID").type(NUMBER),
fieldWithPath("questions[].content").description("문제 내용").type(STRING),
fieldWithPath("questions[].number").description("문제 번호").type(NUMBER),
fieldWithPath("questions[].answer").description("문제 정답").type(STRING),
fieldWithPath("questions[].attachmentData").optional().description("문제 첨부파일 데이터").type(STRING),
fieldWithPath("questions[].attachmentType").optional().description("문제 첨부파일 타입").type(STRING),
fieldWithPath("questions[].choices[].number").description("선택지 번호").type(NUMBER),
fieldWithPath("questions[].choices[].content").description("선택지 내용").type(STRING)
)
요청 JSON으로 필드를 나타내는 부분이다. PayloadDocumentation
.fieldWithPath
메서드를 사용해서 표현하는데, 복잡한 JSON의 경우 표현식으로 나타내야한다. 표현식이 틀리면 에러가 발생한다.
공식문서에도 나와있듯이 만약 다음과 같은 JSON이 있을 때
{
"a":{
"b":[
{
"c":"one"
},
{
"c":"two"
},
{
"d":"three"
}
],
"e.dot" : "four"
}
}
a
: b
를 포함하고 있는 객체 a
를 표현함a.b
: a
객체 내부에 있는 b
를 표현함a.b[].c
: a
객체 내부에 있는 b
리스트의 c
를 표현함a.b[].d
: a
객체 내부에 있는 b
리스트의 d
를 표현함a[e.dot]
: a
객체 내부에 있는 e.dot
값을 표현함이런식으로 정해져있는 표현식으로 JSON에서의 위치를 표현할 수 있다.
JsonFieldType
열거형에 정의된 타입이름을 사용한다.위 코드에서는 fieldWithPath
만 사용해서 필드를 표현하였다. 하지만 모든 속성에 대한 요청을 표현할 필요가 없는 상황이 있을 수 있다. subSectionWithPath
를 사용하면 대상 필드의 하위에 속성이 있을 때 표현하지 않아도 에러가 발생하지 않게 해준다.
responseFields(
fieldWithPath("quizId").description("퀴즈 ID").type(NUMBER),
fieldWithPath("title").description("퀴즈 제목").type(STRING),
fieldWithPath("description").description("퀴즈 설명").type(STRING),
fieldWithPath("thumbnailData").optional().description("퀴즈 썸네일이미지 데이터").type(STRING),
fieldWithPath("likeCount").description("퀴즈 좋아요 수").type(NUMBER),
fieldWithPath("playTime").description("퀴즈 플레이 횟수").type(NUMBER),
subSectionWithPath("questions").description("문제 목록"),
)
questions에 subSectionWithPath를 사용함으로써 questions 하위의 속성들을 표현해주지 않아도 오류가 발생하지 않는다.
하지만 나는 이런 요청속성의 경우에는 클라이트트가 필요한 모든 것을 표현해주어야 한다고 생각해 사용하지 않았다. 중요하지 않다고 생각되는 응답 필드에만 간간히 사용해주었다.
이렇게 해서 단일 퀴즈 조회에 대한 문서화를 완료 했다. .adoc
파일을 만들고 문서를 조회하는 부분은 이후에 알아보고 우선은아직 다루지않은 내용들에 대해서 좀더 설명한다.
퀴즈를 생성할 때는 퀴즈 생성정보에 대한 폼 데이터와 퀴즈의 썸네일로 사용할 이미지를 모두 전달 받아야 했기 때문에 Content-Type
으로 multipart/form-data
를 받는다. 단순히 HTTP 요청메세지 바디로 폼 데이터를 담은 JSON만을 전달받는 것과 multipart/form-data
를 사용하는 것과는 문서화 할때 차이점이 있다.
먼저 컨트롤러 코드를 살펴보자
@PostMapping(value = "/new", consumes = {MULTIPART_FORM_DATA_VALUE})
public ResponseEntity<Void> createQuiz(
@RequestPart(value = "form") QuizCreateRequestForm form,
@RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail
) {
//...
return ResponseEntity
.created(URI.create("/api/v1/quiz/" + createdQuiz.getId()))
.build();
}
multipart/form-data
을 허용하고 있다.form
이라는 이름으로 QuizCreateRequestForm
형식의 JSON을 받고있다.thumbnail
이라는 이름으로 이미지 데이터를 MultiaprtFile
로 받고있다. 필수 요청데이터가 아니다.테스트를 위해서 사용할 MultipartFile을 만들어야한다.
QuizCreateRequestForm requestForm = new QuizCreateRequestForm("quiz name", "12345", "example new quiz");
String formJson = objectMapper.writeValueAsString(requestForm);
MockMultipartFile formFile = new MockMultipartFile("form", "", "application/json", formJson.getBytes());
MockMultipartFile imageFile = new MockMultipartFile("thumbnail", "", "image/png", (byte[]) null);
mvc.perform(
multipart("/api/v1/quiz/new")
.file(formFile)
.file(imageFile)
.accept(APPLICATION_JSON, IMAGE_PNG, IMAGE_JPEG)
)
.andDo(print())
.andExpect(status().isCreated())
.andDo(
document(
"퀴즈 생성",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestPartBody("form"),
requestPartFields(
"form",
fieldWithPath("title").type(STRING).description("퀴즈 이름"),
fieldWithPath("password").type(STRING).description("퀴즈 비밀번호"),
fieldWithPath("description").type(STRING).description("퀴즈 설명")
)
)
);
multipart/form-data
를 테스트할 때는 요청 URL을 전달할 때 multipart()
메서드를 사용해서 file을 전달해야한다. 여기서는 경로변수가 없기 때문에 RestDocumentationRequestBuilders
가 아니라 MockMvcRequestBuilders
의 메서드를 사용하였다.application/json
, image/png
, image/jpg
를 허용하고있다.requestPartBody
, requestPartFields
를 사용해야한다..adoc
파일의 이름을 결정한다.request-part-${전달한 이름}-body.adoc
request-part-${전달한 이름}-fields.adoc
퀴즈 수정 컨트롤러는 퀴즈에 대한 정보와 함께 퀴즈의 썸네일 데이터를 받기 때문에 퀴즈 생성때와 동일하게 multipart/form-data를 허용한다. 다른점은 POST메서드 대신 PUT메서드를 사용한다는 점이다.
multipart/form-data를 사용할 때는 앞에서 보았던 대로 get(), post(), delete() 같은 메서드가 아니라 multipart() 메서드로 요청 정보를 표현하는 것에 대해 살펴보았다. 그런데 이 방법의 문제점은 multipart() 메서드가 항상 POST 로만 요청한다는 점이다. 만약 다른 메서드를 사용하고 싶다면 multipart() 메서드의 반환타입인 MockMultipartHttpServletRequestBuilder의 with 메서드를 사용해서 미리 구현한 다음 사용해야한다.
MockMultipartHttpServletRequestBuilder builder = RestDocumentationRequestBuilders.multipart("/api/v1/quiz/{quizId}/edit", testQuizId);
builder.with(request -> {
request.setMethod("PUT");
return request;
});
mvc.perform(
builder
.file(form)
.file(thumbnail)
.accept(APPLICATION_JSON, IMAGE_PNG, IMAGE_JPEG)
)
//...
);
경로 변수를 문서에서 표현해야하기 때문에
ResetDocumentationRequestBuilders
의 메서드를 사용하는 것을 볼 수 있다.
모든 테스트 코드를 작성하고 나서 테스트를 수행하거나 ./gradlew clean build 명령으로 빌드를 했다면 테스트가 수행되면서 .adoc 문서파일이 생성된다. 우리는 build.gradle 설정파일에서 테스트를 마치면 build/generated-snippets 디렉토리에 문서파일이 생성되도록 설정했었다.
생성되는 .adoc 파일에는 기본적으로 생성되는 것이 있고, 특정한 경우에 생성되는 파일이 있다.
예를 들어서 경로 변수관련 정보가 있을 때만 path-parameters.adoc 문서가 생성된다.
기본적으로 생성되는 문서 목록은 다음과 같다.
테스트 코드에 따라 추가적으로 생성되는 문서목록은 다음과 같다. 이것 말고도 다른것들도 있다.
이제 이렇게 만들어진 조각(snippet)을 가지고 실제 문서를 만들어볼 차례이다.
IntelliJ에서 AsciiDoc 플러그인을 설치하면 편리하게 .adoc 문서를 편집할 수 있다.
main/resource/static/docs
디렉토리를 만들어준다. 위에서 했던 gradle 설정에서 html 문서 경로설정을 했다면 완성된 html 파일이 이곳에 위치하게 된다.src/docs/asciidoc
디렉토리 안에 index.adoc
파일을 만들어준다. 이 index.adoc
파일을 바탕으로 html파일이 만들어진다.index.adoc
파일을 수정한다.= Harpseal API Docs
Harseal API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
[[퀴즈-목록-조회]]
=== 퀴즈 목록 조회
operation::퀴즈 목록 조회[snippets='http-request,query-parameters,request-fields,http-response,response-fields']
:toc: left
Table Of Content를 문서의 좌측에 둡니다.:source-highlighter: highlightjs
문서에 표기되는 코드의 하이트라이트를 highlightjs를 사용합니다.=
, ==
, ===
: 마크다운의 #
, ##
, ###
에 해당합니다.[[텍스트]]
: 해당 텍스트에 링크를 겁니다.operation::디렉토리명[snippets='원하는 스니펫 조각']
: 원하는 스니펫 조각을 가져와 반영합니다.퀴즈 생성
디렉토리에 있는 http-request.snippet
, http-response.snippet
을 반영하고 싶다면operation::퀴즈 생성[snippet='http-request,http-response']
처럼 작성하면 됩니다.추가적으로 AsciiDoc 문법에 더 자세히 알고싶다면 AsciiDoc 기본 사용법을 참고해주세용
이렇게 문서를 편집하고 난후 빌드를 해주면 html 파일이 생성됩니다.
그런데 기본적으로 제공되는 내용말고도 추가적으로 정보를 제공하고 싶을 수도 있습니다.
테스트 코드에서 작성했지만 타입(type)이나 필수여부(optional)등이 문서에는 반영되어 있지 않습니다.
추가적인 정보를 제공하기 위해서 사용자가 원하는 문서 형식으로 커스텀해야 합니다.
src/test/resources/org/springframework/templates/asciidoctor
내부에 사용자가 원하는 .snippet
문서를 이름 규칙만 지켜서 정의해두면 테스트가 실행되면서 build/generated-snippets에 기본형식의 스니펫 대신 사용자가 커스텀한 형식의 스니펫을 등록해줍니다.
만약 http-request.adoc을 커스텀하고 싶다면 src/test/resources/org/springframework/templates/asciidoctor
에 http-request.snippet
을 정의하면됩니다.
참고로 저는
request-part-form-fields.adoc
의 커스텀 형식이 그대로 이 이름을 따르는 줄 착각해 2시간을 날렸습니다.form
은 등록한 문서의 이름으로 문서를 생성할 때 들어가는 이름이기 때문에 기본형식이 아니였기 때문입니다. 기본형식 이름은request-part-fields.adoc
이였습니다. 커스텀 문서를 만들때는 항상 기본형식 이름을 사전에 확인합시다... 기본형식의 이름에서 default만 빼면 됩니다.
.snippet 문서는 mustache 문법을 사용한다고 한다.
|===
|파라미터|설명
{{#parameters}}
|{{#tableCellContent}}`+{{name}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/parameters}}
|===
기본 스니펫들을 살펴보고 원하는 컬럼의 이름을 수정하거나 원하는 컬럼을 넣을 수 있다.