요청 값이 컨트롤러 경계를 넘기 전에 규칙을 만족하는지 검사하는 것.
경계에서 막는다
record OrderRequest(
@NotNull Long productId,
@Min(1) @Max(999) int quantity,
@Email String contactEmail,
@NotBlank @Size(max = 200) String address
) {}
@PostMapping("/orders")
OrderResponse create(@Valid @RequestBody OrderRequest req) { ... }
서비스 안에서 if (quantity < 1) throw ...를 흩뿌리는 대신 한곳에 모은다. 규칙이 타입 선언 옆에 있어 읽기도 쉽다.
@Valid와 @Validated
- @Valid — 표준(Jakarta Bean Validation). 중첩 객체까지 따라 들어간다
- @Validated — Spring 확장. 검증 그룹(group)을 지정할 수 있고
- 클래스에 붙이면 서비스 메서드 파라미터도 검증한다(AOP)
@Service @Validated
class OrderService {
void cancel(@NotNull Long id) { } // 서비스 계층에서도 검증된다
}
중첩 검증은 @Valid를 또 붙여야 한다
record OrderRequest(
@NotNull @Valid AddressDto address, // ← @Valid 가 없으면 안쪽은 검사되지 않는다
@NotEmpty List<@Valid ItemDto> items // 컬렉션 요소도 마찬가지
) {}
가장 자주 놓치는 지점이다. 바깥만 붙이고 안쪽 규칙이 조용히 무시된다.
실패를 표준 형식으로
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handle(MethodArgumentNotValidException e) {
var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("검증 실패");
pd.setProperty("errors", e.getBindingResult().getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage())).toList());
return pd; // RFC 9457 application/problem+json
}
}
ProblemDetail은 RFC 9457 표준 오류 포맷이라 클라이언트가 일관되게 처리할 수 있다.
커스텀 검증
@Constraint(validatedBy = PhoneValidator.class)
@Target(FIELD) @Retention(RUNTIME)
public @interface Phone { String message() default "전화번호 형식이 아닙니다"; ... }
어디까지 검증할 것인가
-
형식 검증 (길이 · 필수 · 패턴) — → @Valid 로 경계에서
-
업무 규칙 (재고가 충분한가) — → 도메인 안에서
-
"이메일 형식" 은 형식이고 "이미 가입된 이메일인가" 는 업무 규칙이다
면접 함정
- ❌ "@Valid만 붙이면 다 검증된다" → 중첩 객체는 안쪽에도 붙여야 한다.
- ❌ "검증 실패는 500" → 클라이언트 잘못이므로 400이다.