๋ณธ๋ฌธ ๋ฐ”๋กœ๊ฐ€๊ธฐ
๐Ÿ’ป ํ”„๋กœ์ ํŠธ/๐Ÿ€ํ–‰์šด์˜ ๋กœ๋˜ ๋งˆ๋ฒ•์ง„๐Ÿ›ธ

[Spring, React] ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ•˜๊ณ  ์‹คํŒจ๊ฐ€ ๋œจ๋Š” ์›์ธ์„ ์ฐพ์•„ ์ˆ˜์ •ํ•ด๋ณด์ž.

by hyeong._.ing 2026. 5. 21.

 

 

์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์—๋Ÿฌ๋ฅผ ์ œ๋Œ€๋กœ ์ฒ˜๋ฆฌํ•˜๋Š”์ง€
์•Œ์•„๋ณด๊ธฐ ์œ„ํ•ด ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ๋‹ค.
๊ทธ๋Ÿฐ๋ฐ ์ž๊พธ๋งŒ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํŒจํ•œ๋‹ค.
์™œ๊ทธ๋Ÿฐ์ง€ ๋ถ„์„ํ•˜๊ณ  ๊ณ ์ณ๋ณด์ž!

 

 

 

 

 

https://lotto-magic-frontend.vercel.app/

 

๋กœ๋˜ ๋ฒˆํ˜ธ ์ƒ์„ฑ ๋งˆ๋ฒ•์ง„

ํ–‰์šด ์š”์†Œ 3๊ฐœ๋ฅผ ์„ ํƒํ•˜๋ฉด ์˜ค๋Š˜์˜ ๋กœ๋˜ ๋ฒˆํ˜ธ์™€ ํ–‰์šด ์ ์ˆ˜๋ฅผ ๋งŒ๋“ค์–ด์ฃผ๋Š” ์‚ฌ์ดํŠธ์ž…๋‹ˆ๋‹ค.

lotto-magic-frontend.vercel.app

 

 

 

 

 

 

 

1. ์˜ค๋ฅ˜ ๋ถ„์„

  • LottoControllerErrorTest.java
    ์—๋Ÿฌ๋ฅผ ์ œ๋Œ€๋กœ ์ฒ˜๋ฆฌํ•˜๋Š”์ง€ ๋ณด๋Š” ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค
@WebMvcTest(LottoController.class)
@Import(GlobalExceptionHandler.class)
class LottoControllerErrorTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private LottoService lottoService;

    @Test
    @DisplayName("์„ ํƒ ์š”์†Œ๊ฐ€ 3๊ฐœ๊ฐ€ ์•„๋‹ˆ๋ฉด 400 ์—๋Ÿฌ ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค")
    void drawReturnsBadRequestWhenSelectedOptionsAreNotThree() throws Exception {
        // given
        LottoRequest request = new LottoRequest(
                List.of("ํ–‰์šด", "์กฐ์ƒ๋‹˜์˜๋„์›€")
        );

        when(lottoService.draw(any(LottoRequest.class)))
                .thenThrow(new IllegalArgumentException("3๊ฐœ์˜ ์š”์†Œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”."));

        // when & then
        mockMvc.perform(
                        post("/api/lotto/draw")
                                .contentType(MediaType.APPLICATION_JSON)
                                .content(objectMapper.writeValueAsString(request))
                )
                .andExpect(status().isBadRequest())
                .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.status").value(400))
                .andExpect(jsonPath("$.error").value("Bad Request"))
                .andExpect(jsonPath("$.message").value("3๊ฐœ์˜ ์š”์†Œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”."))
                .andExpect(jsonPath("$.path").value("/api/lotto/draw"));

        verifyNoInteractions(lottoService);
    }

 }
