-
Swagger
-
-
RestDocs
-
-
epages, OpenAPI Specification(OAS) ๊ธฐ๋ฐ API ๋ฌธ์ํ
-
restdocs-api-spec ์คํ์์ค
-
epages ํธ๋ฌ๋ธ์ํ
-
๋ถ์กฑํ ์ฐธ๊ณ ์๋ฃ
-
@ModelAttribute, @RequestParts ๋์
-
Swagger์ RestDocs๊ฐ์ ํ์ ์ง์ ํ์์ ๋ถ์ผ์น
-
๊ฐ์ ๋์ง ์๋ ํ ์คํธ ์ฝ๋์ธํ ๋ถ์กฑํ ์ ๋ขฐ๋
-
์ด๋ ธํ ์ด์ ๊ณผ ๋น์ฆ๋์ค ๋ก์ง์ด ์์์ผ๋ก ์ธํ ๊ฐ๋ ์ฑ ๋ฌธ์
-
์์ธํ๊ณ ์ ๋ณด๋์ด ๋ง์ ๋ฌธ์
-
์ค์ ์ฝ๋์ ๋ฌธ์๊ฐ ๋ฌ๋ผ์ง๋ ๋ฌธ์
-
OAS ๋น๋ ๋ฐ Swagger ๋ฐ์ ์๋ํ ๊ทธ๋ ์ด๋ค
-
์ฐธ๊ณ ์๋ฃ

์ด์ ๋ ธ์ ๋ธ๋ก๊ทธ์ Transaction ์ด๋? (2023.08.11)๋ก๋ถํฐ ๋ง์ด๊ทธ๋ ์ด์ ๋ ๊ธ์ ๋๋ค.
์ ๋ ๋ Swagger์ ์๋ํ์ ๊ฐ๋จํ ์ด๋ ธํ ์ด์ ์ ํตํด ๋น ๋ฅธ API ๋ฌธ์ ๋ฐฐํฌ๋ง ํด๋ดค์ต๋๋ค. ๋น ๋ฅด๊ฒ ์์ ๊ฐ๋ฅํ๋ค๋ ์ฅ์ ์ ์์์ง๋ง, ๋ชจ๋ ์์ฒญ์ Postman์ผ๋ก ํ ์คํธํ๋ ๊ณผ์ ์์ ์ค์๊ฐ ์์ ์ ๋ฐ์ ์์๊ณ Swagger์ ์๋ ์์ฑ ๋ฌธ์๊ฐ ์น์ ํ ํธ์ ์๋๊ธฐ์ ์ฝ๋ ์ฌ๋๋ง๋ค ๋ค๋ฅด๊ฒ ์ดํด๋๋ ๊ฒฝ์ฐ๊ฐ ์์์ต๋๋ค. ๋ฐ๋ผ์ ํ๋ก ํธ์์ API๋ฅผ ์ฐ๊ฒฐํ๋ ๊ณผ์ ์์ ์ค์๊ฐ ์์ฃผ ๋ฐ์ํ๊ฑฐ๋, ์ค๋ฅ๊ฐ ๋ฐ์ํ๋๋ผ๋ ์ ์๊ฒ ์ง์ ๋ฌผ์ด๋ณด๋ ๊ฒฝ์ฐ๊ฐ ๋ง์์ต๋๋ค. API ๋ฌธ์๋ฅผ ์์ฑํ ์์๊ฐ ์ฌ๋ผ์ ธ๋ฒ๋ฆฌ๋ ์ผ์ด ๋ง์์ต๋๋คโฆ ๐
์ด๋ฒ ๋ฆฌ๋ทฐ๋ฉ์ดํธ ํ๋ก์ ํธ์์๋ ์ ํ๋ ์๊ฐ ๋ด์ FE์ AI ๋ชจ๋๊ฐ ์ฌ์ฉํ ์๋ฒ๋ฅผ ๋ง๋ค์ด์ผ ํ๋ฏ๋ก, ์ ๋ขฐ์ฑ์ด ๋๊ณ ๋ช ๋ฃํ API ๋ฌธ์๋ฅผ ๋ง๋ค๊ธฐ ์ํด ๊ณต๋ถํ๊ณ ๊ณ ๋ฏผํ ๊ณผ์ ์ ์ ๋ฆฌํด๋ณด์์ต๋๋ค.
API ๋ฌธ์ํ ๋๊ตฌ ๋น๊ต
Spring ๊ธฐ๋ฐ ์๋ฒ ํ๋ก์ ํธ์์ API ๋ฌธ์ํ๋ ๋๊ฒ Swagger์ Spring RestDocs๋ฅผ ์ฌ์ฉํ๊ณ , ๋ ๊ฐ์ ์ฅ์ ๋ง ๊ฐ์ ธ๊ฐ๊ธฐ ์ํ OpenAPI Specification(OAS)๊ธฐ๋ฐ Swagger๊ฐ ์์ต๋๋ค. (OAS๊ธฐ๋ฐ Swagger๋ epages๋ผ๋ ํ์ฌ๊ฐ ๋ง๋ OAS ์ถ์ถ ๋ฐ Swagger ์ ์ฉ ์คํ์์ค๋ฅผ ํตํด ์ฃผ๋ก ์ฌ์ฉ๋๋ฏ๋ก, ์ดํ epages๋ผ๊ณ ๋ถ๋ฅด๊ฒ ์ต๋๋ค)
Swagger | RestDocs | epages | |||
์ฅ์ | ๋จ์ | ์ฅ์ | ๋จ์ | ์ฅ์ | ๋จ์ |
๊น๋ํ๊ณ ์์ ๋ฌธ์ |
ํ ์คํธ ์ฝ๋๋ฅผ ๊ฐ์ ํ์ง ์์ผ๋ฏ๋ก, ๋์ง ์์ ์ ๋ขฐ๋ | ํ ์คํธ ์ฝ๋ ์์ฑ ๊ฐ์ ๋ก ์ธํ ๋์ ์ ๋ขฐ๋ | ์ฐ์ํ์ง ๋ชปํ ๋ฌธ์ | Swagger์ ์ฐ์ํ ๋ฌธ์ (๊น๋ํ๊ณ ์์๋ฉฐ, API Test ๊ธฐ๋ฅ) |
๋ํ๋จผํธ๊ฐ ์๊ณ , ํธ๋ฌ๋ธ์ํ ์ ์ํ ์ฐธ๊ณ ์๋ฃ๋ ์ ์ |
API Test ๊ธฐ๋ฅ ์ง์ |
Swagger ์ด๋
ธํ
์ด์
์ด ๋น์ฆ๋์ค ์ฝ๋์ ์์ |
๋น์ฆ๋์ค ์ฝ๋์ ๋ฌธ์ํ ์ฝ๋๊ฐ ๋ถ๋ฆฌ๋จ | API Test ๊ธฐ๋ฅ ๋ฏธ์ง์ | ํ
์คํธ ์ฝ๋๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์์ฑ๋๋ฏ๋ก ๋์ ์ ๋ขฐ๋ |
Swagger์์ RestDocs ์ฐจ์ด๋ก ์ธํด ์๋ชป ์์ฑ๋๋ ๋ฌธ์ |
Swagger
Swagger๋ ๊ต์ฅํ ์ฐ์ํ ๋ฌธ์๊ฐ ํน์ง์ ๋๋ค. ๋ฌธ์๊ฐ ์์๋ฉฐ, ์ ๊ณตํ๋ ์ ๋ณด๋ ๋ํ ๋ง์ต๋๋ค. ํ๋ผ๋ฏธํฐ์ ์ข ๋ฅ์ ์์, response, ๋ฆฌํด ํน์ ์ฒจ๋ถ๋๋ DTO์ ์คํค๋ง ๋ฑ๋ฑ์ด ์ ๊ณต๋๋ฉฐ, ์ฌ์ฉ์๊ฐ ๋ณ๋๋ก ์ด๋ฅผ ๋ช ์ํ์ง ์์ผ๋ฉด ์๋ฒ์ ์ฝ๋๋ฅผ ๋ฐํ์ผ๋ก ์์ฑ๋๋ ํน์ง ๋๋ถ์ ๋จ์ ๋ ธ๋์ ๊ฐ๊น์ด ์ฝ๋ ์์ฑ ์๊ฐ์ ๋จ์ถ์์ผ์ค๋๋ค.
์ค๋ช ๋ง ๋ค์ผ๋ฉด ๋ณ๊ฒ ์๋ ๊ฒ ๊ฐ์ ์ฅ์ ๊ฐ์ง๋ง, RestDocs๋ฅผ ์จ๋ณด๊ณ ๋๋ฉด ๊ทธ ์ฅ์ ์ ์ ์คํ ๋๋ผ๊ฒ ๋ฉ๋๋คโฆ

ํ์ง๋ง ์ด ์ฅ์ ์ ์์์ํฌ ์ ๋๋ก ์น๋ช ์ ์ธ ๋จ์ ์ด๋ผ๋ฉด, API ๋ฌธ์์ ๋ด์ฉ๋ค ์ถ๊ฐ ์์ฑํ๊ธฐ ์ํ ์ด๋ ธํ ์ด์ ๋ค์ด ๋น์ฆ๋์ค ๋ก์ง๊ณผ ์์ธ๋ค๋ ์ ์ ๋๋ค. DTO์ ๊ฒฝ์ฐ์๋ ์ํธํ ํธ์ด์ง๋ง, Controller๋ ์์ ํ ํผ๋์ค๋ฌ์์ง๋๋ค.


