@Valid가 거부한 요청이 어떤 응답이 되는가 — 검증과 예외 처리 파이프라인

@PostMapping("/api/users")
public UserDto create(@Valid @RequestBody CreateUserRequest request) { ... }

클라이언트가 {"email": "invalid", "age": -5}를 보냈다. @Valid가 검증을 수행하고, 실패하면 MethodArgumentNotValidException을 던진다. 이 예외를 처리하지 않으면 — 클라이언트는 Spring의 기본 에러 페이지(또는 500)를 받는다. 올바른 API는 구조화된 400 에러 응답을 반환해야 한다.

이 글은 Bean Validation 3.1(Jakarta), @ControllerAdvice로 전역 예외 처리, 그리고 일관된 에러 응답 구조를 풀어간다. (Spring Framework Reference - Validation)

Bean Validation 3.1 — 선언적 검증

Spring 7은 Jakarta Bean Validation 3.1(Hibernate Validator 9.x)을 사용한다. DTO 필드에 제약 어노테이션을 붙여 검증을 선언한다.

// Spring 7 / Java 25 — Bean Validation
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;

public record CreateUserRequest(
    @NotBlank(message = "이름은 필수입니다") String name,

    @Email(message = "유효한 이메일 형식이어야 합니다")
    @NotBlank String email,

    @NotNull
    @Min(value = 0, message = "나이는 0 이상이어야 합니다")
    @Max(value = 150) Integer age,

    @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다")
    String password,

    @Valid @NotNull Address address   // 중첩 객체 검증
) {}

public record Address(
    @NotBlank String city,
    @NotBlank String street,
    @Pattern(regexp = "^\\d{5}$", message = "우편번호는 5자리 숫자") String zipCode
) {}
어노테이션 검증
@NotBlank null, 빈 문자열, 공백만 있는 문자열 거부
@NotNull null 거부
@Email 이메일 형식
@Min / @Max 최소/최대값
@Size(min, max) 문자열/컬렉션 길이
@Pattern(regexp) 정규식
@Positive / @Negative 양수/음수
@Future / @Past 미래/과거 날짜
@Valid 중첩 객체 검증

Bean Validation 3.1은 Jakarta EE 11의 일부다. 패키지는 jakarta.validation.constraints.* (javax.validation이 아님). Spring 7에서는 Hibernate Validator 9.x가 기본 구현체다.

@Valid가 어떻게 검증을 수행하는가 — 내부 메커니즘: Spring MVC의 RequestResponseBodyMethodProcessor@Valid를 발견하면 → Hibernate Validator에게 객체를 넘긴다 → Hibernate Validator가 리플렉션으로 필드의 제약 어노테이션(@NotBlank, @Email 등)을 읽고 → 각 필드에 대해 검증 메서드를 호출한다. 실패한 필드가 하나라도 있으면 MethodArgumentNotValidException을 던진다.

비유: @Valid공항 수하물 검사다. 가방(CreateUserRequest)을 X선(Hibernate Validator)에 통과시키면 — 금지 물품(검증 위반)이 있는지 모든 칸(필드)을 검사한다. 금지 물품이 발견되면 가방 전체가 반려된다(예외). "이 필드만 통과"가 아니라 "전부 통과해야 가능"이다.

@Valid의 작동 흔적

@Valid가 실패하면 Spring MVC는 MethodArgumentNotValidException을 던진다. 이 예외에는 어떤 필드가 어떤 제약을 위반했는지 정보가 들어있다.

// Spring 7 / Java 25 — 예외에서 필드 에러 추출
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
    List<String> errors = ex.getBindingResult()
        .getFieldErrors()
        .stream()
        .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
        .toList();

    return ResponseEntity.badRequest()
        .body(new ErrorResponse("VALIDATION_FAILED", "입력값 검증 실패", errors));
}

@ControllerAdvice — 전역 예외 처리

각 컨트롤러에 예외 처리를 중복하지 않고, 한 곳에서 모든 컨트롤러의 예외를 처리한다. (Spring Framework Reference - Exception Handling)