์‚ฌ์šฉ์ž๊ฐ€ ์„ ํƒ ์š”์†Œ๋ฅผ 3๊ฐœ ์„ ํƒํ•˜์ง€ ์•Š๊ณ  2๊ฐœ ์„ ํƒํ•œ ๊ฒฝ์šฐ ์–ด๋–ป๊ฒŒ ์—๋Ÿฌ๋ฅผ ์ฒ˜๋ฆฌํ• ์ง€ ๊ฐ€์ •ํ•œ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์ด๋‹ค. thenThrow(new IllegalArgumentException("์š”์†Œ๋Š” ์ •ํ™•ํžˆ 3๊ฐœ ์„ ํƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")); ์ฝ”๋“œ๋ฅผ ๋ณด๋ฉด, ์ •์ƒ์ ์œผ๋กœ Mocking์ด ์ž‘๋™๋˜์—ˆ์„ ๊ฒฝ์šฐ 400 ์—๋Ÿฌ๊ฐ€ ๋‚˜์•ผํ•œ๋‹ค.

 

 

  • GlobalExceptionHandler.java
    ์ „๋ฐ˜์ ์ธ ์—๋Ÿฌ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” ํ•ธ๋“ค๋Ÿฌ ํด๋ž˜์Šค ์ฝ”๋“œ ์ค‘ ์ผ๋ถ€๋ถ„
@RestControllerAdvice
public class GlobalExceptionHandler {


    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleIllegalArgumentException(
            IllegalArgumentException exception,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                400,
                "Bad Request",
                exception.getMessage(),
                request.getRequestURI()
        );

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(response);
    }


    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ErrorResponse> handleHttpMessageNotReadableException(
            HttpMessageNotReadableException exception,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                400,
                "Bad Request",
                "์š”์ฒญ JSON ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.",
                request.getRequestURI()
        );

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(response);
    }



    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleException(
            Exception exception,
            HttpServletRequest request
    ) {
        ErrorResponse response = new ErrorResponse(
                500,
                "Internal Server Error",
                "์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.",
                request.getRequestURI()
        );

        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(response);
    }


    public record ErrorResponse(
            int status,
            String error,
            String message,
            String path
    ) {

    }

}
@RestControllerAdvice๋Š” ์ปจํŠธ๋กค๋Ÿฌ ๊ณ„์ธต์—์„œ ๋ฐœ์ƒํ•˜๋Š” ์˜ˆ์™ธ๋ฅผ ํ•œ ๊ณณ์—์„œ ์บ์น˜ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•˜๋Š” ์—ญํ• ์„ ํ•œ๋‹ค. ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ๋กœ์ง์œผ๋กœ IllegalArgumentException, HttpMessageNotReadableException, Exception 3๊ฐ€์ง€ ์œ ํ˜•์œผ๋กœ ์ฝ”๋“œ๋ฅผ ์ž‘์„ฑํ–ˆ๋‹ค.

IllegalArgumentException์€ ๋ฉ”์„œ๋“œ์— ๋ถ€์ ์ ˆํ•œ ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ ์ „๋‹ฌ๋˜์—ˆ์„ ๋•Œ ๋ฐœ์ƒํ•œ๋‹ค. HttpMessageNotReadableException๋Š” ํด๋ผ์ด์–ธํŠธ๊ฐ€ ๋ณด๋‚ธ HTTP ์š”์ฒญ ๋ณธ๋ฌธ(JSON)์˜ ํ˜•์‹์ด ์ž˜๋ชป๋˜์—ˆ๊ฑฐ๋‚˜ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์„ ๋•Œ ๋ฐœ์ƒํ•œ๋‹ค. Exception์€ ์œ„์—์„œ ์žกํžˆ์ง€ ์•Š์€ ๋ชจ๋“  ๋Ÿฐํƒ€์ž„/์ฒดํฌ ์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๋Š” Catch-all์ด๋‹ค. ์ฃผ๋กœ NullPointerException์ด๋‚˜ DB ์ ‘์† ๋ถˆ๋Ÿ‰ ๋“ฑ ์„œ๋ฒ„ ์ธก ์˜ค๋ฅ˜์ด๋‹ค.

