승이의 기술블로그
article thumbnail

💭 들어가며

최근, 이벤트 스토밍을 팀원들과 하며 대대적인 리팩토링을 진행했다.

 

이벤트 스토밍 구경하러 가기

 

훕치치의 이벤트 스토밍

이벤트 스토밍 소개 및 경험 공유

hufscheer-techblog.vercel.app

도메인 용어들을 대다수 변경하다 보니, 엔드포인트도 다수 변경되었는데 해당 변경사항들을 모두 일일이 노션에 기입하려니 불편함과 불안함이 생겼다. 

작성한 API 스펙이 맞는지 헷갈렸고, 팀원분과의 상의를 통해 API 문서화를 할 수 있는 Spring REST Docs 를 도입하기로 했다.

💬 Spring REST Docs 의 전체적인 플로우

우선, 전체적인 플로우에 대한 이해를 먼저 하고 난 뒤에 코드를 살펴보면 더 빠른 이해가 가능할 것 같아 플로우를 먼저 짚어보자.

 

1. 테스트 코드를 작성한다.

2. 테스트 코드를 기반으로 build/generated-snippets 내부에 코드 스니펫이 생성된다.

3. 해당 스니펫을 기반으로 adoc 파일에 API 명세를 작성한다.

4. adoc 파일을 기반으로 html 파일이 생성된다.

5. 해당 html 파일을 서빙한다.

 

📝 build.gradle 작성

Spring REST Docs 의 핵심은 빌드 환경 세팅이기에 이를 잘 이해하고 넘어가는 것이 중요하다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.4'
    id 'io.spring.dependency-management' version '1.1.3'
    id "org.asciidoctor.jvm.convert" version '3.3.2' // 1
}

configurations {
    asciidoctorExt // 2
}

dependencies {
    
    // 중략..
    
    // rest docs
    asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor' // 3
    testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' // 4
}

ext {
    snippetsDir = file('build/generated-snippets') // 5
}

test {
    outputs.dir snippetsDir // 6
    useJUnitPlatform()
}

asciidoctor {
    configurations 'asciidoctorExt' // 7
    inputs.dir snippetsDir // 8
    dependsOn test
}

tasks.register('copyDocument', Copy) { // 9
    dependsOn asciidoctor

    from file("build/docs/asciidoc")
    into file("src/main/resources/static/docs")
}

asciidoctor.doFirst { // 10
    delete file('src/main/resources/static/docs')
}

bootJar { // 11
    dependsOn copyDocument
}

 

1️⃣ org.asciidoctor.jvm.convert

asciidoctor 를 jvm 에서 사용할 수 있도록 하는 플러그인이다.

asciidoctor 은 asciidoc 문서를 HTML, PDF 등으로 변환하는 역할을 한다.

 

2️⃣ asciidoctorExt

asciidoctor 에 관련된 구성들을 설정한다.

 

3️⃣ asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'

Spring REST Docs 에서 생성된 스니펫을 asciidoctor 로 변환할 때 필요한 의존성을 제공한다.

 

4️⃣ org.springframework.restdocs:spring-restdocs-mockmvc

실제 요청을 보내지 않고 Mock 객체를 이용해서 테스트할 수 있도록 해주는 의존성이다.

 

5️⃣ snippetsDir = file('build/generated-snippets')

snippetsDir 을 전역적으로 선언한다.

 

6️⃣ outputs.dir snippetsDir

테스트의 결과를 5에서 선언한 경로에 저장할 것을 선언한다.

 

7️⃣ configurations 'asciidoctorExt'

asciidoctor 플러그인이 참조할 구성을 선택한다.

 

8️⃣ inputs.dir snippetsDir 

asciidoctor 에서 참조하는 파일의 위치를 나타낸다.

 

9️⃣ tasks.register('copyDocument', Copy)

9는 build/docs/asciidoc 에 있는 파일을 src/main/resources/static/docs 로 복사하는 태스크를 선언한다.

 

🔟 asciidoctor.doFirst

10은 asciidoctor 가 이루어질 때 먼저 src/main/resources/static/docs 에 위치한 파일들을 삭제하는 것이다. 이는 asciidoctor 가 test 에 의존하기 때문에, test 가 먼저 이루어지면, 해당 태스크가 이루어진다.

 

1️⃣1️⃣ dependsOn copyDocument

11은 jar 파일이 생성되는 부분이다. 이는 jar 파일이 생성될 때 항상 9에서 정의한 복사 태스크가 먼저 이루어지도록 한다.

📝 DocumentationTest 클래스 생성

@WebMvcTest(controllers = {
        CheerTalkController.class,
        // 중략 ..
})
@AutoConfigureRestDocs
public class DocumentationTest {

    private static final OperationRequestPreprocessor HOST_INFO = preprocessRequest(modifyUris()
            .scheme("https")
            .host("주소")
            .removePort(), prettyPrint()
    );

