Spring Validation 심화 — 커스텀 Validator부터 그룹 검증까지
@NotBlank,@Size같은 기본 어노테이션으로는 표현할 수 없는 복잡한 검증 규칙을 어떻게 구현할까요?
Spring Validation 기본
Spring은 Java Bean Validation(JSR-380, Hibernate Validator)을 기반으로 입력 데이터를 검증합니다.
public class ArticleRequest {
@NotBlank(message = "제목은 필수입니다")
@Size(max = 100, message = "제목은 100자 이하여야 합니다")
private String title;
@NotBlank(message = "내용은 필수입니다")
private String content;
@Email(message = "올바른 이메일 형식이 아닙니다")
private String authorEmail;
이어서 검증 로직을 구현합니다.
@Min(value = 0, message = "가격은 0 이상이어야 합니다")
private Integer price;
}
@RestController
public class ArticleController {
@PostMapping("/articles")
public ResponseEntity<?> create(
@Valid @RequestBody ArticleRequest request) {
// @Valid가 검증 실패 시 MethodArgumentNotValidException 발생
return ResponseEntity.ok(articleService.create(request));
}
}
커스텀 ConstraintValidator
기본 제약 조건으로 표현할 수 없는 규칙을 직접 만들 수 있습니다.
예제: 비속어 필터
// 1. 어노테이션 정의
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NoProfanityValidator.class)
@Documented
public @interface NoProfanity {
String message() default "부적절한 표현이 포함되어 있습니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 2. Validator 구현
public class NoProfanityValidator
implements ConstraintValidator<NoProfanity, String> {
private static final Set<String> BLOCKED_WORDS =
Set.of("금지어1", "금지어2");
이어서 검증 로직을 구현합니다.
@Override
public boolean isValid(String value,
ConstraintValidatorContext context) {
if (value == null) return true; // null 체크는 @NotBlank에 위임
return BLOCKED_WORDS.stream()
.noneMatch(word -> value.contains(word));
}
}
// 3. 사용
public class CommentRequest {
@NotBlank
@NoProfanity
private String content;
}
예제: 비밀번호 정책
@Target({ ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordPolicyValidator.class)
public @interface PasswordPolicy {
String message() default "비밀번호 정책을 만족하지 않습니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int minLength() default 8;
boolean requireUppercase() default true;
boolean requireDigit() default true;
boolean requireSpecialChar() default true;
}
이어서 검증 로직을 구현합니다.
public class PasswordPolicyValidator
implements ConstraintValidator<PasswordPolicy, String> {
private int minLength;
private boolean requireUppercase;
private boolean requireDigit;
private boolean requireSpecialChar;
@Override
public void initialize(PasswordPolicy annotation) {
this.minLength = annotation.minLength();
this.requireUppercase = annotation.requireUppercase();
this.requireDigit = annotation.requireDigit();
this.requireSpecialChar = annotation.requireSpecialChar();
}
이어서 검증 로직을 구현합니다.
@Override
public boolean isValid(String password,
ConstraintValidatorContext context) {
if (password == null) return false;
List<String> violations = new ArrayList<>();
if (password.length() < minLength) {
violations.add(minLength + "자 이상이어야 합니다");
}
if (requireUppercase && !password.matches(".*[A-Z].*")) {
violations.add("대문자를 포함해야 합니다");
}
if (requireDigit && !password.matches(".*\\d.*")) {
violations.add("숫자를 포함해야 합니다");
}
if (requireSpecialChar &&
!password.matches(".*[!@#$%^&*].*")) {
violations.add("특수문자를 포함해야 합니다");
}
이어서 나머지 구현 부분입니다.
if (!violations.isEmpty()) {
// 기본 메시지 대신 상세 메시지 설정
context.disableDefaultConstraintViolation();
violations.forEach(msg ->
context.buildConstraintViolationWithTemplate(msg)
.addConstraintViolation());
return false;
}
return true;
}
}
클래스 레벨 검증 — 여러 필드 간 관계
@Target({ ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "시작일이 종료일보다 이후일 수 없습니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator
implements ConstraintValidator<ValidDateRange, EventRequest> {
이어서 검증 로직을 구현합니다.
@Override
public boolean isValid(EventRequest request,
ConstraintValidatorContext context) {
if (request.getStartDate() == null ||
request.getEndDate() == null) {
return true;
}
return request.getStartDate().isBefore(request.getEndDate());
}
}
@ValidDateRange
public class EventRequest {
@NotNull private LocalDate startDate;
@NotNull private LocalDate endDate;
@NotBlank private String title;
}
그룹(Groups) 기반 검증
같은 DTO를 생성/수정에 재사용하면서 검증 규칙을 다르게 적용할 수 있습니다.
// 검증 그룹 인터페이스 (마커)
public interface OnCreate {}
public interface OnUpdate {}
public class UserRequest {
@Null(groups = OnCreate.class,
message = "생성 시 ID를 지정할 수 없습니다")
@NotNull(groups = OnUpdate.class,
message = "수정 시 ID는 필수입니다")
private Long id;
이어서 @NotBlank을 적용한 나머지 구현부입니다.
@NotBlank(groups = { OnCreate.class, OnUpdate.class })
private String name;
@NotBlank(groups = OnCreate.class,
message = "생성 시 비밀번호는 필수입니다")
private String password; // 수정 시에는 선택
@Email
@NotBlank(groups = OnCreate.class)
private String email;
}
@RestController
public class UserController {
@PostMapping("/users")
public ResponseEntity<?> create(
@Validated(OnCreate.class) @RequestBody UserRequest request) {
// OnCreate 그룹의 규칙만 적용
}
@PutMapping("/users/{id}")
public ResponseEntity<?> update(
@Validated(OnUpdate.class) @RequestBody UserRequest request) {
// OnUpdate 그룹의 규칙만 적용
}
}
주의: @Valid는 그룹을 지원하지 않습니다. 그룹 검증에는 반드시 @Validated(Group.class)를 사용하세요.
중첩 객체 검증 — @Valid
public class OrderRequest {
@NotBlank
private String customerName;
@Valid // 중첩 객체 내부도 검증
@NotNull
private AddressRequest address;
@Valid
@NotEmpty(message = "주문 항목이 비어있습니다")
private List<OrderItemRequest> items;
}
이어서 관련 클래스를 추가로 정의합니다.
public class AddressRequest {
@NotBlank private String city;
@NotBlank private String street;
@Pattern(regexp = "\\d{5}", message = "우편번호 5자리")
private String zipCode;
}
public class OrderItemRequest {
@NotNull private Long productId;
@Min(1) private int quantity;
}
@Valid를 빼면 address와 items 내부의 제약 조건은 검증되지 않습니다.
메서드 검증 (Method Validation)
컨트롤러가 아닌 서비스 레이어에서도 검증을 적용할 수 있습니다.
@Service
@Validated // 클래스 레벨에 @Validated 필요
public class OrderService {
// 파라미터 검증
public Order createOrder(
@Valid OrderRequest request,
@NotNull Long userId) {
// 검증 실패 시 ConstraintViolationException 발생
return processOrder(request, userId);
}
// 반환값 검증
@NotNull
public Order findById(@Min(1) Long id) {
return orderRepository.findById(id).orElseThrow();
}
}
Spring Boot 3.2+에서는 MethodValidationPostProcessor가 자동으로 등록되어 @Validated가 붙은 빈의 메서드 파라미터와 반환값을 자동 검증합니다.
에러 응답 커스터마이징
@RestControllerAdvice
public class ValidationExceptionHandler {
// @Valid 검증 실패 (RequestBody)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e) {
List<FieldError> fieldErrors = e.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new FieldError(
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue()))
.toList();
이어서 나머지 구현 부분입니다.
ErrorResponse response = new ErrorResponse(
"VALIDATION_ERROR",
"입력값이 올바르지 않습니다",
fieldErrors
);
return ResponseEntity.badRequest().body(response);
}
이어서 각 예외 타입별 처리 메서드를 정의합니다.
// 메서드 검증 실패 (@Validated 서비스)
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraint(
ConstraintViolationException e) {
List<FieldError> errors = e.getConstraintViolations()
.stream()
.map(v -> new FieldError(
v.getPropertyPath().toString(),
v.getMessage(),
v.getInvalidValue()))
.toList();
이어서 나머지 구현 부분입니다.
return ResponseEntity.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR",
"입력값이 올바르지 않습니다", errors));
}
record ErrorResponse(String code, String message,
List<FieldError> errors) {}
record FieldError(String field, String message,
Object rejectedValue) {}
}
응답 예시:
{
"code": "VALIDATION_ERROR",
"message": "입력값이 올바르지 않습니다",
"errors": [
{
"field": "title",
"message": "제목은 필수입니다",
"rejectedValue": null
},
{
"field": "email",
"message": "올바른 이메일 형식이 아닙니다",
"rejectedValue": "invalid-email"
}
]
}
실무 패턴
Validator에 빈 주입
커스텀 Validator에서 DB 조회 같은 작업이 필요하면 빈을 주입받을 수 있습니다.
public class UniqueEmailValidator
implements ConstraintValidator<UniqueEmail, String> {
@Autowired // Spring이 자동 주입
private UserRepository userRepository;
@Override
public boolean isValid(String email,
ConstraintValidatorContext context) {
if (email == null) return true;
return !userRepository.existsByEmail(email);
}
}
프로그래밍 방식 검증
@Service
@RequiredArgsConstructor
public class ManualValidationService {
private final Validator validator; // JSR-380 Validator
public void validateAndProcess(OrderRequest request) {
Set<ConstraintViolation<OrderRequest>> violations =
validator.validate(request);
if (!violations.isEmpty()) {
String messages = violations.stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(", "));
throw new ValidationException(messages);
}
// 비즈니스 로직 처리
}
}
주의할 점
1. 중첩 객체에 @Valid를 빠뜨리면 내부 필드 검증이 무시된다
OrderRequest 안에 Address address 필드가 있을 때, Address의 @NotBlank 등을 검증하려면 필드에 @Valid를 붙여야 합니다. @Valid가 없으면 Address 내부의 제약 조건이 완전히 무시되어, 빈 주소가 저장되는 버그가 발생합니다.
2. @Validated의 groups를 사용할 때 그룹을 지정하지 않은 제약 조건은 검증되지 않는다
@Validated(CreateGroup.class)로 그룹을 지정하면, 그룹이 지정되지 않은 @NotBlank(기본 그룹 Default)는 검증에서 제외됩니다. 모든 제약 조건에 그룹을 일일이 지정하지 않으면 일부 검증이 누락됩니다. @GroupSequence를 사용하여 Default 그룹을 포함시키는 것이 안전합니다.
3. 커스텀 ConstraintValidator에서 DB를 조회하면 성능 문제가 발생할 수 있다
이메일 중복 검사 같은 검증을 ConstraintValidator에서 구현하면, 요청마다 DB 쿼리가 실행됩니다. 대량 요청 시 DB 부하가 급증하고, 트랜잭션 컨텍스트 밖에서 실행되어 데이터 일관성도 보장되지 않습니다. DB 의존 검증은 서비스 레이어에서 처리하는 것이 적합합니다.
정리
- 커스텀 ConstraintValidator 로 기본 어노테이션으로 표현할 수 없는 검증 규칙을 구현합니다.
- 그룹(Groups) 으로 같은 DTO에서 생성/수정별로 다른 검증 규칙을 적용합니다.
@Validated(Group.class)를 사용하세요. - @Valid 를 중첩 객체에 붙여야 내부의 제약 조건도 검증됩니다.
- 서비스 레이어 에서도
@Validated로 메서드 파라미터를 자동 검증할 수 있습니다. - 에러 응답은
@RestControllerAdvice에서 통일된 형식으로 변환하세요.