Spring Validation 커스텀 검증

Spring Validation이란?

입력값 검증은 애플리케이션 보안과 안정성의 첫 번째 방어선입니다. Spring Boot는 Bean Validation(JSR 380)을 기반으로 @Valid, @Validated 어노테이션과 다양한 제약 조건 어노테이션을 제공합니다. 하지만 기본 어노테이션만으로는 실전의 복잡한 검증 요구사항을 충족할 수 없습니다. 이 글에서는 커스텀 Validator 작성, 그룹 검증, 크로스필드 검증, 컬렉션 검증, 에러 응답 표준화까지 심화 패턴을 다룹니다.

기본 검증 어노테이션 정리

어노테이션 대상 설명
@NotNull 모든 타입 null 불허 (빈 문자열 허용)
@NotBlank String null, “”, ” ” 모두 불허
@NotEmpty String, Collection null, 빈 컬렉션 불허
@Size(min, max) String, Collection 길이/크기 범위
@Min / @Max 숫자 최소/최대값
@Email String 이메일 형식
@Pattern String 정규식 매칭
@Past / @Future 날짜 과거/미래 날짜
@Positive 숫자 양수만 허용
public class CreateUserRequest {

    @NotBlank(message = "이름은 필수입니다")
    @Size(min = 2, max = 50, message = "이름은 2~50자여야 합니다")
    private String name;

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

    @NotBlank
    @Pattern(
        regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$",
        message = "비밀번호는 8자 이상, 대소문자·숫자·특수문자를 포함해야 합니다"
    )
    private String password;

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

    @Past(message = "생년월일은 과거 날짜여야 합니다")
    private LocalDate birthDate;

    // 중첩 객체 검증: @Valid 필수
    @Valid
    @NotNull
    private AddressRequest address;

    // 컬렉션 내부 요소 검증
    @Valid
    @Size(max = 5, message = "태그는 최대 5개까지 가능합니다")
    private List<@NotBlank String> tags;
}

커스텀 Validator 작성

비즈니스 규칙에 맞는 커스텀 검증 어노테이션을 만드는 패턴입니다.

// 1. 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
@Documented
public @interface ValidPhoneNumber {
    String message() default "올바른 전화번호 형식이 아닙니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String region() default "KR";  // 커스텀 속성
}

// 2. Validator 구현
public class PhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> {

    private static final Map<String, Pattern> PATTERNS = Map.of(
        "KR", Pattern.compile("^01[016789]-?\d{3,4}-?\d{4}$"),
        "US", Pattern.compile("^\+?1?[-.]?\(?\d{3}\)?[-.]?\d{3}[-.]?\d{4}$"),
        "JP", Pattern.compile("^0\d{1,4}-?\d{1,4}-?\d{4}$")
    );

    private String region;

    @Override
    public void initialize(ValidPhoneNumber annotation) {
        this.region = annotation.region();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) return true;  // null 허용은 @NotNull로 처리

        Pattern pattern = PATTERNS.get(region);
        if (pattern == null) return false;

        return pattern.matcher(value).matches();
    }
}

// 3. 사용
public class ContactRequest {
    @ValidPhoneNumber(region = "KR")
    private String phone;

    @ValidPhoneNumber(region = "US", message = "미국 전화번호 형식이 아닙니다")
    private String usPhone;
}

크로스필드 검증 (클래스 레벨)

두 개 이상의 필드를 비교하는 검증은 클래스 레벨 어노테이션으로 구현합니다. Spring 전역 예외 처리와 결합하여 일관된 에러 응답을 반환할 수 있습니다.

// 비밀번호 확인 검증
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
    String message() default "비밀번호가 일치하지 않습니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String password();
    String confirmPassword();
}

public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {

    private String passwordField;
    private String confirmField;

    @Override
    public void initialize(PasswordMatch annotation) {
        this.passwordField = annotation.password();
        this.confirmField = annotation.confirmPassword();
    }

    @Override
    public boolean isValid(Object obj, ConstraintValidatorContext context) {
        try {
            Object password = BeanUtils.getPropertyDescriptor(obj.getClass(), passwordField)
                .getReadMethod().invoke(obj);
            Object confirm = BeanUtils.getPropertyDescriptor(obj.getClass(), confirmField)
                .getReadMethod().invoke(obj);

            boolean valid = Objects.equals(password, confirm);

            if (!valid) {
                // 에러를 confirmPassword 필드에 바인딩
                context.disableDefaultConstraintViolation();
                context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                    .addPropertyNode(confirmField)
                    .addConstraintViolation();
            }
            return valid;
        } catch (Exception e) {
            return false;
        }
    }
}

// 사용
@PasswordMatch(password = "password", confirmPassword = "confirmPassword")
public class SignUpRequest {
    @NotBlank
    private String password;

    @NotBlank
    private String confirmPassword;
}

// 날짜 범위 검증
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
    String message() default "시작일은 종료일보다 이전이어야 합니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    String startDate();
    String endDate();
}

@ValidDateRange(startDate = "startDate", endDate = "endDate")
public class SearchRequest {
    @NotNull
    private LocalDate startDate;

    @NotNull
    private LocalDate endDate;
}

검증 그룹 (Validation Groups)

생성과 수정 시 다른 검증 규칙을 적용해야 할 때 그룹을 사용합니다.

// 그룹 인터페이스 정의
public interface OnCreate {}
public interface OnUpdate {}

// DTO: 그룹별 다른 규칙
public class ProductRequest {

    @Null(groups = OnCreate.class, message = "생성 시 ID를 지정할 수 없습니다")
    @NotNull(groups = OnUpdate.class, message = "수정 시 ID는 필수입니다")
    private Long id;