RestDocs
์ปจํธ๋กค๋ฌ ํ ์คํธ ์ฝ๋๋ฅผ ๊ธฐ๋ฐ์ผ๋ก API ๋ฌธ์๊ณผ ์๋๋๊ธฐ ๋๋ฌธ์, ์ ๋ขฐ์ฑ ๋์ ์์ฒญ๋ง ๋ฌธ์์ ๊ธฐ์ฌ๋๋ ํน์ง์ด ์์ต๋๋ค. ์ด์ฐ๋ณด๋ฉด ๋ฐ์๋ํ๋ผ๊ณ ๋ณผ ์๋ ์์ ๊ฒ ๊ฐ์ต๋๋ค. RestDocs๋ Asciidoctor ๋ฌธ์๋ฅผ ํตํด API ๋ฌธ์๋ฅผ ์์ฑํ๋๋ฐ, ์ด API ๋ฌธ์๊ฐ ์ฐ์ํ์ง ๋ชปํ๋ค๋ ์ ์ด ํฐ ๋จ์ ์ ๋๋ค. Swagger์ ์ต์ํ ์ ์๊ฒ ์ญ์ฒด๊ฐ์ด ์ฌํด์ ๊ทธ๋ ๊ฒ ๋๋ผ๋ ๊ฒ์ผ์ง ๋ชจ๋ฅด๊ฒ ์ง๋ง, ์์์ง ๋ชปํ ๋ฌธ์๋ ๊ฐ๋ ์ฑ๋ง์ ํค์นฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ๋ฌธ์์์ ์ ๊ณต๋๋ ์ ๋ณด๋๋ Swagger ๋๋น ์ ์ต๋๋ค. ํน์ ์ ๋ฌธ๋ฒ์ผ๋ก ์ธํ ๋จ์ ์ epages๋ ๋ง์ฐฌ๊ฐ์ง์ด๋ฏ๋ก, ํ์ ํ๊ฒ ์ต๋๋ค.

epages, OpenAPI Specification(OAS) ๊ธฐ๋ฐ API ๋ฌธ์ํ
OpenAPI Specification(OAS)๋ RESTful API ์คํ ๋ฌธ์์ ํ์ค์ผ๋ก์ ํ์ฉ๋๊ณ ์๋ ๊ท์ ์ ๋๋ค. Swagger-UI๋ OAS๋ฅผ ํด์ํ์ฌ API ์คํ์ ์๊ฐํํด์ค๋๋ค. ๋ํ Postman, Paw๊ฐ์ API Client๋ค๋ OAS๋ฅผ ์ง์ํ๊ณ ์์ด ํ์ฉ๋๊ฐ ๋ค์ํฉ๋๋ค.
restdocs-api-spec ์คํ์์ค
์ด ์๋ฆฌ๋ฅผ ์ด์ฉํด์, epages๋ ์ ๊ณตํ๋ **RestDocs Wrapper ํด๋์ค๋ก ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ฉด OAS๋ฅผ ์ถ์ถํด์ค๋๋ค. ๊ทธ๋ฌ๋ฉด static routing์ ํตํด ์ถ์ถ๋ OAS๋ฅผ ๊ธฐ๋ฐ์ผ๋ก swagger ํ์ด์ง๋ฅผ ํธ์คํ **ํ๊ฒ ๋ฉ๋๋ค. ์ฆ, ๋ฌธ์ ์์ฑ์ RestDocs, ๋ฌธ์ ์ ๊ณต์ Swagger๋ฅผ ์ฌ์ฉํ๋ฏ๋ก ๋ ์ฅ์ ๋ง ํํ๋ ๋ฐฉ์์ด๋ฏ๋ก ์ฅ์ ๋ง ์์ ๊ฒ ๊ฐ์๋ณด์์ง๋ง, ์ ๊ฐ ์ ์ฉํ๋ฉด์ ๋ง๋ ๋ฌธ์ ๋ค์ ์๊ฐํด ๋๋ฆฌ๊ฒ ์ต๋๋ค.
epages ํธ๋ฌ๋ธ์ํ
๋ถ์กฑํ ์ฐธ๊ณ ์๋ฃ
epages๋ 2๊ฐ์ง ๋ฐฉ๋ฒ์ผ๋ก ์ฌ์ฉํ ์ ์์ต๋๋ค.
- RestDocs์ ๋ฌธ๋ฒ๊ณผ ์ ์ฌํ์ง๋ง, epages ๋ ์์ ์ธ ResourceSnippetParameters.builder() ํจํด์ ์ฌ์ฉํ API ๋ฌธ์ ์์ฑ
- RestDocs์ ๋๊ฐ์ ๋ฌธ๋ฒ์ผ๋ก ์์ฑ๋ ์ฝ๋์, epages๊ฐ ์ ๊ณตํ๋ RestDocs Wrapper ํด๋์ค๋ก ๊ต์ฒด
1๋ฒ ๋ฐฉ์์ ๋ ์์ ์ธ ๋ฐฉ์์ด๊ธฐ ๋๋ฌธ์ ๊ด๋ จ ์๋ฃ๋ฅผ ์ฐธ๊ณ ํ๋ฉด์ ์ฌ์ฉํด์ผ ํ์ง๋ง, ์ฐธ๊ณ ์๋ฃ๊ฐ ๋ถ์กฑํฉ๋๋ค. ์ฐ์ OAS๊ธฐ๋ฐ API ๋ฌธ์ํ๋ ์ผ๋ฐ ๊ฐ๋ฐ ๋ธ๋ก๊ทธ์์๋ ์ ์๊ฐ๋์ง ์๊ณ ์๊ณ ์นด์นด์ค ํ์ด๋ ์ปฌ๋ฆฌ ๋ฑ์ ์ผ๋ถ ํ ํฌ ๋ธ๋ก๊ทธ์์๋ง ์๊ฐ๋๊ณ ์์ต๋๋ค. ์ฆ, ๋ค์ํ ํ๊ฒฝ์์ ์ ์ฉ์ ์๋ํ ๊ธฐ๋ก์ด๋ ๊ทธ ๊ณผ์ ์์ ๋ฐ์ํ ํธ๋ฌ๋ธ์ ํด๊ฒฐํ ๊ธฐ๋ก์ ๋ํ ์๋ฃ๊ฐ ๊ฑฐ์ ์์ต๋๋ค.(๋ฌผ๋ก ์์ด๋ก ๊ฒ์ํด๋ ๋ง์ฐฌ๊ฐ์ง..) ๊ณต์ ๋ํ๋จผํธ ๋ํ ์กด์ฌํ์ง ์๊ณ , ๋ ํผ์งํ ๋ฆฌ์ README์ ๊ฐ๋จํ setup๊ณผ ์ฌ์ฉ๋ฒ์ ๋ํด์๋ง ์๊ฐํ๊ณ ์์ต๋๋ค.
ํด๋์ค๋ฅผ ๋ฏ์ด๊ฐ๋ฉด์ ์ฌ์ฉํด๊ฐ๋ ค๊ณ ํ์ง๋ง ๋๋ฒ๊น ์ ๋๋ฌด ๋ง์ ์๊ฐ์ด ์๋ชจ๋์๊ธฐ ๋๋ฌธ์, RestDocs ์๋ฃ๋ฅผ ์ฐธ๊ณ ํ ์ ์๋ 2๋ฒ ๋ฐฉ์์ผ๋ก ์๋ํ์์ต๋๋ค.
@ModelAttribute, @RequestParts ๋์
๋ฆฌ๋ทฐ๋ฉ์ดํธ์ ๋ฆฌ๋ทฐ ์ ๋ก๋์ ์ํ ์ถ๊ฐ ์์ฒญ์ DTO์ ์ฌ์ง MultipartFile์ ํ ๋ฒ์ ์ฒ๋ฆฌํฉ๋๋ค. ๋ ํ๋ผ๋ฏธํฐ๋ Content-type ๋ ๊ตฌ๋ถํด์ ๋ณด๋ด์ผํ๋ ๋งํผ ๋ฌธ์ํ๊ฐ ์ค์ํ POST ์์ฒญ์ ๋๋ค๋ง, epages์์๋ ์ ๋๋ก ๋ฌธ์ํ๋์ง๊ฐ ์์์ต๋๋ค.
โ ๏ธ ์ฐ์ epages์ OpenAPI๋ฅผ 2๋ฒ์ ์ผ๋ก ๋ฎ์ถฐ์ผ ํฉ๋๋ค.
- OpenAPI 2.0 โ RestDocs 2.0.X
- OpenAPI 3.0 โ RestDocs 3.0.X
์ ๊ฐ์ด ๋์๋๋๋ฐ, RestDocs 3.0.X ๋ถํฐ๋ @ModelAttribute ์ @RequestParts ํ๋ผ๋ฏธํฐ๋ฅผ ๋์ํ๋ requestParts ์ง์์ด ์ญ์ ๋์์ต๋๋ค. Form parameter์ QueryParameter๋ฅผ ๊ตฌ๋ถํ๊ธฐ ์ํ ๋ณ๊ฒฝ์ฌํญ์ด๋ผ๊ณ ์ ํ์์ง๋ง, mockMvc์ mulipart() ์์ฒญ์ผ๋ก ์ ๋ฌ๋๋ multipartFile ํ๋ผ๋ฏธํฐ๋ ์ด๋ป๊ฒ ๋์ํด์ผํ๋์ง์ ๋ํ ๋ฌธ์๊ฐ ์๊ณ , ๊ตฌ๊ธ๋งํด๋ ์ฐพ์ ์ ์์์ต๋๋ค.
OpenAPI 2.0 ์ด ์ ๋๋ก ๋ฌธ์ํ๋์ง ์๋ ์ ์ ๋ค๋ฃจ๊ณ ์์ง๋ง, OpenAPI 3.0 ์ ์ปดํ์ผ ์กฐ์ฐจ ์๋๋ฏ๋ก, ๋ฒ์ ์ ๋ฎ์ถฐ์ผํฉ๋๋ค.
@ModelAttribute ์ @RequestParts ํ๋ผ๋ฏธํฐ๋ฅผ ๋์ํ๋ requestParts() ๋ ๋ฌธ์ํ๋์ง๊ฐ ์์ต๋๋ค.
// ReviewController
@PostMapping("/")
public ResponseEntity<Void> createReview(@Valid @ModelAttribute ReviewCreateRequest reviewCreateRequest) {
Long reviewId = 1L;
return ResponseEntity.created(URI.create("/api/v1/review/" + reviewId)).build();
}
// ReviewCreateRequest
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewCreateRequest {
@NotNull
private Integer rating;
@NotBlank
private String title;
@NotBlank
private String content;
private List<MultipartFile> reviewImages;
@NotNull
private Long travelProductId;
@NotNull
private Long customerId;
}
@Test
void ๋ฆฌ๋ทฐ๋ฅผ_์์ฑํ๋ค() throws Exception {
// when
ResultActions response = mockMvc.perform(
multipart("/api/v1/review/")
.file("reviewImages", testImage1.getBytes())
.param("rating", "5")
.param("title", "๋ฆฌ๋ทฐ ์ ๋ชฉ")
.param("content", "๋ฆฌ๋ทฐ ๋ด์ฉ")
.param("travelProductId", "1")
.param("customerId", "1")
.contentType(MediaType.MULTIPART_FORM_DATA)
)
.andExpect(status().isCreated())
.andExpect(header().string(HttpHeaders.LOCATION, "/api/v1/review/1"));
// then
response.andDo(
document("review-createReview",
requestHeaders(
HeaderDocumentation.headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.MULTIPART_FORM_DATA_VALUE)
), requestParts(
partWithName("reviewImages").description("๋ฆฌ๋ทฐ ์ด๋ฏธ์ง ํ์ผ")
), requestParameters(
RequestDocumentation.parameterWithName("rating").description("๋ณ์ "),
RequestDocumentation.parameterWithName("title").description("๋ฆฌ๋ทฐ ์ ๋ชฉ"),
RequestDocumentation.parameterWithName("content").description("๋ฆฌ๋ทฐ ๋ด์ฉ"),
RequestDocumentation.parameterWithName("travelProductId").description("์ฌํ ์ํ ID"),
RequestDocumentation.parameterWithName("customerId").description("๊ณ ๊ฐ(์
๋ก๋) ID")
))
);
}

