🤔Swegger대신 Spring Rest Docs를 왜 사용하나요?
일단 Swegger와 Spring Rest Docs를 왜 사용해야 할까요?
백엔드 개발의 주된 일은 바로 Api를 제공하는 일 입니다. Api를 프론트 개발자에게 전달한다는 것은 수 많은 대화가 필요하다는 것을 의미합니다.
이러한 대화를 조금이라도 줄인다면 더 많은 성과와 업무 효율을 극대화 할 수 있습니다. 따라서 백엔드 개발자는 Api문서를 제공하여 파라미터 설명, 요청 url, 응답 형식 등을 알려주어 해당 Api를 사용할 개발자들에게 알려줄 의무가 있습니다.
주로 Java 진영에서는 Swegger, Spring Rest Docs를 사용하여 Api 문서를 제공합니다.
우선 Swegger, Spring Rest Docs의 특징을 비교해 보겠습니다.
Swegger 특징
- api호출을 문서상에서 직접 호출할 수 있습니다.
- 애노테이션 기반으로 빠르게 작성할 수 있습니다.
- 서비스 코드가 테스트 문서를 작성하기 위한 애노테이션으로 복잡해질 수 있습니다.
- 애노테이션 기반임으로 실제 제공되는 api와 다를 수 있습니다.
Spring Rest Docs 특징
- 테스트 코드 기반으로 작성되어 테스트가 성공해야 문서가 만들어 짐으로 Api를 신뢰할 수 있습니다.
- Api를 작성하려면 테스트 코드를 무조건 작성해야 합니다.(강제성)
- 서비스 소스코드에 Api문서를 작성하기 위한 어떠한 코드도 들어가지 않습니다.
- AsciiDoc, MarkDown을 지원합니다.
Api 문서화를 제공하는 가장 큰 이유는 해당 문서만으로 서비스에서 제공하는 api를 파악하고 손쉽게 사용할 수 있도록 하기 위함입니다. 그래서 테스트 코드 작성의 강제성과 이를 기반으로 작성되는 Api의 신뢰성을 보장하는 Spring Rest Docs를 사용하도록 결정하였습니다.
Spring Rest Docs반영 방법 및 발생했던 이슈 정리
build.gradle
파일의 내용 중 Spring Rest Docs과 관련된 내용만 추출하였습니다.
plugins {
id 'org.springframework.boot' version '2.5.4'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
//gradle 7 부터는 org.asciidoctor.convert가 아닌asciidoctor.jvm.convert를 사용해야한다
id "org.asciidoctor.jvm.convert" version "3.3.2"
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
asciidoctorExtensions
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// .adoc 파일에서 빌드, 스니펫 생성을 자동으로 구성되기 위해 추가하는 의존성
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
// restdocs에서 MockMvc를 사용할 때 사용하는 의존성
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
// 스니펫이 위치할 경로를 지정한다
ext {
snippetsDir = file('build/generated-snippets')
}
test {
useJUnitPlatform()
// 스니펫 디렉터리를 출력으로 추가하도록 테스트 작업
outputs.dir snippetsDir
// Jenkins에서 빌드할때에는 JPA구성이 되어있지 않아 빌드시에는 제외하도록 설정
filter {
excludeTestsMatching "com.ae.stagram.domain.**.dao.*"
}
}
// ext에서 정의한 경로에 스니펫을 넣어준뒤 테스트를 진행한다
asciidoctor {
configurations "asciidoctorExtensions"
inputs.dir snippetsDir
dependsOn test
}
// 기존에 존재하는 docs를 삭제한다
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// build시 생성된 html문서를 static/docs에 copy하기 위한 테스크
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
//bootJar는 실행가능한 jar을 빌드하는데 사용하며, Jar로 빌드가 되면서 실제 배포시 생성된 명세 html파일의 경로를 찾아 BOOT-INF/classes/static/docs 맵핑
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}") {
into 'static/docs'
}
}
// build시 copyDocument 테스크를 실행한다
build {
dependsOn copyDocument
}
Grable 문법정리
- dependsOn : 지정한 task에 의존한다.(dependsOn test 의 의미는 test task가 완료된 후 해당 task를 수행한다는 의미이다.)
- finalizedBy : 지정한 task가 해당 task 후 실행된다.( finalizedBy test 이면 해당 task 완료 후 test task가 수행된다는 의미)
테스트 코드 작성방법
package com.ae.stagram.controller;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.restdocs.RestDocumentationContextProvider;
import org.springframework.restdocs.RestDocumentationExtension;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document;
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(value = RestDocumentationExtension.class)//junit5에서 Spring Rest Docs 사용방법
@WebMvcTest(ApiController.class) //MVC 테스트에 필요한 클래스만 사용하기위해 WebMvcTest사용
public class ApiControllerTest {
private MockMvc mockMvc;
//MockMvc 설정
@BeforeEach
public void setUp(WebApplicationContext webApplicationContext,
RestDocumentationContextProvider restDocumentation) {
this.mockMvc = MockMvcBuilders
.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation))
.build();
}
@Test
void contextLoads() throws Exception {
this.mockMvc.perform(get("/api/sample")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andDo(document("api")); // 해당 identifier기준으로 snippets 생성
}
}
해당 테스트 코드 작성 후 빌드를 하게되면 테스트 성공 시 build/generated-snippets경로에 snippets가 생성되어있는 것을 확인할 수 있습니다.
이젠 만들어진 snippets를 가지고 AsciiDoc 문법을 사용하여 Api문서를 만들어보겠습니다.
api.adoc의 위치는 Build Tool에 따라 다르기 때문에 공식문서를 참고하여 위치 시켰습니다.
api.adoc
= index
== get index
CURL:
include::{snippets}/api/curl-request.adoc[]
Request Parameter:
include::{snippets}/api/http-request.adoc[]
Response:
include::{snippets}/api/http-response.adoc[]
문서를 작성 후 다시 빌드를 하게되면 build.gradle에서 추가한 플러그인 중 org.asciidoctor.jvm.convert가 동작하여 해당 adoc 문서를 html로 convert하게됩니다.
convert된 문서는 build/docs/asciidoc에 위치하게 되며 이를 build.gradle에 작성한 테스크 중 copyDocument가 복사하여 resources/main/static/docs에 넣어주게 됩니다.
그러면 프로젝트 실행 시 http:localhost:8080/docs/api.html 로 접속 시 해당 api문서가 정상적으로 출력되는 것을 확인할 수 있습니다.
Customizing requests and responses
문서는 보기 좋아야 계속 업데이트 하고 싶고, 가독성도 증가합니다. rest docs를 그냥 사용하면 Request 와 Response의 출력물이 한 줄로 표시됨으로 Api문서에 적합하지 않습니다. 그래서 Custom처리를 하여 가독성을 증가시켜 보겠습니다.
우선 의존성을 추가합니다.(gradle 기준)
implementation group: 'org.springframework.restdocs', name: 'spring-restdocs-core'
package com.ae.stagram;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint;
import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor;
import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor;
public interface ApidocumentUtils {
static OperationRequestPreprocessor getDocumentRequest() {
return preprocessRequest(
modifyUris()
.scheme("http")
.host("localhost")
.port(8080),
prettyPrint());
}
static OperationResponsePreprocessor getDocumentResponse() {
return preprocessResponse(prettyPrint());
}
}
생성한 getDocumentRequest, getDocumentResponse메서드들을 테스트 코드 작성 시 document에 파라미터로 넘겨 설정하도록 합니다.
@Test
public void update() throws Exception {
//given
Person response = Person.builder()
.id(1L)
.firstName("준희")
.lastName("이")
.birthDate(LocalDate.of(1985, 2, 1))
.gender(Gender.MALE)
.hobby("신나게놀기")
.build();
given(apiService.insert(eq(1L),any(Person.class)))
.willReturn(response);
//when
ResultActions result = this.mockMvc.perform(
put("/api/{id}",1L)
.content(objectMapper.writeValueAsString(response))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
);
//then
result.andExpect(status().isOk())
.andDo(document("persons-update",
getDocumentRequest(),
getDocumentResponse(),
pathParameters(
parameterWithName("id").description("아이디")
),
requestFields(
fieldWithPath("id").type(JsonFieldType.NUMBER).description("아이디"),
fieldWithPath("firstName").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("lastName").type(JsonFieldType.STRING).description("성"),
fieldWithPath("birthDate").type(JsonFieldType.STRING).description("생년월일"),
fieldWithPath("gender").type(JsonFieldType.STRING).description("성별, `MALE`, `FEMALE`"),
fieldWithPath("hobby").type(JsonFieldType.STRING).description("취미").optional()
),
responseFields(
fieldWithPath("id").type(JsonFieldType.NUMBER).description("아이디"),
fieldWithPath("firstName").type(JsonFieldType.STRING).description("이름"),
fieldWithPath("lastName").type(JsonFieldType.STRING).description("성"),
fieldWithPath("birthDate").type(JsonFieldType.STRING).description("생년월일"),
fieldWithPath("gender").type(JsonFieldType.STRING).description("성별, `MALE`, `FEMALE`"),
fieldWithPath("hobby").type(JsonFieldType.STRING).description("취미").optional()
)
));
}
Optional 처리
Api를 작성하다보면 필수값과 optional 값이 존재합니다. 이를 표현하기위해 requestFields의 값으로 optional을 붙였습니다.
하지만 optional만 붙인다고 Asciidoc에도 반영되는 것은 아닙니다. 기존의 칼럼에서 필수항목인지 알려주는 필드가 필요합니다.
우선 src/main/test/resources/org/springframework/restdocs/templates 폴더를 만듭니다.
해당 폴더에 build해서 생성된 snippet 중 커스텀 할 snippet을 만듭니다. 이때 spring-restdocs-core를 참고하여 만들면 됩니다.
Reference
rest docs 번역본 : https://springboot.tistory.com/26
https://docs.spring.io/spring-restdocs/docs/2.0.5.RELEASE/reference/html5/
https://techblog.woowahan.com/2678/
https://techblog.woowahan.com/2597/
https://velog.io/@max9106/Spring-Spring-rest-docs를-이용한-문서화
Custom 처리
https://github.com/spring-projects/spring-restdocs/tree/main/spring-restdocs-core/src/main/resources/org/springframework/restdocs/templates/asciidoctor
https://www.raegon.com/spring-rest-docs
'프로젝트 > AeStagram' 카테고리의 다른 글
배포환경에서 애플리케이션과 S3 연동방법 (0) | 2021.10.26 |
---|---|
Spring Boot AWS S3 연동 (0) | 2021.10.22 |
페이지네이션 어떻게 처리해야 할까요? (0) | 2021.09.30 |
댓글