API 문서 자동화를 위한 RestDocs 적용 방법에 대해 알아봅시다!
API 문서 자동화는 정말 중요한 일입니다.
사람이 손으로 해서는 결국 한계가 있고, 규모가 커질수록 걷잡을 수 없게 됩니다.
가령 문서 파일로 API를 관리하고, 이를 공유하고 업데이트 하는 식으로 사용하게 될 경우,
형상관리를 하더라도 분명 이슈가 발생하게 됩니다.
코드에 반영을 했는데 문서에 반영을 하지 않는다던가,
최신화되지 않은 문서를 참조하고 있다거나, 변형된 파일이 공유된다거나..
정말 많은 이슈가 생길 수 있습니다.
그래서, 프로덕트가 배포되는 시점에 문서도 자동화되어 배포될 수 있게 구성할 수 있다면,
그리고 프로덕션 코드에는 영향이 없다면, 정말 좋겠습니다.
초기 설정이 다소 까다로워서 그렇지 한 번 구축을 완료하고 나면
RestDocs의 효용은 충분히 비용을 상회하고 남을 겁니다.
RestDocs 로 API 문서가 만들어지는 과정
크게 보면 총 세 가지 작업을 개발자가 해야 합니다.
첫번째로 build.gradle로 플러그인과 몇가지 task 설정을 추가해줘야 합니다.
build, test 등의 작업이 수행되기 전과 후에 추가적으로 작업을 설정해줘야 합니다.
이 작업들의 내용은 다음과 같습니다.
- 테스트 수행 결과를 이용해 스니펫을 만드는 작업
- 스니펫을 조합해 adoc을 만드는 작업
- adoc을 조합해 최종 adoc을 만드는 작업
- 최종 adoc을 html파일로 만드는 작업
- 특정 경로로 이동시키는 작업
build.gradle에 설정만 해두면 위 작업들을 자동으로 수행해줍니다.
두번째로 작업해야 하는 내용은 테스트 코드의 작성입니다.
슬라이스 테스트로 컨트롤러에 대한 테스트를 작성한다고 생각하시면 됩니다.
요청 정보에 따라 어떤 응답이 오는지 테스트를 작성하기만 하면, 그 내용대로 스니펫이 생성됩니다.
세번째로 작업해야 하는 내용은 스니펫을 조합하는 것입니다.
커스터마이징이 가능하도록 테스트 결과물로 생성되는 문서는 조각으로 나누어져 있는데요,
이중에서 원하는 조각들을 모아서 하나의 조각(혹은 문서)로 만들 수 있고,
이렇게 모아진 조각들로 최종 문서를 만든 뒤에 이를 html로 변환함으로써 작업이 완료됩니다.
build.gradle 설정
가장 먼저 build.gradle을 설정해줍니다.
플러그인 추가, 테스트 코드 작성을 위한 의존성 추가,
문서 조각 생성 위치 설정, 문서 생성을 위해 사용할 문서조각 설정,
삭제와 복사 등의 설정입니다.
만약 RestDocs를 처음 사용하신다면, 모든 설정을 한 번에 이해하려 하시기 보다는
일단 사용해서 index.html이 생성되는 과정까지 진행하신 다음에
그 이후에 천천히 공식문서를 살펴보시는 걸 추천드립니다.
plugins {
id 'org.springframework.boot' version '2.7.3'
id 'io.spring.dependency-management' version '1.0.13.RELEASE'
id 'java'
id 'org.asciidoctor.jvm.convert' version '3.3.2' // (1) 플러그인 추가
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
repositories {
mavenCentral()
}
configurations {
asciidoctorExt // (2) Asciidoctor 사용을 위한 설정 선언
}
dependencies {
// (3) 의존성 추가 - build/generated-snippets 안에 있는 .adoc파일을 바라보게 됨
//restdocs
asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
// (4) 의존성 추가 - 테스트 작성을 위한 MockMVC, RestAssured 의존성 추가
testImplementation 'io.rest-assured:rest-assured:4.4.0'
testImplementation 'io.rest-assured:spring-mock-mvc:4.4.0'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testImplementation 'org.springframework.restdocs:spring-restdocs-restassured'
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
// (5) 스니펫이 만들어질 경로로 사용하기 위한 변수 선언
ext {
set('snippetsDir', file("build/generated-snippets"))
}
tasks.named('test') {
outputs.dir snippetsDir // (6) 스니펫 생성 위치 선언
useJUnitPlatform()
}
// (7) asciidoctor 작업 설정
tasks.named('asciidoctor') {
configurations 'asciidoctorExt' // 앞서 설정한 값, 의존성을 사용
sources {
include("**/index.adoc") // 모든 adoc 파일이 아닌 특정 adoc만 생성
}
baseDirFollowsSourceFile() // 베이스 경로를 각 소스파일 경로와 동일하게 처리
inputs.dir snippetsDir // snippetsDir 변수에 선언된 경로에 있는 파일들을 읽어들여 처리
dependsOn test // 테스트가 먼저 수행되어야 함
}
// (8) 기존 파일 제거
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// (9) 만들어진 파일 복사 createDocument, displaceDocument
// 임의의 task 이름을 지은 것. type: Copy는 from에서 into로 파일을 복사함
// asciidoctor 작업이 수행한 뒤에 수행됨
task createDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
task displaceDocument(type: Copy) {
dependsOn asciidoctor
from("${asciidoctor.outputDir}")
into("build/resources/main/static")
}
(10) 빌드 파일 생성 시 문서가 생성된 이후 빌드되도록 구성
bootJar {
dependsOn createDocument
dependsOn displaceDocument
}
테스트 코드 작성을 위한 준비
컨트롤러 슬라이스 테스트를 작성하면, 그것이 문서화된다 라고 간단히 생각하시면 되는데요,
그 컨트롤러 테스트들을 작성하기 위한 부모 클래스를 설정하는 과정입니다.
즉, 컨트롤러들의 테스트 클래스를 이 클래스를 상속해서 작성하시면 됩니다.
@WebMvcTest
@ExtendWith(RestDocumentationExtension.class)
public class DocumentationTest {
protected MockMvcRequestSpecification docsGiven;
@BeforeEach
void setDocsGiven(final WebApplicationContext webApplicationContext,
final RestDocumentationContextProvider restDocumentation) {
docsGiven = RestAssuredMockMvc.given()
.mockMvc(MockMvcBuilders.webAppContextSetup(webApplicationContext)
.apply(documentationConfiguration(restDocumentation)
.operationPreprocessors()
.withRequestDefaults(prettyPrint())
.withResponseDefaults(prettyPrint()))
.build()).log().all();
}
}
샘플 컨트롤러 작성
컨트롤러를 하나 작성해봤습니다.
MemberService는 인터페이스이고, 구현체가 주입될 것입니다.
회원 ID를 PathVariable로 전달하며 단일 회원을 조회하는 간단한 컨트롤러입니다.
응답 객체, 서비스와 리포지토링 등은 커밋 링크에서 확인하실 수 있습니다.
@RequestMapping("/api/members")
@RestController
public class MemberController {
private final MemberService memberService;
public MemberController(final MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/{memberId}")
public ResponseEntity<MemberResponse> findById(@PathVariable Long memberId) {
Member member = memberService.findById(memberId);
MemberResponse memberResponse = MemberResponse.from(member);
return ResponseEntity.ok(memberResponse);
}
}
테스트 코드 작성
이제 스니펫 생성을 위한 테스트 코드를 작성해봅시다.
MemberController가 MemberService 를 의존하고 있으니,
부모 클래스의 필드로 MockBean 애너테이션과 함께 MemberService를 작성해줍니다.
memberService에 반환값을 미리 설정해두고, 요청을 보내는 식입니다.
document("members/findById") 에 전달된 문자열은 테스트 메서드 단위를 식별할 값이라고 생각하시면 됩니다.
저는 회원 API 관련이니 members로 하나의 컨텍스트로 묶고
그 안에서 findById, findAll 등으로 구분하고자 의도했습니다.
만들어진 스니펫을 보면 폴더 트리 형태로 만들어짐을 확인할 수 있습니다.
@WebMvcTest
@ExtendWith(RestDocumentationExtension.class)
public class DocumentationTest {
protected MockMvcRequestSpecification docsGiven;
@MockBean
protected MemberService memberService;
class MemberControllerTest extends DocumentationTest {
@DisplayName("아이디로 단일 회원 조회")
@Test
void find_single_member_using_memberId() {
given(memberService.findById(any()))
.willReturn(new Member(11L, "Richard", "https://richard.png"));
docsGiven
.contentType(MediaType.APPLICATION_JSON_VALUE)
.when().get("/api/members/11")
.then().log().all()
.apply(document("members/findById"))
.statusCode(HttpStatus.OK.value());
}
}
스니펫 조합
src/main/docs/asciidoc 이하에 members.adoc 파일을 만들어 아래와 같이 작성했습니다.
adoc 문법을 알아가며 차차 원하시는 입맛대로 수정하시면 되겠지만,
핵심적인 내용만 짚어보자면 operations는 스니펫을 import한다고 생각하시면 쉽습니다.
members/findAll 경로 아래에 있는 스니펫 중, http-request.adoc, http-response.adoc만 import하는 겁니다.
이렇게 만들어진 문서 조각은 회원 관련 API를 담당하는 문서조각이 될 것입니다.
이제 이러한 문서조각들을 합쳐서 하나의 문서로 만들어봅시다.
[[Member]]
== 회원 API
==== 회원 전체 조회
operation::members/findAll[snippets='http-request,http-response']
==== 단일 회원 아이디로 조회
operation::members/findById[snippets='http-request,http-response']
최종 문서 adoc 작성
상단에 있는 :로 시작하는 설정들은 가독성, 편의성을 위한 설정들입니다.
이하에는 제목과 포함시킬 문서조각들을 명시하면 됩니다.
앞서 members.adoc 파일을 만들었는데요, 그 파일 을 그대로 가져오는 거라고 보시면 됩니다.
향후 이러한 문서조각들이 늘어나면 include:: 를 개행해서 추가로 선언하시면 됩니다.
:doctype: book
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:
== RestDocs Example
include::members.adoc[]
최종 결과
최종적으로 만들어진 index.html파일입니다
배포시에 이 html파일을 별도로 처리해서 nginx가 정적 파일로 serve하게 처리하거나,
프로젝트 내부에서 문서를 serve하는 핸들러를 추가할 수 있겠습니다.
참고 링크
'우아한테크코스 4기' 카테고리의 다른 글
🐹 줍줍 스티커 제작기 (0) | 2022.08.21 |
---|---|
@Transactional 테코톡 완료! (4) | 2022.07.22 |
IntelliJ 플러그인으로 Co-authored-by 설정하기 (0) | 2022.07.19 |
Sign in with Slack 으로 워크스페이스 내부 구성원 식별하기 (0) | 2022.07.15 |
Git Flow 가 CI/CD 와 어울리지 않는 이유 by David Farley (0) | 2022.07.14 |