// Spring 7 / Java 25 — 전역 예외 처리
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 검증 실패 (400)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
        List<String> details = ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
            .toList();
        return ResponseEntity.badRequest()
            .body(new ErrorResponse("VALIDATION_FAILED", "입력값 검증 실패", details));
    }

    // 리소스 없음 (404)
    @ExceptionHandler(NoSuchElementException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(NoSuchElementException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorResponse("NOT_FOUND", ex.getMessage(), null));
    }

    // 비즈니스 규칙 위반 (409)
    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<ErrorResponse> handleConflict(IllegalStateException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(new ErrorResponse("CONFLICT", ex.getMessage(), null));
    }

    // 예상치 못한 예외 (500)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorResponse("INTERNAL_ERROR", "서버 오류가 발생했습니다", null));
    }
}

일관된 에러 응답 구조

// Spring 7 / Java 25 — 표준 에러 응답
public record ErrorResponse(
    String code,        // 에러 코드 (VALIDATION_FAILED, NOT_FOUND 등)
    String message,     // 사용자 친화적 메시지
    List<String> details // 상세 정보 (필드별 검증 에러 등)
) {}
{
  "code": "VALIDATION_FAILED",
  "message": "입력값 검증 실패",
  "details": [
    "email: 유효한 이메일 형식이어야 합니다",
    "age: 나이는 0 이상이어야 합니다"
  ]
}

@ExceptionHandler의 우선순위

// Spring 7 / Java 25 — 예외 계층에 따른 매칭
@ExceptionHandler(Exception.class)           // 가장 일반적 (최후의 보루)
@ExceptionHandler(RuntimeException.class)    // 더 구체적
@ExceptionHandler(IllegalStateException.class) // 가장 구체적 — 이것이 우선

Spring은 가장 구체적인 예외 타입을 처리하는 handler를 선택한다. IllegalStateException이 발생하면 Exception handler가 아니라 IllegalStateException handler가 실행된다.

실습 — 검증 없이 vs 검증 후

// Spring 7 / Java 25 — ValidationDemo.java
import java.util.*;

public class ValidationDemo {
    record UserRequest(String name, String email, int age) {}

    static List<String> validate(UserRequest req) {
        List<String> errors = new ArrayList<>();
        if (req.name() == null || req.name().isBlank())
            errors.add("name: 필수");
        if (req.email() == null || !req.email().contains("@"))
            errors.add("email: 유효한 이메일 형식");
        if (req.age() < 0 || req.age() > 150)
            errors.add("age: 0 ~ 150 사이");
        return errors;
    }

    public static void main(String[] args) {
        var valid = new UserRequest("Alice", "alice@test.com", 30);
        var invalid = new UserRequest("", "bad-email", -5);

        System.out.println("유효한 요청: " + validate(valid));
        System.out.println("무효한 요청: " + validate(invalid));
    }
}
java ValidationDemo.java
유효한 요청: []
무효한 요청: [name: 필수, email: 유효한 이메일 형식, age: 0 ~ 150 사이]

확인할 것: 검증 로직이 모든 필드를 확인하고 실패 목록을 반환한다. Spring의 @Valid + Bean Validation이 이 과정을 어노테이션 기반으로 자동화한다.

커스텀 검증 어노테이션 — ConstraintValidator

// Spring 7 / Java 25 — @PhoneNumber 커스텀 검증
import jakarta.validation.*;

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneValidator.class)
public @interface PhoneNumber {
    String message() default "유효한 전화번호 형식이 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

class PhoneValidator implements ConstraintValidator<PhoneNumber, String> {
    private static final java.util.regex.Pattern PATTERN =
        java.util.regex.Pattern.compile("^01[0-9]-?\\d{3,4}-?\\d{4}$");