@PostMapping("/request-part")
public ResponseEntity<Void> createReview(@Valid @RequestPart("reviewCreateRequest") ReviewCreateRequest reviewCreateRequest,
@RequestPart("reviewImages") List<MultipartFile> reviewImages) {
Long reviewId = 1L;
return ResponseEntity.created(URI.create("/api/v1/review/" + reviewId)).build();
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewCreateRequest {
@NotNull
private Integer rating;
@NotBlank
private String title;
@NotBlank
private String content;
@NotNull
private Long travelProductId;
@NotNull
private Long customerId;
}
@Test
void ๋ฆฌ๋ทฐ๋ฅผ_์์ฑํ๋ค_request_part() throws Exception {
String content = objectMapper.writeValueAsString(new ReviewCreateRequest(5, "๋ฆฌ๋ทฐ ์ ๋ชฉ", "๋ฆฌ๋ทฐ ๋ด์ฉ", 1L, 1L));
MockMultipartFile json = new MockMultipartFile("reviewCreateRequest", "", "application/json", content.getBytes());
// when
ResultActions response = mockMvc.perform(
multipart("/api/v1/review/request-part")
.file("reviewImages", testImage1.getBytes())
.file(json)
.contentType("multipart/mixed")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isCreated())
.andExpect(header().string(HttpHeaders.LOCATION, "/api/v1/review/1"));
// then
response.andDo(
document("review-createReview-request-part",
requestHeaders(
HeaderDocumentation.headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.MULTIPART_FORM_DATA_VALUE)
), requestParts(
partWithName("reviewImages").description("๋ฆฌ๋ทฐ ์ด๋ฏธ์ง ํ์ผ"),
partWithName("reviewCreateRequest").description("๋ฆฌ๋ทฐ ์ด๋ฏธ์ง ํ์ผ")
))
);
}

@RequestPart ํ๋ผ๋ฏธํฐ์ ํด๋นํ๋ DTO์ Multipart list ๋ชจ๋ ํํ๋์ง ์๋ ๋ชจ์ต
์ฆ, MultipartFile์ด๋ RequestPart ํ๋ผ๋ฏธํฐ๋ค์ด ์ ๋๋ก ๋ฌธ์ํ๋์ง ์๋ ๋ชจ์ต์ด๋ฏ๋ก, ์ด ํ๋ผ๋ฏธํฐ๋ฅผ ์ฌ์ฉํ์ง ์์ผ๋ฉด์ epages์ ์ฅ์ ์ ๊ฐ์ ธ๊ฐ๊ณ ์ถ๋ค๋ฉด ๊ณ ๋ คํด๋ณผ๋ง ํ ๊ฒ ๊ฐ๋ค.
Swagger์ RestDocs๊ฐ์ ํ์ ์ง์ ํ์์ ๋ถ์ผ์น
๋ฐ๋ก ์์์ ์๊ฐํ @ModelAttribute๋ฅผ ํฌํจํ์ฌ PathVariable ๋ฑ reqeustParameters() ์ ํด๋นํ๋ ํ๋ผ๋ฏธํฐ๋ค์ ํ์ ์ ์ง์ ํ ์ ์๊ณ , ์ค์ง json ํ์ ํ๋ผ๋ฏธํฐ๋ค๋ง requestFields() ํ์ ์ ์ง์ ํ ์ ์๋ค. ์ ๋ ฅ ๋ฟ๋ง ์๋๋ผ ๋ฐํ์์๋ json ํ์์ ๋ฐ์ดํฐ ๊ฐ์ฒด๋ง ํ์ ์ ๋ช ์ํ ์ ์๋ค.
๋จ์ํ ํ์ ์ ํํํ ์ ์๋ค๋ ๊ฒ ๋ํ ๋จ์ ์ด์ง๋ง, ๋ฌธ์ ๋ Swagger์ ํํ๋ ๋๊ฐ ๋ฌธ์ ๋ค. Swagger๋ ๋ชจ๋ ์ ๋ ฅ ๋ฐ ๋ฐํ ๋ฐ์ดํฐ์ ๋ฐ์ดํฐ๊ฐ ๋ช ์๋๊ธฐ ๋๋ฌธ์ RestDocs์์ ๋ช ์ํ์ง ๋ชปํ ๋ฐ์ดํฐ๋ค์ ๊ธฐ๋ณธ๊ฐ์ธ string์ผ๋ก ์ง์ ๋๋ค. ์ด๋ API ์ฌ์ฉ์๋ค์๊ฒ ์คํด๋ฅผ ๋ถ๋ฌ์ผ์ผํฌ ์ ์๋ค๊ณ ์๊ฐํ๋ค.

๋์ ์ ํ
๋ฐ๋ผ์ epages๋ ์์ง ํ๋ก์ ํธ์ ๋์ ํ๊ธฐ ์ด๋ฅด๋ค๊ณ ํ๋จํ์๊ธฐ ๋๋ฌธ์, Swagger๋ฅผ ๋์ ํ๋ ๊ทธ ๋จ์ ์ ๋ณด์ํ๋ ๋ฐฉํฅ์ ์ ํํ์๋ค.
๊ฐ์ ๋์ง ์๋ ํ ์คํธ ์ฝ๋์ธํ ๋ถ์กฑํ ์ ๋ขฐ๋
mockMvc์ andExpect() ๋ฅผ ์ด์ฉํ์ฌ, ์ปจํธ๋กค๋ฌ๋ง๋ค ํ ์คํธ ์ฝ๋๋ฅผ ์ ์ฉํ๋ค.
์ด๋ ธํ ์ด์ ๊ณผ ๋น์ฆ๋์ค ๋ก์ง์ด ์์์ผ๋ก ์ธํ ๊ฐ๋ ์ฑ ๋ฌธ์
๊ณต๋ฐฑ์ ์ต๋ํ ํ์ฉํ๊ณ Controller๋ฅผ ์ธ๋ถํํ์ฌ, ์ด๋ ธํ ์ด์ ์ ์ฃผ์์ ๋๋ก ๋๋ ์ ์๋ ์ ์ผ๋ก ๊ฐ๋ ์ฑ์ ์ ์งํ๊ธฐ ์ํด ๋ ธ๋ ฅํ๋ค. ์๋ ์ฌ์ง์ ReviewController ์ด๊ณ , ๋ฆฌ๋ทฐ์ ์ง์ ์ ์ธ CRUD์ ์์ฒญ๋ง ๋ค์ด์๊ณ , ๋ฆฌ๋ทฐ ๋ถ์๊ฒฐ๊ณผ์ ํด๋นํ๋ ์์ฒญ๋ค์ ReviewTagController ๋ก ๋ถ๋ฆฌํ์๋ค.

์์ธํ๊ณ ์ ๋ณด๋์ด ๋ง์ ๋ฌธ์
Swagger๊ฐ ์ฝ๋๋ฅผ ๊ฐ์งํ์ฌ ์๋์ผ๋ก ๋ฌธ์๋ฅผ ์์ฑํด์ฃผ์ง๋ง, ์ด๋ ธํ ์ด์ ์ ํตํด ์ถ๊ฐ์ ์ธ ์ ๋ณด์ ํ์ํ ๋งํ ์ ๋ณด๋ฅผ ์ต๋ํ ์์ธํ ์ ๊ณ , Swagger์ API Test ๊ธฐ๋ฅ์ ์ ๊ทน ํ์ฉํ๋๋ก ์ธํ ํ ์์ ์ด๋ค. ๋ค์์ ๊ทธ ๊ณผ์ ๊ณผ ๊ณผ์ ์ค์ ์๋ ํธ๋ฌ๋ธ์ํ ์ ๋ถ์ํด๋ณธ ํฌ์คํ ์ด๋ค.
์ค์ ์ฝ๋์ ๋ฌธ์๊ฐ ๋ฌ๋ผ์ง๋ ๋ฌธ์
์ด ๋ฌธ์ ๋ ์ด๋ ธํ ์ด์ ์์ฑํ๋ ๋์ค ์ค์ํ๊ฑฐ๋ ์ปจํธ๋กค๋ฌ๋ฅผ ๋ฆฌํํ ๋งํ๊ณ ๋์ ๋ฐ์ํ์ง ์์์ ๋ฐ์ํ๋๋ฐ, Github Copilot๋ฅผ ์ด์ฉํ์ฌ ๋ง์ด ๋ณด์ํ ์ ์์๋ค.

๋ฆฌํฉํ ๋งํ๊ณ ๋์, ๋ณ๊ฒฝ๋ ์ด๋ ธํ ์ด์ ์ ์ง์ ๋ค๊ฐ ์ฝํ์ผ๋ฟ์ ํตํด ๋ค์ ์์ฑํ๋ฉด 95%์ ๋๋ ์ ์์ ์ผ๋ก ์์ฑ๋์๋ค. 5%์ ๋์ ์ค์ฐจ ์ค์ 3%๋ ๋์ ์ค์ ๋๋ฌธ์ ๋ฐ์ํ ๊ฒ์ด์๋๋ฐ, ์ฝํ์ผ๋ฟ์ ๋ด๊ฐ ์์ฑํ ์ฝ๋๋ฅผ ๋ฐํ์ผ๋ก ์๋์์ฑ์์ผ์ฃผ๊ธฐ ๋๋ฌธ์ ๋ฆฌํด ๊ฐ์ด๋ผ๋๊ฐ ๋งค๊ฐ๋ณ์ ๋ฑ์ ์๋ชป ์์ฑํ ์ฝ๋๊ฐ ์์ ๊ฒฝ์ฐ ์๋์์ฑ๋๋ ์ฝ๋ ๋ํ ์๋ชป๋๋ค.
๋ง์ฝ ๋ด๊ฐ ์์ฑํ API ๋ฌธ์์ ์ ์ฌํ ํํ๋ก ์๋์์ฑ์์ผ ์ฃผ๊ณ ์ถ์ ๊ฒฝ์ฐ, IDE์ ์ฐธ๊ณ ํ ๋ค๋ฅธ ์ฝ๋๋ฅผ ๋์ด๋๋๋ค๋ฉด ์ฝํ์ผ๋ฟ์ด ์ด๋ฏธ ์์ฑ๋ ์ ์ฌํ ์ฝ๋๋ฅผ ์ฐธ๊ณ ํ์ฌ ์๋์์ฑ์์ผ ์ค๋ค. ์๋๋ ์ฐธ๊ณ ํ ์ฝ๋๋ฅผ ์๋์ด ์๋์์ฑ๊ณผ ๋์ด ์๋์์ฑ์ ๋น๊ตํ ๊ฒ์ด๋ค. ์๋๋ @APIResponse ์ @Header ์ด๋ ธํ ์ด์ ์ description ๋ถ๋ถ์ URL ์ด ์ถ๊ฐ ๋์ง ์์์ง๋ง, ์ฐธ๊ณ ํ ์ฝ๋๋ฅผ ๋์ฐ๊ณ ๋์๋ URL๊น์ง ์ถ๊ฐ๋๋ ๋ชจ์ต์ ๋ณผ ์ ์๋ค.