์•„๊นŒ ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ thenThrow์— new IllegalArgumentException์„ ์„ค์ •ํ–ˆ๋‹ค. ๊ทธ๋ž˜์„œ 400 ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  ํ…Œ์ŠคํŠธ๋Š” ์„ฑ๊ณต๋˜์—ˆ์–ด์•ผ ํ•œ๋‹ค.

 

 

  • ํ…Œ์ŠคํŠธ ์‹คํŒจ
Handler:
Type = com.lottomagic.lotto.controller.LottoController
Method = com.lottomagic.lotto.controller.LottoController#draw(LottoRequest)


Resolved Exception:
Type = org.springframework.web.bind.MethodArgumentNotValidException


MockHttpServletResponse:
Status = 500
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"status":500,"error":"Internal Server Error","message":"์„œ๋ฒ„ ๋‚ด๋ถ€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.","path":"/api/lotto/draw"}
Forwarded URL = null
Redirected URL = null
Cookies = []


Status
Expected :400
Actual :500
ํ•˜์ง€๋งŒ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค. 400 ์—๋Ÿฌ๋ฅผ ๋ฐœ์ƒํ• ๊ฑฐ๋ž€ ์˜ˆ์ƒ๊ณผ ๋‹ฌ๋ฆฌ, 500 ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ•œ ๊ฒƒ์ด๋‹ค. ์™œ ํ…Œ์ŠคํŠธ๋Š” 500 ์—๋Ÿฌ๋ฅผ ๋ฐ˜ํ™˜ํ• ๊ฑธ๊นŒ?
Resolved Exception:
             Type = org.springframework.web.bind.MethodArgumentNotValidException

ํ…Œ์ŠคํŠธ ์ฝ”๋“œ์—์„œ๋Š” lottoService.draw( )๊ฐ€ ํ˜ธ์ถœ๋  ๋•Œ IllegalArgumentException์ด ๋ฐœ์ƒํ•˜๋„๋ก Mock์„ ์„ค์ •ํ–ˆ์—ˆ๋‹ค. ํ•˜์ง€๋งŒ ์‹ค์ œ๋กœ๋Š” ์„œ๋น„์Šค ๊ณ„์ธต์— ๋„๋‹ฌํ•˜๊ธฐ๋„ ์ „์— ์ปจํŠธ๋กค๋Ÿฌ์˜ @Vaild(์œ ํšจ์„ฑ ๊ฒ€์‚ฌ)์—์„œ ์‹คํŒจํ•˜์—ฌ MethodArgumentNotValidException์ด ํ„ฐ์ ธ๋ฒ„๋ฆฐ ๊ฒƒ์ด๋‹ค. 

๊ทธ๋Ÿฌ๋ฉด LottoRequest์™€ LottoController๋ฅผ ์‚ดํŽด๋ณด์ž.
// LottoRequest.java 
    @Size(min = 3, max = 3, message = "3๊ฐœ์˜ ์š”์†Œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”.")
    List<@NotBlank(message = "์š”์†Œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”.") String> selectedOptions

// GlobalExceptionHandler.java
@PostMapping("/draw")
public ResponseEntity<LottoResponse> draw(@Valid @RequestBody LottoRequest request) {
    LottoResponse response = lottoService.draw(request);
    return ResponseEntity.ok(response);
}