    @NotBlank(groups = {OnCreate.class, OnUpdate.class})
    @Size(max = 200)
    private String name;

    @NotNull(groups = OnCreate.class)
    @Positive
    private BigDecimal price;

    @NotBlank(groups = OnCreate.class)
    private String category;  // 수정 시에는 선택적
}

// 컨트롤러: @Validated로 그룹 지정 (@Valid는 그룹 미지원)
@RestController
@RequestMapping("/products")
public class ProductController {

    @PostMapping
    public ProductResponse create(
            @Validated(OnCreate.class) @RequestBody ProductRequest request) {
        return productService.create(request);
    }

    @PutMapping("/{id}")
    public ProductResponse update(
            @PathVariable Long id,
            @Validated(OnUpdate.class) @RequestBody ProductRequest request) {
        return productService.update(id, request);
    }
}

// 그룹 시퀀스: 순서대로 검증 (앞 그룹 실패 시 뒤 그룹 미검증)
@GroupSequence({BasicCheck.class, AdvancedCheck.class, ProductRequest.class})
public class ProductRequest {
    @NotBlank(groups = BasicCheck.class)
    private String name;

    @ValidSku(groups = AdvancedCheck.class)  // DB 조회가 필요한 비싼 검증
    private String sku;
}

서비스 레이어 검증: @Validated

컨트롤러뿐 아니라 서비스 메서드의 파라미터와 반환값도 검증할 수 있습니다.

@Service
@Validated  // 클래스 레벨에 선언
public class UserService {

    // 파라미터 검증
    public UserDto findByEmail(@NotBlank @Email String email) {
        return userRepository.findByEmail(email)
            .map(UserDto::from)
            .orElseThrow(() -> new NotFoundException("User not found"));
    }

    // 반환값 검증
    @NotNull
    public UserDto createUser(@Valid CreateUserRequest request) {
        // ...
        return UserDto.from(savedUser);
    }

    // 컬렉션 파라미터 검증
    public void deleteUsers(
            @NotEmpty @Size(max = 100, message = "한 번에 최대 100명까지 삭제 가능")
            List<@Positive Long> userIds) {
        userRepository.deleteAllById(userIds);
    }
}

DB 연동 검증: Unique 체크

데이터베이스와 연동하여 중복 검사를 수행하는 커스텀 Validator입니다. Spring MapStruct DTO 매핑과 함께 사용하면 입력 → 검증 → 변환 파이프라인이 완성됩니다.

// 범용 Unique 검증 어노테이션
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueValidator.class)
public @interface Unique {
    String message() default "이미 사용 중인 값입니다";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    Class<?> entity();
    String field();
}

@Component
public class UniqueValidator implements ConstraintValidator<Unique, Object> {

    @Autowired
    private EntityManager entityManager;

    private Class<?> entityClass;
    private String fieldName;

    @Override
    public void initialize(Unique annotation) {
        this.entityClass = annotation.entity();
        this.fieldName = annotation.field();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value == null) return true;

        String jpql = String.format(
            "SELECT COUNT(e) FROM %s e WHERE e.%s = :value",
            entityClass.getSimpleName(), fieldName
        );

        Long count = entityManager.createQuery(jpql, Long.class)
            .setParameter("value", value)
            .getSingleResult();

        return count == 0;
    }
}

// 사용
public class CreateUserRequest {
    @Unique(entity = User.class, field = "email", message = "이미 등록된 이메일입니다")
    @Email
    private String email;

    @Unique(entity = User.class, field = "nickname", message = "이미 사용 중인 닉네임입니다")
    @Size(min = 2, max = 20)
    private String nickname;
}

에러 응답 표준화

@RestControllerAdvice
public class ValidationExceptionHandler {

    // @Valid/@Validated 실패 → MethodArgumentNotValidException
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(
            MethodArgumentNotValidException ex) {

        List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors()
            .stream()
            .map(error -> new FieldError(
                error.getField(),
                error.getDefaultMessage(),
                error.getRejectedValue()
            ))
            .toList();

        return ResponseEntity.badRequest().body(
            new ErrorResponse("VALIDATION_ERROR", "입력값이 올바르지 않습니다", fieldErrors)
        );
    }

    // @Validated 서비스 레이어 → ConstraintViolationException
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraint(
            ConstraintViolationException ex) {

        List<FieldError> errors = ex.getConstraintViolations().stream()
            .map(v -> new FieldError(
                extractFieldName(v.getPropertyPath()),
                v.getMessage(),
                v.getInvalidValue()
            ))
            .toList();

        return ResponseEntity.badRequest().body(
            new ErrorResponse("VALIDATION_ERROR", "입력값이 올바르지 않습니다", errors)
        );
    }

    private String extractFieldName(Path path) {
        String fullPath = path.toString();
        return fullPath.contains(".")
            ? fullPath.substring(fullPath.lastIndexOf('.') + 1)
            : fullPath;
    }
}

// 표준 에러 응답
public record ErrorResponse(
    String code,
    String message,
    List<FieldError> errors
) {}

public record FieldError(
    String field,
    String message,
    Object rejectedValue
) {}

정리

Spring Validation은 입력값 검증을 선언적으로 표현하는 강력한 프레임워크입니다. 기본 어노테이션으로 단순 검증을 처리하고, 커스텀 Validator로 비즈니스 규칙을 표현하며, 클래스 레벨 어노테이션으로 크로스필드 검증을 구현합니다. 검증 그룹으로 생성/수정 시 다른 규칙을 적용하고, @Validated로 서비스 레이어까지 검증을 확장하세요. 에러 응답을 @RestControllerAdvice로 표준화하면 프론트엔드와의 협업이 훨씬 매끄러워집니다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux