스프링 부트 실무 아키텍처 가이드: @Valid와 @RestControllerAdvice를 결합한 완벽한 데이터 검증 스펙 구축하기

지난 포스팅에서는 @RestControllerAdvice를 활용해 애플리케이션 전역의 예외를 한곳에서 가로채는 중앙 집중식 예외 처리 시스템을 구축해 보았습니다.

하지만 백엔드 개발을 하다 보면 예외 처리만큼이나 자주 마주치고, 또 수많은 중복 코드를 양산하는 주범이 있습니다. 바로 클라이언트가 보낸 데이터가 유효한지 검증(Validation)하는 작업입니다.

예를 들어 회원가입 요청 데이터에서 이메일 형식이 맞는지, 비밀번호가 공백은 아닌지, 나이가 음수로 들어오진 않았는지 등을 컨트롤러나 서비스 로직마다 if문으로 일일이 검증한다면 코드는 금방 지저분해질 것입니다.

오늘은 스프링이 제공하는 @Valid 어노테이션과 지난 시간에 만든 전역 예외 처리기를 연동하여, 단 몇 줄의 어노테이션만으로 실무급 데이터 검증 및 에러 응답 시스템을 완성하는 방법을 알아보겠습니다.

1. Bean Validation과 @Valid의 한계, 그리고 해결책

스프링은 자바 표준 검증 기술인 Bean Validation(Hibernate Validator)을 지원합니다. DTO 클래스의 필드에 @NotBlank, @Email, @Min 같은 어노테이션을 붙이고, 컨트롤러 메서드 파라미터 앞에 @Valid만 추가하면 귀찮은 검증 로직을 프레임워크가 대신해 줍니다.

문제는 “검증에 실패했을 때 어떤 일이 일어나는가?”입니다.

스프링 부트에서 @Valid 검증이 실패하면 기본적으로 MethodArgumentNotValidException이라는 예외가 발생하며, 아무런 처리를 하지 않으면 스프링이 제공하는 다소 불친절하고 복잡한 기본 에러 JSON이 클라이언트에 반환됩니다. 지난 시간에 우리가 정해둔 일관된 ErrorResponse 포맷이 깨지게 되는 것이죠.

이 문제를 해결하기 위해, 지난번 구축해 둔 성벽인 GlobalExceptionHandler에 이 MethodArgumentNotValidException을 처리하는 전용 인터셉터 로직을 추가해야 합니다.

2. 실무형 검증 예외 처리 시스템 구현 3단계

1단계: 검증용 DTO 설계 (@Valid 적용)

먼저 회원가입 요청을 받는 DTO를 정의하고, 필드마다 검증 규칙과 실패 시 보여줄 메시지를 지정합니다.

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class SignUpRequest {

    @NotBlank(message = "이메일은 필수 입력 값입니다.")
    @Email(message = "이메일 형식에 맞지 않습니다.")
    private String email;

    @NotBlank(message = "비밀번호는 필수 입력 값입니다.")
    @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z]).{8,16}", 
             message = "비밀번호는 영문, 숫자를 포함한 8~16자여야 합니다.")
    private String password;

    @NotBlank(message = "이름은 필수 입력 값입니다.")
    private String name;
}

2단계: 에러 코드 Enum 추가

기존에 작성했던 ErrorCode Enum에 데이터 검증 실패를 표현할 공통 에러 코드를 한 줄 추가합니다.

@Getter
public enum ErrorCode {
    // 기존 에러 코드 생략...
    INVALID_INPUT_VALUE(400, "COMMON_001", "잘못된 입력 값입니다."),
    USER_NOT_FOUND(404, "USER_001", "존재하지 않는 유저 회원 정보입니다.");

    private final int status;
    private final String code;
    private final String message;

    ErrorCode(int status, String code, String message) {
        this.status = status;
        this.code = code;
        this.message = message;
    }
}

3단계: GlobalExceptionHandler에 검증 예외 처리기 추가

이제 핵심 단계입니다. @Valid 과정에서 에러가 발생하면 스프링은 MethodArgumentNotValidException 내부의 BindingResult 객체에 어떤 필드에서, 어떤 이유로 검증이 깨졌는지 상세히 담아둡니다. 이를 파싱하여 우리가 만든 ErrorResponse에 실어 보내도록 예외 처리기를 확장합니다.

import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
import java.util.stream.Collectors;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 3. @Valid 또는 @Validated 검증 실패 시 발생하는 예외 처리
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    protected ResponseEntity<ErrorResponse> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        log.error("handleMethodArgumentNotValidException: {}", e.getMessage());
        
        BindingResult bindingResult = e.getBindingResult();
        
        // 여러 개의 검증 에러가 발생했을 때, 에러 메시지들을 하나로 묶어줍니다.
        String detailedMessage = bindingResult.getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining(", "));

        ErrorResponse response = ErrorResponse.builder()
                .status(ErrorCode.INVALID_INPUT_VALUE.getStatus())
                .error(ErrorCode.INVALID_INPUT_VALUE.name())
                .code(ErrorCode.INVALID_INPUT_VALUE.getCode())
                .message(detailedMessage) // "이메일 형식에 맞지 않습니다., 비밀번호는..." 형태로 출력
                .build();

        return ResponseEntity.status(ErrorCode.INVALID_INPUT_VALUE.getStatus()).body(response);
    }

    // 기존 BusinessException, Exception 처리 로직 생략...
}

3. @Valid와 @RestControllerAdvice 결합이 주는 시너지

이렇게 두 기술을 융합하면 백엔드 아키텍처 관점에서 엄청난 시너지가 발생합니다.

  • 컨트롤러 코드의 극단적인 간결함: 컨트롤러 메서드 파라미터 앞에 @Valid 한 줄만 붙이면 끝납니다. 검증이 실패하면 컨트롤러 메서드 내부 코드가 실행되기도 전에 RestControllerAdvice가 가로채서 응답을 보내버리므로, 컨트롤러는 오직 성공 케이스만 신경 쓰면 됩니다.
  • 프론트엔드와의 완벽한 에러 통신: 프론트엔드 개발자는 데이터 검증 실패(400 Bad Request) 시에도 항상 동일한 JSON 구조(status, code, message)를 받게 됩니다. 특히 message 필드에 우리가 DTO에 적어둔 “이메일 형식에 맞지 않습니다.” 같은 친절한 문구가 정확히 전달되므로, 프론트엔드 화면에서 별도의 매핑 없이 이 메시지를 그대로 UI에 띄워줄 수 있습니다.

4. 결론 및 요약

데이터 검증(Validation)과 예외 처리(Exception Handling)는 백엔드 애플리케이션의 양대 제동 장치와 같습니다.

단순히 if문으로 땜질하듯 검증하는 방식에서 벗어나, Bean Validation(@Valid)으로 검증 표준을 세우고, @RestControllerAdvice로 예외 응답 표준을 일치시키는 아키텍처를 구축하는 것이 좋습니다.

초기 세팅에는 약간의 공수가 들지만, 프로젝트의 규모가 커질수록 이 시스템은 개발자의 생산성을 극대화하고 서비스의 안정성을 단단하게 지켜주는 훌륭한 밑거름이 될 것입니다.