์ปจํธ๋กค๋ฌ๊ฐ ์๋ฌ๋ฅผ ์ ๋๋ก ์ฒ๋ฆฌํ๋์ง
์์๋ณด๊ธฐ ์ํด ํ ์คํธ ์ฝ๋๋ฅผ ์์ฑํ๋ค.
๊ทธ๋ฐ๋ฐ ์๊พธ๋ง ํ ์คํธ๋ฅผ ์คํจํ๋ค.
์๊ทธ๋ฐ์ง ๋ถ์ํ๊ณ ๊ณ ์ณ๋ณด์!
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๊ฐ์ ์์๋ฅผ ์ ํํด์ฃผ์ธ์."๋ผ๋ ๋ฌธ๊ตฌ๋ฅผ ๋ง์ถฐ์ผํ๋ ๊ฒ๋ ๊ธฐ์ตํ์.