Spring REST Docs
오늘은 스프링 REST Docs에 대해 적용하고, 관리하는 방법을 알아보고자 합니다. 스프링을 어느 정도 써 보고, 테스트 및 문서화등을 해 보신 분들이라면 스웨거나 노션, 포스트맨으로 API 문서를 만든 경험이 있으실 텐데요, 스프링에 있는 REST Docs는 현업에서 많이 쓰는 문서화 스킬이라고 할 수 있습니다.
기존 문서화 도구들은 스웨거의 경우 스웨거를 위한 코드가 프로덕션 환경에 묻는다는 점, 그리고 수정 시 업데이트 상태와 문서의 상태가 서로 맞지 않는 문제 등이 있었는데요, 스프링 REST Docs는 테스트, 빌드를 기반으로 작성하기에 싱크가 유지될 수 있다는 장점이 있습니다.
아마 시작하는 단계에서부터 어떻게 적용해야 할지 감이 오시지 않는 분들이 많을 것 같아, 제가 해결한 과정을 단계적으로 공유하고자 합니다!
1. build.gradle에 필요한 코드 작성
기술을 적용하기 위해서는 스프링 REST Docs와 관련된 내용들을 build.gradle에 작성해야 합니다. 공식 문서를 참고해봅시다.
1. Asciidoctor 플러그인 설치
Asciidoctor 플러그인을 설치합니다. Asciidoctor (줄여서 adoc)은 스프링 REST Docs에서 기본적으로 사용하는 파일 형식입니다. 마크다운 (Markdown)과 비슷하다고 생각하시면 됩니다.
Writing high-quality documentation is difficult. One way to ease that difficulty is to use tools that are well-suited to the job. To this end, Spring REST Docs uses Asciidoctor by default.
2. asciidoctorExt 작성
build.gradle 설정 (configuration)에 asciidoctorExt를 작성합니다. asciidoctor의 확장 파일 (Extension)을 등록해 둡니다.
3. spring-restdocs-asciidoctor 의존성 등록
asciidoctorExt 안에 spring-restdocs-asciidoctor 의존성을 등록합니다. 이렇게 하면 저희가 다룰 .adoc 파일에 있는 snippets라는 용어가 build/generated-snipptes를 가리키도록 할 수 있습니다. 또한, operation 블록 매크로를 진행할 수 있습니다. (이것들은 아래 예시를 보면 이해하실 수 있습니다.)
4. testImplementation에 spring-restdocs-mockmvc 등록
REST Docs는 mockMvc의 andDo 단계에서 작업을 합니다. 만약 mockMvc를 사용하지 않고 WebTestClient나 REST Assured를 사용한다면 spring-restdocs-webtestclient, spring-restdocs-restassured를 작성하시면 됩니다.
5. snippetsDir 명시
앞서 3번에서 spring-restdocs-asciidoctor를 등록하면 스니펫 (snippets) 용어가 자동으로 build/generated-snipptes를 가리킨다고 하였습니다. 그러나 때로는 이 위치에 관해 기본값이 어떤 것인지를 모를 수도 있고, 경로를 바꾸고 싶을 때도 있을 것입니다. 그러한 경우 이렇게 snippetsDir를 작성합니다.
6. 테스트 결과물 위치 정의
테스트에 대한 출력이 snipptesDir에 저장되도록 합니다.
7 ~ 10. asciidoctor 명령 최종 정의
build.gradle에서 asciidoctor를 실행할 때, snipptesDir을 입출력 위치로 설정하고 asciidoctorExt를 구성 정보로 사용하도록 합니다.
2. 실제 적용
실습 테스트는 이전 게시물에서 작성한 게시글 작성/조회를 기반으로 하겠습니다.
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
@WebMvcTest(BoardController.class)
public class BoardControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private BoardService boardService;
@Test
void 게시글을_저장한다() throws Exception {
// given
BoardWriteRequest request = new BoardWriteRequest("test title", "test content");
Board newBoard = 게시글_id_있음();
when(boardService.write(request)).thenReturn(newBoard);
// when & then
mockMvc.perform(post("/boards")
.contentType(APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(jsonPath("$.id").value(newBoard.getId()))
.andExpect(jsonPath("$.title").value(newBoard.getTitle()))
.andExpect(jsonPath("$.content").value(newBoard.getContent()))
.andDo(print())
.andDo(document("boards/save"));
}
@Test
void 게시글을_조회한다() throws Exception {
// given
Board writeBoard = 게시글_id_있음();
when(boardService.findBoardById(any())).thenReturn(writeBoard);
// when & then
mockMvc.perform(get("/boards/" + writeBoard.getId()))
.andExpect(jsonPath("$.title").value(writeBoard.getTitle()))
.andExpect(jsonPath("$.content").value(writeBoard.getContent()))
.andDo(print())
.andDo(document("boards/find"));
}
}
크게 보실 부분은 @AutoConfigureRestDocs와 document("...")입니다.
@AutoConfigureRestDocs
해당 어노테이션은 RestDocs에 대한 기본 설정을 자동으로 적용해 줍니다.
공식 문서를 보면, JUnit과 MockMvc를 사용하는 경우 (해당 어노테이션을 사용하지 않을 경우) 아래와 같은 코드를 작성해야 했습니다.
// import 표현은 생략
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
@MockBean(JpaMetamodelMappingContext.class)
@WebMvcTest(BoardController.class)
public class BoardControllerTest {
// @Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private BoardService boardService;
@RegisterExtension
final RestDocumentationExtension restDocumentation = new RestDocumentationExtension("build/generated-snippets/boards");
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
...
}
RestDocumentationExtension은 RestDocumentationContext (REST API가 수행된 컨텍스트를 캡슐화한 인터페이스)를 자동으로 관리하는 JUnit의 확장 (Extension)입니다. build/generated-snippets/boards는 무엇을 의미할까요? 내부 코드를 보면 출력 경로 (outputDir)을 의미함을 알 수 있습니다.
그런데 처음에 build.gradle에서 어떤 것을 작성했었는지 다시 생각해 봅시다. 스니펫 (snipptes)에 대해 자동으로 불러오고 (spring-restdocs-asciidoctor), 심지어 경로를 명시하기까지 했습니다. (snippetsDir = file('build/generated-snippets'))
따라서 이러한 내부 과정을 자동으로 해 주는 게 바로 @AutoConfigureRestDocs입니다. 직접 작성할 때 보다 필요한 코드 양이 줄어드니 해당 어노테이션을 사용하도록 합시다!
document("...")
조금 많이 뜯어보게 됩니다! 아래에도 작성했듯 결론 먼저 말씀드리면 붙인 문자열을 경로로 하여 adoc 파일들을 작성한다는 것으로 이해하시면 됩니다 :)
다음으로는 document("...")를 보겠습니다. 결론 먼저 말씀드리면, {스니펫 경로}/{document 안에 작성한 경로}에 adoc 파일들이 작성됩니다.
![](https://blog.kakaocdn.net/dn/czETKl/btsEy7zsp6V/8g2D0xfUnVvKMJOwfdO4sk/img.png)
이것은 MockMvcRestDocumentation에서 작성되어 있는 메서드입니다.
public abstract class MockMvcRestDocumentation {
...
public static RestDocumentationResultHandler document(String identifier, Snippet... snippets) {
return new RestDocumentationResultHandler(new RestDocumentationGenerator(identifier, REQUEST_CONVERTER, RESPONSE_CONVERTER, snippets));
}
}
document("boards/save")를 할 시, 위에 정의된 document 메서드를 실행합니다. 그리고 RestDocumentationGenerator에 있는 handle 메서드를 실행합니다.
public final class RestDocumentationGenerator<REQ, RESP> {
...
public void handle(REQ request, RESP response, Map<String, Object> configuration) {
Map<String, Object> attributes = new HashMap(configuration);
OperationRequest operationRequest = this.preprocessRequest(this.requestConverter.convert(request), attributes);
OperationResponse operationResponse = this.preprocessResponse(this.responseConverter.convert(response), attributes);
Operation operation = new StandardOperation(this.identifier, operationRequest, operationResponse, attributes);
try {
Iterator var8 = this.getSnippets(attributes).iterator();
while(var8.hasNext()) {
Snippet snippet = (Snippet)var8.next();
snippet.document(operation);
}
} catch (IOException var10) {
throw new RestDocumentationGenerationException(var10);
}
}
...
}
이 메서드에서 request는 테스트 환경에서 보낸 HTTP request, response는 테스트 환경에서 받아진 HTTP response 정보가 있습니다. configuration에는 org.springframework.restdocs 하위 구성 정보 (TemplateEngine, MockHttpServletRequest 등)가 있습니다.
![](https://blog.kakaocdn.net/dn/3VmXc/btsEydAe5RY/pI93nkcqwlbFM45U3oT98k/img.png)
attribute 안에 있는 RestDocumentationContext는 위의 build.gradle 부분에서 말했듯이 REST API가 수행된 컨텍스트를 캡슐화한 인터페이스라고 하였습니다. 기본적인 스니펫 경로 (build/generated-snippets)를 가집니다.
![](https://blog.kakaocdn.net/dn/dUkEsf/btsEya4BsQD/sMwvOlVw8GWhZSxLLWhwAK/img.png)
operation은 Snippet이 수행할 내용을 전체적으로 담고 있는 클래스입니다. 앞서 "boards/save"를 작성한 것은, 이 operation의 이름 (name)을 뜻하게 됩니다.
![](https://blog.kakaocdn.net/dn/1J8o6/btsEA4240yc/r18YSBSS242ahZpErBSEh1/img.png)
this.getSnippets를 보면 왜 adoc 파일들이 curl-request.adoc, http-request.adoc, http-response.adoc 등 총 6개로 생성되는지도 알 수 있습니다.
![](https://blog.kakaocdn.net/dn/mLKC3/btsEAVL4LOQ/bdZojBjXbUo1ikvg1VH6DK/img.png)
그 이유는 org.springframework.restdocs.defaultSnippets에 6개의 snippetName이 있기 때문입니다.
getSnippets 작업이 끝나면 각각 가져온 Snippet들의 document 작업을 하게 됩니다. Snippet은 아래처럼 25가지의 구현 코드가 있습니다. 그래서 document라는 로직을 수행할 수 있습니다.
![](https://blog.kakaocdn.net/dn/c65ux6/btsEBTNyiWE/MFLu7pJ1uua2ZktOeI1fL1/img.png)
CurlRequestSnippet은 TemplatedSnippet을 상속받으며, 이 TemplatedSnippet은 Snippet을 구현합니다. curl-request.adoc과 관련 있는 스니펫입니다.
![](https://blog.kakaocdn.net/dn/cGNxA5/btsEyRjstaJ/5ViHMgVbSipKB2CFWj5vZK/img.png)
바로 위에서 operation의 이름 (name)은 저희가 작성한 경로라고 하였습니다. 그리고 스니펫 이름은 curl-request 등을 의미합니다.
RestDocumentationContext에는 기본 스니펫 경로 (build/generated-snippets)를 가지고 있습니다.
이번에는 writerResolver.resolve를 보겠습니다!
public interface WriterResolver {
Writer resolve(String var1, String var2, RestDocumentationContext var3) throws IOException;
}
WriterResolver는 인터페이스로 되어 있습니다. 구현체는 StandardWriterResolver입니다.
![](https://blog.kakaocdn.net/dn/bks4kk/btsEAVL8WXz/VeB7xdKvB7Uq8lOnRz1hv1/img.png)
resolveFile을 볼까요? 절대 경로냐 절대 경로가 아니냐에 따라 만들어지는 파일이 달라짐을 알 수 있습니다. 저희는 절대 경로로 작성하지 않았기 때문에 (인자로 받은 outputDirectory는 boards/save입니다.) makeRelativeToConfiguredOutputDir가 실행됩니다.
![](https://blog.kakaocdn.net/dn/bnoVgb/btsEyuhr2F9/EfxBjraCndwWipYQKNfcs0/img.png)
File의 생성자는 아래처럼 되어 있습니다.
![](https://blog.kakaocdn.net/dn/eaA0E7/btsExPl9Lmt/BMvAH7nHJ0UBGsKXypwfKK/img.png)
처음 인자를 부모로 받고, 두 번째 인자를 자식으로 하는군요! 그래서 파일 경로를 부모/자식으로 함을 알 수 있습니다.
요약하자면
- 테스트 코드에서 경로와 함께 document를 호출
- MockMvcRestDocumentation에서 RestDocumentationGenerator 생성, 작성한 경로를 identifier로 취함
- RestDocumentationGenerator에서 handle 메서드 실행
- 작성한 경로, 요청 정보, 응답 정보, 기타 설정 정보 안에 있는 속성들 (RestDocumentationContext, MockHttpServletRequest 등)을 가진 Operation을 StandardOperation으로 생성
- 속성 안에 있던 defaultSnippets들로부터 Snippet 목록 취함
- 각 Snippet마다의 document 메서드를 실행 (Operation을 인자로 넘김)
- TemplatedSnippet이 Snippet을 구현하고, 하위 Snippet들은 전부 TemplatedSnippet을 상속받음
- 결과적으로 TemplatedSnippet의 document가 실행됨, 인자로 받은 Operation에 있는 RestDocumentationContext를 받고, 받은 operation의 이름 (작성한 identifier)과 RestDocumentationContext에 있는 outputDirectory(build/generated-snippets)를 결합하여 최종 파일 경로를 지정함 (build/generated-snippets/boards/save)
- 각 스니펫 파일이 작성됨
라고 할 수 있습니다!
그래서 이것들을 어떻게 쓸 수 있나?
위의 document 로직이 다소 복잡했는데 여기까지 읽어주셔서 고생하셨습니다. 그럼 이제는 이렇게 생성된 adoc 파일들을 이용할 수 있어야겠죠. 이렇게 하기 위해서는 템플릿 (Template)을 만들어야 합니다.
gradle의 경우 src/docs/asciidoc에 adoc 파일들을 만들어야 한다고 합니다. 이 adoc 파일들은 저희가 html로 만들 adoc 파일, 즉 문서화할 adoc 파일을 의미합니다.
저는 다음과 같이 src/docs/asciidocs에 board.adoc을 작성했습니다.
snippets라는 용어가 build/generated-snippets를 가리킨다고 하였으므로 저렇게 축약할 수 있습니다.
마주친 문제점: html 파일이 안 보인다!
아마 이 단계에서 고전하신 분들이 있을 것 같습니다. 분명 저렇게 작성했는데, html 파일이 보이지 않습니다.
위의 build.gradle에는 아래 코드를 작성하지 않았지만, 공식 문서에서는 아래 코드를 추가하라고 하였습니다.
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}/html5") {
into 'static/docs'
}
}
추가해 보면, html 파일이 보이긴 합니다. 단, 빌드 패키지에만 보입니다.
다른 분께서 작성하신 글을 참고하니, Gradle 버전에 의한 오류가 원인인 것으로 추정된다고 합니다. (저는 gradle 8.5 사용 중입니다.) 그래서 위의 build.gradle 코드를 아래처럼 바꾸게 되었습니다.
tasks.register('copyDocument', Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc/")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
build/docs/asciidoc에 있는 html 파일들을 src/main/resources/static/docs에 복사하는 방식입니다.
실제로 html 파일을 확인하려면 스프링 애플리케이션을 실행한 뒤 localhost:8080/docs/board.html 등으로 접근하시면 됩니다.
결론
여기까지 REST Docs에 대한 기본 환경설정과 document에 대한 원리, html 파일 확인 등을 알아봤습니다.
이제 남은 것은 얼마나 더 문서화를 깔끔하게 하고, MockMvc에서 문서화에 대한 내용을 추가하는 등이 남았는데, 이 부분은 아직 초보다 보니 더 익힌 뒤에 정리하도록 하겠습니다.
혹여나 잘못된 부분이 있다면 댓글 부탁드립니다!
Reference
- 스프링 공식 문서 - REST Docs
- 스프링 공식 문서 - RestDocumentationExtension class
- [Docs] Spring Rest Docs HTML 출력하는 법.
- [Spring] Rest Docs 도입 중 맞닥뜨린 asciiDoctor에 대한 에러
24.03.05 수정본: build.gradle 코드
최종적인 build.gradle 코드를 전달해드립니다.
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
group = 'restdocs'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
asciidoctorExt
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
}
ext {
snippetsDir = file('build/generated-snippets')
}
test {
outputs.dir snippetsDir
useJUnitPlatform() // 이걸 써야 snippet들이 만들어지는 게 허가됩니다.
}
asciidoctor {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn test
}
tasks.register('copyDocument', Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc/")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
- src/docs/asciidoc/이름.adoc 형식으로 adoc 파일들을 만들어놔야 합니다.
- src/main/resources/static/docs 패키지를 만들어놔야 합니다.
'🚀 팁 (기술 적용 방법 등)' 카테고리의 다른 글
[Github] Github의 Issue와 PR (Pull Request) 알아보기 (PR 병합 후 이슈가 자동으로 닫히게 하려면?) (0) | 2024.05.18 |
---|---|
[Spring REST Docs ✍️] 어렵게만 느껴졌던 REST Docs를 적용해보자! (2) (0) | 2024.03.05 |
[Spring MVC 🌐] 회원 식별을 해 보자! (2) - 세션 적용 방법 📦 (1) | 2024.02.10 |
[Spring MVC 🌐] 회원 식별을 해 보자! (1) - 쿠키 적용 방법 🍪 (0) | 2024.02.08 |