@NotBlank, @Size 같은 기본 어노테이션으로는 표현할 수 없는 복잡한 검증 규칙을 어떻게 구현할까요?

Spring Validation 기본

Spring은 Java Bean Validation(JSR-380, Hibernate Validator)을 기반으로 입력 데이터를 검증합니다.

JAVA
public class ArticleRequest {

    @NotBlank(message = "제목은 필수입니다")
    @Size(max = 100, message = "제목은 100자 이하여야 합니다")
    private String title;

    @NotBlank(message = "내용은 필수입니다")
    private String content;

    @Email(message = "올바른 이메일 형식이 아닙니다")
    private String authorEmail;

이어서 검증 로직을 구현합니다.

JAVA
    @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

기본 제약 조건으로 표현할 수 없는 규칙을 직접 만들 수 있습니다.

예제: 비속어 필터

JAVA
// 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");

이어서 검증 로직을 구현합니다.

JAVA
    @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;
}

예제: 비밀번호 정책

JAVA
@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;
}

이어서 검증 로직을 구현합니다.

JAVA
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();
    }

이어서 검증 로직을 구현합니다.

JAVA
    @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("특수문자를 포함해야 합니다");
        }

이어서 나머지 구현 부분입니다.

JAVA
        if (!violations.isEmpty()) {
            // 기본 메시지 대신 상세 메시지 설정
            context.disableDefaultConstraintViolation();
            violations.forEach(msg ->
                context.buildConstraintViolationWithTemplate(msg)
                    .addConstraintViolation());
            return false;
        }

        return true;
    }
}

클래스 레벨 검증 — 여러 필드 간 관계

JAVA
@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> {

이어서 검증 로직을 구현합니다.

JAVA
    @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를 생성/수정에 재사용하면서 검증 규칙을 다르게 적용할 수 있습니다.

JAVA
// 검증 그룹 인터페이스 (마커)
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을 적용한 나머지 구현부입니다.

JAVA
    @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;
}
JAVA
@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

JAVA
public class OrderRequest {

    @NotBlank
    private String customerName;

    @Valid                          // 중첩 객체 내부도 검증
    @NotNull
    private AddressRequest address;

    @Valid
    @NotEmpty(message = "주문 항목이 비어있습니다")
    private List<OrderItemRequest> items;
}

이어서 관련 클래스를 추가로 정의합니다.

JAVA
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를 빼면 addressitems 내부의 제약 조건은 검증되지 않습니다.

메서드 검증 (Method Validation)

컨트롤러가 아닌 서비스 레이어에서도 검증을 적용할 수 있습니다.

JAVA
@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가 붙은 빈의 메서드 파라미터와 반환값을 자동 검증합니다.

에러 응답 커스터마이징

JAVA
@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();

이어서 나머지 구현 부분입니다.

JAVA
        ErrorResponse response = new ErrorResponse(
            "VALIDATION_ERROR",
            "입력값이 올바르지 않습니다",
            fieldErrors
        );

        return ResponseEntity.badRequest().body(response);
    }

이어서 각 예외 타입별 처리 메서드를 정의합니다.

JAVA
    // 메서드 검증 실패 (@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();

이어서 나머지 구현 부분입니다.

JAVA
        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) {}
}

응답 예시:

JSON
{
  "code": "VALIDATION_ERROR",
  "message": "입력값이 올바르지 않습니다",
  "errors": [
    {
      "field": "title",
      "message": "제목은 필수입니다",
      "rejectedValue": null
    },
    {
      "field": "email",
      "message": "올바른 이메일 형식이 아닙니다",
      "rejectedValue": "invalid-email"
    }
  ]
}

실무 패턴

Validator에 빈 주입

커스텀 Validator에서 DB 조회 같은 작업이 필요하면 빈을 주입받을 수 있습니다.

JAVA
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);
    }
}

프로그래밍 방식 검증

JAVA
@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에서 통일된 형식으로 변환하세요.
댓글 로딩 중...