본문 바로가기

우테코

[Rest Docs vs Swagger] 1편 : Rest Docs로 API 문서 자동화해보기

         

            우테코 선배들이 캠퍼스에 방문했을 때, 프론트 개발자이신 분에게 질문을 한 적이 있다.

             

            - 프로젝트를 할 때 백엔드가 프론트를 위해 가장 중요하게 생각해야할 점이 무엇일까요?

             

            답을 주셨다.

            - API 문서만 잘 뽑아주면 됩니다.

             

            이렇듯 코드협업에 있어 API 문서화는 프로그래밍의 중요한 부분 중 하나이다.

            이 글은 개인 학습의 목적으로 작성된 글이다.

             

            API 자동화 도구인 Swagger와 RestDocs를 공식문서를 참고해 한번씩 실습해보고,

            이를 통해 내가 느낀 장단을 비교해보고자 한다.

             

            API문서란?

            웹은 정보를 원하는 클라이언트와 그 정보를 제공하는 서버로 나뉜다.

             

            클라이언트들은 서버에게 자신이 원하는 정보를 요청한다.

            - 클라이언트1 : 정보를 주세요

            - 클라이언트2 : give me information

            - 클라이언트3 : 情報をください

             

            정보를 제공하는 서버는 각자 다른 언어로 정보를 요청하는 클라이언트들로 인해 혼란스럽다.

            그래서 HTTP라는 규약을 기반으로 일정한 형식으로 정보를 소통하기에 이른다.

             

            그런데 여기서 끝일까? 아니다.

            클라이언트는 원하는 정보를 얻기 위해 어떤 형식과 경로로 요청을 보내야하는지 모른다.

            또, 서버가 제공해준 정보를 어떻게 해석할지 모른다.

             

            그래서 서버는 다음과 같은 안내판을 준비한다.

            - 요청 : A정보를 얻으려면 이렇게 요청해주면 돼!

            - 응답 : A정보를 이렇게 나올거야, 이렇게 해석하면 돼!

            - 예외 : 이따구로 요청하면 예외가 발생할거야, 그땐 내가 이렇게 말할게

             

            이렇게 클라이언트에게 지침이 되는 서버와의 소통방식을 담은 안내판이 API문서이다.

             

            ex) 실제 API문서의 일부

             

             

            토스 페이먼츠의 API문서를 살펴보는 것도 이해에 도움이 된다.

            https://docs.tosspayments.com/reference

             

            코어 API | 토스페이먼츠 개발자센터

            토스페이먼츠 API 엔드포인트(Endpoint)와 객체 정보, 파라미터, 요청 및 응답 예제를 살펴보세요.

            docs.tosspayments.com

             

            그리고 이러한 API문서를 코드를 기반으로 자동화하여 만들어주는 툴이 바로

            REST DOCS와 SWAGGER이다.


            Rest Docs

             

            Spring Rest Docs는 REST API를 자동으로 문서화시켜준다.

             

            Spring Rest Docs는 텍스트 프로세서(디폴트 : AsciiDoctor)와  snippet 파일로 구성된다.

             

             > Code Snippet 짚고 가기

            여기서 snippets 파일이란 재사용가능한 소스코드로, 반복 사용되는 코드를 작게 스크립트화하여 작업 시간을 단축시켜주는 역할을 담당한다. 예를 들어 intellij 내부에 있는 자동완성 기능을 들 수 있다.

             

            > snippet 예시

            더보기

            실제로 intellij 내부에는 fori라는 접두어를 기반으로 반복 등장하는 코드인 반복문을 템플릿화해두었다.

             

            fori 접두어를 입력하면 이에 해당하는 코드 스니펫들을 선택지로 주어지고, 저장되어 있던 템플릿이 불러와진다.

             

            이처럼 스니펫은 반복 등장하는 코드 패턴을 쉽게 활용가능하게 만들어준다.

            쉽게 말해 자동완성 기능이라 생각하면 된다.

            > AsciiDoc 짚고 가기

            더보기

            Rest Docs는 주로 AsciiDoctor라는 텍스트 프로세서와 함께 사용되는데, AsciiDoctor는 구문 분석을 통해 HTML 5, DocBook 5, manual pages, PDF, EPUB 3 등등의 문서로 자동 문서화를 해주는 도구이다.

             

            예를 들어 왼쪽의 asciiDoc source를 Html 파일로 렌더링할 수 있다.

            출처 : https://asciidoctor.org/

             

            즉, 다양한 컨버터를 통해 원하는 문서형식으로 변환시켜주는 텍스트 프로세서이다.

             

            Spring Rest Docs는 테스트를 통해 생성된 스니펫을 활용해 구성된다.

            이 테스트라 함은 다음과 같은 항목을 포함한다.

            - Spring MVC test

            - Rest Assured 5

            - WebTestClient

             

             

            https://dukcode.github.io/spring/spring-rest-docs/

             

            이 테스트를 통해 Api의 요청/응답과 관한 문서 스니펫이 자동생성되고,

            이 스니펫을 기반으로 텍스트 프로세서가 문서를 자동화하여 구성한다고 생각하면 된다.

             

            여기서 Rest Docs의 첫번째 장점을 알 수 있다.

            - 테스트 코드를 기반으로 문서화가 가능하다 

            == 프로덕션 코드에 영향을 주지 않는다

             

            필수 환경

            - java 17이상

            - spring framework 6+

            - 추가적으로 spring -restdocs-restassured의 경우 REST Assured 5.2이상의 버전이 필요하다

             

            Build Configuration

            앞선 문서화 작업을 순서화하여 나누어보면 다음과 같다.

            1) 테스트 실행 > 2) 문서 스니펫 생성 > 3) 스니펫 프로세싱 > 4) 문서화

             

            이 작업을 우리가 직접 asciidoctor 플러그인을 제어하며 실행하는 것은 번거롭다.

            따라서 해당 과정을 자동화하는 설정이 필요하다.

             

            공식문서에 나와있는 설정은 다음과 같다.

             

            개인적으로 실습까지 해보며 느낀 점은 단순 설정이 중요한 게 아니라,

            이 configuration이 무엇을 의미하는 것인지 추상적으로라도 알고 있는 것이 중요하다는 생각이 들었다.

            plugins { (1)
            	id "org.asciidoctor.jvm.convert" version "3.3.2"
            }
            
            configurations {
            	asciidoctorExt (2)
            }
            
            dependencies {
            	asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor:{project-version}' (3)
            	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc:{project-version}' (4)
            }
            
            ext { (5)
            	snippetsDir = file('build/generated-snippets')
            }
            
            test { (6)
            	outputs.dir snippetsDir
            }
            
            asciidoctor { (7)
            	inputs.dir snippetsDir (8)
            	configurations 'asciidoctorExt' (9)
            	dependsOn test (10)
            }

             

            1) asciidoctor 플러그인 적용

            2) AsciiDoctorExt를 asciidoctor 태스크에 설정함 : 개발자가 작성한 adoc 파일에서 ``을 통해 스니펫 경로에 있는 스니펫을 쉽게 불러옴

            3) AscciDctorExt로 의존성을 추가해 라이브러리를 불러올 수 있도록 선언

            4) MovckMvc에 기반해 스니펫을 뽑아내는 라이브러리 / WebTestClient나 RestAssured라면 spring-restdocs-webtestclient나 spring-restdocs-restassured를 사용

            5)  스니펫이 저장될 snippetDir 변수를 정함.

            6)  test를 실행하고 나온 snippet들이 저장될 경로를 설정 : 이 설정을 통해 자동 생성된 snippet들이 다음과 같이 build.generated-snippets 하위 파일에 저장된다.

            7) asciidoctor task를 환경 설정

            8) gradle이 snippetsDir로부터 파일을 읽을 수 있도록 설정

            9) asciidoctorExt configuration을 활용할 수 있도록 설정

            10) 문서가 만들어지기 이전에 테스트를 실행하도록 의존성 설정

             

            그러나, 이 설정 대로라면 문서를 작성하고 run을 해도 문서가 반영되지 않는다.

            test를 하면 asciidoctor 태스크가 실행되고, 결과물을 `/static/docs`로 복사하도록 설정해보자

             

            최종적인 build.gradle은 dukcode님의 설정을 참고했다.

            plugins {  
                // 생략
                id 'org.asciidoctor.jvm.convert' version '3.3.2'
            }  
              
            // 생략
              
            configurations {  
                asciidoctorExt
                // 생략
            }  
              
            // 생략
              
            ext {  
                set('snippetsDir', file("build/generated-snippets"))
                // 생략
            }  
              
            dependencies {  
            
                // 생략
            
                asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
                testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
            }  
              
            // 생략
            
              tasks.named('testClasses') { // (1)
                doFirst {
                    delete file('build/docs/asciidoc')
                }
            }
            
            tasks.named('test') {  
                outputs.dir snippetsDir
                // 생략
                finalizedBy asciidoctor // (2)
            }  
              
            tasks.named('asciidoctor') {
                dependsOn test  
                configurations 'asciidoctorExt'  
                inputs.dir snippetsDir  
                finalizedBy copyDocument // (3)
                doFirst { // (4)
                    delete file('src/main/resources/static/docs')  
                }  
            }  
              
            task copyDocument(type: Copy) { // (5)
                dependsOn asciidoctor  
                from file('build/docs/asciidoc')  
                into file('src/main/resources/static/docs')  
            }
            
            bootJar {  // (6)
                dependsOn asciidoctor  
                doFirst {  
                    delete file('static/docs')  
                }  
                from("${asciidoctor.outputDir}") {  
                    into 'static/docs'  
                }  
            }

             

            이 설정을 이해하려면 먼저 dependsOn과 finalizedBy를 간단히 짚고 가야 한다.

             

            dependsOn : 이 task를 실행하기 전에 실행될 beforeTask를 지정한다.

            - beforeTask가 성공해야 task가 실행된다.

            ex) TaskA.dependsOn('TaskB') : TaskB > 성공시 > TaskA 실행

             

             finalizedBy : task 이후 실행할 afterTask를 설정한다.

            - 성공/ 실패 여부와 상관없이 실행된다.

            ex) TaskA.finalizedBy('TaskedB')라고 쓰면 TaskA 성공여부와 관계없이, TaskA이후 TaskB가 실행된다.

             

            위의 설정을 그럼 하나하나 해석해보자.

             

            tasks.named('testClasses') { // (1) 이 태스크가 실행되면 먼저 build/docs/asciidoc 경로의 파일을 지운다.
                doFirst {
                    delete file('build/docs/asciidoc')
                }
            }

             

             
            tasks.named('test') {  
                outputs.dir snippetsDir  // (1) test가 실행되면 문서 스니펫을 snippetsDir에 저장한다.
                // 생략
                finalizedBy asciidoctor // (2) 이후 asciidoctr Task를 test 성공여부와 관계없이 실행한다.
            }
            tasks.named('asciidoctor') {
                dependsOn test  // 1) 테스트를 실행한 이후 실행한다.
                configurations 'asciidoctorExt'  3) asciidoctorExt 설정을 기반으로 실행한다.
                inputs.dir snippetsDir   4) snippetsDir 경로에 있는 snippet 파일들을 읽는다.
                finalizedBy copyDocument // 5) 이 태스크 성공여부와 관계없이 copyDocument task를 실행한다.
                doFirst { // 2) 이 태스크가 실행되면 밑 경로의 파일을 삭제한다.
                    delete file('src/main/resources/static/docs')  
                }  
            }

             

            task copyDocument(type: Copy) { // 파일을 from 경로에서 to 경로로 복사한다.
                dependsOn asciidoctor  // asciidoctor 태스크 실행 이후 실행한다.
                from file('build/docs/asciidoc') 
                into file('src/main/resources/static/docs')  
            }

             

            이렇게 되면 우리는 자동으로 asciidoctor 태스크 실행의 결과물을 /static/docs로 복사해올 수 있다.

             

            WebMvcTest를 통해 Rest Docs 만들어보기

            이제 본격적으로 Rest Docs를 만들어보자

             

            example code)

            예시로 쓸 Member request/response  dto는  record로 만들어 주었다.

            public record MemberRequest(
                    String name,
                    int age
            ) {
            }
            
            public record MemberResponse (
                    long id,
                    String name,
                    int age
            ){
            }

             

            controller에서는 member post 요청을 처리하고 있으며, service가 이를 처리하고 있다.

            @RestController
            public class MemberController {
            
                @Autowired
                MemberService memberService;
            
                @PostMapping("/member")
                ResponseEntity<MemberResponse> saveMember(@RequestBody MemberRequest request){
                    MemberResponse memberResponse = memberService.save(request);
                    return ResponseEntity.ok(memberResponse);
                }
            }

             

            이제 member를 저장하는 save와 관한 POST API를 Rest Docs를 통해 문서화 해보자.

             

            위에도 설명했지만 Rest Docs는 테스트 실행 결과를 기반으로 생성된 snippet으로부터 만들어진다.

            - MockMvc : 특정 계층 단위 테스트를 위해 의존한 계층을 mocking하여 수행하는 테스트

            - WebTestClient : 외부 api와의 소통을 위한 테스트

            - RestAssured E2E : 실제 객체를 사용하여 최초 request와 최종 response를 검증하는 테스트

             

            이 세가지 테스트를 통해 Rest Docs 생성할 수 있다.

            나는 RestAssured Test가 이미 작성되어 있어서, controller sliceTest도 할 겸 MockMvcTest를 선택했다.

             

             

            기본 작성법

            먼저 간단한 mockMvc test 코드를 작성해보자.

             

                @Test
                @DisplayName("member 저장하기")
                void saveMember() throws Exception {
                    //given
                    MemberRequest request = new MemberRequest("새로운 멤버", 21); // 요청 생성
                    MemberResponse response = new MemberResponse(1L, "새로운 멤버", 21);
                    Mockito.when(memberService.save(any(MemberRequest.class)))
                            .thenReturn(response); // memberService.save를 호출하면 response 반환하기
            
                    //when-then
                    this.mockMvc.perform(post("/member")
                                            .contentType(MediaType.APPLICATION_JSON)
                                            .content(objectMapper.writeValueAsString(request))
                                ).andExpect(status().isOk())
                                 .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1L))
                                 .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("새로운 멤버"))
                                 .andExpect(MockMvcResultMatchers.jsonPath("$.age").value(21));
                }
            }

             

            위의 테스트 코드는 새로운 멤버 저장 요청 시, 저장 이후 MemberResponse가 반환되는 API를 명시하고 있다.

             

            Rest Docs는 이 테스트에서 andDo() 메서드와 함께

            RestDocumentRequestHandler를 생성하여 작성하고 싶은 내용을 넣기만 하면 된다.

             

            그럼 RestDocumentRequestHandler를 생성하고 넣는 방법을 알아보자.


            문서화-1 : Request와 Response Field

             

            Member의 id, name, age에 대한 설명을 덧붙이고 싶다.

            document를 통해 RestDocumentRequestHandler를 생성하고,

            requestFields와 responseFields를 활용하면 된다.

             

             

            @WebMvcTest(controllers = MemberController.class)
            @AutoConfigureRestDocs
            class MemberControllerTest {
            
                @Autowired
                MockMvc mockMvc;
            
                @MockBean
                MemberService memberService;
            
                @Autowired
                ObjectMapper objectMapper;
            
                @Test
                @DisplayName("member 저장하기")
                void saveMember() throws Exception {
                    //given
                    MemberRequest request = new MemberRequest("새로운 멤버", 21); // 요청 생성
                    MemberResponse response = new MemberResponse(1L, "새로운 멤버", 21);
                    Mockito.when(memberService.save(any(MemberRequest.class)))
                            .thenReturn(response); // memberService.save를 호출하면 response 반환하기
            
                    //when-then
                    this.mockMvc.perform(post("/member")
                                            .contentType(MediaType.APPLICATION_JSON)
                                            .content(objectMapper.writeValueAsString(request))
                                ).andExpect(status().isOk())
                                 .andExpect(MockMvcResultMatchers.jsonPath("$.id").value(1L))
                                 .andExpect(MockMvcResultMatchers.jsonPath("$.name").value("새로운 멤버"))
                                 .andExpect(MockMvcResultMatchers.jsonPath("$.age").value(21))
                            
                                 .andDo(MockMvcRestDocumentation.document(
                                         "{class-name}/{method-name}", // 저장될 위치
                                         requestFields( //요청 필드들
                                                 fieldWithPath("name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."),
                                                 fieldWithPath("age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")
                                         ),
                                         responseFields( // 응답 필드들
                                            fieldWithPath("id").type(JsonFieldType.NUMBER).description("새로운 멤버의 아이디입니다."), 
                                            fieldWithPath("name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."), 
                                            fieldWithPath("age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")  
                                         )
                                 ));
                }
            }

             

            requestFields : 요청 필드들에 대한 설명

            responseFields :  응답 필드 들에 대한 설명

             

            그 중 문서화와 관한 부분을 조금 더 들여다보자.

            requestFields( //요청 필드들
                    fieldWithPath("name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."),
                    fieldWithPath("age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")
            ),

             

            요청 필드에는 name과 age가 있다.

            name은 String 타입이며 새로운 멤버의 이름을 나타낸다.

            age는 Number 타입이며 새로운 멤버의 나이를 나타낸다.

             

            이를 기반으로 snippet 파일이 생성된다.

            responseFields( // 응답 필드들
               fieldWithPath("id").type(JsonFieldType.NUMBER).description("새로운 멤버의 아이디입니다."),
               fieldWithPath("name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."),
               fieldWithPath("age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")
            )

             

            응답 필드에는 id, name과 age가 있다.

            id는 Number 타입이며 새로운 멤버의 아이디를 나타낸다.

            name은 String 타입이며 새로운 멤버의 이름을 나타낸다.

            age는 Number 타입이며 새로운 멤버의 나이를 나타낸다.


            문서화-2 : depth가 2 이상인 Json

            그런데 만약 depth가 2이상인 경우에는 어떻게 jsonField Paths를 나타낼 수 있을까?

             

            예를 들어 다음과 같은 상황 말이다.

             

            {
            	"a":{
            		"b":[
            			{
            				"c":"one"
            			},
            			{
            				"c":"two"
            			},
            			{
            				"d":"three"
            			}
            		],
            		"e.dot" : "four"
            	}
            }

             

             

            공식문서에서는 각 json을 다음 path를 활용해 조회할 수 있다고 명시하고 있다.

            배열은 []를 통해 조회가능하며, depth가 1인 원소는 .을 통해 조회가능하다

             

             

             

             

            이 방식을 한번 모든 멤버를 조회하는 get 요청을 만들어 적용해보자

             

            @RestController
            public class MemberController {
            
                @Autowired
                MemberService memberService;
            
                @GetMapping("/member")
                ResponseEntity<MemberResponses> getAllMembers(){
                    MemberResponses all = memberService.findAll();
                    return ResponseEntity.ok(all);
                }
            }
            
            public record MemberResponses(
                    List<MemberResponse> memberResponses
            ) {
            }

             

             

            이제 GET /member를 호출했을 때 예측되는 json 반환값은 다음과 같다.

             

             

            이제 [].id, [].name, [].age를 통해 각 배열의 원소인 member의 프로퍼티를 지정가능하다.

            @Test
                @DisplayName("member 가져오기")
                void getMember() throws Exception {
                    //given
                    MemberResponses responses = new MemberResponses(
                            List.of(
                                    new MemberResponse(1L, "새로운 멤버1", 21),
                                    new MemberResponse(2L, "새로운 멤버2", 21)
                                    )
                    );
            
                    Mockito.when(memberService.findAll())
                            .thenReturn(responses); // memberService.findAll을 호출하면 responses 반환하기
            
                    //when-then
                    this.mockMvc.perform(get("/member")
                                    .contentType(MediaType.APPLICATION_JSON)
                            ).andExpect(status().isOk())
                            .andDo(MockMvcRestDocumentation.document(
                                    "{class-name}/{method-name}", // 저장될 위치
                                    responseFields( // 응답 필드들
                                            fieldWithPath("memberResponses").type(JsonFieldType.ARRAY).description("전체 멤버 목록"),
                                            fieldWithPath("memberResponses[].id").type(JsonFieldType.NUMBER).description("새로운 멤버의 아이디입니다."),
                                            fieldWithPath("memberResponses[].name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."),
                                            fieldWithPath("memberResponses[].age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")
                                    )
                            ));
                }
            }

             

             

            이중 responseFields를 보면 각 MemberResponse 배열의 원소를 []. prefix를 통해 지정해주고 있는 것을 알 수 있다.


            문서화-3 : QueryParameters 문서화

            쿼리 파라미터 문서화는 queryParameters를 활용하여 구성된다.

             

            먼저 이름을 통해 일치하는 이름의 멤버를 찾아오는 api를 설계해보자

            @RestController
            public class MemberController {
            
                @Autowired
                MemberService memberService;
            
                @GetMapping("/member-name")
                ResponseEntity<MemberResponse> getMemberByName(@RequestParam(value = "name") String name ){
                    MemberResponse member = memberService.findByName(name);
                    return ResponseEntity.ok(member);
                }
            }

             

            쿼리 파라미터 name은 어떻게 문서화할 수 있을까?

                @Test
                @DisplayName("일치하는 이름 가져오기")
                void findByName() throws Exception {
                    //given
                    MemberResponse response = new MemberResponse(1L, "새로운 멤버", 21);
                    Mockito.when(memberService.findByName(any()))
                            .thenReturn(response); // memberService.findByName을 호출하면 response 반환하기
            
                    //when-then
                    this.mockMvc.perform(get("/member-name")
                                    .contentType(MediaType.APPLICATION_JSON)
                                    .param("name", "새로운 멤버")
                            ).andExpect(status().isOk())
            
                            .andDo(MockMvcRestDocumentation.document(
                                    "{class-name}/{method-name}", // 저장될 위치
                                    queryParameters( //쿼리 파라미터
                                            parameterWithName("name").description("필터링할 멤버 이름입니다.")
                                    ),
                                    responseFields( // 응답 필드들
                                            fieldWithPath("id").type(JsonFieldType.NUMBER).description("새로운 멤버의 아이디입니다."),
                                            fieldWithPath("name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."),
                                            fieldWithPath("age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")
                                    )
                            ));
                }

             

            요청 값에는 .param("name", "새로운 멤버")를 통해

            name이라는 paramter에 "새로운 멤버"라는 파라미터를 지정하여 호출 가능하다

             

            문서화 작업은 queryParameters안에 parameterWithName을 통해 지정한다.


            문서화-4 : Path Parameters

            이제 API 요청 방식 중의 하나인 Path Parameter를 어떻게 문서화하는지 알아보자

             

            예를 들어 아이디가 x인 member를 가져오는 API를 다음과 같이 설계했다고 가정하자

            GET /members/{id}

             

            만약 클라이언트가 /members/7 요청을 보내온다면 7이라는 id를 가진 member를 반환해달라는 요청으로 해석가능하다.

             

            간단히 controller에 endpoint를 만들어주고

                @GetMapping("/member/{id}")
                ResponseEntity<MemberResponse> findById(@PathVariable(name = "id") Long id) {
                    MemberResponse member = memberService.findById(id);
                    return ResponseEntity.ok(member);
            
                }

             

             

            이를 기반으로 테스트 코드를 작성해준다.

            @Test
            @DisplayName("아이디를 기반으로 멤버 가져오기")
            void findById() throws Exception {
                //given
                MemberResponse response = new MemberResponse(1L, "새로운 멤버", 21);
                Mockito.when(memberService.findById(anyLong()))
                        .thenReturn(response); // memberService.findById을 호출하면 response 반환하기
            
                //when-then
                this.mockMvc.perform(RestDocumentationRequestBuilders.get("/member/{id}", 1L)
                                .contentType(MediaType.APPLICATION_JSON)
                        ).andExpect(status().isOk())
            
                        .andDo(MockMvcRestDocumentation.document(
                                "{class-name}/{method-name}",
                                pathParameters(// path parameter 설명
                                        parameterWithName("id").description("찾아올 멤버 아이디입니다.")
                                ),
                                responseFields( // 응답 필드들
                                        fieldWithPath("id").type(JsonFieldType.NUMBER).description("새로운 멤버의 아이디입니다."),
                                        fieldWithPath("name").type(JsonFieldType.STRING).description("새로운 멤버의 이름입니다."),
                                        fieldWithPath("age").type(JsonFieldType.NUMBER).description("새로운 멤버의 나이입니다.")
                                )
                        ));
            }

             

            여기서 한가지 주의점이 있다. 

             

            MockMvc를 사용할 때 PathParameter를 사용하려면 request method를

            MockMvcRequestBuilders보다 RestDocumentationRequestBuilders를 이용해야 한다.

             

            만약 MockMvcRequestBuilders를 사용하면 urlTemplate을 찾을 수 없다는 다음과 같은 오류가 발생한다.

             

            MockMvcRequestBuilders pathParameter snippet을 구성하는 과정에서 내부적으로 urlTemplate을 탐색하기 때문에 지정 경로에 urlTemplate을 찾을 수 없어, 오류가 발생하는 것이다.

            private String extractUrlTemplate(Operation operation) {
                String urlTemplate = (String)operation.getAttributes().get("org.springframework.restdocs.urlTemplate");
                Assert.notNull(urlTemplate, "urlTemplate not found. If you are using MockMvc did you use RestDocumentationRequestBuilders to build the request?");
                return urlTemplate;
            }

             

            그에 반해 RestDocumentationBuilders는 get 요청 과정에서 첫 String 파라미터인 url 주소를 urlTemplate 인수로 받는다.

            public abstract class RestDocumentationRequestBuilders {
                private RestDocumentationRequestBuilders() {
                }
            
                public static MockHttpServletRequestBuilder get(String urlTemplate, Object... urlVariables) {
                    return MockMvcRequestBuilders.get(urlTemplate, urlVariables).requestAttr("org.springframework.restdocs.urlTemplate", urlTemplate);
                }

             


            문서화-5: 제한 조건 추출하기

            이 뿐만 아니라 REST Docs는 문서 제한 조건을 추출하는 것도 도와준다.

            이 과정을 ConstraintDesciptions instance를 통해 문서화할 수 있는데 과정은 다음과 같다.

             

            예를 들어 MemberRequest Dto에 다음과 같은 제한 조건이 추가되었다고 가정해보자

            public record MemberRequest(
                    @NotNull @Length(min = 1, max = 50) String name,
                    @Positive int age
            ) {
            }

             

            이제 ConstraintDescription을 통해

            속성이름(name) 에 설정되어 있는 제한 요건을 문자열 형태로 가져올 수도 있다.

            @Test
            void example( ) {
                ConstraintDescriptions memberConstraints = new ConstraintDescriptions(MemberRequest.class);
                List<String> descriptions = memberConstraints.descriptionsForProperty("name");
                for (String description : descriptions) {
                    System.out.println(description);
            
                }
            }

             

            결과

            Length must be between 1 and 50 inclusive
            Must not be null


            API 문서 만들어보기

            이제 각 테스트 문서화 결과가 snippet 파일로 저장되었다.

            중요한 건, 이 snippets 조각들로 API문서를 만드는 일이다.

             

             

            그럼 adoc 파일을 하나 만들어 docs를 작성해보자

             

            그럼 다음과 같은 docs 파일이 자동으로 생성된다.

             

            테스트가 수정됨에 따라 docs는 변화를 자동으로 반영해준다.

             

            최종적으로 완성된 docs는 다음과 같다.

            memberDocs.pdf
            0.08MB

             

            happy case만 서술되어 있고 아직 제약조건을 모두 반영치 못했지만,

            rest docs 사용법을 익히고 복습하기엔 충분한 경험이었다 생각한다.

             


            REST DOCS의 장단점

            잠시나마 rest docs를 사용하고 느낀 첫 느낌을 기록해두자

             

            장점

            - 프로덕션 코드에 영향을 주지 않는다.

            - 문서 최신화를 위해 유지보수(테스트)를 강제화한다.

            - 이미 테스트가 있다면 쉽게 추가가능하다.

             

            단점

            - UI가 swagger에 비해 예쁘지 않다.

            - 환경설정이 복잡하고, 의존성 추가로 프로젝트가 상대적으로 무거워진다

            - mockMvc의 경우, service 시그니처 변경에 따라 stubbing을 계속 조정해주어야 한다.

             

             

            2편에서는 Swagger로 위의 예제를 전환하며 어떤 부분에서 rest docs보다 뛰어난지,

            또 어떤 부분에서 swagger가 더 좋지 않는지 알아볼 예정이다.

             

             

            예제 코드)

            직접 작성해본 예제코드는 다음 링크에서 확인할 수 있다

            https://github.com/coli-geonwoo/blog_hey_bro

             

            GitHub - coli-geonwoo/blog_hey_bro

            Contribute to coli-geonwoo/blog_hey_bro development by creating an account on GitHub.

            github.com

             

             

            reference)

            https://docs.spring.io/spring-restdocs/docs/current/reference/htmlsingle/