๐Ÿ› ๏ธ Controller ๋ ˆ์ด์–ด์™€ ๋ฌธ์„œํ™” (Swagger / OpenAPI) ์–ด๋…ธํ…Œ์ด์…˜ ๋ถ„๋ฆฌ ์ „๋žต

๋ฐ•์ค€ํ˜•ยท2025๋…„ 6์›” 2์ผ

์Šคํ”„๋ง ๊ฐœ๋ฐœ

๋ชฉ๋ก ๋ณด๊ธฐ
9/20
post-thumbnail

์ด๋ฒˆ ๊ฒŒ์‹œ๊ธ€์—์„  "Controller ๋ ˆ์ด์–ด์™€ ๋ฌธ์„œํ™” (Swagger / OpenAPI) ์–ด๋…ธํ…Œ์ด์…˜ ๋ถ„๋ฆฌ ์ „๋žต"์— ๋Œ€ํ•ด์„œ ๋งํ•˜๋ ค๊ณ  ํ•œ๋‹ค.

โœ… ์™œ Interface๋กœ ๋”ฐ๋กœ ๋ถ„๋ฆฌ๋ฅผ ์„ ํƒํ–ˆ๋‚˜

1๏ธโƒฃ ์ฝ”๋“œ ๊ฐ€๋…์„ฑ/์ฑ…์ž„ ๋ถ„๋ฆฌ

์ปจํŠธ๋กค๋Ÿฌ ํŒŒ์ผ์— @Operation, @Parameter, @ApiResponses ๋“ฑ Swagger ๋ฌธ์„œ์šฉ ์–ด๋…ธํ…Œ์ด์…˜์ด ๋งŽ์•„์ง€๋ฉด,
๐Ÿ‘‰ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ์ฝ”๋“œ๋ณด๋‹ค ๋ฌธ์„œ์šฉ ์–ด๋…ธํ…Œ์ด์…˜ ์ฝ”๋“œ๊ฐ€ ๋” ๋งŽ์•„์ง โ†’ ๊ฐ€๋…์„ฑ์ด ๋–จ์–ด์ง

์ธํ„ฐํŽ˜์ด์Šค๋กœ ๋ฌธ์„œํ™”๋ฅผ ๋”ฐ๋กœ ๋ถ„๋ฆฌํ•œ๋‹ค๋ฉด???

์ปจํŠธ๋กค๋Ÿฌ ํด๋ž˜์Šค๋Š” โ†’ ๋น„์ฆˆ๋‹ˆ์Šค ์ฒ˜๋ฆฌ์™€ ๋ผ์šฐํŒ… ๋‹ด๋‹น
์ธํ„ฐํŽ˜์ด์Šค ํŒŒ์ผ์€ โ†’ API ๋ช…์„ธ ๋‹ด๋‹น (Swagger์šฉ ์–ด๋…ธํ…Œ์ด์…˜๋งŒ ๋ชจ์Œ)

2๏ธโƒฃ ์žฌ์‚ฌ์šฉ์„ฑ

๊ฐ™์€ API ๋ช…์„ธ๋ฅผ ์—ฌ๋Ÿฌ ์ปจํŠธ๋กค๋Ÿฌ์—์„œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅ (ex: BaseApi, CommonApi, AdminApi ๋“ฑ)

ํ…Œ์ŠคํŠธ์—์„œ๋„ interface ๊ธฐ๋ฐ˜์œผ๋กœ ๋ฌธ์„œ ์ž๋™ํ™”๋ฅผ ์‰ฝ๊ฒŒ ํ•  ์ˆ˜ ์žˆ๋‹ค.

3๏ธโƒฃ Swagger ๋ฌธ์„œ ๋ฒ„์ „ ๊ด€๋ฆฌ/์œ ์ง€๋ณด์ˆ˜ ํŽธ๋ฆฌ

API Spec ๋ณ€๊ฒฝ์‹œ โ†’ Interface ํŒŒ์ผ๋งŒ ์ˆ˜์ •ํ•˜๋ฉด ๋จ
Controller ์ฝ”๋“œ์—๋Š” ์˜ํ–ฅ์ด ์ตœ์†Œํ™”๋˜์–ด ์œ ์ง€๋ณด์ˆ˜์— ์ข‹๋‹ค!!



โœ… ๊ตฌ์„ฑ ํŒจํ„ด - V1

1๏ธโƒฃ Interface ์ •์˜ (Api ๋ช…์„ธ ํŒŒ์ผ)

@SecurityRequirement(name = "bearerAuth")
@Tag(name = "Answer", description = "๋‹ต๋ณ€ ๊ด€๋ฆฌ API")
public interface AnswerApi {

