λ³Έλ¬Έ λ°”λ‘œκ°€κΈ°
πŸš€ 팁 (기술 적용 방법 λ“±)/🌱 Spring

[Spring REST Docs ✍️] μ–΄λ ΅κ²Œλ§Œ 느껴쑌던 REST Docsλ₯Ό μ μš©ν•΄λ³΄μž! (2)

by dev_writer 2024. 3. 5.

μ˜€λžœλ§Œμ— 기술 νŒμ„ μž‘μ„±ν•˜κ² μŠ΅λ‹ˆλ‹€. 사싀 JWT κ΄€λ ¨ν•΄μ„œ 글을 계속 μž‘μ„±ν•˜κ³  μžˆμ—ˆλŠ”λ°, ν”„λ‘œμ νŠΈλ„ 있고 κ°œκ°•μ„ ν•œ 지 μ–Όλ§ˆ λ˜μ§€ μ•Šμ•„ λ°”μœ 것도 μžˆμ–΄μ„œ 팁 글은 거의 ν•œ 달 λ§Œμ— μ˜¬λ¦¬λ„€μš”.. 😭 ν”„λ‘œμ νŠΈλ₯Ό ν•˜λ©° λ°”μ˜κ²Œ 된 것도 μžˆμ§€λ§Œ κ·Έ 덕뢄에 REST Docs에 λŒ€ν•΄ 더 잘 λ‹€λ£° 수 있게 λ˜μ–΄ 이 과정을 κ³΅μœ ν•©λ‹ˆλ‹€.

 

이전 κΈ€μ—μ„œλŠ” document λ©”μ„œλ“œλ‘œ κ°„λ‹¨νžˆ docs νŒ¨ν‚€μ§€ μ•„λž˜μ— html λ¬Έμ„œλ“€μ΄ λ³΄κ΄€λ˜λŠ” κ²ƒκΉŒμ§€ λ‹€λ£¨μ—ˆλŠ”λ°μš”, μ΄λ²ˆμ—λŠ” ν…ŒμŠ€νŠΈλ₯Ό 톡해 μ‹€μ œ λ¬Έμ„œκ°€ μž‘μ„±λ˜λ„λ‘ ν•˜λŠ” 것을 μ•Œμ•„λ³΄κ² μŠ΅λ‹ˆλ‹€.

 

κΈ°μ‘΄ ν…ŒμŠ€νŠΈ μ½”λ“œ

κΈ°μ‘΄ ν…ŒμŠ€νŠΈ μ½”λ“œλŠ” μ•„λž˜μ™€ κ°™μŠ΅λ‹ˆλ‹€.

// import ν‘œν˜„μ€ μƒλž΅