    @Override
    public boolean isValid(String value, ConstraintValidatorContext ctx) {
        return value != null && PATTERN.matcher(value).matches();
    }
}

// 사용
public record ContactRequest(
    @PhoneNumber String phone   // 커스텀 검증 적용
) {}

검증 그룹(Validation Groups) — 상황별 다른 검증

// Spring 7 / Java 25 — 검증 그룹
public interface OnCreate {}
public interface OnUpdate {}

public record UserRequest(
    @Null(groups = OnCreate.class)    // 생성 시에는 id가 null이어야 함
    @NotNull(groups = OnUpdate.class) // 수정 시에는 id가 필수
    Long id,

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    String name
) {}

// 컨트롤러에서 그룹 지정
@PostMapping
public UserDto create(@Validated(OnCreate.class) @RequestBody UserRequest request) { ... }

@PutMapping("/{id}")
public UserDto update(@Validated(OnUpdate.class) @RequestBody UserRequest request) { ... }

검증 그룹은 같은 DTO를 생성/수정 시나리오에서 다르게 검증할 때 유용하다. @Validated(OnCreate.class)로 그룹을 지정하면 해당 그룹의 제약만 검증된다.

ResponseStatusException — 직접 HTTP 상태 코드 지정

// Spring 7 / Java 25 — ResponseStatusException
@GetMapping("/orders/{id}")
public Order getOrder(@PathVariable Long id) {
    return service.findById(id)
        .orElseThrow(() -> new ResponseStatusException(
            HttpStatus.NOT_FOUND, "주문을 찾을 수 없습니다: " + id));
}

ResponseStatusException@ControllerAdvice 없이도 간단히 HTTP 에러 응답을 만든다. 복잡한 애플리케이션에서는 @ControllerAdvice + 커스텀 예외 조합이 더 일관성 있지만, 간단한 경우 ResponseStatusException이 유용하다.

커스텀 비즈니스 예외 + @ControllerAdvice 조합

// Spring 7 / Java 25 — 비즈니스 예외 패턴
public class InsufficientStockException extends RuntimeException {
    private final Long productId;
    private final int requested;
    private final int available;

    public InsufficientStockException(Long productId, int requested, int available) {
        super(String.format("상품 %d: 요청 %d개, 재고 %d개", productId, requested, available));
        this.productId = productId;
        this.requested = requested;
        this.available = available;
    }
}

@RestControllerAdvice
public class BusinessExceptionHandler {
    @ExceptionHandler(InsufficientStockException.class)
    public ResponseEntity<ErrorResponse> handleStock(InsufficientStockException ex) {
        return ResponseEntity.status(HttpStatus.CONFLICT).body(
            new ErrorResponse("INSUFFICIENT_STOCK", ex.getMessage(), null));
    }
}

요약 — 이 글의 결론

  • Bean Validation 3.1(jakarta.validation.constraints)으로 DTO를 선언적 검증한다. @NotBlank, @Email, @Min 등의 어노테이션을 필드에 붙이고, 컨트롤러 매개변수에 @Valid를 붙인다.
  • @RestControllerAdvice로 전역 예외 처리를 한다. 각 컨트롤러에 예외 처리를 중복하지 않고, 한 곳에서 모든 예외를 처리한다.
  • 일관된 에러 응답 구조를 정의한다. code, message, details를 포함한 표준 에러 응답을 모든 API에서 사용한다.
  • @ExceptionHandler는 가장 구체적인 예외 타입을 우선 매칭한다. Exception handler는 최후의 보후로 둔다.
  • Spring 7은 Hibernate Validator 9.x를 사용한다. Bean Validation 3.1(Jakarta EE 11) 기반이다.

생각해 볼 문제

  1. @Valid@Validated의 차이는 무엇인가? 메서드 수준 검증은 어느 것으로 하는가?
  2. 커스텀 검증 어노테이션을 만들려면 ConstraintValidator를 어떻게 구현하는가?
  3. @ControllerAdvice vs @RestControllerAdvice — 차이는?
  4. 500 에러의 상세 메시지(스택 트레이스)를 클라이언트에 노출하면 안 되는 이유는?
  5. Bean Validation 3.1에서 추가된 기능은 무엇인가? (3.0과의 차이)

참고

'Develop Artifacts > Spring' 카테고리의 다른 글

Spring - 10. data-access  (0) 2026.07.12
Spring - 09. transaction  (0) 2026.07.12
Spring - 07. spring mvc  (0) 2026.07.12
Spring - 06. aop  (0) 2026.07.12
Spring - 05. bean scope profile  (0) 2026.07.12