    @Operation(summary = "๋‹ต๋ณ€ ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ๋‹ต๋ณ€์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.")
    @PostMapping
    ResponseEntity<AnswerDetailResponse> createAnswer(...);

    @Operation(summary = "๋‹ต๋ณ€ ์‚ญ์ œ", description = "๋‹ต๋ณ€์„ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค.")
    @DeleteMapping("/{answerId}")
    ResponseEntity<Void> deleteAnswer(@PathVariable Long answerId);

    // ๊ธฐํƒ€ API...
}

๐Ÿ‘‰ ๊ตฌํ˜„์€ ์—†์Œ, API ์‹œ๊ทธ๋‹ˆ์ฒ˜ + ๋ฌธ์„œํ™” ์–ด๋…ธํ…Œ์ด์…˜๋งŒ ์กด์žฌ

2๏ธโƒฃ Controller ์—์„œ ๊ตฌํ˜„

@RestController
@RequestMapping("/api/answers")
@RequiredArgsConstructor
public class AnswerController implements AnswerApi {

    private final AnswerService answerService;

    @Override
    public ResponseEntity<AnswerDetailResponse> createAnswer(...) {
        // ์„œ๋น„์Šค ํ˜ธ์ถœ
    }

    @Override
    public ResponseEntity<Void> deleteAnswer(Long answerId) {
        // ์„œ๋น„์Šค ํ˜ธ์ถœ
    }
}

๐Ÿ‘‰ implements AnswerApi โ†’ ์ธํ„ฐํŽ˜์ด์Šค์—์„œ ์ •์˜ํ•œ ๋ฉ”์„œ๋“œ๋ฅผ ์˜ค๋ฒ„๋ผ์ด๋“œ
๐Ÿ‘‰ @RestController โ†’ ์‹ค์งˆ์ ์ธ ์š”์ฒญ ์ฒ˜๋ฆฌ ๋‹ด๋‹น

โœ… ์žฅ์  ์ •๋ฆฌ

๊ธฐ์กด ๋ฐฉ์‹ (@Operation ์ง์ ‘ ์ปจํŠธ๋กค๋Ÿฌ์— ์ž‘์„ฑ)Interface ๋ถ„๋ฆฌ ๋ฐฉ์‹
์ปจํŠธ๋กค๋Ÿฌ ํŒŒ์ผ ๋ณต์žกํ•ด์ง์ปจํŠธ๋กค๋Ÿฌ๋Š” ๋กœ์ง๋งŒ ๋‹ด๋‹น, ๋ฌธ์„œ๋Š” ๋”ฐ๋กœ ๊ด€๋ฆฌ
๋ฌธ์„œ ๋ณ€๊ฒฝ ์‹œ Controller๋„ ์ˆ˜์ • ํ•„์š”Interface๋งŒ ์ˆ˜์ •
ํ…Œ์ŠคํŠธ / ๋ฆฌํŒฉํ† ๋ง ์‹œ ์˜ํ–ฅ ์žˆ์Œ์ฑ…์ž„ ๋ถ„๋ฆฌ๋กœ ์•ˆ์ •์ 
๋ฌธ์„œ์™€ ๋กœ์ง์ด ์„ž์—ฌ ์žˆ์Œ๋ฆฌ๋ทฐ ์‹œ ๋กœ์ง๊ณผ ๋ฌธ์„œ ๋ถ„๋ฆฌ๋กœ ํŽธ์˜์„ฑ โ†‘


โœ… ์ถ”์ฒœ ์ตœ์ ํ™” ํŒจํ„ด - V2

1๏ธโƒฃ BaseApi ์ž‘์„ฑ (๊ณตํ†ตํ™”)

@SecurityRequirement(name = "bearerAuth")
@Parameter(
    in = ParameterIn.HEADER,
    name = "Authorization", required = true,
    schema = @Schema(type = "string"),
    description = "Bearer [Access ํ† ํฐ]"
)
public interface BaseApi {
}

๐Ÿ‘‰ ๋Œ€๋ถ€๋ถ„ ์„œ๋น„์Šค์—์„œ๋Š” ๋กœ๊ทธ์ธ์„ ํ†ตํ•œ ์ธ์ฆ / ์ธ๊ฐ€๊ฐ€ ์žˆ๊ธฐ ๋•Œ๋ฌธ์— ๊ณตํ†ต๋œ ํ† ํฐ์ธ์ฆ๊ณผ ๊ด€๋ จ๋œ ๋ถ€๋ถ„์„ ์œ„์™€ ๊ฐ™์ด BaseApi๋กœ ๋งŒ๋“ค์–ด๋‘”๋‹ค.
๊ทธ๋Ÿฌ๋ฉด ํ•˜์œ„ Api์—์„œ ๋ฐ˜๋ณต ์ž‘์„ฑํ–ˆ๋˜ Authorization ํ—ค๋”/๋ณด์•ˆ ์„ค์ • ์ œ๊ฑฐ ๊ฐ€๋Šฅ!