LottoRequest ์ฝ”๋“œ์— ๋ฆฌ์ŠคํŠธ ์š”์†Œ ๊ฐœ์ˆ˜๋ฅผ 3๊ฐœ๋กœ ์ œํ•œํ•˜๋Š” ์–ด๋…ธํ…Œ์ด์…˜(@Size(min=3, max=3)์ด ์žˆ๊ณ  LottoController ์ฝ”๋“œ๋ฅผ ๋ณด๋‹ˆ @Valid๊ฐ€ ๋ถ™์–ด ์žˆ๋‹ค. @Valid๋Š” ์š”์ฒญ๊ฐ’์ด ์„œ๋น„์Šค๊นŒ์ง€ ๊ฐ€๊ธฐ ์ „์— ์ปจํŠธ๋กค๋Ÿฌ ์ž…๊ตฌ์—์„œ ๋จผ์ € ๊ฒ€์‚ฌํ•˜๊ฒŒ ๋œ๋‹ค. ๊ทธ๋ž˜์„œ LottoService์˜ validateSelectedOptions( )๊นŒ์ง€ ๊ฐ€์ง€ ๋ชปํ•˜๊ณ  Spring์ด ๋จผ์ € MethodArgumentNotValidException์„ ํ„ฐ๋œจ๋ฆฐ ๊ฒƒ์ด๋‹ค.

๊ทธ๋Ÿฐ๋ฐ GlobalExceptionHandler ํด๋ž˜์Šค์—๋Š” MethodArgumentNotValidException์ด ์—†์–ด์„œ ์ด ์˜ˆ์™ธ๋ฅผ 400 ์—๋Ÿฌ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜๊ฐ€ ์—†์—ˆ๋‹ค. ๊ทธ๋ž˜์„œ @ExceptionHandler(Excetion.class)๊ฐ€ ์˜ˆ์™ธ๋ฅผ ์žก์œผ๋ฉด์„œ 500 ์‘๋‹ต์ด ๋‚˜ํƒ€๋‚œ ๊ฒƒ์ด๋‹ค.

์ˆ˜์ • ๋ฐฉ๋ฒ•์€ ๊ฐ„๋‹จํ•˜๋‹ค. MethodArgumentNotValidException์— ์ฒ˜๋ฆฌ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

 

 

 


 

 

 

 

2. ํ•ด๊ฒฐ

    // @Valid ๊ฒ€์ฆ ์‹คํŒจ ์ฒ˜๋ฆฌ
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(
            MethodArgumentNotValidException exception,
            HttpServletRequest request
    ) {
        String message = exception.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(fieldError -> fieldError.getDefaultMessage())
                .orElse("์š”์ฒญ ๊ฐ’์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.");

        log.warn("MethodArgumentNotValidException occurred at {}: {}",
                request.getRequestURI(),
                message
        );

        ErrorResponse response = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                HttpStatus.BAD_REQUEST.getReasonPhrase(),
                message,
                request.getRequestURI(),
                LocalDateTime.now()
        );

        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST)
                .body(response);
    }
์˜ˆ์™ธ๋ฅผ ์ฒ˜๋ฆฌํ•  ๋ฉ”์„œ๋“œ๊ฐ€ ์—†์–ด์„œ ๋ฐœ์ƒํ•œ ๊ฒƒ์ด๋‹ˆ ๋งŒ๋“ค์–ด์ฃผ๋ฉด ๊ฐ„๋‹จํ•˜๊ฒŒ ํ•ด๊ฒฐ๋œ๋‹ค. ๊ทธ๋ฆฌ๊ณ  DTO ๋ฉ”์‹œ์ง€๋ฅผ ํ…Œ์ŠคํŠธ ๊ธฐ๋Œ€๊ฐ’์— ๋งž์ถ”๋Š” ๊ฒƒ๋„ ์ค‘์š”ํ•˜๋‹ค. ํ…Œ์ŠคํŠธ ์ฝ”๋“œ๋ฅผ ํ”„๋กœ์ ํŠธ์—์„œ ์ž‘์„ฑํ•ด๋ณธ ๊ฒƒ์ด ์ฒ˜์Œ์ด๋ผ, ์ด๋Ÿฐ๊ฒƒ๋„ ๋ชฐ๋ž๋‹ค. "3๊ฐœ์˜ ์š”์†Œ๋ฅผ ์„ ํƒํ•ด์ฃผ์„ธ์š”."๋ผ๋Š” ๋ฌธ๊ตฌ๋ฅผ ๋งž์ถฐ์•ผํ•˜๋Š” ๊ฒƒ๋„ ๊ธฐ์–ตํ•˜์ž.