[Spring][Docs] Spring REST Docs
들어가며
디프만 11기 백엔드 개발자로 합류하면서 API 문서화를 Spring REST Docs로 진행하게 되었다. 따라서 기존에 사용하던 Swagger와 더불어 그 사용법과 원리를 기록하기 위한 포스팅이다.
출처
Spring REST Docs 적용 및 최적화 하기, 공식문서, 우아한형제들 기술 블로그
Swagger vs REST Docs
- Swagger
- 테스트 기반이 아니므로 실제 API와 다를 수 있으며 접근 안전성이 보장되지 않는다.
- UI에서 바로 API 테스트가 가능한다.
- 잘 구성되어 있는 화면 UI를 통해 가독성이 매우 좋다.
- product 코드가 문서화를 위한 애노테이션에 오염되어
Controller
가독성이 조금은 떨어질 수 있다. - 애노테이션 기반으로 적용하기 매우 쉽다.
- REST Docs
- 테스트 기반 API 문서이므로 안전성이 보장된다.
- 문서화를 위한 테스트 코드가 많이 소요된다.
- product 코드에 영향이 없다.
- 나는 다음과 같은 이유로 Swagger가 조금 더 낫다고 생각한다.
- 어차피
Controller
단위 테스트를 진행하므로 REST Docs처럼 안정성을 보장받을 수 있는 점 - 애노테이션 기반으로 적용하기 매우 쉽고 생산성이 향상된다는 점
- API 문서는 백엔드뿐만 아니라 프론트 개발자분들과의 협업을 위해 필요하므로 간편한 UI 테스트 등을 지원하는 Swagger에 대한 프론트 개발자의 의견이 고려되어야 한다는 점
- 문서화 코드에 오염된다는 부분은 어느 정도 긍정하나
Controller
에는 URL 매핑을 위한 코드만 존재할 뿐 서비스 로직이 없어야 한다는 점 등을 고려할 때 문서화를 위한 애노테이션 2~3개가 붙는다는 이유로 가독성이 떨어진다는 것은 크게 공감하기 어려운 점
- 어차피
- 위와 같은 이유로 Swagger에 한 표 던졌지만… 다른 팀원분들의 다수결에 따라 REST Docs로 진행하게 되었다. 둘 다 다룰 줄 안다면 시너지 효과를 기대해 볼 수 있겠다는 생각에 제대로 공부를 하게 되었다.
사용 코드
build.gradle
plugins {
...
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
configurations {
asciidoctorExtensions
...
}
repositories {
mavenCentral()
}
dependencies {
...
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
}
ext {
snippetsDir = file('build/generated-snippets')
}
test {
useJUnitPlatform()
outputs.dir snippetsDir
}
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
asciidoctor {
inputs.dir snippetsDir
dependsOn test
configurations 'asciidoctorExtensions'
sources {
include("**/*.adoc")
}
baseDirFollowsSourceFile()
}
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc/")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument
}
bootJar {
dependsOn copyDocument
from file('src/main/resources/static/docs')
into file('build/resources/main/static/docs')
}
RestDocsConfig
@TestConfiguration
public class RestDocsConfig {
@Bean
public RestDocumentationResultHandler write() {
return MockMvcRestDocumentation.document(
"{class-name}/{method-name}",
Preprocessors.preprocessRequest(Preprocessors.prettyPrint()),
Preprocessors.preprocessResponse(Preprocessors.prettyPrint())
);
}
}
RestDocsSupport
public interface RestDocsSupport {
public static Attribute field(String key, String value) {
return new Attribute(key, value);
}
}
TestControllerTest
@WebMvcTest(TestController.class)
@Import(RestDocsConfig.class)
@ExtendWith(RestDocumentationExtension.class)
class TestControllerTest {
MockMvc mockMvc;
ObjectMapper objectMapper = new ObjectMapper();
@Autowired
RestDocumentationResultHandler restDocs;
@BeforeEach
void setUp(WebApplicationContext context, RestDocumentationContextProvider provider) {
this.mockMvc = MockMvcBuilders.webAppContextSetup(context)
.apply(MockMvcRestDocumentation.documentationConfiguration(provider))
.alwaysDo(MockMvcResultHandlers.print())
.alwaysDo(restDocs)
.addFilter(new CharacterEncodingFilter("UTF-8", true))
.build();
}
@Test
@DisplayName("getAccount")
public void getAccount() throws Exception {
mockMvc.perform(get("/{id}", 1L).contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(
restDocs.document(
pathParameters(
parameterWithName("id").description("Account id")
),
responseFields(
fieldWithPath("id").description("id"),
fieldWithPath("name").description("name"),
fieldWithPath("email").description("email")
))
);
}
@Test
@DisplayName("getAccountList")
public void getAccountList() throws Exception {
mockMvc.perform(get("/accounts").param("size", "10").param("page", "0"))
.andExpect(status().isOk())
.andDo(
restDocs.document(
requestParameters(
parameterWithName("size").optional().description("size"),
parameterWithName("page").optional().description("page")
),
responseFields(
fieldWithPath("data").description("데이터"),
fieldWithPath("data.list[0].id").description("id"),
fieldWithPath("data.list[0].email").description("email"),
fieldWithPath("data.list[0].name").description("name")
)
)
);
}
@Test
@DisplayName("createAccount")
public void createAccount() throws Exception {
AccountDTO dto = AccountDTO.builder().name("yhw").email("yhwjjang1995@naver.com").build();
String json = objectMapper.writeValueAsString(dto);
mockMvc.perform(post("/").contentType(MediaType.APPLICATION_JSON).content(json))
.andExpect(status().isCreated())
.andDo(
restDocs.document(
requestFields(
fieldWithPath("name").description("name").attributes(field("constraints", "길이 10 이하")),
fieldWithPath("email").description("email").attributes(field("constraints", "길이 30 이하"))
)
)
);
}
}
index.adoc
= REST Docs 문서 만들기
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
[[Account-API]]
== Account API
=== Account 단일 조회
* link:Account-단일-조회.html[Account 단일 조회 API, window=_blank]
=== Account 모두 조회
* link:Account-모두-조회.html[Account 모두 조회 API, window=_blank]
=== Account 생성
* link:Account-생성.html[Account 생성 조회 API, window=_blank]
Account-단일-조회.adoc
: 나머지 조각adoc
파일도 비슷한 형태
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toclevels: 2
:sectlinks:
[[Account-단일-조회]]
=== Account 단일 조회
include::{snippets}/test-controller-test/get-account/http-request.adoc[]
include::{snippets}/test-controller-test/get-account/http-response.adoc[]
include::{snippets}/test-controller-test/get-account/path-parameters.adoc[]
include::{snippets}/test-controller-test/get-account/response-body.adoc[]
include::{snippets}/test-controller-test/get-account/response-fields.adoc[]
멀티 모듈 프로젝트와 관계
- 이번 프로젝트는 MSA 구조로 진행하게 되었다. 이와 같은 구조에서 어떻게 하면 깔끔한 API 문서를 제공할 수 있을까 고민해 보았다.
- 각 기능 모듈에서 각자
Controller
단위 테스트 진행 후.html
파일을 생성한 후에jar
파일로 패키징을 하고 이후app
모듈에서index.adoc
파일을 하나만 유지한 채 각 기능 모듈의.html
링크만 거는 방식을 생각해 보았다. 그 이유는 하나의app
모듈 같은 패키지에서 하나의index.adoc
파일로 여러 명이 같이 테스트를 진행한다면 다음과 같은 문제가 발생할 수 있을 것 같기 때문이다.- 기능별로 모듈을 나눴다면 결국 API 문서도 모듈 단위로 제공되어야 하는 점
- 기능 모듈 API 문서가 존재하지 않는다면 인수인계받은 개발자가 특정 기능 모듈을 사용하려고 할 때 결국 의존성을 뒤져서 API 스펙을 확인해야 한다는 점
- 하나의
index.adoc
파일로 여러 명이 한 번에 작업을 한다면 conflict이 발생할 수 있는 점 - 각 개발자가 같은
app
모듈 패키지에서 테스트를 진행한다면 커스텀한snippet
이 겹치거나 충돌될 수 있는 점 - 기능 모듈에서는 단위 테스트를 진행하고 필요한 기능을 갖춘 하나의
app
모듈에서는 통합 테스트가 진행되는 것이 적절하다고 생각되는 점 - 하나의
app
모듈에서 테스트를 진행하게 된다면 힘들게 기능별로 모듈을 나눴는데 문서화를 위해서 굳이 또Controller
테스트만 합쳐야 된다는 점 - 특정 기능 모듈이 다른
app
모듈에서 중복 사용된다면 결국 테스트 코드를 중복해서 계속 작성해야 하는 점 - 프로젝트 구조가 커진다면 결국 문서 분리화를 통한 가독성 확보가 필수 불가결 한 점
- 하지만 이번 프로젝트에서는 규모가 크지는 않을 것 같고 다른 팀원분들의 의견에 따라 하나의
app
모듈에서 하나의index.adoc
파일에서 함께 작업하기로 했다.
고찰
- 이번 프로젝트에서 사용한 Spring REST Docs를 통해 Swagger와는 다른 장점을 볼 수 있었다.
- 코딩을 하면서 공통된 API 스펙의 경우 따로 공통화 시키는 작업이 필요할 수 있을 것 같다.
댓글남기기