    protected static final RestDocumentationResultHandler RESULT_HANDLER = document(
            "{class-name}/{method-name}",
            HOST_INFO,
            preprocessResponse(prettyPrint())
    );

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    // 중략 ..
}

 

모든 테스트가 상속받을 클래스를 생성한다.

해당 클래스에서는 모든 테스트에 해당하는 설정을 해준다.

 

@WebMvcTest

이는 웹 계층 테스트에서 사용되는 어노테이션이다.

이를 이용해 필요한 구성이나 빈들만 로드하도록 하여 속도를 높일 수 있다.

 

@AutoConfigureRestDocs

이를 이용하면 자동으로 REST Docs 를 구성해준다.

해당 어노테이션을 붙이면 build/generated-snippets 에 스니펫이 저장된다.

 

HOST_INFO

해당 부분은 명세 내에서 호스트의 주소로 명시되게 된다.

 

RESULT_HANDLER

REST Docs 에 사용되는 테스트 코드를 작성하다 보면, 반복되는 부분들이 존재한다.

해당 스니펫의 identifier, prettyPrint 가 그렇다.

identifier 는 다음과 같이 build/generated-snippets 에 위치한 스니펫들의 경로를 나타낸다.

만일 위처럼, RESULT_HANDLER 를 선언해놓지 않는다면, 다음과 같이 매번 identifier 와 HOST_INFO, response 에 대한 prettyPrint 를 지정해줘야 한다.

result.andExpect(status().isOk())
    .andDo(document("identifier에 해당하는 문자열", HOST_INFO,
            preprocessResponse(prettyPrint())

이처럼 identifier 를 지정하게 되면, 클래스마다 다른 이름을 사용하게 될 우려가 있기 때문에 위처럼 상수로 지정해 고정적으로 클래스 이름 / 메서드 이름으로 스니펫이 생성되도록 했다.

 

prettyPrint

이는 사람이 읽기 쉽게 좀 더 정제해서 출력하는 옵션이다.

 

chatGPT 에 따른 prettyPrint 옵션이 존재할 때

|===
|Path |Method |Status |Response-Time
|/api/resource |GET |200 |35
|===

chatGPT 에 따른 prettyPrint 옵션이 존재하지 않을 때

// get-endpoint-request.adoc
[[
{
  "uri": "/api/resource",
  "method": "GET",
  "status": 200,
  "responseTime": 35
}
]]

 

위처럼, 보다 깔끔하고 명확하게 볼 수 있다.

 

이제 실제 테스트 코드를 작성해보자.

 

📝 테스트 코드 작성

예시 컨트롤러

@RestController
@RequiredArgsConstructor
public class CheerTalkController {

    private final CheerTalkService cheerTalkService;

    @PostMapping("/cheer-talks")
    public ResponseEntity<Void> register(@RequestBody @Valid final CheerTalkRequest cheerTalkRequest) {
        cheerTalkService.register(cheerTalkRequest);
        return ResponseEntity.ok(null);
    }
}

 

위 컨트롤러를 테스트하는 코드를 작성해보자.

class CheerTalkControllerTest extends DocumentationTest {

    @Test
    void 응원톡을_저장한다() throws Exception {
        // given
        CheerTalkRequest request = new CheerTalkRequest("응원해요~", 1L);

        // when
        ResultActions result = mockMvc.perform(post("/cheer-talks")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
        );

        result.andExpect((status().isOk()))
                .andDo(RESULT_HANDLER.document(
                        requestFields(
                                fieldWithPath("content").type(JsonFieldType.STRING).description("응원톡의 내용"),
                                fieldWithPath("gameTeamId").type(JsonFieldType.NUMBER).description("응원하는 게임팀")
                        )
                ));
    }
}

테스트용으로 사용할 Request DTO 를 생성하고, MockMvc 를 이용해서 요청을 보낸다.

 

아래의 result 에서 document 이하가 실제로 스니펫에 기록되는 부분이다.

위의 코드의 경우에는, request body 에 어떤 필드가 담기는지 기록했다.

 

이후에는, 해당 테스트 코드를 돌려본다.

bulid.gradle 에서 작성했던 것처럼 asciidoctor 이 돌려져 buid/generated-snippets 에 스니펫이 생성된다.

 

위처럼 request fields, request body 등이 생성된다.

이전에 지정한 것처럼, 클래스명/메서드명으로 만들어진 것을 확인할 수 있다.

 

이후에는, 위의 스니펫을 이용해 명세서를 작성한다.

= API 문서
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 2
:sectlinks:

== 응원톡 API

=== 응원톡 저장

operation::cheer-talk-controller-test/응원톡을_저장한다[snippets='http-request,request-fields,http-response']

 

이후 bootJar 를 이용해 jar 파일을 생성하면, build.gradle 에 정의한대로 src/main/resources/docs 에 해당 파일을 복제한다.

src/main/resources/docs 에 api.html 이 생성되고, 이를 Application 을 가동하면 확인할 수 있다.

 

검색 태그