@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는 가장 구체적인 예외 타입을 우선 매칭한다.Exceptionhandler는 최후의 보후로 둔다.- Spring 7은 Hibernate Validator 9.x를 사용한다. Bean Validation 3.1(Jakarta EE 11) 기반이다.
생각해 볼 문제
@Valid와@Validated의 차이는 무엇인가? 메서드 수준 검증은 어느 것으로 하는가?- 커스텀 검증 어노테이션을 만들려면
ConstraintValidator를 어떻게 구현하는가? @ControllerAdvicevs@RestControllerAdvice— 차이는?- 500 에러의 상세 메시지(스택 트레이스)를 클라이언트에 노출하면 안 되는 이유는?
- Bean Validation 3.1에서 추가된 기능은 무엇인가? (3.0과의 차이)
참고
- Spring Framework 7 - Validation - 접근 2026-07-10
- Spring Framework 7 - Exception Handling - 접근 2026-07-10
- Jakarta Bean Validation 3.1 - 접근 2026-07-10
- Hibernate Validator 9.x - 접근 2026-07-10
'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 |