@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
@SuppressWarnings("NonAsciiCharacters")
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureRestDocs
@WebMvcTest(BoardController.class)
public class BoardControllerWebMvcTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockBean
    private BoardService boardService;

    @Test
    void κ²Œμ‹œκΈ€μ„_μ €μž₯ν•œλ‹€() throws Exception {
        // given
        BoardWriteRequest request = new BoardWriteRequest("test title", "test content");
        Board newBoard = κ²Œμ‹œκΈ€_id_있음();
        when(boardService.write(request)).thenReturn(newBoard);

        // when & then
        mockMvc.perform(post("/boards")
                        .contentType(APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(jsonPath("$.id").value(newBoard.getId()))
                .andExpect(jsonPath("$.title").value(newBoard.getTitle()))
                .andExpect(jsonPath("$.content").value(newBoard.getContent()))
                .andDo(print())
                .andDo(document("boards/save"));
    }

    @Test
    void κ²Œμ‹œκΈ€μ„_μ‘°νšŒν•œλ‹€() throws Exception {
        // given
        Board writeBoard = κ²Œμ‹œκΈ€_id_있음();
        when(boardService.findBoardById(any())).thenReturn(writeBoard);

        // when & then
        mockMvc.perform(get("/boards/" + writeBoard.getId()))
                .andExpect(jsonPath("$.title").value(writeBoard.getTitle()))
                .andExpect(jsonPath("$.content").value(writeBoard.getContent()))
                .andDo(print())
                .andDo(document("boards/find"));
    }
}

 

이에 λ”°λ₯Έ REST Docs κ²°κ³ΌλŠ” μ•„λž˜μ²˜λŸΌ λ‚˜μ˜΅λ‹ˆλ‹€. (μš”μ²­μ˜ 경우)

 

curl-request.adoc

 

curl-requestλŠ” CURL μš”μ²­ ν˜•μ‹μ„ λ³΄μ—¬μ€λ‹ˆλ‹€.

 

http-request.adoc

http-request.adoc은 μ‹€μ œ HTTPμ—μ„œ μ–΄λ–€ μ‹μœΌλ‘œ μš”μ²­μ΄ ν˜λŸ¬κ°€λŠ”μ§€ (HTTP λ©”μ„œλ“œ, 경둜 λ“±)λ₯Ό λͺ¨λ‘ λ‹΄κ³  μžˆμŠ΅λ‹ˆλ‹€.

 

request-body.adoc

request-body.adoc은 HTTP 쀑 body λΆ€λΆ„λ§Œμ„ λ³΄μ—¬μ€λ‹ˆλ‹€.

 

κΈ°μ‘΄ λ¬Έμ„œμ˜ μ•„μ‰¬μš΄ 점

κΈ°μ‘΄ λ¬Έμ„œλŠ” μ •ν™•νžˆ title, content 등이 μ–΄λ–€ λœ»μΈμ§€λ₯Ό λͺ…μ‹œν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€. λ˜ν•œ, λ§Œμ•½ 헀더에 인증 정보 (Authorization)κ°€ λ“€μ–΄κ°„λ‹€λ©΄ μ΄λŸ¬ν•œ 뢀가적인 정보도 담을 수 μ—†μŠ΅λ‹ˆλ‹€. (μ΄λŠ” μ‘λ‹΅μ—μ„œλ„ λ™μΌν•©λ‹ˆλ‹€.)

 

λ”°λΌμ„œ, restdocs μ•ˆμ— μžˆλŠ” λ©”μ„œλ“œλ“€λ‘œ μ„€λͺ…을 직접 μž‘μ„±ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

 

Request, Response Fields

곡식 λ¬Έμ„œλ₯Ό μ°Έκ³ ν•˜μ—¬ Request와 Response의 ν•„λ“œλ₯Ό μž‘μ„±ν•΄ λ³΄κ² μŠ΅λ‹ˆλ‹€.

mockMvc 기반의 μž‘μ„± 방법

 

μš”μ²­κ³Ό μ‘λ‹΅μ˜ ν•„λ“œλŠ” org.springframework.restdocs.payload.PayloadDocumentation의 requsetFields, responseFieldsλ₯Ό ν™œμš©ν•˜μ—¬ λ‚΄μš©μ„ μΆ”κ°€ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

package org.springframework.restdocs.payload;

import ...

public abstract class PayloadDocumentation {
    private PayloadDocumentation() {
    }
    
    ...
    
    public static RequestFieldsSnippet requestFields(FieldDescriptor... descriptors) {
        return requestFields(Arrays.asList(descriptors));
    }

    public static RequestFieldsSnippet requestFields(List<FieldDescriptor> descriptors) {
        return new RequestFieldsSnippet(descriptors);
    }
    
    ...
    
    public static ResponseFieldsSnippet responseFields(FieldDescriptor... descriptors) {
        return responseFields(Arrays.asList(descriptors));
    }

    public static ResponseFieldsSnippet responseFields(List<FieldDescriptor> descriptors) {
        return new ResponseFieldsSnippet(descriptors);
    }
    
    ...
}

 

μš”μ²­κ³Ό μ‘λ‹΅μ˜ ꡬ쑰λ₯Ό λ³΄λ‹ˆ, FieldDescriptorκ°€ κ³΅ν†΅μœΌλ‘œ μ“°μ΄λŠ” 게 λ³΄μž…λ‹ˆλ‹€. λ‚΄λΆ€ μ½”λ“œλ₯Ό λ³΄κ² μŠ΅λ‹ˆλ‹€.

FieldDescriptor
FieldDescriptor docs

  • pathλŠ” JSON λ‚΄μ—μ„œμ˜ 경둜λ₯Ό λœ»ν•©λ‹ˆλ‹€.
  • type은 λ¬˜μ‚¬λœ ν•„λ“œμ˜ νƒ€μž… (String, Number λ“±)을 λœ»ν•©λ‹ˆλ‹€.
  • isOptional은 ν•΄λ‹Ή ν•„λ“œκ°€ ν•„μˆ˜μ μΈμ§€, μ˜΅μ…˜μΈμ§€λ₯Ό λœ»ν•©λ‹ˆλ‹€.

그런데 μœ„μ˜ ν…ŒμŠ€νŠΈ μ½”λ“œλ₯Ό 보면, description 즉 "μ„€λͺ…"이 μž‘μ„±λ˜μ–΄ μžˆλŠ” 것을 λ³Ό 수 μžˆμŠ΅λ‹ˆλ‹€. 그런데 μ§€κΈˆ FieldDescriptorμ—μ„œλŠ” 보이지 μ•ŠμŠ΅λ‹ˆλ‹€. 이것은 FieldDescriptor > IgnorableDescriptor<FieldDescriptor> > AbstractDescriptor<T extends AbstractDescriptor<T>>에 μžˆμŠ΅λ‹ˆλ‹€.

AbstractDescriptor<T extends AbstractDescriptor<T>>

 

attributesλŠ” ν•„λ“œ ν…Œμ΄λΈ”μ— λ“€μ–΄κ°€ μžˆλŠ” 값을 μ»€μŠ€ν…€ν•˜κ²Œ λ°”κΏ€ λ•Œ μœ μš©ν•˜κ²Œ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€. μ•„λž˜λŠ” κ·Έ μ˜ˆμ‹œμž…λ‹ˆλ‹€.

@Test
    void κ²Œμ‹œκΈ€μ„_μ €μž₯ν•œλ‹€() throws Exception {
        // given
        BoardWriteRequest request = new BoardWriteRequest("test title", "test content");
        Board newBoard = κ²Œμ‹œκΈ€_id_있음();
        when(boardService.write(request)).thenReturn(newBoard);

        // when & then
        mockMvc.perform(post("/boards")
                        .contentType(APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(request)))
                .andExpect(jsonPath("$.id").value(newBoard.getId()))
                .andExpect(jsonPath("$.title").value(newBoard.getTitle()))
                .andExpect(jsonPath("$.content").value(newBoard.getContent()))
                .andDo(print())
                .andDo(document("boards/save",
                        requestFields(
                                fieldWithPath("title").description("제λͺ©"),
                                fieldWithPath("content").description("λ‚΄μš©")
                                        .attributes(
                                                key("path").value("hello")
                                        )
                        )
                ));
    }

 

ν•΄λ‹Ή ν…ŒμŠ€νŠΈ μ½”λ“œλ₯Ό μ‹€ν–‰ν•˜λ©΄ μƒˆλ‘œμš΄ μŠ€λ‹ˆνŽ«μΈ request-fields.adoc이 μƒμ„±λ©λ‹ˆλ‹€.

path에 "hello"둜 μ μ—ˆμœΌλ―€λ‘œ κ΄€λ ¨ λ‚΄μš©μ΄ λ°˜μ˜λ˜μ—ˆμŠ΅λ‹ˆλ‹€.

 

κ²°λ‘  및 ν™•μž₯ 예

이번 글은 κ°„λ‹¨ν•˜κ²Œ λ§ˆλ¬΄λ¦¬ν•˜λ € ν•©λ‹ˆλ‹€. μ˜¬λ €λ‘” 곡식 λ¬Έμ„œλ₯Ό μ°Έκ³ ν•˜λ©΄, μžμ‹ μ΄ μ›ν•˜λŠ” λŒ€λ‘œ μž¬μ‘°μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

그리고 μ΄λ ‡κ²Œ μŠ€λ‹ˆνŽ« νŒŒμΌλ“€μ΄ μƒμ„±λ˜λ©΄, μ›ν•˜λŠ” μˆœμ„œλ‘œ adoc νŒŒμΌλ“€μ„ λ°°μΉ˜ν•˜λ©΄ λ©λ‹ˆλ‹€.

두 κ°€μ§€μ˜ μŠ€λ‹ˆνŽ«μ„ κ²°ν•©ν•œ 예

 

μ•„λž˜ μ˜ˆμ‹œλŠ” ν”„λ‘œμ νŠΈλ₯Ό ν•˜λ©΄μ„œ μž‘μ„±ν•œ ν…ŒμŠ€νŠΈ μ½”λ“œμΈλ°μš”, μ•žμ„œ λ§ν•œ attributesλ₯Ό μ΄μš©ν•˜μ§€ μ•ŠλŠ”λ‹€λ©΄ Path의 경우 λ°°μ—΄μ˜ μ›μ†Œμ—λŠ” hobbies[].hobby 이런 μ‹μœΌλ‘œ κΉ”λ”ν•˜μ§€ μ•Šκ²Œ ν‘œν˜„λ˜λŠ” 점 (이런 μ‹μœΌλ‘œ μž‘μ„±ν•˜μ§€ μ•Šμ„ 경우 ν…ŒμŠ€νŠΈ μ‹œ μ˜ˆμ™Έκ°€ λ°œμƒν•©λ‹ˆλ‹€.)을 ν•΄κ²°ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

DTO 값이 λ§Žμ•„μ§„λ‹€λ©΄ μœ„μ™€ 같이 속성이 λ§Žμ•„μ§‘λ‹ˆλ‹€.

 

이λ₯Ό html둜 ν‘œν˜„ν•˜λ©΄ μ•„λž˜μ™€ 같이 λ‚˜μ˜΅λ‹ˆλ‹€.

μƒμ„±λœ HTML μ˜ˆμ‹œ

 

λ‹€μŒ 글을 μž‘μ„±ν•œλ‹€λ©΄ ν˜„μž¬ κ²Œμ‹œνŒ JSON이 ν•œ μ€„λ‘œ ν‘œν˜„λ˜λŠ” λ¬Έμ œκ°€ μžˆλŠ”λ°, 이λ₯Ό λ§ˆμ§€λ§‰ μ‚¬μ§„μ²˜λŸΌ μ •λ ¬μ‹œμΌœ ν‘œν˜„ν•  수 μžˆλŠ” 방법에 λŒ€ν•΄ μ•Œμ•„λ³΄λ„λ‘ ν•˜κ² μŠ΅λ‹ˆλ‹€.

 

λ§ˆμ§€λ§‰ μ£Όμ˜μ‚¬ν•­!

λ§ˆμ§€λ§‰μœΌλ‘œ μ•Œλ €λ“œλ¦΄ 것은, 쑰회의 경우 νŠΉμ • ν•„λ“œλ₯Ό μš”μ²­μ— 담지 μ•ŠλŠ” κ²½μš°κ°€ λ§ŽμŠ΅λ‹ˆλ‹€. 그렇기에 가끔 μš”μ²­ adoc νŒŒμΌμ„ μ•„μ˜ˆ λΉ„μ›Œλ²„λ¦΄ λ•Œκ°€ μžˆμ„ κ²ƒμž…λ‹ˆλ‹€.

 

κ·ΈλŸ¬λ‚˜ μ΄λ ‡κ²Œ 되면 μ–΄λŠ URL둜 μš”μ²­μ„ ν•˜λŠ”μ§€ μ „ν˜€ λͺ¨λ₯΄κΈ° λ•Œλ¬Έμ—, μš”μ²­ ν•„λ“œκ°€ 없더라도 http-request.adoc νŒŒμΌμ€ κΌ­ λ„£μ–΄μ£Όμ‹œλŠ” 게 μ’‹μŠ΅λ‹ˆλ‹€.

κ°„λ‹¨ν•œ μ‘°νšŒμ—¬λ„ λ‘œκ·ΈμΈμ€ λ˜μ–΄ μžˆμ–΄μ•Ό ν•˜κΈ°μ— 토큰과 URL을 λͺ…μ‹œν•˜μ˜€μŠ΅λ‹ˆλ‹€.

Reference