들어가며
안녕하세요. 현재 플러피(fluffy)라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 1인 개발을 하다 보니 서버와 웹 프론트엔드를 모두 담당하고 있습니다. 개발을 진행하면서 새로운 API를 추가하거나 기존 API를 변경하는 일이 빈번한데, 작성한 API를 프론트엔드에서 사용할 때마다 헷갈려 직접 코드를 살펴보는 번거로움이 있습니다. 또한, 나중에 다른 개발자가 프로젝트에 참여하거나 제가 다시 프로젝트를 돌이켜볼 때 이해하기 어려울 수 있습니다.
이러한 문제를 해결하기 위해 자동화 문서 생성 도구인 Spring REST Docs를 도입하기로 결정했습니다. 이번 글에서는 Spring REST Docs가 무엇인지 소개하고, 이것을 선택한 이유와 구체적인 사용법에 대해서 알아보겠습니다.
Spring REST Docs란?
스프링 공식 문서에서 Spring REST Docs는 다음과 같이 소개합니다.
Spring REST Docs는 Spring RESTful 서비스를 문서화하는데 도움을 줍니다.
Spring MVC 테스트로 생성된 자동 생성 스니펫과 직접 작성한 Asciidoctor를 결합하여 API 문서를 생성합니다.
Spring REST Docs는 테스트 코드를 기반으로 API 문서를 자동으로 생성합니다. MockMvc, WebTestClient, RestAssured 등의 테스트 프레임워크를 함께 사용할 수 있고, Asciidoctor를 사용하여 문서를 작성할 수 있습니다. API Spec과 문서화를 위한 테스트 코드가 일치하지 않을 경우, 빌드가 실패하게 됩니다. 이런 특성 덕분에 Spring REST Docs를 사용하면 API Spec과 문서가 항상 일치하게 유지되어 문서의 신뢰도를 높일 수 있습니다.
Spring REST Docs를 선택한 이유
Spring에서 문서화를 위해 주로 사용되는 방법은 REST Docs와 Swagger입니다. 두 가지 방법을 비교해보고, 왜 Spring REST Docs를 선택했는지 알아보겠습니다.
먼저, Swagger에 대해서 간단하게 알아보겠습니다. Swagger는 어노테이션을 사용하여, API Spec을 정의하고, 이를 기반으로 문서를 생성합니다. 다양한 언언와 프레임워크에서 사용 가능하며, 직관적인 UI를 제공하여 사용자 친화적입니다. 저도 예전에 Typescript, NestJS로 개발한 프로젝트에서 Swagger를 사용한 경험이 있어, 서버 개발자나 클라이언트 개발자 모두에게 친숙한 도구라고 생각합니다. Swagger는 Postman과 같이 API를 직접 요청할 수 있는 기능도 제공하여, 동작을 확인하기에도 용이합니다.
하지만, Swagger는 실제 코드에 API Spec을 정의하는 어노테이션이 계속 붙기 때문에 가독성이 떨어질 수 있습니다. 또한, 실제 코드와 문서가 분리되어 있어, 코드가 변경될 때 문서가 업데이트되지 않을 위험이 있습니다. 이는 문서의 신뢰도를 저하시킬 수 있습니다.
// Swagger 코드에 침투된 API Spec 어노테이션
@PostMapping
@ApiOperation(value = "사용자 추가", notes = "새로운 사용자를 추가합니다.")
@ApiResponses(value = {
@ApiResponse(code = 201, message = "사용자가 성공적으로 추가되었습니다."),
@ApiResponse(code = 400, message = "잘못된 요청입니다.")
})
public void addUser(@RequestBody CreateUserRequest request) {
// 사용자 추가 로직
}
반면, Spring REST Docs는 실제 코드에 API Spec을 정의하는 어노테이션이 없습니다. 대신, 테스트 코드를 기반으로 문서를 생성합니다. 위에서 설명한 것처럼 테스트가 성공했을 경우에만 문서가 생성되므로, API Spec과 문서가 항상 일치하게 유지됩니다. 또한, 테스트 코드를 작성하면서 API Spec을 작성하므로, API Spec을 작성하는 부담이 줄어듭니다. Asciidoctor를 사용하여 문서를 작성할 수 있기 때문에, 문서의 내용을 자유롭게 작성할 수 있습니다.
참고로, REST Docs의 테스트를 통한 문서 생성 방식의 장점과 Swagger의 직접 동작을 테스트할 수 있는 UI의 장점을 결합한 restdocs-api-spec이 있습니다. 이 프로젝트에서는 사용하지 않았지만, 참고하시면 좋을 것 같습니다.
테스트 프레임워크 선택
Spring REST Docs를 사용하기에 앞서, 테스트 프레임워크를 선택해야 합니다.
Spring REST Docs는 MockMvc, WebTestClient, RestAssured 등의 테스트 프레임워크를 지원합니다.
Spring MVC를 사용하고 있기 때문에 MockMvc와 RestAssured 중에서 선택하였습니다.
RestAssured의 경우 별도의 의존성도 추가해야 하고, 전체 컨텍스트를 로드해야 하기 때문에 테스트 시간이 오래 걸릴 수 있습니다.
통합 테스트의 경우 적합할 수 있으나, 문서화를 위한 테스트이기 때문에 서비스 계층을 로드할 필요가 없는 MockMvc를 선택하였습니다.
Spring REST Docs 사용법
스프링 공식 문서를 바탕으로 Spring REST Docs 사용법을 알아보겠습니다.
의존성 추가
plugins {
id "org.asciidoctor.jvm.convert" version "3.3.2" -- (1) asciidoctor 플러그인 추가
}
configurations {
asciidoctorExt -- (2) asciidoctorExt 구성 추가
}
dependencies {
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' -- (3) asciidoctorExt 의존성 추가
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' -- (4) spring-restdocs-mockmvc 의존성 추가
}
ext {
snippetsDir = file('build/generated-snippets') -- (5)
}
test {
outputs.dir snippetsDir -- (6)
}
asciidoctor { -- (7)
inputs.dir snippetsDir -- (8)
configurations 'asciidoctorExt' -- (9)
dependsOn test -- (10)
}
(5)번은 문서화 과정에서 생성되는 snippets 파일들을 저장할 디렉토리를 정의합니다.
(6)번은 테스트 결과를 (5)번에서 정의한 snippetsDir 디렉토리에 저장합니다.
(7)번은 asciidoctor 플러그인 설정입니다.
(8)번은 snippetsDir 디렉토리를 읽어 변경된 파일만 다시 생성하게 합니다.
(9)번은 (2)번에서 추가한 asciidoctorExt 구성을 사용합니다.
(10)번은 문서화 과정이 테스트에 의존한다는 것을 나타냅니다.
이 부분부터는 공식문서에 없는 내용입니다.
// build.gradle
asciidoctor {
baseDirFollowsSourceFile() -- (1)
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs') -- (2)
}
tasks.register('copyDocument', Copy) { -- (3)
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
build { -- (4)
dependsOn copyDocument
}
(1)번은 특정 .adoc에서 다른 .adoc 파일을 include할 때, 경로를 baseDir 기준으로 설정합니다.
(2)번은 빌드 시 기존에 생성된 문서를 삭제합니다.
(3)번은 copyDocument 작업을 정의합니다. asciidoctor 작업이 완료된 후에 실행되며, build/docs/asciidoc 디렉토리에 있는 파일을 src/main/resources/static/docs 디렉토리로 복사합니다. 공식 문서에서는 다음과 같은 내용만 있습니다.
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}") { // html5 붙이지 말기!!
into 'static/docs'
}
}
이 경우, JAR 파일을 만들 때만 src/main/resources/static/docs 디렉토리에 문서가 포함됩니다. 이 경우 IDE에서 build/docs/asciidoc 디렉토리에서만 문서를 확인할 수 있습니다. 하지만, copyDocument 작업을 추가하면 빌드 시 src/main/resources/static/docs 디렉토리에 문서가 포함되어 IDE에서도 문서를 확인할 수 있습니다. 상황에 맞게 선택하시면 됩니다.
(4)번은 copyDocument 작업이 완료된 후에 빌드합니다.
문서화 추상 클래스와 컨트롤러 테스트 추상 클래스 작성
저같은 경우 @WebMvcTest를 사용하는 추상 클래스인 AbstractControllerTest와 문서화를 위한 추상 클래스인 AbstractDocumentation을 만들었습니다. AbstractDocumentTest는 AbstractControllerTest를 상속받아 사용합니다.
public abstract class AbstractControllerTest { ... }
public abstract class AbstractDocumentTest extends AbstractControllerTest { ... }
각각을 간단하게 살펴보겠습니다. 이 부분은 선호하시는 방식으로 작성하시고, 참고만 해주시면 좋을 것 같습니다.
먼저, AbstractControllerTest입니다.
각 테스트 클래스마다 WebMvcTest 어노테이션을 사용하는 것보다 추상 클래스를 두고, 필요한 컨트롤러를 한 번에 등록하는 방식을 사용하였습니다. 이 경우 컨텍스트를 한 번만 로드하므로 테스트 시간을 단축할 수 있습니다. 필요한 것들은 @MockBean으로 등록합니다.
@WebMvcTest({
AuthController.class,
ExamController.class,
OAuth2Controller.class,
SubmissionController.class,
})
@ActiveProfiles("test")
public abstract class AbstractControllerTest {
protected MockMvc mockMvc;
@Autowired
protected ObjectMapper objectMapper;
// ...생략... 필요한 것들 @MockBean으로 등록
}
다음으로, AbstractDocumentTest입니다.
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
public abstract class AbstractDocumentTest extends AbstractControllerTest {
@Autowired
protected RestDocumentationResultHandler restDocs;
@BeforeEach
public void setUp(
WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation
) {
mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation)
.operationPreprocessors()
.withRequestDefaults(
prettyPrint(),
modifyUris().scheme("https").host("api.fluffy.com").removePort()
)
.withResponseDefaults(prettyPrint())
)
.alwaysDo(print())
.alwaysDo(restDocs)
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.build();
}
@TestConfiguration
static class RestDocsConfig {
@Bean
public RestDocumentationResultHandler restDocs() {
return MockMvcRestDocumentation.document("{class-name}/{method-name}");
}
}
}
먼저, 코드의 아래에 있는 RestDocsConfig에 대해서 설명하겠습니다.
@TestConfiguration으로 RestDocumentationResultHandler를 빈으로 등록합니다. 이것을 @Import(RestDocsConfig.class)로 가져오고, @Autowired로 주입받아 사용합니다.
MockMvcRestDocumentation.document("{class-name}/{method-name}")는 테스트 클래스 이름과 메서드 이름을 기반으로 문서를 생성하는 방식입니다. MockMvcRestDocumentation.document("{class-name}/{method-name}") 메서드는 각 테스트의 클래스 이름과 메서드 이름을 기반으로 문서를 생성하는 방식을 제공합니다. REST Docs에서는 각 테스트에서 문서화할 스니펫에 고유한 식별자를 지정해야 하며, 이 식별자는 각 API 문서를 구분하는 데 사용됩니다. 이름 짓기는 종종 개발자들에게 번거로운 작업이 될 수 있으며, 일관된 네이밍 규칙을 적용하는 것이 중요합니다. 따라서, 클래스와 메서드 이름을 활용하여 자동으로 문서화 식별자를 생성하는 이 방식을 선택하였습니다.
다음으로, @ExtendWith(RestDocumentationExtension.class)와 @BeforeEach 부분에 대해서 설명하겠습니다.
아래는 스프링 공식 문서에 나오는 기본적인 설정입니다.
private MockMvc mockMvc;
@BeforeEach
void setUp(WebApplicationContext webApplicationContext, RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
여기서 withRequestDefaults와 withResponseDefaults에 prettyPrint()를 추가하여 요청과 응답의 JSON을 가독성이 좋게 출력하도록 설정하였습니다. 또한, modifyUris().scheme("https").host("api.fluffy.com").removePort()를 통해 요청 URI를 수정할 수 있습니다. 이렇게 설정하면 문서화된 URI가 'https://api.fluffy.com'으로 표시됩니다.
.alwaysDo(print())는 테스트 결과를 콘솔에 출력해줍니다. .addFilter(new CharacterEncodingFilter("UTF-8", true))는 한글이 깨지는 문제를 해결하기 위해 추가하였습니다.
.alwaysDo(restDocs)는 MockMvcRestDocumentation.document("{class-name}/{method-name}")로 설정된 것을 적용하기 위해 사용했습니다.
모든 설정이 끝났습니다. 이제 아래와 같이 AbstractDocumentTest를 상속받아 테스트 코드를 작성하면 됩니다.
class ExamControllerTest extends AbstractDocumentTest { ... }
테스트 코드 작성
아래의 코드는 시험 상세 정보와 답안을 함께 조회하는 API를 테스트한 코드입니다.
위에서 아래로 코드를 살펴보며, 각 부분에 대해서 설명하겠습니다.
@Test
@DisplayName("시험 상세 정보와 답안을 함께 조회할 수 있다.")
void getExamDetailWithAnswers() throws Exception {
ExamWithAnswersResponse response = new ExamWithAnswersResponse(...생략...);
when(examQueryService.getExamWithAnswers(any(), any()))
.thenReturn(response);
mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/exams/{examId}/with-answers", 1)
.param("page", "0")
.param("size", "2")
.accept(MediaType.APPLICATION_JSON)
.cookie(new Cookie("accessToken", "{ACCESS_TOKEN}"))
)
.andExpectAll(
status().isOk(),
content().json(objectMapper.writeValueAsString(response))
)
.andDo(restDocs.document(
pathParameters(
parameterWithName("examId").description("시험 ID")
),
responseFields(
fieldWithPath("id").description("시험 ID"),
fieldWithPath("title").description("시험 제목"),
fieldWithPath("questions").description("문제 목록"),
fieldWithPath("questions[].id").description("문제 ID"),
fieldWithPath("questions[].options").description("선택지 목록").optional(),
fieldWithPath("questions[].options[].id").description("선택지 ID").optional(),
// ...생략...
)
));
}
mockMvc.perform(RestDocumentationRequestBuilders.get("/api/v1/exams/{examId}/with-answers", 1) ... )
위 코드는 MockMvc 내용이기는 하나 실수할 수 있는 부분이 있어 설명드리겠습니다.
일반적으로 MockMvc에서 get, post 등등을 사용하는 경우 MockMvcRequestBuilders를 사용합니다. 하지만, /api/v1/exams/{examId}/with-answers와 같이 URI에 path variable이 있는 경우, RestDocumentationRequestBuilder를 사용해야 오류가 발생하지 않습니다.
다음으로, andDo(restDocs.document(...)) 부분에 대해서 설명하겠습니다.
일반적으로 andDo(docuemnt(...))로 사용하지만 저는 위에서 설명한 MockMvcRestDocumentation.document("{class-name}/{method-name}")를 사용하여 identifier를 자동 생성하기 때문에 RestDocumentationResultHandler 빈을 주입받은 restDocs를 사용하였습니다.
document 메서드의 인자로는 pathParameters, requestParameters, requestFields, responseFields 등이 있습니다. 각각의 메서드는 문서화할 스니펫을 생성합니다. fieldWithPath 메서드는 문서화할 필드를 지정합니다. description 메서드는 해당 필드에 대한 설명을 추가합니다. optional 메서드는 해당 필드가 필수가 아님을 나타냅니다. optional을 사용해도 문서에 표시되지 않습니다. 그렇기 때문에 snippet을 커스텀해서 사용해야 합니다. 이 부분은 이후에 다루도록 하겠습니다.
이제, 테스트 코드를 실행하면 build/generated-snippets 디렉토리에 curl-request, http-request, http-response, path-parameters, request-fields, response-fields 등의 adoc 파일이 생성됩니다.
이제 이 파일들을 조합하여 문서를 만들도록 하겠습니다.
adoc 파일 작성
먼저, src/docs/asciidoc 디렉토리를 생성하고, index.adoc 파일과 exam.adoc 파일을 만듭니다.
// index.adoc
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
= Fluffy API 명세서
include::exam.adoc[]
// exam.adoc
== Exam API
=== 시험 상세 정보와 답안을 함께 조회
operation::exam-document-test/get-exam-detail-with-answers[]
index.adoc 파일은 문서의 메인 페이지입니다. exam.adoc 파일을 `include`하여 문서를 구성합니다. 'operation::'으로 시작하는 부분은 스니펫 파일의 이름을 참조합니다. exam-document-test/get-exam-detail-with-answers[]는 exam-document-test/get-exam-detail-with-answers 디렉토리에 있는 모든 스니펫 파일을 참조합니다. exam-document-test/get-exam-detail-with-answers[snippets='curl-request,http-request']와 같이 특정 스니펫만 참조할 수도 있습니다.
이제 빌드 후 'https://localhost:8080/docs/index.html'로 접속하면 다음과 같은 문서를 확인할 수 있습니다.
참고
문서화할 스니펫 커스텀하기
REST Docs에서 제공하는 기본 스니펫은 일반적인 경우에는 문제가 없지만, 특정한 경우에는 커스텀이 필요할 수 있습니다. 예를 들어, 날짜 형식이 필요하거나, 기본 값, 필수 여부 등을 표시하고 싶을 때가 있습니다. 이런 경우 커스텀 스니펫을 만들어 사용할 수 있습니다.
예를 들어, response-fields 스니펫에 optional을 사용하면 문서에 표시되지 않습니다. 이런 경우에 커스텀 스니펫을 만들어 사용할 수 있습니다.
먼저, src/test/resources/org/springframework/restdocs/templates 디렉토리를 생성하고, response-fields.snippet 파일을 만듭니다.
default-response-fields.snippet은 다음과 같이 생겼습니다.
|===
|Path|Type|Description
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/fields}}
|===
여기에 필수가 아닌 경우 true, 필수가 아닌 경우 비어 있게 하기 위해 다음과 같이 수정합니다.
|===
|Path|Type|Optional|Description
{{#fields}}
|{{#tableCellContent}}`+{{path}}+`{{/tableCellContent}}
|{{#tableCellContent}}`+{{type}}+`{{/tableCellContent}}
|{{#tableCellContent}}{{#optional}}true{{/optional}}{{/tableCellContent}}
|{{#tableCellContent}}{{description}}{{/tableCellContent}}
{{/fields}}
|===
더 자세한 내용은 이 블로그를 참고하시면 좋을 것 같습니다.
AsciiDoc이 인식되지 않는 문제
커스텀 snippet 파일을 작성할 때, Unexpected token 오류가 발생할 수 있습니다. 이는 AsciiDoc이 인식되지 않아 발생하는 문제입니다. Intellij -> Settings -> Editor -> File Types 에서 AsciiDoc files를 찾아서, '*.snippets'를 추가하면 됩니다.
Writing AsciiDoc works best with soft-wrap enabled. Do you want to enable it by default?라는 메시지가 뜨면 Enable을 눌러주시면 됩니다.
다음으로 Soft Wraps에 '*.snippets;'를 추가하시면 됩니다.
마치며
이번 글에서는 Spring REST Docs가 무엇인지 소개하고, 이것을 선택한 이유와 구체적인 사용법에 대해서 알아보았습니다.
좋은 코드를 작성하는 것도 중요하지만, 그 코드를 쉽게 사용하기 위해 문서화하는 것도 중요하다고 생각합니다.
이 글을 작성한 시점의 코드입니다. 필요하신 분들은 확인하시길 바랍니다.
커스텀할 수 있는 부분이 많아 이 글에서는 다루지 못한 부분이 많습니다.
그래도 이 글이 Spring REST Docs를 사용하고자 하는 분들에게 도움이 되었으면 좋겠습니다. ⛅️
참고
'서버' 카테고리의 다른 글
커버링 인덱스를 활용한 페이지네이션 성능 개선하기 (1) | 2025.01.19 |
---|---|
Docker Desktop 오류, "'com.docker.vmnetd'에 악성 코드가 포함되어 있어서 열리지 않았습니다." 해결 방법 (1) | 2025.01.13 |
무중단 배포(블루/그린 배포)로 서비스 중단 없이 배포하기 (5) | 2025.01.09 |
Flyway를 통한 데이터베이스 마이그레이션을 알아보자 (1) | 2025.01.03 |
@JsonTypeInfo와 @JsonSubTypes를 활용하여 요청 데이터에 다형성을 적용해 보자 (1) | 2024.12.25 |