방탈출 사용자 예약 미션에서 요청 DTO 검증과 전역 예외 처리를 구현하며 MethodArgumentNotValidException을 @ExceptionHandler로 처리한 경험이 있다. 당시에는 이 예외가 왜 발생하는지, 내부적으로 어떻게 동작하는지 제대로 이해하지 못한 채 구현에 임했다. 미션이 끝난 지금, 동작 원리를 제대로 정리해두고자 한다.
MethodArgumentNotValidException이란 무엇인가?
BindException to be thrown when validation on an argument annotated with @Valid fails. Spring Docs - MethodArgumentNotValidException
@Valid가 붙은 파라미터의 Bean Validation 검증이 실패했을 때 Spring이 던지는 예외다. BindException을 상속하기 때문에, 검증에 실패한 모든 오류 정보를 BindingResult를 통해 꺼낼 수 있다.
BindingResult에는 두 종류의 에러가 담긴다.
FieldError: 특정 필드에서 발생한 검증 실패 (예:name이 비어있음)ObjectError: 객체 전체 수준의 검증 실패 (Cross-field validation)
즉, MethodArgumentNotValidException은 @Valid 검증 실패 시 발생하며, 어떤 필드가 왜 실패했는지 상세 정보를 담고 있는 예외다.
MethodArgumentNotValidException 예외는 언제 발생할까?
MethodArgumentNotValidException은 @RequestBody 파라미터에 @Valid가 선언된 상태에서 Bean Validation 검증이 실패했을 때 발생한다.
내부적으로는 RequestResponseBodyMethodProcessor의 resolveArgument()에서 다음 순서로 동작한다.
Object arg = readWithMessageConverters(...) // 1. JSON → Java 객체 변환
WebDataBinder binder = binderFactory.createBinder(...) // 2. DataBinder 생성
validateIfApplicable(binder, parameter) // 3. Validator에게 검증 위임
if (binder.getBindingResult().hasErrors()
&& isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(...) // 4. 에러 있으면 예외 던짐
}
HttpMessageConverter를 통해 JSON을 Java 객체로 변환DataBinder를 생성하고validateIfApplicable()로 Validator에게 검증 위임- 검증 완료 후 아래 두 조건을 동시에 확인
binder.getBindingResult().hasErrors()→ 검증 실패 에러가 존재하는가isBindExceptionRequired()→ 컨트롤러 메서드 파라미터에BindingResult가 없는가
- 두 조건이 모두 참이면
MethodArgumentNotValidException을 던짐- 둘 중 하나라도 거짓이면 예외를 던지지 않음
주목할 조건은 isBindExceptionRequired()다. 컨트롤러 메서드 파라미터 목록에 Errors 또는 BindingResult가 함께 선언되어 있으면 false를 반환한다. 이 경우 예외를 던지지 않고, 검증 실패 정보를 메서드가 직접 받아 처리할 수 있도록 넘긴다.
즉, MethodArgumentNotValidException은 검증 실패 결과를 메서드가 직접 받지 않는 경우에만 발생하며, 예외를 던지는 주체는 Spring MVC의 RequestResponseBodyMethodProcessor다.
MethodArgumentNotValidException 발생했을 때 어떻게 적절한 에러 응답을 반환하는가?
MethodArgumentNotValidException은 두 가지 방식으로 처리할 수 있다.
1. 전역 처리 — @RestControllerAdvice
애플리케이션 전체에서 발생하는 MethodArgumentNotValidException을 한 곳에서 처리한다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handle(MethodArgumentNotValidException e,
HttpServletRequest request) {
List<FieldErrorResponse> fieldErrors = e.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldErrorResponse(fe.getField(), fe.getDefaultMessage()))
.toList();
ErrorResponse errorResponse = new ErrorResponse(
"INVALID_CONSTRAINT", request.getRequestURI(), "요청 값이 유효하지 않습니다.", fieldErrors
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
}
getFieldErrors()로 꺼낸 FieldError에서 getField()(실패한 필드명)와 getDefaultMessage()(제약 조건에 선언한 메시지)를 사용해 에러 응답을 구성한다.
위 핸들러가 반환하는 에러 응답은 다음과 같다.
{
"code": "INVALID_CONSTRAINT",
"path": "/reservations",
"message": "요청 값이 유효하지 않습니다.",
"fieldErrors": [
{
"field": "name",
"message": "이름은 필수입니다."
},
{
"field": "date",
"message": "날짜는 필수입니다."
}
]
}
2. 로컬 처리 — 컨트롤러 내 @ExceptionHandler
특정 컨트롤러에서만 다르게 처리하고 싶을 때 사용한다.
@RestController
public class ReservationController {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handle(MethodArgumentNotValidException e) {
// 이 컨트롤러에서만 적용되는 처리
}
}
즉, MethodArgumentNotValidException 처리의 핵심은 e.getBindingResult().getFieldErrors()로 실패한 필드 목록을 꺼내 클라이언트에게 어떤 필드가 왜 잘못됐는지 구체적으로 전달하는 것이라고 생각한다.
ResponseEntityExceptionHandler를 통해서 핸들링하는 방식
ResponseEntityExceptionHandler란?
A class with an @ExceptionHandler method that handles all Spring MVC raised exceptions by returning a ResponseEntity with RFC 9457 formatted error details in the body.
Spring MVC가 기본으로 제공하는 추상 클래스로, MethodArgumentNotValidException을 포함한 스프링 내부 표준 예외들을 처리하는 @ExceptionHandler 메서드들이 미리 구현되어 있다.
왜 쓰는가? 직접 @ExceptionHandler 작성 방식과의 차이
직접 작성 방식은 @ExceptionHandler(MethodArgumentNotValidException.class)를 선언하고 처리 로직을 처음부터 구현해야 한다. 반면 ResponseEntityExceptionHandler를 상속하면, 이미 등록된 핸들러 메서드를 오버라이드하는 방식으로 응답 형식만 바꿀 수 있다.
또한 MethodArgumentNotValidException 하나만이 아니라 Spring이 내부적으로 발생시키는 수십 개의 표준 예외들을 일관된 구조로 처리할 수 있다는 장점이 있다.
어떻게 쓰는가 (상속 + 오버라이드)
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
List<FieldErrorResponse> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> new FieldErrorResponse(fe.getField(), fe.getDefaultMessage()))
.toList();
ErrorResponse errorResponse = new ErrorResponse(
"INVALID_CONSTRAINT", "요청 값이 유효하지 않습니다.", fieldErrors
);
return ResponseEntity.status(status).body(errorResponse);
}
}
직접 작성 방식과 비교했을 때 두 가지 차이가 눈에 띈다.
HttpStatus.BAD_REQUEST를 하드코딩하지 않고, 파라미터로 넘어온status를 그대로 사용한다.HttpServletRequest대신WebRequest를 사용한다.
즉, ResponseEntityExceptionHandler는 응답 형식만 커스터마이징하고 예외 등록은 Spring에 위임하는 방식으로, 표준 예외를 일관되게 처리할 때 유리하다.
자료 조사하면서 더 알아볼 키워드
BindingResultFieldErrorHttpMessageConverterMappingJackson2HttpMessageConverter
'Spring' 카테고리의 다른 글
| @Valid vs @Validate 차이는 무엇인가? (0) | 2026.05.24 |
|---|---|
| Bean Validation으로 요청 DTO 검증하기 (0) | 2026.05.23 |