Asciidoctor를 사용해서 AsciiDoc(adoc 파일)을 HTML 등으로 다양한 포멧으로 문서화를 변환할 수 있습니다.
snippet(adoc 파일)은 문서화에 필요한 문서 조각이며, Asciidoctor가 이러한 파일을 HTML 파일 등으로 변환시켜주게 준다.
문서화를 위한 테스트 코드와 API 명세가 일치하지 않으면, 테스트가 실패하게 되고 문서화가 진행되지 않습니다. 그러므로 검증된 문서 임을 보장할 수 있습니다.
예전 프로젝트에서는 Swagger를 사용하여 API 문서화를 진행했던 적이 있었습니다. 이번에 Rest docs를 적용하면서 이 둘의 차이점도 간단히 정리해보고자 합니다.
Swagger
Rest docs
장점
애노테이션 기반으로 사용하기 편리하다.
테스트가 성공해야지 문서화가 진행된다.
쉽게 적용할 수 있다.
프로적션 코드에 영향이 없다.
UI에서 API 테스트가 가능하다.
API 변경시 문서화 최신화가 강제화된다.
단점
테스트 기반이 아니므로 문서를 보장할 수 없다.
사용하기 어렵다.
프로덕션 코드에 API 문서화 코드가 작성된다.
테스트 코드의 양이 많아진다.
build.gradle
참고로 해당 프로젝트는 Java 11, Spring Boot 2.7.9, Gradle 7.6.1 환경으로 구성되어 있습니다.
plugins {
...
// Asciidoctor 플러그인 사용
// gradle 7.0 이상부터는 jvm 사용하기
id 'org.asciidoctor.jvm.convert' version '3.3.2'
}
group = 'project'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
asciidoctorExtensions // dependencies 에서 적용한 것 추가
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
...
// build/generated-snippets 에 생기는 .adoc 조각들을 프로젝트 내의 .adoc 파일을 읽어들 일 수 있도록 연동해준다.
// 이로 인해, .adoc 파일에서 operation 같은 매크로르 사용하여 스니펫 조작들을 연동할 수 있다.
// 최종적으로 .adoc 파일을 HTML로 만들어 export 해준다.
asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'
// restdocs-mockmvc 의 testCompile 구성 -> mockvMvc를 사용해서 snippets 조각들을 뽑아 낼 수 있다.
// MockMvc 대신 WebTestClient을 사용하려면 spring-restdocs-webtestclient 추가
// MockMvc 대신 REST Assured를 사용하려면 spring-restdocs-restassured 를 추가
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
}
tasks.named('test') {
useJUnitPlatform()
}
//Rest docs 설정
ext {
// 아래에서 사용할 변수 선언
snippetsDir = file('build/generated-snippets')
}
test {
//위의 snippetsDir 디펙토리를 test의 output으로 구성하는 설정
//스니펫 조각들이 build/generated-snippets로 출력된다.
useJUnitPlatform()
outputs.dir snippetsDir
}
asciidoctor { //asciidoctor 관련 설정
dependsOn test //test 작업 이후에 작동하도록 구성하는 설정
configurations 'asciidoctorExtensions' //위에서 작성한 configuration 적용
inputs.dir snippetsDir //snippets 를 입력으로 구성한다.
// source 가 없으면 .adoc 파일을 전부 html로 만들어버림.
// source 지정시 특정 .adoc 파일만 html로 만듬.
sources{
include("**/index.adoc")
}
// 특정 .adoc에 다른 adoc 파일을 가져와서(include) 사용하고 싶을 경우 경로를 baseDir로 맞춰주는 설정입니다.
// 개별 adoc으로 운영한다면 필요 없는 옵션입니다.
baseDirFollowsSourceFile()
}
// 기존에 만들어진 adoc 삭제
asciidoctor.doFirst {
delete file('src/main/resources/static/docs')
}
// asciidoctor 작업 이후 생성된 HTML 파일을 static/docs 로 copy
task copyDocument(type: Copy) {
dependsOn asciidoctor
from file("build/docs/asciidoc")
into file("src/main/resources/static/docs")
}
// build 시 copyDocument 를 의존합니다.
build {
dependsOn copyDocument
}
// bootjar 와 관련된 설정이며, 스니펫을 이용해 문서 작성 후,
// build - docs - asciidoc 하위에 생기는 html 파일을 jar파일안에 /resources/static/docs로 복사해줍니다.
bootJar {
dependsOn asciidoctor
from ("${asciidoctor.outputDir}"){
into 'static/docs'
}
}
최종적으로 ./gradle build 시 test -> asciidoctor -> copyDocument -> bootJar 순으로 실행되게 된다.
테스트 코드 및 index.adoc
테스트 코드에 Rest docs를 사용하기 위해서 초기 세팅이 필요한데, 이러한 설정을 @AutoConfigureRestDocs 애노테이션을 붙히게 되면 스프링이 필요한 설정을 자동 주입해주므로 편리하게 사용할 수 있습니다.
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@AutoConfigureRestDocs //Rest docs 에 필요한 정보 자동 주입
class RecruitmentControllerTestForQuery {
@Test
public void recruitment_list_get() throws Exception {
//given
MultiValueMap<String,String> info = new LinkedMultiValueMap();
info.add("page", "0");
info.add("volunteering_category", "001");
info.add("volunteering_category", "002");
info.add("sido", "11");
info.add("sigungu","1111");
info.add("volunteering_type", VolunteeringType.REG.getId());
info.add("volunteer_type", VolunteerType.TEENAGER.getId());
info.add("is_issued", "true");
//when
ResultActions result = mockMvc.perform(get("/recruitment")
.header(AUTHORIZATION_HEADER, "access Token")
.params(info)
);
//then
result.andExpect(status().isOk())
.andExpect(jsonPath("$.recruitmentList[0].no").value(saveRecruitmentList.get(0).getRecruitmentNo()))
...
.andDo(print())
.andDo(document("APIs/volunteering/recruitment/GET-List",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(
headerWithName(AUTHORIZATION_HEADER).optional().description("JWT Access Token")
),
requestParameters(
parameterWithName("page").optional().description("페이지 번호"),
parameterWithName("volunteering_category").optional().description("Code VolunteeringCategory 참고바람(다중 선택 가능)"),
parameterWithName("sido").optional().description("시/도 코드"),
parameterWithName("sigungu").optional().description("시/군/구 코드"),
parameterWithName("volunteering_type").optional().description("Code VolunteeringType 참고바람."),
parameterWithName("volunteer_type").optional().description("Code VolunteerType 참고바람."),
parameterWithName("is_issued").optional().description("봉사 시간 인증 가능 여부")
),
responseFields(
fieldWithPath("isLast").type(JsonFieldType.BOOLEAN).description("마지막 봉사 모집글 유무"),
fieldWithPath("lastId").type(JsonFieldType.NUMBER).description("응답 봉사 모집글 리스트 중 마지막 모집글 고유키 PK"),
fieldWithPath("recruitmentList").type(JsonFieldType.ARRAY).description("봉사 모집글 리스트")
).andWithPrefix("recruitmentList.[].",
fieldWithPath("no").type(JsonFieldType.NUMBER).description("봉사 모집글 고유키 PK"),
fieldWithPath("volunteeringCategory").type(JsonFieldType.STRING).description("Code VolunteeringCategory 참고바람"),
fieldWithPath("picture.type").type(JsonFieldType.STRING).description("Code ImageType 참고바람."),
fieldWithPath("picture.staticImage").type(JsonFieldType.STRING).optional().description("정적 이미지 코드, ImageType이 UPLOAD 일 경우 NULL"),
fieldWithPath("picture.uploadImage").type(JsonFieldType.STRING).optional().description("업로드 이미지 URL, ImageType이 STATIC 일 경우 NULL"),
fieldWithPath("title").type(JsonFieldType.STRING).description("봉사 모집글 제목"),
fieldWithPath("sido").type(JsonFieldType.STRING).description("시/구 코드"),
fieldWithPath("sigungu").type(JsonFieldType.STRING).description("시/군/구 코드"),
fieldWithPath("startDay").type(JsonFieldType.STRING).attributes(key("format").value("MM-dd-yyyy")).description("봉사 모집 시작 날짜"),
fieldWithPath("endDay").type(JsonFieldType.STRING).attributes(key("format").value("MM-dd-yyyy")).description("봉사 모집 종료 날짜"),
fieldWithPath("volunteeringType").type(JsonFieldType.STRING).description("Code VolunteeringType 참고바람"),
fieldWithPath("isIssued").type(JsonFieldType.BOOLEAN).description("봉사 시간 인증 가능 여부"),
fieldWithPath("volunteerNum").type(JsonFieldType.NUMBER).description("봉사 모집 인원"),
fieldWithPath("currentVolunteerNum").type(JsonFieldType.NUMBER).description("현재 봉사 모집글 참여(승인된) 인원"),
fieldWithPath("volunteerType").type(JsonFieldType.STRING).description("Code VolunteerType 참고바람."))
)
);
}
...
}
PathParameter가 있는 Rest docs 문서화를 하는 경우, MockMvcBuilders보다RestDocumentationRequestBuilder를 사용하는것을 추천한다고 합니다.
requestFields, responseFields 등 문서에 필요한 내용들을 정의했습니다. 또한, 리스트 안의 객체를 명세해주기 위해서 andWithPrefix를 사용해서 접두사를 명시해주었습니다.
참고로 attributes(key("format").value("MM-dd-yyy"))와 같이 문서화 중 반복적으로 사용되는 경우에는 아래와 같이 Java 8 부터 지원되는 static 메서드(or default)를 사용해서 조금 더 리팩토링이 가능하다.
테스트 코드 리팩토링
앞 테스트 코드를 보게 되면,andDo(document())로 문서 패키지명 선언과 가독성 좋은 출력을 위해 prettyPrint가 테스트 코드마다 매번 사용되게 됩니다. 이 부분을 리팩토링 해보겠습니다.
test 디펙토리에 아래와 같이 RestDocsConfig 파일을 생성합니다.
문서 조각이 생성되는 디렉토리 명을 클래스명/메서드명으로 지정하고, prettyPrint 설정을 통해 json 파일을 가독성있게 출력합니다.
기존의 테스트 코드에서, 앞서 등록한 빈을 사용하기 위해 RestDocsConfig을 @import() 시키고,
커스텀한 RestDocumentationResultHandler를 주입받아서 Rest docs 문서를 작성합니다.
문서화를 위해 매번 패키지명 및 prettyPrint 코드 중복이 사라진 것을 볼 수 있습니다.
추가로, 현재는 @AutoConfigureRestDocs를 통해 커스텀한RestDocumentationResultHandler을 비롯해 Rest docs에 필요한 설정 정보를 자동으로 주입받았는데, 추가적인 커스텀마이징이 필요할 경우 직접 MockMvc를 직접 주입해서 사용하는 방법도 있다.
하지만, 현재는 별도의 커스텀마이징이 필요없어 애노테이션을 통해 설정 정보를 주입받았다.
enum 코드 문서화
프로젝트를 진행하다보니 문서 작성 시 Enum 타입이 필요했고, Enum 타입은 별도로 제공하지 않기 때문에 커스텀할 필요가 있었습니다.
public enum HourFormat implements CodeCommonType {
AM("오전"), PM("오후");
private final String viewName;
HourFormat(String viewName){
this.viewName = viewName;
}
@Override
public String getId() {
return this.name();
}
@Override
public String getDesc() {
return this.viewName;
}
}
public interface CodeCommonType {
String getId();
String getDesc();
}
문서화할 Enum 타입은 CodeCommonType 인터페이스를 구현해야하고, 문서화 작업에서 사용됩니다.