๋ง์น๋ฉฐ
OAS๊ธฐ๋ฐ Swagger๋ผ๋ ์๋ก์ด ๊ธฐ์ ์ ๋์ ํ์ฌ ๊ธฐ์กด์ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ๊ธด ์๊ฐ ๋ ธ๋ ฅํ์์ง๋ง, ๋จ์ ์ ๋ฐ๊ฒฌ์ผ๋ก ๋ค์ ์๋๋๋ก ๋์์ค์ง๋ง ๋จ์ ์ ์ต๋ํ ์์ํ๋ ๋ฐฉํฅ์ผ๋ก ๋ง๋ฌด๋ฆฌ ๋์๋ค. ์ดํ์ ๋ค์ํ Swagger ์ด๋ ธํ ์ด์ ์ ํตํด API ๋ฌธ์์์ฑํ๋ ๊ณผ์ ์์ ์งํํ ๋ค์ํ ํธ๋ฌ๋ธ์ํ ์ ์๋์ ๊ฒ์๊ธ์ ์ด์ด๊ฐ ๋ณผ ์์ ์ด๋ค.
OAS ๋น๋ ๋ฐ Swagger ๋ฐ์ ์๋ํ ๊ทธ๋ ์ด๋ค
ํน์ ๋์ ๋จ์ ๋ถ์์ด ์๋ชป๋์๊ฑฐ๋ ๋จ์ ์ ๊ฐ์ํ๊ณ ๋ epages๋ฅผ ์ฐ๊ณ ์ถ๋ค๋ฉด, OAS ์ถ์ถ ๋ฐ ๋ฐ์ ์๋ํ๋ฅผ ์ํด ์์ฑํ ๋์ ๊ทธ๋ ์ด๋ค ์ฝ๋๋ฅผ ์ฐธ๊ณ ํ๊ธธ ๋ฐ๋๋ค. epageSwagger ํ ์คํฌ๋ฅผ ์คํํ๋ฉด, ๋ณ๊ฒฝ๋ ์ปจํธ๋กค๋ฌ์ ํ ์คํธ๋ฅผ ๋ฐ์ํ์ฌ ๋น๋ํ๊ณ , Swagger์ ๋ฐ์ํ๋ ์๋ํ์ด๋ค. ๊ธฐ์กด์ ๋น๋ํ์ผ์ ์ง์ฐ๊ณ ๋น๋ํ์ง ์์ผ๋ฉด, ์ญ์ ๋ ์ปจํธ๋กค๋ฌ๊ฐ ๋ฐ์๋์ง ์๋ ๋ฌธ์ ๋ฅผ ๋ฐ๊ฒฌํด์ ๋ง๋ค์๋ค.
openapi3 {
servers = [{ url = "https://localhost:8080/" }]
title = "Review Mate API"
description = "Review Mate WAS API"
version = "0.1.0"
format = "yaml"
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.register('clearSwagger') {
mustRunAfter('test')
doLast {
delete("build/")
println "::: 1. Clear 'build/'"
delete("src/main/resources/static/swagger-ui/openapi3.yaml") // ๊ธฐ์กด OAS ํ์ผ ์ญ์
println "::: 2. Delete 'rc/main/resources/static/swagger-ui/openapi3.yaml'"
}
}
tasks.register('runOpenApi3') {
mustRunAfter('clearSwagger')
doLast {
exec {
commandLine './gradlew', 'openapi3'
}
println "::: 3. Run openapi3'"
}
}
tasks.register('copyOasToSwagger') {
mustRunAfter('runOpenApi3')
doLast {
copy {
from "$buildDir/api-spec/openapi3.yaml" // ๋ณต์ ํ OAS ํ์ผ ์ง์
into "src/main/resources/static/swagger-ui/." // ํ์ผ ๋๋ ํ ๋ฆฌ์ ํ์ผ ๋ณต์
}
println "::: 4. Copy OAS file to swagger-ui"
}
}
tasks.register('epagesSwagger') {
doFirst {
println ":: 0. Run ePages Swagger'"
}
dependsOn 'test'
dependsOn 'clearSwagger'
dependsOn 'runOpenApi3'
dependsOn 'copyOasToSwagger'
}
tasks.named('build') {
dependsOn 'copyOasToSwagger'
}
์ฐธ๊ณ ์๋ฃ
https://tech.kakaopay.com/post/openapi-documentation/#13-swagger-file-์์
https://helloworld.kurly.com/blog/spring-rest-docs-guide/
https://luvstudy.tistory.com/186
https://github.com/spring-projects/spring-restdocs/wiki/Spring-REST-Docs-3.0-Release-Notes
https://ykh6242.tistory.com/entry/Spring-Web-MVC-Multipart-์์ฒญ-๋ค๋ฃจ๊ธฐ
https://velog.io/@tmdgh0221/Spring-Rest-Docs-์ ์ฉํด๋ณด๊ธฐ
https://techblog.woowahan.com/2597/
https://discuss.gradle.org/t/what-is-dolast-for/27731
'๐ค Backend > SpringBoot' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ

์ด์ ๋ ธ์ ๋ธ๋ก๊ทธ์ Transaction ์ด๋? (2023.08.11)๋ก๋ถํฐ ๋ง์ด๊ทธ๋ ์ด์ ๋ ๊ธ์ ๋๋ค.
์ ๋ ๋ Swagger์ ์๋ํ์ ๊ฐ๋จํ ์ด๋ ธํ ์ด์ ์ ํตํด ๋น ๋ฅธ API ๋ฌธ์ ๋ฐฐํฌ๋ง ํด๋ดค์ต๋๋ค. ๋น ๋ฅด๊ฒ ์์ ๊ฐ๋ฅํ๋ค๋ ์ฅ์ ์ ์์์ง๋ง, ๋ชจ๋ ์์ฒญ์ Postman์ผ๋ก ํ ์คํธํ๋ ๊ณผ์ ์์ ์ค์๊ฐ ์์ ์ ๋ฐ์ ์์๊ณ Swagger์ ์๋ ์์ฑ ๋ฌธ์๊ฐ ์น์ ํ ํธ์ ์๋๊ธฐ์ ์ฝ๋ ์ฌ๋๋ง๋ค ๋ค๋ฅด๊ฒ ์ดํด๋๋ ๊ฒฝ์ฐ๊ฐ ์์์ต๋๋ค. ๋ฐ๋ผ์ ํ๋ก ํธ์์ API๋ฅผ ์ฐ๊ฒฐํ๋ ๊ณผ์ ์์ ์ค์๊ฐ ์์ฃผ ๋ฐ์ํ๊ฑฐ๋, ์ค๋ฅ๊ฐ ๋ฐ์ํ๋๋ผ๋ ์ ์๊ฒ ์ง์ ๋ฌผ์ด๋ณด๋ ๊ฒฝ์ฐ๊ฐ ๋ง์์ต๋๋ค. API ๋ฌธ์๋ฅผ ์์ฑํ ์์๊ฐ ์ฌ๋ผ์ ธ๋ฒ๋ฆฌ๋ ์ผ์ด ๋ง์์ต๋๋คโฆ ๐
์ด๋ฒ ๋ฆฌ๋ทฐ๋ฉ์ดํธ ํ๋ก์ ํธ์์๋ ์ ํ๋ ์๊ฐ ๋ด์ FE์ AI ๋ชจ๋๊ฐ ์ฌ์ฉํ ์๋ฒ๋ฅผ ๋ง๋ค์ด์ผ ํ๋ฏ๋ก, ์ ๋ขฐ์ฑ์ด ๋๊ณ ๋ช ๋ฃํ API ๋ฌธ์๋ฅผ ๋ง๋ค๊ธฐ ์ํด ๊ณต๋ถํ๊ณ ๊ณ ๋ฏผํ ๊ณผ์ ์ ์ ๋ฆฌํด๋ณด์์ต๋๋ค.
API ๋ฌธ์ํ ๋๊ตฌ ๋น๊ต
Spring ๊ธฐ๋ฐ ์๋ฒ ํ๋ก์ ํธ์์ API ๋ฌธ์ํ๋ ๋๊ฒ Swagger์ Spring RestDocs๋ฅผ ์ฌ์ฉํ๊ณ , ๋ ๊ฐ์ ์ฅ์ ๋ง ๊ฐ์ ธ๊ฐ๊ธฐ ์ํ OpenAPI Specification(OAS)๊ธฐ๋ฐ Swagger๊ฐ ์์ต๋๋ค. (OAS๊ธฐ๋ฐ Swagger๋ epages๋ผ๋ ํ์ฌ๊ฐ ๋ง๋ OAS ์ถ์ถ ๋ฐ Swagger ์ ์ฉ ์คํ์์ค๋ฅผ ํตํด ์ฃผ๋ก ์ฌ์ฉ๋๋ฏ๋ก, ์ดํ epages๋ผ๊ณ ๋ถ๋ฅด๊ฒ ์ต๋๋ค)
Swagger | RestDocs | epages | |||
์ฅ์ | ๋จ์ | ์ฅ์ | ๋จ์ | ์ฅ์ | ๋จ์ |
๊น๋ํ๊ณ ์์ ๋ฌธ์ |
ํ ์คํธ ์ฝ๋๋ฅผ ๊ฐ์ ํ์ง ์์ผ๋ฏ๋ก, ๋์ง ์์ ์ ๋ขฐ๋ | ํ ์คํธ ์ฝ๋ ์์ฑ ๊ฐ์ ๋ก ์ธํ ๋์ ์ ๋ขฐ๋ | ์ฐ์ํ์ง ๋ชปํ ๋ฌธ์ | Swagger์ ์ฐ์ํ ๋ฌธ์ (๊น๋ํ๊ณ ์์๋ฉฐ, API Test ๊ธฐ๋ฅ) |
๋ํ๋จผํธ๊ฐ ์๊ณ , ํธ๋ฌ๋ธ์ํ ์ ์ํ ์ฐธ๊ณ ์๋ฃ๋ ์ ์ |
API Test ๊ธฐ๋ฅ ์ง์ |
Swagger ์ด๋
ธํ
์ด์
์ด ๋น์ฆ๋์ค ์ฝ๋์ ์์ |
๋น์ฆ๋์ค ์ฝ๋์ ๋ฌธ์ํ ์ฝ๋๊ฐ ๋ถ๋ฆฌ๋จ | API Test ๊ธฐ๋ฅ ๋ฏธ์ง์ | ํ
์คํธ ์ฝ๋๋ฅผ ๊ธฐ๋ฐ์ผ๋ก ์์ฑ๋๋ฏ๋ก ๋์ ์ ๋ขฐ๋ |
Swagger์์ RestDocs ์ฐจ์ด๋ก ์ธํด ์๋ชป ์์ฑ๋๋ ๋ฌธ์ |
Swagger
Swagger๋ ๊ต์ฅํ ์ฐ์ํ ๋ฌธ์๊ฐ ํน์ง์ ๋๋ค. ๋ฌธ์๊ฐ ์์๋ฉฐ, ์ ๊ณตํ๋ ์ ๋ณด๋ ๋ํ ๋ง์ต๋๋ค. ํ๋ผ๋ฏธํฐ์ ์ข ๋ฅ์ ์์, response, ๋ฆฌํด ํน์ ์ฒจ๋ถ๋๋ DTO์ ์คํค๋ง ๋ฑ๋ฑ์ด ์ ๊ณต๋๋ฉฐ, ์ฌ์ฉ์๊ฐ ๋ณ๋๋ก ์ด๋ฅผ ๋ช ์ํ์ง ์์ผ๋ฉด ์๋ฒ์ ์ฝ๋๋ฅผ ๋ฐํ์ผ๋ก ์์ฑ๋๋ ํน์ง ๋๋ถ์ ๋จ์ ๋ ธ๋์ ๊ฐ๊น์ด ์ฝ๋ ์์ฑ ์๊ฐ์ ๋จ์ถ์์ผ์ค๋๋ค.
์ค๋ช ๋ง ๋ค์ผ๋ฉด ๋ณ๊ฒ ์๋ ๊ฒ ๊ฐ์ ์ฅ์ ๊ฐ์ง๋ง, RestDocs๋ฅผ ์จ๋ณด๊ณ ๋๋ฉด ๊ทธ ์ฅ์ ์ ์ ์คํ ๋๋ผ๊ฒ ๋ฉ๋๋คโฆ

ํ์ง๋ง ์ด ์ฅ์ ์ ์์์ํฌ ์ ๋๋ก ์น๋ช ์ ์ธ ๋จ์ ์ด๋ผ๋ฉด, API ๋ฌธ์์ ๋ด์ฉ๋ค ์ถ๊ฐ ์์ฑํ๊ธฐ ์ํ ์ด๋ ธํ ์ด์ ๋ค์ด ๋น์ฆ๋์ค ๋ก์ง๊ณผ ์์ธ๋ค๋ ์ ์ ๋๋ค. DTO์ ๊ฒฝ์ฐ์๋ ์ํธํ ํธ์ด์ง๋ง, Controller๋ ์์ ํ ํผ๋์ค๋ฌ์์ง๋๋ค.


RestDocs
์ปจํธ๋กค๋ฌ ํ ์คํธ ์ฝ๋๋ฅผ ๊ธฐ๋ฐ์ผ๋ก API ๋ฌธ์๊ณผ ์๋๋๊ธฐ ๋๋ฌธ์, ์ ๋ขฐ์ฑ ๋์ ์์ฒญ๋ง ๋ฌธ์์ ๊ธฐ์ฌ๋๋ ํน์ง์ด ์์ต๋๋ค. ์ด์ฐ๋ณด๋ฉด ๋ฐ์๋ํ๋ผ๊ณ ๋ณผ ์๋ ์์ ๊ฒ ๊ฐ์ต๋๋ค. RestDocs๋ Asciidoctor ๋ฌธ์๋ฅผ ํตํด API ๋ฌธ์๋ฅผ ์์ฑํ๋๋ฐ, ์ด API ๋ฌธ์๊ฐ ์ฐ์ํ์ง ๋ชปํ๋ค๋ ์ ์ด ํฐ ๋จ์ ์ ๋๋ค. Swagger์ ์ต์ํ ์ ์๊ฒ ์ญ์ฒด๊ฐ์ด ์ฌํด์ ๊ทธ๋ ๊ฒ ๋๋ผ๋ ๊ฒ์ผ์ง ๋ชจ๋ฅด๊ฒ ์ง๋ง, ์์์ง ๋ชปํ ๋ฌธ์๋ ๊ฐ๋ ์ฑ๋ง์ ํค์นฉ๋๋ค. ๊ทธ๋ฆฌ๊ณ ๋ฌธ์์์ ์ ๊ณต๋๋ ์ ๋ณด๋๋ Swagger ๋๋น ์ ์ต๋๋ค. ํน์ ์ ๋ฌธ๋ฒ์ผ๋ก ์ธํ ๋จ์ ์ epages๋ ๋ง์ฐฌ๊ฐ์ง์ด๋ฏ๋ก, ํ์ ํ๊ฒ ์ต๋๋ค.

epages, OpenAPI Specification(OAS) ๊ธฐ๋ฐ API ๋ฌธ์ํ
OpenAPI Specification(OAS)๋ RESTful API ์คํ ๋ฌธ์์ ํ์ค์ผ๋ก์ ํ์ฉ๋๊ณ ์๋ ๊ท์ ์ ๋๋ค. Swagger-UI๋ OAS๋ฅผ ํด์ํ์ฌ API ์คํ์ ์๊ฐํํด์ค๋๋ค. ๋ํ Postman, Paw๊ฐ์ API Client๋ค๋ OAS๋ฅผ ์ง์ํ๊ณ ์์ด ํ์ฉ๋๊ฐ ๋ค์ํฉ๋๋ค.
restdocs-api-spec ์คํ์์ค
์ด ์๋ฆฌ๋ฅผ ์ด์ฉํด์, epages๋ ์ ๊ณตํ๋ **RestDocs Wrapper ํด๋์ค๋ก ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ฉด OAS๋ฅผ ์ถ์ถํด์ค๋๋ค. ๊ทธ๋ฌ๋ฉด static routing์ ํตํด ์ถ์ถ๋ OAS๋ฅผ ๊ธฐ๋ฐ์ผ๋ก swagger ํ์ด์ง๋ฅผ ํธ์คํ **ํ๊ฒ ๋ฉ๋๋ค. ์ฆ, ๋ฌธ์ ์์ฑ์ RestDocs, ๋ฌธ์ ์ ๊ณต์ Swagger๋ฅผ ์ฌ์ฉํ๋ฏ๋ก ๋ ์ฅ์ ๋ง ํํ๋ ๋ฐฉ์์ด๋ฏ๋ก ์ฅ์ ๋ง ์์ ๊ฒ ๊ฐ์๋ณด์์ง๋ง, ์ ๊ฐ ์ ์ฉํ๋ฉด์ ๋ง๋ ๋ฌธ์ ๋ค์ ์๊ฐํด ๋๋ฆฌ๊ฒ ์ต๋๋ค.
epages ํธ๋ฌ๋ธ์ํ
๋ถ์กฑํ ์ฐธ๊ณ ์๋ฃ
epages๋ 2๊ฐ์ง ๋ฐฉ๋ฒ์ผ๋ก ์ฌ์ฉํ ์ ์์ต๋๋ค.
- RestDocs์ ๋ฌธ๋ฒ๊ณผ ์ ์ฌํ์ง๋ง, epages ๋ ์์ ์ธ ResourceSnippetParameters.builder() ํจํด์ ์ฌ์ฉํ API ๋ฌธ์ ์์ฑ
- RestDocs์ ๋๊ฐ์ ๋ฌธ๋ฒ์ผ๋ก ์์ฑ๋ ์ฝ๋์, epages๊ฐ ์ ๊ณตํ๋ RestDocs Wrapper ํด๋์ค๋ก ๊ต์ฒด
1๋ฒ ๋ฐฉ์์ ๋ ์์ ์ธ ๋ฐฉ์์ด๊ธฐ ๋๋ฌธ์ ๊ด๋ จ ์๋ฃ๋ฅผ ์ฐธ๊ณ ํ๋ฉด์ ์ฌ์ฉํด์ผ ํ์ง๋ง, ์ฐธ๊ณ ์๋ฃ๊ฐ ๋ถ์กฑํฉ๋๋ค. ์ฐ์ OAS๊ธฐ๋ฐ API ๋ฌธ์ํ๋ ์ผ๋ฐ ๊ฐ๋ฐ ๋ธ๋ก๊ทธ์์๋ ์ ์๊ฐ๋์ง ์๊ณ ์๊ณ ์นด์นด์ค ํ์ด๋ ์ปฌ๋ฆฌ ๋ฑ์ ์ผ๋ถ ํ ํฌ ๋ธ๋ก๊ทธ์์๋ง ์๊ฐ๋๊ณ ์์ต๋๋ค. ์ฆ, ๋ค์ํ ํ๊ฒฝ์์ ์ ์ฉ์ ์๋ํ ๊ธฐ๋ก์ด๋ ๊ทธ ๊ณผ์ ์์ ๋ฐ์ํ ํธ๋ฌ๋ธ์ ํด๊ฒฐํ ๊ธฐ๋ก์ ๋ํ ์๋ฃ๊ฐ ๊ฑฐ์ ์์ต๋๋ค.(๋ฌผ๋ก ์์ด๋ก ๊ฒ์ํด๋ ๋ง์ฐฌ๊ฐ์ง..) ๊ณต์ ๋ํ๋จผํธ ๋ํ ์กด์ฌํ์ง ์๊ณ , ๋ ํผ์งํ ๋ฆฌ์ README์ ๊ฐ๋จํ setup๊ณผ ์ฌ์ฉ๋ฒ์ ๋ํด์๋ง ์๊ฐํ๊ณ ์์ต๋๋ค.
ํด๋์ค๋ฅผ ๋ฏ์ด๊ฐ๋ฉด์ ์ฌ์ฉํด๊ฐ๋ ค๊ณ ํ์ง๋ง ๋๋ฒ๊น ์ ๋๋ฌด ๋ง์ ์๊ฐ์ด ์๋ชจ๋์๊ธฐ ๋๋ฌธ์, RestDocs ์๋ฃ๋ฅผ ์ฐธ๊ณ ํ ์ ์๋ 2๋ฒ ๋ฐฉ์์ผ๋ก ์๋ํ์์ต๋๋ค.
@ModelAttribute, @RequestParts ๋์
๋ฆฌ๋ทฐ๋ฉ์ดํธ์ ๋ฆฌ๋ทฐ ์ ๋ก๋์ ์ํ ์ถ๊ฐ ์์ฒญ์ DTO์ ์ฌ์ง MultipartFile์ ํ ๋ฒ์ ์ฒ๋ฆฌํฉ๋๋ค. ๋ ํ๋ผ๋ฏธํฐ๋ Content-type ๋ ๊ตฌ๋ถํด์ ๋ณด๋ด์ผํ๋ ๋งํผ ๋ฌธ์ํ๊ฐ ์ค์ํ POST ์์ฒญ์ ๋๋ค๋ง, epages์์๋ ์ ๋๋ก ๋ฌธ์ํ๋์ง๊ฐ ์์์ต๋๋ค.
โ ๏ธ ์ฐ์ epages์ OpenAPI๋ฅผ 2๋ฒ์ ์ผ๋ก ๋ฎ์ถฐ์ผ ํฉ๋๋ค.
- OpenAPI 2.0 โ RestDocs 2.0.X
- OpenAPI 3.0 โ RestDocs 3.0.X
์ ๊ฐ์ด ๋์๋๋๋ฐ, RestDocs 3.0.X ๋ถํฐ๋ @ModelAttribute ์ @RequestParts ํ๋ผ๋ฏธํฐ๋ฅผ ๋์ํ๋ requestParts ์ง์์ด ์ญ์ ๋์์ต๋๋ค. Form parameter์ QueryParameter๋ฅผ ๊ตฌ๋ถํ๊ธฐ ์ํ ๋ณ๊ฒฝ์ฌํญ์ด๋ผ๊ณ ์ ํ์์ง๋ง, mockMvc์ mulipart() ์์ฒญ์ผ๋ก ์ ๋ฌ๋๋ multipartFile ํ๋ผ๋ฏธํฐ๋ ์ด๋ป๊ฒ ๋์ํด์ผํ๋์ง์ ๋ํ ๋ฌธ์๊ฐ ์๊ณ , ๊ตฌ๊ธ๋งํด๋ ์ฐพ์ ์ ์์์ต๋๋ค.
OpenAPI 2.0 ์ด ์ ๋๋ก ๋ฌธ์ํ๋์ง ์๋ ์ ์ ๋ค๋ฃจ๊ณ ์์ง๋ง, OpenAPI 3.0 ์ ์ปดํ์ผ ์กฐ์ฐจ ์๋๋ฏ๋ก, ๋ฒ์ ์ ๋ฎ์ถฐ์ผํฉ๋๋ค.
@ModelAttribute ์ @RequestParts ํ๋ผ๋ฏธํฐ๋ฅผ ๋์ํ๋ requestParts() ๋ ๋ฌธ์ํ๋์ง๊ฐ ์์ต๋๋ค.
// ReviewController
@PostMapping("/")
public ResponseEntity<Void> createReview(@Valid @ModelAttribute ReviewCreateRequest reviewCreateRequest) {
Long reviewId = 1L;
return ResponseEntity.created(URI.create("/api/v1/review/" + reviewId)).build();
}
// ReviewCreateRequest
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewCreateRequest {
@NotNull
private Integer rating;
@NotBlank
private String title;
@NotBlank
private String content;
private List<MultipartFile> reviewImages;
@NotNull
private Long travelProductId;
@NotNull
private Long customerId;
}
@Test
void ๋ฆฌ๋ทฐ๋ฅผ_์์ฑํ๋ค() throws Exception {
// when
ResultActions response = mockMvc.perform(
multipart("/api/v1/review/")
.file("reviewImages", testImage1.getBytes())
.param("rating", "5")
.param("title", "๋ฆฌ๋ทฐ ์ ๋ชฉ")
.param("content", "๋ฆฌ๋ทฐ ๋ด์ฉ")
.param("travelProductId", "1")
.param("customerId", "1")
.contentType(MediaType.MULTIPART_FORM_DATA)
)
.andExpect(status().isCreated())
.andExpect(header().string(HttpHeaders.LOCATION, "/api/v1/review/1"));
// then
response.andDo(
document("review-createReview",
requestHeaders(
HeaderDocumentation.headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.MULTIPART_FORM_DATA_VALUE)
), requestParts(
partWithName("reviewImages").description("๋ฆฌ๋ทฐ ์ด๋ฏธ์ง ํ์ผ")
), requestParameters(
RequestDocumentation.parameterWithName("rating").description("๋ณ์ "),
RequestDocumentation.parameterWithName("title").description("๋ฆฌ๋ทฐ ์ ๋ชฉ"),
RequestDocumentation.parameterWithName("content").description("๋ฆฌ๋ทฐ ๋ด์ฉ"),
RequestDocumentation.parameterWithName("travelProductId").description("์ฌํ ์ํ ID"),
RequestDocumentation.parameterWithName("customerId").description("๊ณ ๊ฐ(์
๋ก๋) ID")
))
);
}