2๏ธโƒฃ ์ตœ์ ํ™”๋œ AnswerApi ์˜ˆ์‹œ

@Tag(name = "Answer", description = "๋‹ต๋ณ€ ๊ด€๋ฆฌ API")
public interface AnswerApi extends BaseApi {

    @Operation(summary = "๋‹ต๋ณ€ ์ƒ์„ฑ", description = "์ƒˆ๋กœ์šด ๋‹ต๋ณ€์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.")
    @ApiResponse(responseCode = "201", description = "๋‹ต๋ณ€ ์ƒ์„ฑ ์„ฑ๊ณต",
            content = @Content(mediaType = "application/json",
                    schema = @Schema(implementation = AnswerDetailResponse.class)))
    @PostMapping(value = "/members/{memberId}/answers", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    ResponseEntity<ApiResponse<AnswerDetailResponse>> createAnswer(
            @PathVariable Long memberId,
            @RequestPart(value = "imageFiles") MultipartFile imageFile,
            @RequestPart(name = "request") @Valid AnswerCreateRequest request);

    @Operation(summary = "๋‹ต๋ณ€ ์กฐํšŒ", description = "๋ชจ๋“  ๋‹ต๋ณ€์„ ํŽ˜์ด์ง€๋„ค์ด์…˜์œผ๋กœ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.")
    @ApiResponse(responseCode = "200", description = "๋‹ต๋ณ€ ์กฐํšŒ ์„ฑ๊ณต",
            content = @Content(mediaType = "application/json",
                    schema = @Schema(implementation = Page.class)))
    @GetMapping("/members/{memberId}/answers")
    ResponseEntity<ApiResponse<Page<AnswerDetailResponse>>> getAllAnswers(
            @PathVariable Long memberId,
            @RequestParam(required = false) Long category,
            Pageable pageable);
            
    ...
}

โœ… ์™œ @ApiResponse ์ž‘์„ฑํ•˜๋Š”๊ฐ€?

  • Swagger ๋ฌธ์„œ์—์„œ ์‘๋‹ต ๊ตฌ์กฐ ๋ฏธ๋ฆฌ ํ™•์ธ ๊ฐ€๋Šฅ
  • ํ”„๋ก ํŠธ / ์•ฑ ๊ฐœ๋ฐœ์ž๊ฐ€ ์‘๋‹ต ๊ตฌ์กฐ ๋ณด๊ณ  ๊ฐœ๋ฐœ ๊ฐ€๋Šฅ
  • ์—๋Ÿฌ ์ผ€์ด์Šค๋„ ๊ณตํ†ต ๋ฌธ์„œํ™” ๊ฐ€๋Šฅ (400, 401, 500 ๋“ฑ)
  • ์œ ์ง€๋ณด์ˆ˜ ์‹œ โ†’ API ๋ช…์„ธ ๊ธฐ๋ฐ˜ ์ž๋™ ๊ฒ€์ฆ ๊ฐ€๋Šฅ

โœ… ์—๋Ÿฌ ์ผ€์ด์Šค ๋ฌธ์„œํ™” ์˜ˆ์‹œ

@ApiResponses(value = {
    @ApiResponse(responseCode = "201", description = "๋‹ต๋ณ€ ์ƒ์„ฑ ์„ฑ๊ณต",
            content = @Content(mediaType = "application/json",
                    schema = @Schema(implementation = AnswerDetailResponse.class))),
    @ApiResponse(responseCode = "400", description = "์ž˜๋ชป๋œ ์š”์ฒญ",
            content = @Content(mediaType = "application/json",
                    schema = @Schema(implementation = ApiResponse.class))),
    @ApiResponse(responseCode = "401", description = "์ธ์ฆ ์‹คํŒจ"),
    @ApiResponse(responseCode = "500", description = "์„œ๋ฒ„ ์˜ค๋ฅ˜")
})

3๏ธโƒฃ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ

@AutoConfigureMockMvc
@SpringBootTest
@AutoConfigureRestDocs
class AnswerControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("๋‹ต๋ณ€ ์ƒ์„ฑ API ํ…Œ์ŠคํŠธ")
    void createAnswerTest() throws Exception {
        MockMultipartFile file = new MockMultipartFile("imageFiles", "test.png", "image/png", "test".getBytes());
        MockMultipartFile request = new MockMultipartFile("request", "", "application/json",
                "{\"title\":\"ํ…Œ์ŠคํŠธ ์ œ๋ชฉ\", \"content\":\"ํ…Œ์ŠคํŠธ ๋‚ด์šฉ\"}".getBytes());

        mockMvc.perform(multipart("/api/answers")
                        .file(file)
                        .file(request)
                        .header("Authorization", "Bearer dummy_token"))
                .andExpect(status().isCreated())
                .andDo(document("answer-create",
                        requestHeaders(
                                headerWithName("Authorization").description("Access Token")
                        ),
                        requestParts(
                                partWithName("imageFiles").description("์—…๋กœ๋“œ ํŒŒ์ผ"),
                                partWithName("request").description("๋‹ต๋ณ€ ์ƒ์„ฑ ์š”์ฒญ ๋ฐ์ดํ„ฐ")
                        ),
                        responseFields(
                                fieldWithPath("id").description("์ƒ์„ฑ๋œ ๋‹ต๋ณ€ ID"),
                                fieldWithPath("title").description("๋‹ต๋ณ€ ์ œ๋ชฉ"),
                                fieldWithPath("content").description("๋‹ต๋ณ€ ๋‚ด์šฉ")
                        )
                ));
    }
}

