Spring REST Docs로 API 문서 자동화하기
이번에는 Spring REST Docs를 활용해서 기존 컨트롤러 테스트에 API 문서화를 추가해봤다.
테스트 기반으로 문서를 자동 생성해주는 도구라, 문서와 실제 코드가 따로 놀지 않게 만들어주는 것이 아주 큰 장점이다.
기존 테스트에 REST Docs 적용
사실 처음엔 어떤 결과물이 나오는지 전혀 감이 없었는데 적용해보니,
테스트를 실행하면서 동시에 문서가 만들어지는 구조라 API 명세서를 따로 관리할 필요가 없다는 점이 꽤 매력적이었다.
예를 들어 `AuthControllerTest` 같은 기존 테스트 클래스에 아래처럼 `restDocs.document()` 구문을 추가해주면,
테스트 결과를 기반으로 자동으로 `.adoc` 파일이 생성된다.
.andDo(restDocs.document(
requestFields(
fieldWithPath("username").description("사용자 이름"),
fieldWithPath("email").description("사용자 이메일"),
fieldWithPath("password").description("비밀번호")
),
...
공통 설정 클래스 분리
테스트마다 REST Docs 설정을 넣었더니 너무 중복이 많아서,
공통 설정을 담당하는 `RestDocsTestSupport` 클래스를 따로 만들었다.
@ExtendWith(RestDocumentationExtension.class)
public abstract class RestDocsTestSupport {
protected MockMvc mockMvc;
protected RestDocumentationResultHandler restDocs;
@BeforeEach
void setup(WebApplicationContext context, RestDocumentationContextProvider restDocumentation) {
this.restDocs = MockMvcRestDocumentation.document("{class-name}/{method-name}",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()));
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.apply(MockMvcRestDocumentation.documentationConfiguration(restDocumentation))
.apply(SecurityMockMvcConfigurers.springSecurity())
.alwaysDo(restDocs)
.build();
}
}
이 클래스에서는 `@ExtendWith(RestDocumentationExtension.class)`를 사용해서 REST Docs 기능을 테스트 환경에 등록했다.
그리고 `MockMvc`를 직접 설정하면서 UTF-8 인코딩, `prettyPrint()`, `Spring Security` 설정 등을 한 번에 적용하도록 만들었다.
테스트마다 매번 환경을 세팅할 필요 없이 공통 테스트 환경을 미리 준비해두는 역할이다.
이제 각 컨트롤러 테스트는 단순히 `extends RestDocsTestSupport`로 이 클래스를 상속하기만 하면, 바로 REST Docs를 사용할 수 있다.
응답 구조를 문서화하기 위한 유틸 — RestDocsUtils
API마다 응답 구조가 거의 비슷했기 때문에 공통 필드(httpStatus, code, message, timestamp 등)를 매번 적는 게 번거로웠다.
그래서 RestDocsUtils를 만들어서 아래처럼 공통화했다.
public class RestDocsUtils {
// 공통 성공 응답 필드 (data 제외)
private static List<FieldDescriptor> commonSuccessResponseFields() {
List<FieldDescriptor> fields = new ArrayList<>();
fields.add(fieldWithPath("httpStatus").description("HTTP 상태 코드"));
fields.add(fieldWithPath("code").description("응답 코드"));
fields.add(fieldWithPath("success").description("성공 여부"));
fields.add(fieldWithPath("message").description("응답 메시지"));
fields.add(fieldWithPath("timestamp").description("응답 시간"));
return fields;
}
// 공통 에러 응답 필드
private static List<FieldDescriptor> commonErrorResponseFields() {
List<FieldDescriptor> fields = new ArrayList<>();
fields.add(fieldWithPath("httpStatus").description("HTTP 상태 코드"));
fields.add(fieldWithPath("code").description("응답 코드"));
fields.add(fieldWithPath("success").description("성공 여부"));
fields.add(fieldWithPath("message").description("오류 메시지"));
fields.add(fieldWithPath("data").description("에러 응답의 경우 항상 null").optional());
fields.add(fieldWithPath("timestamp").description("응답 시간"));
fields.add(fieldWithPath("requestUrl").description("요청 URL"));
return fields;
}
/**
* 공통 성공 응답 필드 + {@code data} 내부의 필드를 병합한 {@code Snippet}을 생성
*
* <p>사용 예시</p>
* <pre>{@code
* .andDo(restDocs.document(
* requestFields(...),
* RestDocsUtils.successWithDataFields(
* fieldWithPath("data.userId").description("회원 ID"),
* fieldWithPath("data.username").description("회원 이름")
* )
* ));
* }</pre>
*/
public static Snippet successWithDataFields(FieldDescriptor... dataFields) {
List<FieldDescriptor> fields = commonSuccessResponseFields();
if (dataFields == null || dataFields.length == 0) {
fields.add(fieldWithPath("data").description("응답 데이터 (null일 수 있음)").optional());
} else {
fields.add(fieldWithPath("data").description("응답 데이터"));
fields.addAll(List.of(dataFields));
}
return responseFields(fields);
}
/**
* 공통 에러 응답 필드에 대한 {@code Snippet} 생성
*
* <p>사용 예시</p>
* <pre>{@code
* .andDo(restDocs.document(
* requestFields(...),
* RestDocsUtils.errorResponseFields()
* ));
* }</pre>
*/
public static Snippet errorResponseFields() {
return responseFields(commonErrorResponseFields());
}
}
이제 테스트 코드에서 이렇게만 써주면 된다.
.andDo(restDocs.document(
requestFields(
fieldWithPath("username").description("사용자 이름"),
fieldWithPath("email").description("사용자 이메일"),
fieldWithPath("password").description("비밀번호")
),
RestDocsUtils.errorResponseFields()
));
index.adoc으로 문서 합치기
테스트를 돌리면 `build/generated-snippets` 아래에
각 테스트별로 `.adoc` 스니펫이 생성된다.
이 파일들을 하나의 문서로 합치기 위해 `index.adoc`을 작성했다.
= Oddventure REST API 문서
:toc: left
:toclevels: 2
:sectnums:
:source-highlighter: highlightjs
:icons: font
== 인증 API (Auth API)
=== 1️⃣ 회원가입 (POST /api/v1/auth/signup)
새로운 사용자를 회원으로 등록합니다.
요청 시 이름, 이메일, 비밀번호를 입력해야 하며, 중복된 이메일은 사용할 수 없습니다.
==== 요청 예시
include::{snippets}/auth-controller-test/signup_success/http-request.adoc[]
==== 응답 예시
include::{snippets}/auth-controller-test/signup_success/http-response.adoc[]
==== 요청 필드 설명
include::{snippets}/auth-controller-test/signup_success/request-fields.adoc[]
==== 응답 필드 설명
include::{snippets}/auth-controller-test/signup_success/response-fields.adoc[]
이 파일을 Asciidoctor로 빌드하면 `build/docs/asciidoc/index.html` 형태로 완성된 API 문서를 볼 수 있다.

마치며
REST Docs를 적용하고 나서 가장 좋았던 점은 테스트가 곧 문서가 되었다는 것이다.
컨트롤러 코드 변경 시 테스트 코드를 변경하게 되고, 그럼 문서도 당연히 최신화될 수 밖에 없었다.
Swagger처럼 실시간 UI는 아니지만, 테스트 기반이라 훨씬 신뢰성과 유지보수성이 높다는 느낌을 받았다.
무엇보다도, 테스트를 잘 짜면 문서도 잘 만들어진다는 점이 제일 마음에 들었다.