0. 들어가며
SSAFY에서 첫 번째 프로젝트를 진행할 당시 API 명세서를 노션에 작성하였다. 프로젝트 관리를 노션으로 했기 때문에 접근이 편하고, 가독성이 좋다는 장점이 있었지만, 개발을 진행하며 API가 수정되었는데 API 명세서에 반영되지 않아 프론트엔드 팀원에게 소환되는 일이 종종 발생했었다. 이러한 문제를 해결하기 위해 API 명세서 작성을 자동화해야겠다고 생각했고, 두 번째 프로젝트에서 바로 적용했다.
비교적 도입이 쉬운 Swagger가 아닌 Spring REST Docs를 채택한 이유는 세 가지다.
- 이전에 써봤다.
Spring 스터디를 진행하며 Spring REST Docs를 완벽하게 이해하고 사용한 것은 아니지만, 어찌저찌 써본 경험이 이번 프로젝트에서는 더 잘 쓸 수 있을 것이라는 용기를 심어줬다. - 테스트 코드를 작성할 수 있다.
테스트 코드에 익숙하지 않아 테스트 코드 작성에 애를 먹었는데, Spring REST Docs를 적용하면 테스트 코드 작성을 통해 사소한 에러라도 사전에 예방할 수 있을 거라 생각했다. - 문서화를 위한 코드가 프로덕션 코드에 영향을 미치지 않는다.
Swagger를 사용하고 싶지 않았던 가장 큰 이유가 프로덕션 코드에 문서화를 위한 코드가 너무 많이 작성된다는 것이다. 이때문에 프로덕션 코드의 가독성이 떨어져서 유지보수 측면에서 좋지 않아보였다.
(적고 보니 내가 써봤다는 점을 제외하면 Spring REST Docs의 장점이다ㅎㅎ)
위 같은 이유들로 Spring REST Docs를 적용하게 되었고, 두 번의 프로젝트에 Spring REST Docs를 적용했지만 여전히 코드를 보고 따라서 타자치는 수준이었다. 그래서 내가 작성한 코드를 제대로 이해하기 위해 Spring REST Docs 적용기를 정리하게 되었다.
1. Spring REST Docs란
Spring에서 제공하는 RESTful 서비스를 위한 정확하고 읽기 쉬운 문서 작성을 도와주는 도구다.
Spring REST Docs의 장점은 크게 두 가지다.
- 테스트가 성공해야 문서 작성이 가능하다.
테스트 코드를 기반으로 작성되기 때문에 테스트 코드가 성공하지 않으면 문서를 작성할 수 없다. 따라서 Spring REST Docs를 사용하면 테스트 코드가 강제되어 API의 신뢰도를 높일 수 있다. - 프로덕션 코드에 영향을 미치지 않는다.
프로덕션 코드 위에 Config 설정 코드나 어노테이션이 추가되는 Swagger와 달리 Spring REST Docs는 프로덕션 코드에 침투하지 않는다.
2. Spring REST Docs 적용
2.0. 작업 환경
Java 8
Spring Boot 2.7
Gradle 7.6
Junit 5
MockMvc
AsciiDoc
2.1. build.gradle 설정
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.13-SNAPSHOT'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
id "org.asciidoctor.jvm.convert" version "3.3.2". (1)
}
group = 'com.eonuenal'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
repositories {
mavenCentral()
}
ext { (2)
set('snippetsDir', file("build/generated-snippets"))
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' (3)
}
test { (4)
outputs.dir snippetsDir
useJUnitPlatform()
}
asciidoctor {
inputs.dir snippetsDir (5)
dependsOn test (6)
}
bootJar {
dependsOn asciidoctor (7)
from ("${asciidoctor.outputDir}/html5") { (8)
into 'static/docs'
}
}
task copyDocument(type: Copy) { (9)
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
build {
dependsOn copyDocument (10)
}
(1) Asciidoctor 플러그인 적용
gradle 7부터는 org.asciidoctor.conver
가 아닌 asciidoctor.jvm.convert
를 사용해야 한다.
이 플러그인은 adoc 파일 변환, build 디렉토리에 복사하기 위한 역할을 한다.
(2) 생성된 스니펫의 출력 위치를 정의하도록 속성 구성
ext는 전역변수를 설정해주는 것이다.
gradle은 build/generated-snippets
에 스니펫이 생성되므로, 스니펫 생성 디렉토리를 변수에 담아준다.
(3) MockMvc 의존성 추가
(4) 스니펫 디렉토리를 출력으로 추가하도록 test 작업 구성
(5) 스니펫 디렉토리를 입력으로 구성
(6) 문서가 생성되기 전에 테스트가 실행되도록 작업이 test 작업에 의존하도록 함
(7) 복사되기 전에 문서가 생성되도록 작업이 asciidoctor 작업에 의존하도록 함
(8) jar 안에 build/docs/asciidoc
하위에 생기는 html 파일을 static/docs
로 복사
(9) build/docs/asciidoc
하위에 생기는 html 파일을 static/docs
로 복사
(10) build 작업이 문서를 복사하는 copyDocument 작업에 의존하도록 함
2.2. MockMvc 설정
| EnableMockMvc.java
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AutoConfigureMockMvc
@Import(EnableMockMvc.Config.class)
public @interface EnableMockMvc { (1)
class Config {
@Bean
public CharacterEncodingFilter characterEncodingFilter() {
return new CharacterEncodingFilter("UTF-8", true); (2)
}
}
}
(1) MockMvc 설정을 위한 커스텀 어노테이션 생성
(2) API 테스트 인코딩 설정
2.3. 공통로직 분리
| ApiDocument.java
@EnableMockMvc
@AutoConfigureRestDocs
public class ApiDocument {
@Autowired
protected MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
protected RestDocumentationResultHandler toDocument(String title) { (1)
return document(title, getDocumentRequest(), getDocumentResponse());
}
protected String toJson(Object object) { (2)
try {
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new IllegalStateException("직렬화 오류");
}
}
private OperationRequestPreprocessor getDocumentRequest() {
return preprocessRequest(
modifyUris(). (3)
.scheme("https")
.host("{domain}")
.removePort(),
prettyPrint()); (4)
}
private OperationResponsePreprocessor getDocumentResponse() { (5)
return preprocessResponse(prettyPrint());
}
}
(1) build/generated-snippets/{title}
폴더 하위에 문서 작성
(2) object를 JSON 형태로 변환
(3) 문서상 uri를 기본값인 http://localhost:8080에서 https://{domain}으로 변경
(4) 문서의 request를 예쁘게 출력
(5) 문서의 response를 예쁘게 출력
2.4. 테스트 코드 작성
@WebMvcTest(PostController.class)
public class PostControllerTest extends ApiDocument {
private static final String SUCCESS_MESSAGE = "success";
private static final String FAIL_MESSAGE = "fail";
private PostRequest postRequest;
private PostResponse successResponse;
private PostResponse failResponse;
@MockBean (1)
private PostService postService;
@BeforeEach
void setUp() { (2)
postRequest = PostRequest.builder()
.title("Spring REST Docs 연습")
.content("즐거운 Spring REST Docs 연습 시간")
.author("jeongyuneo")
.build();
successResponse = PostResponse.builder()
.message(SUCCESS_MESSAGE)
.build();
failResponse = PostResponse.builder()
.message(FAIL_MESSAGE)
.build();
}
@DisplayName("게시글 저장 성공")
@Test
void create_post_success() throws Exception {
// given
willReturn(successResponse).given(postService).create(any(PostRequest.class)); (3)
// when
ResultActions resultActions = 게시글_저장_요청(postRequest);
// then
게시글_저장_성공(resultActions);
}
@DisplayName("게시글 저장 실패")
@Test
void create_post_fail() throws Exception {
// given
willThrow(new IllegalArgumentException(FAIL_MESSAGE)).given(postService).create(any(PostRequest.class));
// when
ResultActions resultActions = 게시글_저장_요청(postRequest);
// then
게시글_저장_실패(resultActions);
}
private ResultActions 게시글_저장_요청(PostRequest postRequest) throws Exception {
return mockMvc.perform(post("/api/v1/posts")
.contentType(MediaType.APPLICATION_JSON)
.content(toJson(postRequest))); (4)
}
private void 게시글_저장_성공(ResultActions resultActions) throws Exception {
resultActions.andExpect(status().isOk())
.andExpect(content().json(toJson(successResponse)))
.andDo(print())
.andDo(toDocument("create-post-success")); (5)
}
private void 게시글_저장_실패(ResultActions resultActions) throws Exception {
resultActions.andExpect(status().isBadRequest())
.andExpect(content().json(toJson(failResponse)))
.andDo(print())
.andDo(toDocument("create-post-fail"));
}
}
(1) mocking을 하기 위해 @MockBean
선언
(2) 반복적으로 사용되는 값들을 전역으로 선언 후 초기화
(3) mocking을 하여 예상 응답값 받음
(4) ApiDocument에 정의한 toJson()
메소드를 사용해 JSON 파싱
(5) ApiDocument에 정의한 toDocument()
메소드를 사용해 문서 작성
테스트 코드 작성 후 테스트가 성공하면 build/generated-snippets
하위에 스니펫이 생성된다.
2.5. AsciiDoc 작성
AsciiDoc?
텍스트 기반 이메일 메시지를 작성하던 규약에서 발전된 것이라 상대적으로 단순한 표현력을 가지고 있는 markdown과 달리 asciidoc은 SGML, Dockbook처럼 전문적인 문서를 작성할 수 있는 매우 강력한 표현력을 제공한다.
- 간단한 기본 문법: Asciidoc 기본 사용법
- 공식 문서: AsciiDoc Writer's Guide
gradle 환경인 경우 src/docs/asciidoc
하위에 adoc 파일을 만들어준다.
만들어준 파일에 생성된 스니펫을 이용해 문서를 작성한다.
ifndef::snippets[]
:snippets: ./build/generated-snippets
endif::[]
= Spring REST Docs Practice Api Specification
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:author: eoneunal
:email: eoneunal@tistory.com
== 1. 게시물
=== 1.1. 저장
==== 성공
.request
include::{snippets}/create-post-success/http-request.adoc[]
.response
include::{snippets}/create-post-success/http-response.adoc[]
==== 실패
.request
include::{snippets}/create-post-fail/http-request.adoc[]
.response
include::{snippets}/create-post-fail/http-response.adoc[]
adoc 작성 후 gradle을 빌드하게 되면 resources/static/docs
하위에 html 문서가 생성된다.
빌드 방법
1. IntelliJ: Gradle > Tasks > build > build
2. Terminal: 프로젝트 위치로 이동 후 ./gradlew build
html 문서가 생성되면 애플리케이션 실행 후 http://localhost:8080/docs/index.html 혹은 https://{domain}/docs/index.html 로 접속 후 API 명세서를 볼 수 있다.
2.6. 정리
- build.gradle 설정
- MockMvc 설정
- 테스트 코드 작성
- 테스트 코드 통과 시
build/generated-snippets
하위에 스니펫 생성됨 - 생성된 스니펫을 사용해
src/docs/asciidoc
하위에 adoc 문서 작성 - gradle 빌드 시 작성한 adoc 문서를 기반으로
build/docs/asciidoc
하위에 html 문서 생성됨 build/docs/asciidoc
하위에 html 문서가resources/static/docs
하위로 복사됨
References
'study > Spring' 카테고리의 다른 글
[Spring WebSocket] Redis를 이용한 채팅 고도화 (0) | 2023.09.01 |
---|---|
[Spring Boot] 전화번호 인증 with NCP SMS API & Redis (0) | 2023.08.03 |
[Spring Boot] AWS S3 이미지 업로드 (0) | 2023.08.03 |
[Spring WebSocket] Spring WebSocket STOMP 적용 (3) | 2023.06.19 |
[Spring] @Bean vs @Component (0) | 2022.04.17 |