์œ„์™€ ๊ฐ™์ด Mock์„ ์‚ฌ์šฉํ•˜์—ฌ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•  ์ˆ˜ ์žˆ๋‹ค.
๊ฐœ๋ฐœ์„ ์ง„ํ–‰ํ•˜๊ณ  ํ•™์Šต์„ ์ง„ํ–‰ํ•  ์ˆ˜๋ก ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์˜ ์ค‘์š”์„ฑ์— ๋Œ€ํ•˜์—ฌ ๋А๋ผ๊ณ  ์žˆ๋‹ค.
์•ž์œผ๋กœ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์— ๋Œ€ํ•œ ํ•™์Šต์„ ์ง„ํ–‰ ํ›„ ์ปจํŠธ๋กค๋Ÿฌ ๊ธฐ๋ฐ˜, ๋ฉ”์†Œ๋“œ ๊ธฐ๋ฐ˜ ๋“ฑ ์„ธ๋ถ€ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ ์ž‘์„ฑ์„ ์ ์šฉํ•ด ๋ณผ ๊ฒƒ์ด๋‹ค.



์ตœ์ข… ๊ฒฐ๋ก 

์ปจํŠธ๋กค๋Ÿฌ์™€ ๋ฌธ์„œํ™” ์„ค๊ณ„ ์ตœ์ ํ™” ๊ตฌ์กฐ๋Š”

  • BaseApi๋กœ ๊ณตํ†ต ์–ด๋…ธํ…Œ์ด์…˜ ํ†ต์ผ
  • @ApiResponse โ†’ ์„ฑ๊ณต/์—๋Ÿฌ ์‘๋‹ต ๋ช…์„ธ ์ž‘์„ฑ
  • HTTP ๋ฉ”์„œ๋“œ๋ณ„ ์• ๋…ธํ…Œ์ด์…˜ ๋ช…ํ™• ์‚ฌ์šฉ
  • DTO์— @Valid ์ ์šฉ โ†’ ์ž…๋ ฅ๊ฐ’ ๊ฒ€์ฆ ๊ฐ€๋Šฅ

๐Ÿ‘‰ "๋ฌธ์„œํ™”์™€ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง ๋ถ„๋ฆฌ" ์ „๋žต์€ ์œ ์ง€๋ณด์ˆ˜์™€ ํŒ€ ํ˜‘์—…์—๋„ ํฐ ๋„์›€์ด ๋ ๊ฑฐ๋ผ ์ƒ๊ฐํ•˜๋ฉฐ ์ฝ”๋“œ ๊ฐ€๋…์„ฑ, ์œ ์ง€๋ณด์ˆ˜์„ฑ, ํ…Œ์ŠคํŠธ ์šฉ์ด์„ฑ ๋ชจ๋‘์— ๋„์›€์ด ๋˜๋ฏ€๋กœ ๋‹ค์Œ ํ”„๋กœ์ ํŠธ์—์„œ ์ง์ ‘ ์ ์šฉํ•ด๋ณผ ๊ณ„ํš์ด๋‹ค!!

profile
๋งค์ผ ๋งค์ผ ์„ฑ์žฅํ•˜๊ธฐ

0๊ฐœ์˜ ๋Œ“๊ธ€