이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • MVC에서 요청 바인딩과 Bean Validation은 어떤 순서로 실행되는가?
  • @Valid, @Validated, BindingResult는 각각 어디에 놓아야 하는가?
  • 검증 실패가 Controller 내부 처리로 남을지 공통 예외 처리로 갈지는 무엇으로 결정되는가?

개요

Controller 검증은 HTTP 요청이 Java 객체로 들어오는 순간의 계약을 확인한다. Spring MVC는 요청 값을 목표 객체에 바인딩하고, 검증 애너테이션이 붙어 있으면 Bean Validation을 실행한다. 여기서 중요한 점은 바인딩과 검증이 같은 말이 아니라는 것이다.

바인딩은 문자열 기반 요청 값을 Long, LocalDate, DTO 필드 같은 목표 타입으로 옮기는 과정이다. 검증은 그렇게 만들어진 객체가 @NotBlank, @Size, @Min 같은 제약 조건을 만족하는지 확인하는 과정이다. 타입 변환 자체가 실패하면 검증 이전에 이미 오류가 생길 수 있다.

MVC 검증 흐름

요청 처리 흐름은 대략 HandlerMapping, HandlerAdapter, Argument Resolver, DataBinder, Validator 순서로 이어진다. Controller 메서드에 도달하기 전에 Spring MVC 인프라가 파라미터를 준비하고, 검증 대상이면 Validator를 호출한다.

@RequestBody는 HTTP body를 HttpMessageConverter로 객체로 읽은 뒤 검증된다. 폼 데이터나 쿼리 파라미터 기반 @ModelAttribute는 DataBinder가 객체를 만들고 프로퍼티를 채운 뒤 검증된다. 같은 @Valid라도 입력을 객체로 만드는 경로가 다르므로 실패 예외나 에러 처리 방식도 달라질 수 있다. body를 객체로 읽는 데 실패한 오류와 객체 생성 후 constraint가 실패한 오류도 구분해야 한다.

BindingResult가 검증 대상 바로 뒤에 있으면 Controller가 오류를 직접 확인할 수 있다. 없으면 Spring MVC는 검증 실패를 예외로 처리하고, @ExceptionHandler나 기본 예외 처리 흐름으로 넘긴다. 개별 argument 검증 실패는 MethodArgumentNotValidException, 메서드 파라미터 제약까지 포함한 method validation 실패는 HandlerMethodValidationException으로 나타날 수 있다.

코드 예제

@PostMapping("/members")
public String create(
        @Valid @ModelAttribute MemberForm form,
        BindingResult bindingResult,
        Model model
) {
    if (bindingResult.hasErrors()) {
        return "members/new";
    }
 
    memberService.create(form.toCommand());
    return "redirect:/members";
}

이 방식은 서버 렌더링 폼에 잘 맞는다. 사용자가 입력한 값을 보존하고, 같은 화면에서 필드별 오류를 보여줄 수 있기 때문이다. 핵심은 BindingResultform 바로 뒤에 있다는 점이다.

@PostMapping("/api/members")
public ResponseEntity<MemberResponse> createApi(@Valid @RequestBody SignupRequest request) {
    Member member = memberService.signup(request.toCommand());
    return ResponseEntity.status(HttpStatus.CREATED).body(MemberResponse.from(member));
}

API에서는 Controller가 정상 흐름만 표현하고 검증 실패는 공통 예외 처리로 넘기는 구성이 더 일관적이다. 이때 공통 응답 형식은 @RestControllerAdvice에서 관리한다.

@Valid와 @Validated

@Valid는 Jakarta Bean Validation 표준이다. 그룹이 필요 없는 대부분의 요청 DTO 검증에는 @Valid만으로 충분하다. @Validated는 Spring의 애너테이션이며 그룹을 지정할 수 있다. 단, @Valid 자체는 constraint가 아니므로 메서드 파라미터 자체를 검증하려면 @NotNull, @Min 같은 실제 제약 조건이 함께 있어야 한다.

public interface CreateGroup {
}
 
public interface UpdateGroup {
}
 
public record ProductRequest(
        @Null(groups = CreateGroup.class) Long id,
        @NotNull(groups = UpdateGroup.class) Long updateId,
        @NotBlank String name
) {
}
 
@PostMapping("/products")
public void create(@Validated(CreateGroup.class) @RequestBody ProductRequest request) {
}

그룹은 강력하지만 DTO가 여러 유스케이스를 동시에 떠안게 만들 수 있다. 생성 요청과 수정 요청의 필드 의미가 크게 다르다면 그룹보다 DTO를 나누는 편이 문서성과 테스트성이 좋아진다.

Spring MVC의 method validation을 다룰 때는 버전과 적용 방식을 확인해야 한다. Spring Framework 6.1 이후에는 MVC가 controller method validation을 직접 지원하며, controller class-level @Validated를 붙이면 AOP proxy 기반 method validation 흐름으로 갈 수 있다. 단순히 “Controller에는 항상 @Validated를 붙인다”라고 외우면 오히려 예외 타입과 처리 흐름을 헷갈릴 수 있다.

실전 팁

  • 폼 화면은 BindingResult로 직접 처리하고, REST API는 공통 예외 처리로 일관화하는 식으로 기본 전략을 정한다.
  • BindingResult 위치는 테스트로 한 번 확인한다. 위치가 틀리면 예상과 다른 예외 흐름으로 갈 수 있다.
  • @Validated 그룹이 늘어나면 DTO 분리를 검토한다. 그룹은 가끔 필요한 도구이지 기본 설계가 아니다.
  • @RequestBody 검증 실패와 @ModelAttribute 바인딩 실패를 같은 응답 모델로 맞추려면 예외 처리 계층에서 차이를 흡수한다.
  • MethodArgumentNotValidExceptionHandlerMethodValidationException은 둘 다 처리 대상으로 두고, 어느 서명에서 어떤 예외가 나는지 테스트로 확인한다.
  • 검증 실패 응답은 필드명과 메시지뿐 아니라 안정적인 오류 코드를 포함하는 편이 클라이언트 변경에 강하다.

위험 신호!

  • 하나의 DTO에 생성, 수정, 관리자 수정, 내부 배치 검증 그룹이 모두 붙어 있다.
  • Controller마다 검증 실패 응답 형식이 다르다.
  • BindingResult를 검증 대상과 떨어진 위치에 둔다.
  • 타입 변환 실패를 Bean Validation 메시지로만 설명하려 한다.
  • @Valid만으로 메서드 파라미터 자체가 null 금지될 것이라고 기대한다.
  • Controller class-level @Validated를 습관적으로 붙여 MVC 내장 method validation 흐름과 AOP proxy 흐름을 섞는다.
  • API Controller에서 검증 실패 처리 코드가 모든 메서드에 반복된다.

확인 질문

확인 질문

  • MVC에서 바인딩과 검증의 차이는 무엇인가?
    • 바인딩은 요청 값을 Java 객체로 옮기는 과정이고, 검증은 만들어진 객체가 제약 조건을 만족하는지 확인하는 과정이다.
  • BindingResult가 Controller에서 검증 실패를 받으려면 어디에 있어야 하는가?
    • 검증 대상 파라미터 바로 뒤에 있어야 한다.
  • @Validated 그룹을 쓰기 전에 검토할 대안은 무엇인가?
    • 유스케이스별 요청 DTO를 분리하는 것이다.

참고 문서