본문 바로가기
JAVA/Spring & Java 학습 기록

Spring Rest docs 적용기

by 구본식 2023. 8. 8.

Volunteer 사이드 프로젝트에 적용하면서 학습했던 Spring Rest docs를 정리해보고자 합니다.

자세한 코드는 깃허브를 참고해주시기 바랍니다.


Rest Docs 란?

  • 테스트 코드 기반으로 Restful API 문서화를 도와주는 도구 입니다.
  • 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를 사용해서 접두사를 명시해주었습니다.

(참고로 type은 생략해도 됩니다.)

자세한 내용은 아래 공식문서를 통해 찾아볼 수 있습니다.

https://docs.spring.io/spring-restdocs/docs/1.0.0.BUILD-SNAPSHOT/reference/html5/#documenting-your-api-path-parameters

 

request & response snippet이 이쁘게 출력되기 위해서 prettyPrint을 적용했습니다.

적용하지 않는다면 json 포멧이 일자로 출력되지만, 적용한다면 아래와 같이 가독성 좋게 출력됩니다.

추가적으로, 코드에서 optional(), attributes() 등의 옵션이 사용된 것을 볼 수 있습니다.

이 부분은 snippet template을 직접 커스텀했기 때문에 사용 가능한 옵션이였고,

지금까지 따라한다면 이부분을 제외하고 필드명, Type, description 옵션만 사용하면 됩니다.

(자세한 내용은 뒤에서 다루도록 하겠습니다.)

 

이제 테스트를 실행해보면 아래와 같이 정상적으로 adoc 파일들이 생성된것을 볼 수 있습니다.

테스트로 생성된 조각 파일들을 이용해서 문서화를 진행해줄 차례입니다.

src/adoc/asciidoc 디렉토리를 만들고 index.adoc 파일을 만듭니다.(API 문서를 만들때 틀이 되는 adoc 파일입니다.)

그리고 recruitment.adoc  파일을 생성해 앞서 생성된 snippet 조각 파일을 사용해 문서화를 진행했습니다.(프로젝트내 recruitment API 관련 문서 파일)

참고로, 저는 처음부터 각 API 별로 문서를 분리해서 작업했습니다.

index.adoc, recruitment.adoc

AscillDoc 플러그인을 설치하면 아래와 같이 미리보기를 통해 문서를 볼 수 있습니다.

여기서 사용된 asciidoctor 문법은 아래 링크를 참고해주시기 바랍니다.

https://narusas.github.io/2018/03/21/Asciidoc-basic.html#asciidoctor

 

마지막으로, 이전에 build.gradle 에 작성한거와 같이 

asciidoctor가 html 파일로 만들 adoc 파일을 index.adoc로 지정해두었고,

asciidoctor 작업 후 생성된 html파일을 src/main/resources/static/docs에 복사해주기 위해서, 

main/resources/static/docs 디렉토리를 만들어줍니다.

스프링 부트를 실행한 후, {ip주소}:{port}/docs/index.html 에 접속해보면 정상적으로 문서파일을 볼 수 있습니다.


snippet template 커스텀 하기

문서를 만들다보면 기본 snippet template에 추가적으로 필수값 여부, 포멧 기능이 필요했습니다.

 

앞서 부분에서 잠깐 언급한거와 같이 앞 코드에서 optional(), attributes() 등의 옵션이 이에 해당됩니다.

이는 기본으로 제공해주는 snippet template에서는 존재하지 않고, snippet template을 직접 커스텀하여 사용했기 때문에 사용 가능한 옵션입니다.

 

asciidoctor는 아래와 같이 기본 snippet template을 제공합니다. 저희는 이 템플릿 파일을 커스텀하여 필요한 추가 정보를 추가해주면 됩니다.

src/test/resources/org/springframework/restdocs/templates 경로에 커스텀한 snippets을 만들어주면 됩니다.

(파일 네이밍은 아래 기본 snippet 파일 네이밍에 default를 제외하면 됩니다.)

기본 snippet 파일 / custom snippet 파일

기타 커스텀 snippets 파일들은 아래 깃허브를 참고해주시기 바랍니다.

(snippets mustache 문법을 사용하게 되는데, 저도 아직 잘모르겠습니다..ㅎㅎ 필요할때 찾아보지모..)

https://github.com/project-Volunteer/BackEnd/pull/91/commits/7b7186ae3cf3fb247621c21b5783f204574fc11d

 

최종적으로 아래와 같은 포멧으로 파일이 만들어지게 됩니다.

참고로 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 인터페이스를 구현해야하고, 문서화 작업에서 사용됩니다.

CustomResponseFieldsSnippet.class / custom-response-fields.snippet

왼쪽 클래스는 default 템플릿이 아닌 custom 템플릿을 사용하기 위한 클래스 입니다.

Enum 문서화에서는 단지 코드명, 코드 설명만일 필요하데, default 템플릿을 사용하면 response-field.snippet을 default로 사용하여 불필요한 컬럼까지 생기기 때문입니다.

 

생성자에서 type 인수를 통해 template를 선택하게 되고, Enum 문서화 테스트 코드 작성 시 type 값으로

"custom-response" 주면 됩니다.

이까지가 custom 템플릿을 사용하기 위한 작업이었고, 실제 Enum을 문서화를 진행해보겠습니다.

Enum 테스트 컨트롤러 반환 값(DTO)으로 사용될 클래스를 만듭니다.

이 클래스는 위의 DTO에서 실제 값으로 사용될 클래스이며, 문서화할 Enum 들을 명시해줍니다.

Enum 문서화를 위한 테스트 컨트롤러이며, EnumDocs에 문서화하기 위해 명시해논 Enum을 모두 생성하고, APIResponseDto에 담아 반환해주게 됩니다.

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
public class CommonDocControllerTest {
    @Autowired private MockMvc mockMvc;
    @Test
    public void commons() throws Exception {
        mockMvc.perform(get("/docs/enums"))
                .andExpect(status().isOk())
                .andDo(document("enums",
                        customResponseFields("custom-response", beneathPath("data.hourFormat").withSubsectionId("hourFormat"),
                                attributes(key("title").value("시간포멧")),
                                enumConvertFieldDescriptor(HourFormat.values())
                        ),
                        customResponseFields("custom-response", beneathPath("data.clientState").withSubsectionId("clientState"),
                                attributes(key("title").value("클라이언트 신청 상태")),
                                enumConvertFieldDescriptor(StateResponse.values())
                        ),
                        customResponseFields("custom-response", beneathPath("data.volunteeringCategory").withSubsectionId("volunteeringCategory"),
                                attributes(key("title").value("봉사 유형 카테고리")),
                                enumConvertFieldDescriptor(VolunteeringCategory.values())
                        ),
                        ...
                ));
    }
    private FieldDescriptor[] enumConvertFieldDescriptor(CodeCommonType[] enumTypes) {
        return Arrays.stream(enumTypes)
                .map(enumType -> fieldWithPath(enumType.getId()).description(enumType.getDesc()))
                .toArray(FieldDescriptor[]::new);
    }
    public static CustomResponseFieldsSnippet customResponseFields(String type,
                                                                   PayloadSubsectionExtractor<?> subsectionExtractor,
                                                                   Map<String,Object> attribute, FieldDescriptor... descriptors){
        return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attribute, true);
    }
}

최종적으로 테스트를 실행하게 되면 문서 조각이 생성되고, 해당 조각들로 Enum을 모아둔 adoc 파일을 구성하면 됩니다.


참고 자료