ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [SpringBoot] Spring REST Docs ๋กœ API ๋ช…์„ธ๋ฅผ ๋ฌธ์„œํ™” ํ•˜์ž
    SpringBoot 2024. 1. 23. 19:38

    ๐Ÿ’ญ ๋“ค์–ด๊ฐ€๋ฉฐ

    ์ตœ๊ทผ, ์ด๋ฒคํŠธ ์Šคํ† ๋ฐ์„ ํŒ€์›๋“ค๊ณผ ํ•˜๋ฉฐ ๋Œ€๋Œ€์ ์ธ ๋ฆฌํŒฉํ† ๋ง์„ ์ง„ํ–‰ํ–ˆ๋‹ค.

     

    ์ด๋ฒคํŠธ ์Šคํ† ๋ฐ ๊ตฌ๊ฒฝํ•˜๋Ÿฌ ๊ฐ€๊ธฐ

     

    ํ›•์น˜์น˜์˜ ์ด๋ฒคํŠธ ์Šคํ† ๋ฐ

    ์ด๋ฒคํŠธ ์Šคํ† ๋ฐ ์†Œ๊ฐœ ๋ฐ ๊ฒฝํ—˜ ๊ณต์œ 

    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 ์„ ๊ฐ€๋™ํ•˜๋ฉด ํ™•์ธํ•  ์ˆ˜ ์žˆ๋‹ค.

     

Designed by Tistory.