๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ

SpringBoot

[SpringBoot] Spring REST Docs ๋กœ API ๋ช…์„ธ๋ฅผ ๋ฌธ์„œํ™” ํ•˜์ž

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

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

 

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

 

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

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

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