@PostMapping("/request-part")
public ResponseEntity<Void> createReview(@Valid @RequestPart("reviewCreateRequest") ReviewCreateRequest reviewCreateRequest,
@RequestPart("reviewImages") List<MultipartFile> reviewImages) {
Long reviewId = 1L;
return ResponseEntity.created(URI.create("/api/v1/review/" + reviewId)).build();
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class ReviewCreateRequest {
@NotNull
private Integer rating;
@NotBlank
private String title;
@NotBlank
private String content;
@NotNull
private Long travelProductId;
@NotNull
private Long customerId;
}
@Test
void ๋ฆฌ๋ทฐ๋ฅผ_์์ฑํ๋ค_request_part() throws Exception {
String content = objectMapper.writeValueAsString(new ReviewCreateRequest(5, "๋ฆฌ๋ทฐ ์ ๋ชฉ", "๋ฆฌ๋ทฐ ๋ด์ฉ", 1L, 1L));
MockMultipartFile json = new MockMultipartFile("reviewCreateRequest", "", "application/json", content.getBytes());
// when
ResultActions response = mockMvc.perform(
multipart("/api/v1/review/request-part")
.file("reviewImages", testImage1.getBytes())
.file(json)
.contentType("multipart/mixed")
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isCreated())
.andExpect(header().string(HttpHeaders.LOCATION, "/api/v1/review/1"));
// then
response.andDo(
document("review-createReview-request-part",
requestHeaders(
HeaderDocumentation.headerWithName(HttpHeaders.CONTENT_TYPE).description(MediaType.MULTIPART_FORM_DATA_VALUE)
), requestParts(
partWithName("reviewImages").description("๋ฆฌ๋ทฐ ์ด๋ฏธ์ง ํ์ผ"),
partWithName("reviewCreateRequest").description("๋ฆฌ๋ทฐ ์ด๋ฏธ์ง ํ์ผ")
))
);
}

@RequestPart ํ๋ผ๋ฏธํฐ์ ํด๋นํ๋ DTO์ Multipart list ๋ชจ๋ ํํ๋์ง ์๋ ๋ชจ์ต
์ฆ, MultipartFile์ด๋ RequestPart ํ๋ผ๋ฏธํฐ๋ค์ด ์ ๋๋ก ๋ฌธ์ํ๋์ง ์๋ ๋ชจ์ต์ด๋ฏ๋ก, ์ด ํ๋ผ๋ฏธํฐ๋ฅผ ์ฌ์ฉํ์ง ์์ผ๋ฉด์ epages์ ์ฅ์ ์ ๊ฐ์ ธ๊ฐ๊ณ ์ถ๋ค๋ฉด ๊ณ ๋ คํด๋ณผ๋ง ํ ๊ฒ ๊ฐ๋ค.
Swagger์ RestDocs๊ฐ์ ํ์ ์ง์ ํ์์ ๋ถ์ผ์น
๋ฐ๋ก ์์์ ์๊ฐํ @ModelAttribute๋ฅผ ํฌํจํ์ฌ PathVariable ๋ฑ reqeustParameters() ์ ํด๋นํ๋ ํ๋ผ๋ฏธํฐ๋ค์ ํ์ ์ ์ง์ ํ ์ ์๊ณ , ์ค์ง json ํ์ ํ๋ผ๋ฏธํฐ๋ค๋ง requestFields() ํ์ ์ ์ง์ ํ ์ ์๋ค. ์ ๋ ฅ ๋ฟ๋ง ์๋๋ผ ๋ฐํ์์๋ json ํ์์ ๋ฐ์ดํฐ ๊ฐ์ฒด๋ง ํ์ ์ ๋ช ์ํ ์ ์๋ค.
๋จ์ํ ํ์ ์ ํํํ ์ ์๋ค๋ ๊ฒ ๋ํ ๋จ์ ์ด์ง๋ง, ๋ฌธ์ ๋ Swagger์ ํํ๋ ๋๊ฐ ๋ฌธ์ ๋ค. Swagger๋ ๋ชจ๋ ์ ๋ ฅ ๋ฐ ๋ฐํ ๋ฐ์ดํฐ์ ๋ฐ์ดํฐ๊ฐ ๋ช ์๋๊ธฐ ๋๋ฌธ์ RestDocs์์ ๋ช ์ํ์ง ๋ชปํ ๋ฐ์ดํฐ๋ค์ ๊ธฐ๋ณธ๊ฐ์ธ string์ผ๋ก ์ง์ ๋๋ค. ์ด๋ API ์ฌ์ฉ์๋ค์๊ฒ ์คํด๋ฅผ ๋ถ๋ฌ์ผ์ผํฌ ์ ์๋ค๊ณ ์๊ฐํ๋ค.

๋์ ์ ํ
๋ฐ๋ผ์ epages๋ ์์ง ํ๋ก์ ํธ์ ๋์ ํ๊ธฐ ์ด๋ฅด๋ค๊ณ ํ๋จํ์๊ธฐ ๋๋ฌธ์, Swagger๋ฅผ ๋์ ํ๋ ๊ทธ ๋จ์ ์ ๋ณด์ํ๋ ๋ฐฉํฅ์ ์ ํํ์๋ค.
๊ฐ์ ๋์ง ์๋ ํ ์คํธ ์ฝ๋์ธํ ๋ถ์กฑํ ์ ๋ขฐ๋
mockMvc์ andExpect() ๋ฅผ ์ด์ฉํ์ฌ, ์ปจํธ๋กค๋ฌ๋ง๋ค ํ ์คํธ ์ฝ๋๋ฅผ ์ ์ฉํ๋ค.
์ด๋ ธํ ์ด์ ๊ณผ ๋น์ฆ๋์ค ๋ก์ง์ด ์์์ผ๋ก ์ธํ ๊ฐ๋ ์ฑ ๋ฌธ์
๊ณต๋ฐฑ์ ์ต๋ํ ํ์ฉํ๊ณ Controller๋ฅผ ์ธ๋ถํํ์ฌ, ์ด๋ ธํ ์ด์ ์ ์ฃผ์์ ๋๋ก ๋๋ ์ ์๋ ์ ์ผ๋ก ๊ฐ๋ ์ฑ์ ์ ์งํ๊ธฐ ์ํด ๋ ธ๋ ฅํ๋ค. ์๋ ์ฌ์ง์ ReviewController ์ด๊ณ , ๋ฆฌ๋ทฐ์ ์ง์ ์ ์ธ CRUD์ ์์ฒญ๋ง ๋ค์ด์๊ณ , ๋ฆฌ๋ทฐ ๋ถ์๊ฒฐ๊ณผ์ ํด๋นํ๋ ์์ฒญ๋ค์ ReviewTagController ๋ก ๋ถ๋ฆฌํ์๋ค.

์์ธํ๊ณ ์ ๋ณด๋์ด ๋ง์ ๋ฌธ์
Swagger๊ฐ ์ฝ๋๋ฅผ ๊ฐ์งํ์ฌ ์๋์ผ๋ก ๋ฌธ์๋ฅผ ์์ฑํด์ฃผ์ง๋ง, ์ด๋ ธํ ์ด์ ์ ํตํด ์ถ๊ฐ์ ์ธ ์ ๋ณด์ ํ์ํ ๋งํ ์ ๋ณด๋ฅผ ์ต๋ํ ์์ธํ ์ ๊ณ , Swagger์ API Test ๊ธฐ๋ฅ์ ์ ๊ทน ํ์ฉํ๋๋ก ์ธํ ํ ์์ ์ด๋ค. ๋ค์์ ๊ทธ ๊ณผ์ ๊ณผ ๊ณผ์ ์ค์ ์๋ ํธ๋ฌ๋ธ์ํ ์ ๋ถ์ํด๋ณธ ํฌ์คํ ์ด๋ค.
์ค์ ์ฝ๋์ ๋ฌธ์๊ฐ ๋ฌ๋ผ์ง๋ ๋ฌธ์
์ด ๋ฌธ์ ๋ ์ด๋ ธํ ์ด์ ์์ฑํ๋ ๋์ค ์ค์ํ๊ฑฐ๋ ์ปจํธ๋กค๋ฌ๋ฅผ ๋ฆฌํํ ๋งํ๊ณ ๋์ ๋ฐ์ํ์ง ์์์ ๋ฐ์ํ๋๋ฐ, Github Copilot๋ฅผ ์ด์ฉํ์ฌ ๋ง์ด ๋ณด์ํ ์ ์์๋ค.

๋ฆฌํฉํ ๋งํ๊ณ ๋์, ๋ณ๊ฒฝ๋ ์ด๋ ธํ ์ด์ ์ ์ง์ ๋ค๊ฐ ์ฝํ์ผ๋ฟ์ ํตํด ๋ค์ ์์ฑํ๋ฉด 95%์ ๋๋ ์ ์์ ์ผ๋ก ์์ฑ๋์๋ค. 5%์ ๋์ ์ค์ฐจ ์ค์ 3%๋ ๋์ ์ค์ ๋๋ฌธ์ ๋ฐ์ํ ๊ฒ์ด์๋๋ฐ, ์ฝํ์ผ๋ฟ์ ๋ด๊ฐ ์์ฑํ ์ฝ๋๋ฅผ ๋ฐํ์ผ๋ก ์๋์์ฑ์์ผ์ฃผ๊ธฐ ๋๋ฌธ์ ๋ฆฌํด ๊ฐ์ด๋ผ๋๊ฐ ๋งค๊ฐ๋ณ์ ๋ฑ์ ์๋ชป ์์ฑํ ์ฝ๋๊ฐ ์์ ๊ฒฝ์ฐ ์๋์์ฑ๋๋ ์ฝ๋ ๋ํ ์๋ชป๋๋ค.
๋ง์ฝ ๋ด๊ฐ ์์ฑํ API ๋ฌธ์์ ์ ์ฌํ ํํ๋ก ์๋์์ฑ์์ผ ์ฃผ๊ณ ์ถ์ ๊ฒฝ์ฐ, IDE์ ์ฐธ๊ณ ํ ๋ค๋ฅธ ์ฝ๋๋ฅผ ๋์ด๋๋๋ค๋ฉด ์ฝํ์ผ๋ฟ์ด ์ด๋ฏธ ์์ฑ๋ ์ ์ฌํ ์ฝ๋๋ฅผ ์ฐธ๊ณ ํ์ฌ ์๋์์ฑ์์ผ ์ค๋ค. ์๋๋ ์ฐธ๊ณ ํ ์ฝ๋๋ฅผ ์๋์ด ์๋์์ฑ๊ณผ ๋์ด ์๋์์ฑ์ ๋น๊ตํ ๊ฒ์ด๋ค. ์๋๋ @APIResponse ์ @Header ์ด๋ ธํ ์ด์ ์ description ๋ถ๋ถ์ URL ์ด ์ถ๊ฐ ๋์ง ์์์ง๋ง, ์ฐธ๊ณ ํ ์ฝ๋๋ฅผ ๋์ฐ๊ณ ๋์๋ URL๊น์ง ์ถ๊ฐ๋๋ ๋ชจ์ต์ ๋ณผ ์ ์๋ค.


๋ง์น๋ฉฐ
OAS๊ธฐ๋ฐ Swagger๋ผ๋ ์๋ก์ด ๊ธฐ์ ์ ๋์ ํ์ฌ ๊ธฐ์กด์ ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ๊ธด ์๊ฐ ๋ ธ๋ ฅํ์์ง๋ง, ๋จ์ ์ ๋ฐ๊ฒฌ์ผ๋ก ๋ค์ ์๋๋๋ก ๋์์ค์ง๋ง ๋จ์ ์ ์ต๋ํ ์์ํ๋ ๋ฐฉํฅ์ผ๋ก ๋ง๋ฌด๋ฆฌ ๋์๋ค. ์ดํ์ ๋ค์ํ Swagger ์ด๋ ธํ ์ด์ ์ ํตํด API ๋ฌธ์์์ฑํ๋ ๊ณผ์ ์์ ์งํํ ๋ค์ํ ํธ๋ฌ๋ธ์ํ ์ ์๋์ ๊ฒ์๊ธ์ ์ด์ด๊ฐ ๋ณผ ์์ ์ด๋ค.
OAS ๋น๋ ๋ฐ Swagger ๋ฐ์ ์๋ํ ๊ทธ๋ ์ด๋ค
ํน์ ๋์ ๋จ์ ๋ถ์์ด ์๋ชป๋์๊ฑฐ๋ ๋จ์ ์ ๊ฐ์ํ๊ณ ๋ epages๋ฅผ ์ฐ๊ณ ์ถ๋ค๋ฉด, OAS ์ถ์ถ ๋ฐ ๋ฐ์ ์๋ํ๋ฅผ ์ํด ์์ฑํ ๋์ ๊ทธ๋ ์ด๋ค ์ฝ๋๋ฅผ ์ฐธ๊ณ ํ๊ธธ ๋ฐ๋๋ค. epageSwagger ํ ์คํฌ๋ฅผ ์คํํ๋ฉด, ๋ณ๊ฒฝ๋ ์ปจํธ๋กค๋ฌ์ ํ ์คํธ๋ฅผ ๋ฐ์ํ์ฌ ๋น๋ํ๊ณ , Swagger์ ๋ฐ์ํ๋ ์๋ํ์ด๋ค. ๊ธฐ์กด์ ๋น๋ํ์ผ์ ์ง์ฐ๊ณ ๋น๋ํ์ง ์์ผ๋ฉด, ์ญ์ ๋ ์ปจํธ๋กค๋ฌ๊ฐ ๋ฐ์๋์ง ์๋ ๋ฌธ์ ๋ฅผ ๋ฐ๊ฒฌํด์ ๋ง๋ค์๋ค.
openapi3 {
servers = [{ url = "https://localhost:8080/" }]
title = "Review Mate API"
description = "Review Mate WAS API"
version = "0.1.0"
format = "yaml"
}
tasks.named('test') {
useJUnitPlatform()
}
tasks.register('clearSwagger') {
mustRunAfter('test')
doLast {
delete("build/")
println "::: 1. Clear 'build/'"
delete("src/main/resources/static/swagger-ui/openapi3.yaml") // ๊ธฐ์กด OAS ํ์ผ ์ญ์
println "::: 2. Delete 'rc/main/resources/static/swagger-ui/openapi3.yaml'"
}
}
tasks.register('runOpenApi3') {
mustRunAfter('clearSwagger')
doLast {
exec {
commandLine './gradlew', 'openapi3'
}
println "::: 3. Run openapi3'"
}
}
tasks.register('copyOasToSwagger') {
mustRunAfter('runOpenApi3')
doLast {
copy {
from "$buildDir/api-spec/openapi3.yaml" // ๋ณต์ ํ OAS ํ์ผ ์ง์
into "src/main/resources/static/swagger-ui/." // ํ์ผ ๋๋ ํ ๋ฆฌ์ ํ์ผ ๋ณต์
}
println "::: 4. Copy OAS file to swagger-ui"
}
}
tasks.register('epagesSwagger') {
doFirst {
println ":: 0. Run ePages Swagger'"
}
dependsOn 'test'
dependsOn 'clearSwagger'
dependsOn 'runOpenApi3'
dependsOn 'copyOasToSwagger'
}
tasks.named('build') {
dependsOn 'copyOasToSwagger'
}
์ฐธ๊ณ ์๋ฃ
https://tech.kakaopay.com/post/openapi-documentation/#13-swagger-file-์์
https://helloworld.kurly.com/blog/spring-rest-docs-guide/
https://luvstudy.tistory.com/186
https://github.com/spring-projects/spring-restdocs/wiki/Spring-REST-Docs-3.0-Release-Notes
https://ykh6242.tistory.com/entry/Spring-Web-MVC-Multipart-์์ฒญ-๋ค๋ฃจ๊ธฐ
https://velog.io/@tmdgh0221/Spring-Rest-Docs-์ ์ฉํด๋ณด๊ธฐ
https://techblog.woowahan.com/2597/
https://discuss.gradle.org/t/what-is-dolast-for/27731