그동안 API 문서화를 위해 Swagger UI를 사용했다. 하지만 사용하면서 몇 가지 아쉬운 점들이 있었다.
- 문서의 최신화가 어려웠다. 코드가 변경될 때마다 어노테이션을 함께 수정해야 했는데, 이 과정에서 종종 누락이 발생했고 실제 API와 문서 간의 불일치가 생겼다.
- 둘째, 비즈니스 로직과 무관한 문서화 코드가 Controller에 계속 쌓이는 것이 불편했다. @ApiOperation, @ApiParam 등의 어노테이션들이 Controller 클래스를 복잡하게 만들고, 코드의 가독성을 떨어뜨렸다.
최근 테스트 코드 작성에 집중하면서 Controller Slice 테스트를 통해 API 문서를 자동 생성할 수 있는 Spring Rest Docs를 알게 되었다. 테스트가 성공해야만 문서가 생성되는 구조라 문서와 실제 API 간의 일치성을 보장할 수 있고, 비즈니스 로직에서 문서화 관련 코드를 완전히 분리할 수 있다는 점이 매력적이었다.
이러한 이유로 프로젝트에 Rest Docs를 도입해보기로 결정했다.
1. 기본 프로젝트 구성
다음은 Spring Rest Docs 공식 문서를 보며 설정한 gradle 빌드 스크립트다.
https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/
Spring REST Docs
Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test or WebTestClient.
docs.spring.io
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
asciidoctorExt
}
repositories {
mavenCentral()
}
ext {
snippetsDir = file('build/generated-snippets')
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
//rest docs
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
}
tasks.named('test') {
useJUnitPlatform()
outputs.dir snippetsDir
}
tasks.named('asciidoctor') {
inputs.dir snippetsDir
configurations 'asciidoctorExt'
dependsOn tasks.test
}
bootJar {
dependsOn tasks.asciidoctor
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
bootRun {
dependsOn tasks.asciidoctor
classpath += files(tasks.asciidoctor.outputDir)
systemProperty "spring.web.resources.static-locations",
"classpath:/static/,file:${tasks.asciidoctor.outputDir}"
}
adoc 파일을 html로 변환해주는 asciidoctor 플러그인, rest docs 관련 dependency, task에 부가적인 작업들을 추가하는 설정을 빌드 스크립트에 작성하였다. bootRun 같은 경우 공식문서에는 나와있지 않지만 build 없이 애플리케이션을 local에서 실행시켰을 때 api 문서를 uri로 접근하기 위해서 설정해 주었다.
아래 Gradle 의존성 그래프를 보며 살펴보자.

- 테스트 단계에서 테스트 코드에 추가한 MockMvcRestDocumentation.documen()를 바탕으로 Snippet이 생성된다.
- 생성된 Snippet을 이용하는 adoc 파일을 src/docs/asciidoc 에 만들어 두면, 플러그인이 자동으로 adoc -> HTML로 변환한다.
- ./gradlew bootRun을 통해 애플리케이션을 실행시키고 localhost:8080/index.html로 접속하면 생성된 API 문서에 접근할 수 있다.
이 블로그 글에서는 위 3단계에 거쳐서 Rest Docs 기반 단일 페이지 API 문서를 만들어 내는 것이 목표이다.
위 task 의존 그래프를 보며 더 간단하게 표현해 보면 다음과 같다.

Java 코드를 이용하여 Test 코드에 Snippet을 생성하는 코드를 작성하고 생성된 Snippet을 적절하게 화면에 배치하는 adoc 파일을 작성한 뒤 asciidoctor를 이용하여 HTML 문서로 만들어내는 것이다.
2. Snippet 생성하기
먼저 실습은 Controller Slice 테스트로 진행하였다. 코드는 다음과 같고 전체 코드의 경우 아래에서 확인할 수 있다.
https://github.com/HeeChanN/java-play-ground/tree/main/practice_rest_docs
java-play-ground/practice_rest_docs at main · HeeChanN/java-play-ground
java로 이것저것. Contribute to HeeChanN/java-play-ground development by creating an account on GitHub.
github.com
@WebMvcTest(ItemController.class)
@SuppressWarnings("NonAsciiCharacters")
@AutoConfigureRestDocs
@Import(CustomRestDocsConfig.class)
public class ItemControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
...
}
@TestConfiguration
public class CustomRestDocsConfig implements RestDocsMockMvcConfigurationCustomizer {
@Override
public void customize(MockMvcRestDocumentationConfigurer configurer) {
configurer.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint());
}
}
`@WebMvcTest` 애노테이션을 통해 Controller Slice 테스트만을 위한 Bean을 Context에 올리고 `@AutoConfigureRestDocs`와 `Import(CustomRestDocsConfig.class)`를 통해 Rest Docs 설정을 진행해 주었다.
Testing Spring Boot Applications :: Spring Boot
Spring Boot’s auto-configuration system works well for applications but can sometimes be a little too much for tests. It often helps to load only the parts of the configuration that are required to test a “slice” of your application. For example, you
docs.spring.io
해당 문서를 살펴보면 @AutoConfigureRestDocs 애노테이션을 통해 간단하게 MockMvc에 Rest Docs 설정을 추가해줄 수 있으며 Import로 추가한 설정의 경우 전역적으로 Snippet에 나오게 될 결과 json을 보기 좋게 생성되도록 추가해 주었다.
테스트할 대상 코드와 실제 테스트 코드는 다음과 같이 작성하였다.
@RestController
@RequiredArgsConstructor
public class ItemController {
private final ItemService itemService;
@PostMapping("/api/items")
public Item createItem(@Valid @RequestBody ItemCreateDto itemCreateDto) {
return itemService.createItem(itemCreateDto);
}
@GetMapping("/api/items/{id}")
public Item getItem(@PathVariable(name = "id") long id){
return itemService.getItem(id);
}
}
@WebMvcTest(ItemController.class)
@SuppressWarnings("NonAsciiCharacters")
@AutoConfigureRestDocs
@Import(CustomRestDocsConfig.class)
public class ItemControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private ItemService itemService;
private Item item;
private ItemCreateDto itemCreateDto;
@BeforeEach
void setUp() throws Exception {
itemCreateDto = new ItemCreateDto("name", "description", 10000);
item = Item.builder()
.id(1L)
.name(itemCreateDto.getName())
.description(itemCreateDto.getDescription())
.price(itemCreateDto.getPrice())
.build();
}
@Test
void 아이템_생성() throws Exception{
//given
given(itemService.createItem(any(ItemCreateDto.class))).willReturn(item);
//when & then
mockMvc.perform(post("/api/items")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(itemCreateDto)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value(itemCreateDto.getName()))
.andExpect(jsonPath("$.description").value(itemCreateDto.getDescription()))
.andExpect(jsonPath("$.price").value(itemCreateDto.getPrice()))
.andDo(ItemDoc.createItem());
}
@Test
void 아이템_생성시_이름_공백_검증() throws Exception{
ItemCreateDto errorRequest = new ItemCreateDto("", "description", 10000);
//when & then
mockMvc.perform(post("/api/items")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(errorRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath("$.message").value("이름은 공백일 수 없습니다."))
.andDo(ItemDoc.errorSnippet("items/create-item/blank-name"));
}
@Test
void 아이템_생성시_가격이_음수일_경우_검증() throws Exception{
ItemCreateDto errorRequest = new ItemCreateDto("name", "description", -10000);
//when & then
mockMvc.perform(post("/api/items")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(errorRequest)))
.andExpect(jsonPath("$.status").value(HttpStatus.BAD_REQUEST.value()))
.andExpect(jsonPath("$.message").value("가격은 0 이상이어야 합니다."))
.andDo(ItemDoc.errorSnippet("items/create-item/minus-price"));
}
@Test
void 아이템_단일_조회() throws Exception{
//given
given(itemService.getItem(eq(1L))).willReturn(item);
//when & then
mockMvc.perform(get("/api/items/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value(itemCreateDto.getName()))
.andExpect(jsonPath("$.description").value(itemCreateDto.getDescription()))
.andExpect(jsonPath("$.price").value(itemCreateDto.getPrice()))
.andDo(ItemDoc.getItem());
}
}
일반적인 테스트 코드와 차이점이 있다면 각 테스트 코드 뒤에 붙은 ItemDoc.메서드() 이다. 실제 아래와 같이 작성해도 되지만 테스트 코드와 Snippet을 생성하는 코드를 분리하고 싶어 위와 같이 작성하였다.
mockMvc.perform(get("/api/items/{id}", 1L)
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1L))
.andExpect(jsonPath("$.name").value(itemCreateDto.getName()))
.andExpect(jsonPath("$.description").value(itemCreateDto.getDescription()))
.andExpect(jsonPath("$.price").value(itemCreateDto.getPrice()))
.andDo(document(
"items/get-item-by-id",
pathParameters(
parameterWithName("id").description("아이템 id")),
responseFields(
fieldWithPath("id").type(JsonFieldType.NUMBER).description("아이템 id"),
fieldWithPath("name").type(JsonFieldType.STRING).description("아이템 이름"),
fieldWithPath("description").type(JsonFieldType.STRING).description("아이템 부가 설명"),
fieldWithPath("price").type(JsonFieldType.NUMBER).description("아이템 가격")
));
document() 메서드 코드를 살펴보면 MockMvc로 검증한 필드들에 대해서 설명을 작성한 것이 보인다. 여기서 만약 검증 조건으로 적혀있는 필드값들 중 하나라도 document에서 빠트리게 된다면 아래와 같이 테스트가 실패하게 된다.

이처럼, 테스트 코드가 실패하기 때문에 테스트 코드를 수정하게되면 Snippet을 생성하는 코드도 수정해야하고 그에 따라 자동으로 API 문서 최신화를 유지할 수 있다.
공식문서에 따르면 snippet의 identifier (items/get-item-by-id)만 설정해주기만 해도 기본적으로 6개의 snippet이 생성된다고 나와있다.
즉 아래와 같이 테스트 코드를 수정하고 ./gradlew clean test를 진행해보면 오르쪽처럼 build/ 디렉토리에 snippet들이 생성된다.

따라서, 간단하게 API 문서를 빠르게 뽑아야 되는 경우 따로 각 스니펫을 커스텀하지 않고 위 코드처럼 뽑아내면 빠르게 Rest Docs를 이용하여 API 문서를 만들어 볼 수 있다. 직접 Custom 하는 것과 차이점이 있다면 Response, Request에 들어가는 json 필드들에 대한 자세한 설명까지는 들어가지 않는다. 물론 직접 Custom할 때에도 따로 Response 필드나 Request 필드에 대해 작성하지 않는다면 설명이 나오지 않는다.
Controller 같은 경우 Validation을 통해 메서드 파라미터에 대한 검증을 진행한다. 하지만, 기본 snippet 생성 템플릿에는 이런 제약조건을 담아주지 않는다. 실제 다음 코드에 대해서 생성된 snippet을 살펴보면 제약조건을 attribute()를 통해 넣어주었지만 생략되서 생성되어있는것을 볼 수 있다.
public static RestDocumentationResultHandler createItem(){
ConstraintDescriptions cons = new ConstraintDescriptions(ItemCreateDto.class);
return document(
"items/create-item",
requestFields(
fieldWithPath("name").type(JsonFieldType.STRING).description("아이템 이름")
.attributes(key("constraints")
.value(String.join(", ", cons.descriptionsForProperty("name")))),
fieldWithPath("description").type(JsonFieldType.STRING).description("아이템 부가 설명"),
fieldWithPath("price").type(JsonFieldType.NUMBER).description("아이템 가격")
.attributes(key("constraints").value(
String.join(", ", cons.descriptionsForProperty("price"))))
),
responseFields(
fieldWithPath("id").type(JsonFieldType.NUMBER).description("아이템 id"),
fieldWithPath("name").type(JsonFieldType.STRING).description("아이템 이름"),
fieldWithPath("description").type(JsonFieldType.STRING).description("아이템 부가 설명"),
fieldWithPath("price").type(JsonFieldType.NUMBER).description("아이템 가격")
)
);
}
`build/generated-snippets/items/create-item/request-fields.adoc`

이를 표현해주기 위해서는 공식문서에서 다음과 같이 진행하라고 나와있다.
Spring REST Docs
Document RESTful services by combining hand-written documentation with auto-generated snippets produced with Spring MVC Test or WebTestClient.
docs.spring.io
이 링크를 따라가 보면 src/test/resources 폴더에 org/springframework/restdocs/templates/asciidoctor 폴더를 만들고 그 안에 <snippet이름>.snippet으로 파일을 생성하라고 나와있다.

이 방법으로 spring-restdocs-core에 담겨있는 default snippet 템플릿을 복사해와서 원하는 형식으로 수정해서 사용하면 다음과 같이 생성되는 snippet이 원하는 모습으로 나오게 된다.
`request-fields.snippet`
|===
|Path|Type|Description|Constraints
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
|{{#constraints}}{{#tableCellContent}}{{.}}{{/tableCellContent}}{{/constraints}}
{{^constraints}}{{#tableCellContent}} {{/tableCellContent}}{{/constraints}}
{{/fields}}
|===

3. adoc 파일 작성
가장 오래 걸렸던 부분은 이 부분이다. 실제 대부분이 adoc이라는 문법을 처음 볼텐데 이부분이 Rest Docs를 도입하는데 가장 큰 벽이지 않나 생각한다. 다음 글에서는 adoc 파일을 사용하지 않아도 API 문서를 간단하게 만들 수 있는 방법까지도 한번 소개해 보려고 한다.
Rest Docs 공식 문서에서도 adoc 문법을 참고하라고 다음 문서를 추천해준다.
https://docs.asciidoctor.org/asciidoc/latest/syntax-quick-reference/#include-files
AsciiDoc - AsciiDoc Syntax Quick Reference
The quick reference for common AsciiDoc document and text formatting markup.
docs.asciidoctor.org
문서를 보며 공부하기 보다 생성형 AI의 도움을 받아서 큰 코드 틀을 작성하고 필요한 부분만 이 문서를 통해 학습하며 원하는 HTML 파일을 만들어나가는 것을 추천한다.
위 테스트 코드를 바탕으로 나온 snippet들을 이용하여 다음과 같이 adoc 파일을 작성하였다.
`src/docs/asciidoc/index.adoc`
= Item API
:toc:
:toclevels: 2
:sectnums:
:toc-title: 목차
:toc: left
== POST /api/items – 아이템 생성
'''
operation::items/create-item[snippets="http-request,request-fields,response-fields,http-response"]
'''
=== Error Response
include::{snippets}/items/create-item/blank-name/response-fields.adoc[]
==== 이름이 공백인 경우
include::{snippets}/items/create-item/blank-name/http-response.adoc[]
==== 가격이 음수인 경우
include::{snippets}/items/create-item/minus-price/http-response.adoc[]
== GET /api/items/{id} – 아이템 단건 조회
operation::items/get-item-by-id[snippets="http-request,response-fields,http-response"]
우리가 작성해야 하는 adoc 파일은 생성된 snippet들을 어느 곳에 위치시킬지 결정해주는 파일이다. 다음 명령어를 실행시켜서 adoc 파일을 html로 변환하고 build/docs/asciidoc에 생성된 index.html 파일을 브라우저로 열어보자.
.\gradlew asciidoctor

방금 작성한 adoc 파일이 사진과 같은 API 문서가 되어있는 것을 볼 수 있다.
4. 실행환경에서 API 문서 접근하기
다 만들어진 API 문서는 따로 웹서버를 통해 배포 할 수도 있고 애플리케이션과 함께 배포할 수도 있다. gradle 빌드 스크립트에 작성했던 다음 코드들이 바로 애플리케이션과 함께 배포할 수 있도록 도와주는 코드이다.
bootJar {
dependsOn tasks.asciidoctor
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
bootRun {
dependsOn tasks.asciidoctor
classpath += files(tasks.asciidoctor.outputDir)
systemProperty "spring.web.resources.static-locations",
"classpath:/static/,file:${tasks.asciidoctor.outputDir}"
}
`./gradlew build`를 통해 jar 파일을 생성하여 배포하면 http://localhost:8080/docs/index.html로 접근할 수 있고
`./gradlew bootRun`을 통해 직접 실행시켰을 때는 http://localhost:8080/index.html로 접근할 수 있다.
5. 마무리 하며
Rest Docs를 도입하고자 마음먹고 공부를 시작했을 때 생각보다 학습곡선도 있고 Swagger-ui를 이용했을 때 보다 더 많은 코드를 작성해야한다는 부분에서 부담감을 느꼈다. 하지만, 그만큼 가독성이 좋고 깔끔한 API 문서를 만들 수 있고, 또 필수로 작성해야하는 테스트코드를 기반으로 작성되기 때문에 최신화도 필수적이라는 부분이 매우 매력적인것 같다.
'Spring Boot' 카테고리의 다른 글
| Spring Boot 로깅 (2) - logback-spring.xml 설정 및 Grafana Loki 연동 (0) | 2025.09.21 |
|---|---|
| Spring Boot 로깅 (1) - 기초 (0) | 2025.09.16 |
| Spring Rest Docs (3) - Validation 규칙을 API 문서에 추가하기 (restdocs-api-spec) (1) | 2025.07.08 |
| Spring Rest Docs (2) - API 문서에 디자인 입히기 (swagger, redoc) (0) | 2025.07.08 |
| Spring Boot에서 발생한 TimeZone 문제 (0) | 2025.05.04 |