Spring Validation 완전 정복 - 올바른 유효성 검증 전략

2026. 1. 29. 23:52·스프링

0. 들어가며

스프링으로 웹 애플리케이션을 개발하다 보면 입력값 검증(Validation) 은 반드시 마주치게 됩니다.

처음 개발을 시작했을 때를 떠올려보면, 회원가입 API를 만들면서 이메일 형식이 올바른지, 비밀번호 길이가 적절한지, 필수 값이 누락되지 않았는지 등을 검증하기 위해 각 메서드마다 if문을 사용해 검증 로직을 작성했습니다. 하지만 이런 방식은 코드가 금방 지저분해졌고, 비즈니스 로직과 검증 로직이 뒤섞이면서 메서드가 점점 길어지고 복잡해졌습니다.

 
 
 
public void createUser(UserRequest request) {
    if (request.getEmail() == null || request.getEmail().isEmpty()) {
        throw new IllegalArgumentException("이메일은 필수입니다.");
    }
    if (!request.getEmail().contains("@")) {
        throw new IllegalArgumentException("올바른 이메일 형식이 아닙니다.");
    }
    if (request.getPassword() == null || request.getPassword().length() < 8) {
        throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다.");
    }
    // 실제 비즈니스 로직은 여기부터...
}

이런 코드를 보면 문제점이 명확합니다. 사용자를 생성하는 핵심 로직보다 입력값을 검증하는 코드가 더 많고, 만약 검증 규칙이 변경되면 여러 곳을 수정해야 하며, 다른 곳에서 같은 검증이 필요할 때 코드를 중복해서 작성해야 합니다.

스프링은 이러한 문제를 해결하기 위해 는 대표적으로 두 가지 방식의 유효성 검사를 제공합니다.

 

이 글에서는

  • Spring Validator
  • Bean Validation (JSR-380, Validation 어노테이션)

두 방식의 차이와 언제 사용해야 하는지에 대해 다루어 보겠습니다.

 


1. Spring Validation이란 무엇인가

스프링의 Validation은 단순히 입력값의 형식을 확인하는 기술이 아닙니다. 이것은 객체지향 설계 원칙 중 하나인 단일 책임 원칙(Single Responsibility Principle)을 지키기 위한 스프링의 전략입니다.

 

 

생각해보면 서비스 레이어의 메서드는 본래 비즈니스 로직을 처리하는 책임을 가져야 합니다. 그런데 입력값 검증 로직이 뒤섞이면 이 메서드는 두 가지 책임을 동시에 가지게 됩니다. 검증 규칙이 변경될 때마다 비즈니스 로직을 담당하는 메서드를 수정해야 하고, 비즈니스 로직이 변경될 때도 검증 코드 때문에 영향을 받을 수 있습니다. 이는 결합도를 높이고 유지보수를 어렵게 만듭니다.

 

 

스프링은 이 문제를 해결하기 위해 크게 두 가지 접근 방식을 제공합니다.

하나는 스프링 프레임워크가 직접 제공하는 Validator 인터페이스이고, 다른 하나는 자바 표준 스펙인 Bean Validation(JSR-303/JSR-380)입니다.

이 두 가지는 서로 다른 철학과 용도를 가지고 있지만, 결국 검증 로직을 비즈니스 로직에서 분리하여 객체가 자신의 역할에 집중할 수 있도록 돕는다는 공통된 목적을 가지고 있습니다.


1-1. Validator 인터페이스 - 스프링의 전통적인 접근

스프링이 제공하는 org.springframework.validation.Validator 인터페이스는 스프링 초기부터 제공되어 온 검증 메커니즘입니다. 이 인터페이스는 매우 단순한 구조를 가지고 있습니다.

 
 
 
public interface Validator {
    boolean supports(Class<?> clazz);
    void validate(Object target, Errors errors);
}

supports 메서드는 이 Validator가 어떤 타입의 객체를 검증할 수 있는지를 나타내고  validate 메서드는 실제 검증 로직을 수행합니다. 검증 과정에서 발견된 오류는 Errors 객체에 누적되며 이를 통해 여러 개의 검증 오류를 한 번에 모아서 처리할 수 있습니다.

 

 

예를 들어 사용자 등록 요청을 검증하는 Validator를 만들어보겠습니다.

public class UserValidator implements Validator {
    
    @Override
    public boolean supports(Class<?> clazz) {
        return UserRequest.class.isAssignableFrom(clazz);
    }
    
    @Override
    public void validate(Object target, Errors errors) {
        UserRequest user = (UserRequest) target;
        
        if (user.getEmail() == null || user.getEmail().trim().isEmpty()) {
            errors.rejectValue("email", "required", "이메일은 필수입니다.");
        } else if (!user.getEmail().contains("@")) {
            errors.rejectValue("email", "invalid", "올바른 이메일 형식이 아닙니다.");
        }
        
        if (user.getPassword() == null || user.getPassword().length() < 8) {
            errors.rejectValue("password", "invalid", "비밀번호는 8자 이상이어야 합니다.");
        }
        
        if (user.getAge() != null && user.getAge() < 19 && user.isAdultContentAgreed()) {
            errors.rejectValue("adultContentAgreed", "invalid", 
                "미성년자는 성인 콘텐츠에 동의할 수 없습니다.");
        }
    }
}

이 코드에서 주목할 점은 검증 로직이 명확하게 분리되어 있다는 것입니다.

 마지막 검증 로직을 보면, 나이가 19세 미만이면서 동시에 성인 콘텐츠에 동의한 경우를 검증하고 있습니다.


이런 종류의 검증은 여러 필드를 동시에 고려해야 하는 복잡한 비즈니스 규칙입니다. Validator 인터페이스는 이런 복잡한 검증 로직을 구현하기에 적합한 구조를 제공합니다.

 

 

컨트롤러에서는 이렇게 만든 Validator를 주입받아 사용할 수 있습니다.

 
@RestController
@RequiredArgsConstructor
public class UserController {
    
    private final UserValidator userValidator;
    
    @PostMapping("/users")
    public ResponseEntity<User> createUser(@RequestBody UserRequest request, 
                                          BindingResult bindingResult) {
        userValidator.validate(request, bindingResult);
        
        if (bindingResult.hasErrors()) {
            return ResponseEntity.badRequest().build();
        }
        
        return ResponseEntity.ok().build();
    }
}

Validator 인터페이스 방식의 가장 큰 장점은 검증 로직을 자유롭게 구현할 수 있다는 것입니다.

데이터베이스를 조회해야 하는 검증이 필요한가요? 그렇다면 Validator에 Repository를 주입해서 사용하면 됩니다.
외부 API를 호출해야 하나요? 그것도 가능합니다. 계절에 따라, 시간에 따라, 사용자의 권한에 따라 달라지는 동적인 검증 로직이 필요한가요? Validator 안에서 모두 처리할 수 있습니다.

 

 

하지만 단점도 명확합니다. 간단한 필드 검증에도 상당히 많은 코드를 작성해야 하고 검증 규칙이 Validator 클래스에 숨어있어서 도메인 객체만 봐서는 어떤 검증이 이루어지는지 파악하기 어렵습니다. 또한 동일한 검증 로직을 여러 Validator에서 중복해서 작성할 가능성도 있습니다.


1-2. Bean Validation - 선언적이고 표준적인 접근

Bean Validation은 JSR-303과 JSR-380으로 표준화된 자바의 유효성 검증 스펙입니다.

이 방식의 핵심은 검증 규칙을 도메인 객체에 어노테이션으로 선언한다는 것입니다.

검증 로직이 별도의 클래스에 숨어있는 것이 아니라, 도메인 객체를 보면 바로 어떤 제약사항이 있는지 알 수 있습니다.

 
 
public class UserRequest {
    
    @NotBlank(message = "이메일은 필수입니다.")
    @Email(message = "올바른 이메일 형식이 아닙니다.")
    private String email;
    
    @NotBlank(message = "비밀번호는 필수입니다.")
    @Size(min = 8, max = 20, message = "비밀번호는 8자 이상 20자 이하여야 합니다.")
    private String password;
    
    @NotBlank(message = "이름은 필수입니다.")
    @Size(min = 2, max = 10, message = "이름은 2자 이상 10자 이하여야 합니다.")
    private String name;
    
    @Min(value = 0, message = "나이는 0 이상이어야 합니다.")
    @Max(value = 150, message = "나이는 150 이하여야 합니다.")
    private Integer age;
    
    @Pattern(regexp = "^01(?:0|1|[6-9])-(?:\\d{3}|\\d{4})-\\d{4}$", 
             message = "올바른 전화번호 형식이 아닙니다.")
    private String phoneNumber;
}

이 코드를 보면 UserRequest가 어떤 제약사항을 가지고 있는지  파악할 수 있습니다.

이메일은 필수이면서 이메일 형식을 따라야 하고, 비밀번호는 8자 이상 20자 이하여야 하며, 전화번호는 특정 패턴을 따라야 합니다.

 

이런 정보가 도메인 객체에 함께 있다는 것은 코드의 가독성을 크게 높여줍니다.컨트롤러에서 사용할 때는 @Valid 어노테이션만 추가하면 됩니다.

 
 
 
@RestController
public class UserController {
    
    @PostMapping("/users")
    public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
        return ResponseEntity.ok().build();
    }
}

@Valid가 붙은 파라미터는 스프링이 자동으로 검증을 수행하고, 검증에 실패하면 MethodArgumentNotValidException 예외를 발생시킵니다. 개발자는 별도로 검증 코드를 작성할 필요가 없습니다.

 

 

Bean Validation의 가장 큰 장점은 간결함입니다.

대부분의 일반적인 검증은 제공되는 어노테이션으로 해결할 수 있고 이는 자바 표준이므로 스프링이 아닌 다른 프레임워크나 환경에서도 동일하게 사용할 수 있습니다. 또한 검증 규칙이 도메인 객체에 함께 있어서 문서화 역할도 합니다.

 

 

하지만 Bean Validation도 한계가 있습니다. 복잡한 비즈니스 로직이나 여러 필드를 조합한 검증, 외부 의존성이 필요한 검증 등은 어노테이션만으로는 표현하기 어렵습니다. 앞서 본 "19세 미만은 성인 콘텐츠에 동의할 수 없다"는 규칙 같은 것은 단순한 어노테이션으로는 구현하기 어렵습니다.


2. Null 검증의 미묘한 차이들

Bean Validation을 처음 접하면서 가장 헷갈렸던 부분이 바로 Null 관련 검증 어노테이션들의 차이였습니다.

@NotNull, @NotEmpty, @NotBlank 이 세 가지는 언뜻 비슷해 보이지만, 실제로는 명확한 차이가 있고 각각 다른 상황에 사용됩니다.

 

@NotNull은 가장 기본적인 검증입니다. 이름 그대로 값이 null이 아니어야 한다는 조건만 검사합니다. 중요한 점은 빈 문자열이나 공백 문자열은 통과시킨다는 것입니다.

 
 
@NotNull(message = "생일은 필수입니다.")
private LocalDate birthDate;

생일 같은 경우는 null이 아닌 실제 날짜 객체가 필요합니다.

빈 문자열이나 공백 같은 개념 자체가 없는 타입이죠. 이런 경우 @NotNull이 적합합니다.

@NotEmpty는 한 단계 더 나아갑니다. null이 아니어야 할 뿐만 아니라, 비어있지도 않아야 합니다. 이 어노테이션은 주로 문자열, 컬렉션, 배열에 사용됩니다.

 
 
 
@NotEmpty(message = "취미는 최소 1개 이상이어야 합니다.")
private List<String> hobbies;
 
 

취미 목록은 빈 리스트여서는 안 됩니다. 적어도 하나 이상의 취미가 있어야 의미가 있으니까요. 하지만 여기서 중요한 점은 @NotEmpty는 공백 문자를 검증하지 않는다는 것입니다. 만약 문자열에 공백만 있다면 이것도 통과시킵니다.

 
 
 
@NotEmpty String value = " ";  // 통과

이런 상황에서 필요한 것이 @NotBlank입니다. 이 어노테이션은 문자열 전용으로, null도 안되고, 빈 문자열도 안되며, 공백만 있는 것도 허용하지 않습니다.

 
 
 
@NotBlank(message = "이름은 필수입니다.")
private String name;

사용자의 이름은 실제로 의미있는 문자가 있어야 합니다. 공백만 입력하는 것은 이름을 입력하지 않은 것과 마찬가지죠. 그래서 이런 경우에는 @NotBlank를 사용해야 합니다.

 

 

실무에서 이 차이를 정확히 이해하지 못하고 사용하면 버그가 발생할 수 있습니다. 예를 들어 사용자 이름 필드에 @NotNull이나 @NotEmpty를 사용했다면, 누군가 공백만 입력해도 검증을 통과하게 됩니다. 그리고 이런 데이터가 데이터베이스에 저장되고, 나중에 이 데이터를 화면에 표시할 때 이상하게 보이는 문제가 발생할 수 있습니다.


3. 크기와 범위의 검증 - 데이터의 경계 정의하기

데이터 검증에서 빼놓을 수 없는 것이 크기나 범위를 제한하는 것입니다. 사용자가 입력할 수 있는 값의 범위를 명확히 정의함으로써, 데이터베이스의 컬럼 크기를 초과하는 입력을 방지하거나, 비즈니스 로직상 말이 안 되는 값을 걸러낼 수 있습니다.

@Size 어노테이션은 문자열의 길이나 컬렉션의 크기를 검증합니다. 닉네임을 예로 들어보겠습니다.

 
 
 
@Size(min = 2, max = 10, message = "닉네임은 2자 이상 10자 이하여야 합니다.")
private String nickname;

닉네임은 너무 짧으면 다른 사용자와 구별하기 어렵고, 너무 길면 화면에 표시하기 어렵습니다. 그래서 적절한 범위를 정의하는 것이 중요합니다.

 

데이터베이스의 VARCHAR(10)으로 정의되어 있다면, 애플리케이션 레벨에서 미리 이를 검증함으로써 데이터베이스 예외가 발생하는 것을 방지할 수 있습니다.

 

 

@Size는 컬렉션에도 사용할 수 있습니다.

@Size(min = 1, max = 5, message = "관심 분야는 1개 이상 5개 이하여야 합니다.")
private List<String> interests;

사용자가 너무 많은 관심 분야를 선택하는 것을 방지하여, UI가 복잡해지거나 성능 문제가 발생하는 것을 막을 수 있습니다.

숫자 범위를 검증할 때는 @Min과 @Max를 사용합니다. 이것은 정수나 실수 타입의 최솟값과 최댓값을 지정합니다.

 
 
 
@Min(value = 0, message = "가격은 0 이상이어야 합니다.")
@Max(value = 1000000, message = "가격은 100만원 이하여야 합니다.")
private Integer price;

상품 가격은 음수일 수 없고, 시스템에서 처리할 수 있는 최대 금액도 있을 것입니다. 이런 비즈니스 규칙을 코드로 명확하게 표현할 수 있습니다.

 

 

더 정밀한 숫자 검증이 필요할 때는 @DecimalMin과 @DecimalMax를 사용합니다. 이것은 BigDecimal 같은 타입이나 실수에 사용되며, inclusive 옵션을 통해 경계값 포함 여부를 지정할 수 있습니다.

 
 
 
@DecimalMin(value = "0.0", inclusive = false, message = "평점은 0보다 커야 합니다.")
@DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다.")
private Double rating;

평점은 0점보다는 커야 하고(0점 제외), 5점 이하여야 합니다. inclusive = false 옵션을 통해 0점을 제외할 수 있습니다.


4. 패턴과 형식 - 정규표현식으로 표현하는 규칙

어떤 검증은 단순히 null이 아니거나 범위 내에 있다는 것만으로는 부족합니다. 데이터가 특정 형식을 따라야 하는 경우가 많습니다. 이메일 주소, 전화번호, 우편번호, 아이디 등은 모두 정해진 형식이 있습니다.

@Email 어노테이션은 이메일 형식을 검증합니다.

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

이 어노테이션은 내부적으로 이메일 형식을 검증하는 정규표현식을 가지고 있어서, 개발자가 직접 복잡한 정규표현식을 작성할 필요가 없습니다. 하지만 @Email은 RFC 5322 표준을 완전히 따르지는 않고, 상대적으로 관대한 검증을 수행합니다. 만약 더 엄격한 검증이 필요하다면 @Pattern을 사용해야 합니다.

 

@Pattern은 정규표현식을 직접 지정할 수 있어서 매우 유연합니다.

 
 
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "아이디는 영문자와 숫자만 가능합니다.")
private String userId;

사용자 아이디는 영문자와 숫자만 허용하고 특수문자는 허용하지 않는다는 규칙을 정규표현식으로 표현했습니다. 이런 규칙은 시스템마다 다를 수 있으므로, @Pattern을 통해 정확한 형식을 강제할 수 있습니다.

전화번호 검증은 특히 중요합니다. 한국 휴대폰 번호는 특정한 패턴을 가지고 있습니다.

 
 
 
@Pattern(regexp = "^01(?:0|1|[6-9])-(?:\\d{3}|\\d{4})-\\d{4}$", 
         message = "올바른 전화번호 형식이 아닙니다.")
private String phoneNumber;

이 정규표현식은 010, 011, 016~019로 시작하는 한국 휴대폰 번호 형식을 검증합니다. 이런 식으로 국가별, 통신사별 전화번호 형식을 정확하게 검증할 수 있습니다.

 

우편번호 같은 경우도 마찬가지입니다.

@Pattern(regexp = "^\\d{5}$", message = "우편번호는 5자리 숫자여야 합니다.")
private String zipCode;

한국 우편번호는 2015년 이후 5자리로 통일되었습니다. 이런 비즈니스 규칙을 코드에 반영하여, 잘못된 형식의 우편번호가 입력되는 것을 방지할 수 있습니다.


5. 날짜와 시간의 검증 - 시간의 흐름을 다루기

날짜와 시간 데이터는 특별한 주의가 필요합니다. 예약 시스템을 만든다고 생각해보겠습니다. 예약 날짜는 과거일 수 없고, 생일은 미래일 수 없습니다. 이런 시간의 방향성을 검증하는 것이 중요합니다.

 

@Past는 날짜가 과거여야 함을 의미합니다.

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

생일은 당연히 과거의 날짜여야 합니다. 누군가 미래의 날짜를 생일로 입력한다면 이것은 명백한 오류입니다. @Past를 사용하면 현재 시점보다 이전의 날짜만 허용됩니다.

 

@PastOrPresent는 오늘을 포함한 과거 날짜를 허용합니다.

@PastOrPresent(message = "가입일은 오늘 또는 과거 날짜여야 합니다.")
private LocalDateTime joinDate;

회원 가입일은 오늘이거나 과거일 수 있습니다. 오늘 가입한 사람도 있으니까요. 이런 경우 @PastOrPresent가 적합합니다.

반대로 미래 날짜를 검증할 때는 @Future를 사용합니다.

 
 
@Future(message = "예약일은 미래 날짜여야 합니다.")
private LocalDate reservationDate;

예약 시스템에서 예약 날짜는 반드시 미래여야 합니다. 과거나 오늘 날짜로 예약을 생성하는 것은 비즈니스 로직상 맞지 않습니다.

 

@FutureOrPresent는 오늘을 포함한 미래 날짜를 허용합니다.

 
@FutureOrPresent(message = "만료일은 오늘 또는 미래 날짜여야 합니다.")
private LocalDate expiryDate;

쿠폰의 만료일은 오늘이거나 미래일 수 있습니다. 오늘까지 유효한 쿠폰도 있으니까요.

이런 날짜 검증은 단순해 보이지만 실무에서 매우 중요합니다. 타임존 문제, 서버 시간과 클라이언트 시간의 차이 등을 고려해야 하기 때문입니다. 스프링의 날짜 검증은 서버의 현재 시간을 기준으로 동작하므로, 이 점을 명확히 이해하고 사용해야 합니다.


6. @Valid와 @Validated의 미묘한 차이

Bean Validation을 사용하다 보면 @Valid와 @Validated라는 두 어노테이션을 만나게 됩니다. 언뜻 보면 같은 역할을 하는 것 같지만, 실제로는 중요한 차이가 있습니다.

 

@Valid는 JSR-303 표준에 정의된 어노테이션입니다. javax.validation 패키지에 속해 있으며, 표준 스펙이기 때문에 스프링이 아닌 다른 자바 프레임워크에서도 사용할 수 있습니다.

 
 
@PostMapping("/users")
public ResponseEntity<User> createUser(@Valid @RequestBody UserRequest request) {
    return ResponseEntity.ok().build();
}

@Valid의 가장 중요한 특징은 중첩된 객체의 검증을 지원한다는 것입니다. 객체 안에 다른 객체가 포함되어 있을 때, 그 내부 객체도 검증하려면 @Valid를 사용해야 합니다.

 
 
public class OrderRequest {
    
    @NotNull
    @Valid  // 중첩 객체도 검증
    private DeliveryInfo deliveryInfo;
}

public class DeliveryInfo {
    
    @NotBlank
    private String recipientName;
    
    @NotBlank
    private String address;
}

OrderRequest를 검증할 때 @Valid가 있으면 DeliveryInfo 내부의 필드들도 자동으로 검증됩니다. 만약 @Valid가 없다면 deliveryInfo가 null인지만 확인하고, 그 안의 필드는 검증하지 않습니다.

 

 

반면 @Validated는 스프링이 제공하는 어노테이션입니다. org.springframework.validation.annotation 패키지에 속해 있으며, @Valid의 기능을 확장한 것입니다.

 
@PostMapping("/users")
public ResponseEntity<User> createUser(@Validated @RequestBody UserRequest request) {
    return ResponseEntity.ok().build();
}

@Validated의 가장 큰 특징은 그룹 검증을 지원한다는 것입니다. 같은 객체라도 상황에 따라 다른 검증 규칙을 적용하고 싶을 때가 있습니다. 예를 들어 사용자를 생성할 때와 수정할 때 필요한 필드가 다를 수 있습니다.

 
 
public class UserRequest {
    
    public interface CreateGroup {}
    public interface UpdateGroup {}
    
    @NotNull(groups = UpdateGroup.class)
    private Long id;
    
    @NotBlank(groups = {CreateGroup.class, UpdateGroup.class})
    @Email
    private String email;
    
    @NotBlank(groups = CreateGroup.class)
    private String password;
}

생성할 때는 id가 필요없지만 수정할 때는 필요합니다. 반대로 비밀번호는 생성할 때는 필수지만 수정할 때는 선택적일 수 있습니다. 이런 경우 그룹을 정의하고, 각 어노테이션에 groups 속성으로 지정합니다.

컨트롤러에서는 상황에 맞는 그룹을 지정하여 검증할 수 있습니다.

 
 
@PostMapping("/users")
public ResponseEntity<User> createUser(
    @Validated(CreateGroup.class) @RequestBody UserRequest request) {
    return ResponseEntity.ok().build();
}

@PutMapping("/users/{id}")
public ResponseEntity<User> updateUser(
    @Validated(UpdateGroup.class) @RequestBody UserRequest request) {
    return ResponseEntity.ok().build();
}

생성 API는 CreateGroup으로, 수정 API는 UpdateGroup으로 검증함으로써, 같은 DTO를 사용하면서도 다른 검증 규칙을 적용할 수 있습니다.

또 하나 중요한 차이는 @Validated는 클래스 레벨에도 적용할 수 있다는 것입니다.

 
 
@Service
@Validated
public class UserService {
    
    public void createUser(@NotNull String email, @Min(18) Integer age) {
        // 메서드 파라미터도 검증됨
    }
}

클래스에 @Validated를 붙이면, 메서드 파라미터에 붙은 검증 어노테이션이 실행 시점에 검증됩니다. 이것은 스프링 AOP를 통해 동작하며, 서비스 레이어에서도 검증을 수행할 수 있게 해줍니다.


7. 언제 어떤 방식을 선택해야 하는가

지금까지 Validator 인터페이스와 Bean Validation, 그리고 다양한 어노테이션들을 살펴봤습니다. 이제 가장 중요한 질문이 남았습니다. 실제 프로젝트에서 언제 어떤 방식을 사용해야 할까요?

 

 

Bean Validation은 단순하고 명확한 필드 검증에 최적화되어 있습니다. 이메일 형식이 맞는지, 문자열 길이가 적절한지, 숫자가 범위 내에 있는지 같은 검증은 Bean Validation으로 처리하는 것이 가장 깔끔합니다. 검증 규칙이 도메인 객체에 명시되어 있어서 코드를 이해하기 쉽고, 테스트하기도 편합니다.

 

 

예를 들어 상품 등록 요청을 검증한다고 생각해봅시다.

 
 
public class ProductRequest {
    
    @NotBlank(message = "상품명은 필수입니다.")
    @Size(min = 1, max = 100, message = "상품명은 1자 이상 100자 이하여야 합니다.")
    private String name;
    
    @NotNull(message = "가격은 필수입니다.")
    @Positive(message = "가격은 양수여야 합니다.")
    private Integer price;
    
    @NotNull(message = "재고는 필수입니다.")
    @Min(value = 0, message = "재고는 0 이상이어야 합니다.")
    private Integer stock;
    
    @Size(max = 1000, message = "상품 설명은 1000자 이하여야 합니다.")
    private String description;
}

이런 검증은 Bean Validation만으로도 충분합니다. 각 필드의 제약사항이 명확하고, 다른 필드나 외부 시스템에 의존하지 않습니다.

하지만 비즈니스 로직이 개입되는 복잡한 검증은 Validator 인터페이스가 더 적합합니다. 여러 필드를 조합해서 판단해야 하거나, 데이터베이스를 조회해야 하거나, 외부 API를 호출해야 하는 경우가 그렇습니다.

 

 

 

주문 생성 요청을 검증하는 경우를 생각해봅시다.

@Component
@RequiredArgsConstructor
public class OrderValidator implements Validator {
    
    private final ProductRepository productRepository;
    private final UserRepository userRepository;
    
    @Override
    public boolean supports(Class<?> clazz) {
        return OrderRequest.class.isAssignableFrom(clazz);
    }
    
    @Override
    public void validate(Object target, Errors errors) {
        OrderRequest order = (OrderRequest) target;
        
        // 결제 수단에 따른 조건부 검증
        if (order.getPaymentMethod() == PaymentMethod.CREDIT_CARD) {
            if (order.getCardNumber() == null || !isValidCardNumber(order.getCardNumber())) {
                errors.rejectValue("cardNumber", "invalid", 
                    "신용카드 결제 시 유효한 카드번호가 필요합니다.");
            }
            
            if (order.getCardExpiry() == null || order.getCardExpiry().isBefore(YearMonth.now())) {
                errors.rejectValue("cardExpiry", "expired", 
                    "카드 유효기간이 만료되었습니다.");
            }
        }
        
        // 날짜 논리 검증
        if (order.getDeliveryStartDate() != null && order.getDeliveryEndDate() != null) {
            if (order.getDeliveryStartDate().isAfter(order.getDeliveryEndDate())) {
                errors.rejectValue("deliveryEndDate", "invalid", 
                    "배송 종료일은 시작일보다 이후여야 합니다.");
            }
        }
        
        // 데이터베이스 조회가 필요한 검증
        for (OrderItem item : order.getItems()) {
            Product product = productRepository.findById(item.getProductId())
                .orElse(null);
            
            if (product == null) {
                errors.rejectValue("items", "notFound", 
                    "존재하지 않는 상품이 포함되어 있습니다.");
                break;
            }
            
            if (item.getQuantity() > product.getStock()) {
                errors.rejectValue("items", "stock", 
                    String.format("%s 상품의 재고가 부족합니다.", product.getName()));
                break;
            }
            
            if (!product.isAvailable()) {
                errors.rejectValue("items", "unavailable", 
                    String.format("%s 상품은 현재 판매 중지 상태입니다.", product.getName()));
                break;
            }
        }
        
        // 사용자 권한 검증
        User user = userRepository.findById(order.getUserId())
            .orElse(null);
            
        if (user != null && !user.isVerified()) {
            errors.reject("userNotVerified", 
                "이메일 인증이 완료된 사용자만 주문할 수 있습니다.");
        }
        
        // 총액 검증
        if (order.getTotalAmount() < order.calculateExpectedTotal()) {
            errors.rejectValue("totalAmount", "invalid", 
                "주문 총액이 올바르지 않습니다.");
        }
    }
    
    private boolean isValidCardNumber(String cardNumber) {
        // Luhn 알고리즘 등을 사용한 카드번호 검증
        return true;
    }
}

이 Validator는 매우 복잡한 비즈니스 로직을 다룹니다. 결제 수단에 따라 다른 검증을 하고, 날짜의 논리적 순서를 확인하며, 데이터베이스에서 상품 정보를 조회하고, 사용자의 인증 상태를 확인합니다. 이런 종류의 검증은 단순한 어노테이션으로는 불가능합니다.

실무에서는 대부분 두 가지를 조합해서 사용합니다. 기본적인 필드 검증은 Bean Validation으로 처리하고, 복잡한 비즈니스 검증은 Validator로 추가하는 것입니다.

 
 
public class OrderRequest {
    
    @NotNull(message = "회원 ID는 필수입니다.")
    private Long userId;
    
    @NotEmpty(message = "주문 상품은 최소 1개 이상이어야 합니다.")
    @Valid
    private List<OrderItem> items;
    
    @NotBlank(message = "배송지 주소는 필수입니다.")
    @Size(max = 200, message = "배송지 주소는 200자 이하여야 합니다.")
    private String deliveryAddress;
    
    @NotNull(message = "결제 수단은 필수입니다.")
    private PaymentMethod paymentMethod;
    
    private String cardNumber;
    private YearMonth cardExpiry;
    
    @NotNull(message = "주문 총액은 필수입니다.")
    @Positive(message = "주문 총액은 양수여야 합니다.")
    private BigDecimal totalAmount;
}

@RestController
@RequiredArgsConstructor
public class OrderController {
    
    private final OrderValidator orderValidator;
    private final OrderService orderService;
    
    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(
        @Valid @RequestBody OrderRequest request,
        BindingResult bindingResult) {
        
        // Bean Validation이 먼저 수행됨
        // 기본 검증 통과 후 추가 비즈니스 검증
        orderValidator.validate(request, bindingResult);
        
        if (bindingResult.hasErrors()) {
            throw new ValidationException(bindingResult);
        }
        
        OrderResponse response = orderService.createOrder(request);
        return ResponseEntity.ok(response);
    }
}

이런 식으로 계층화된 검증 전략을 사용하면, 간단한 검증은 빠르게 처리하고, 복잡한 검증은 필요한 경우에만 수행할 수 있습니다. 또한 검증 실패의 원인을 더 명확하게 파악할 수 있습니다.


8. 중첩 객체 검증과 계층 구조

실제 애플리케이션에서는 단순한 평면 구조의 객체보다 중첩된 복잡한 구조를 다루는 경우가 많습니다. 주문 시스템을 예로 들면, 주문 안에는 배송 정보가 있고, 주문 상품 목록이 있으며, 각 주문 상품은 또 다른 속성들을 가집니다.

 
 
public class OrderRequest {
    
    @NotNull(message = "배송 정보는 필수입니다.")
    @Valid  // 중첩 객체 검증
    private DeliveryInfo deliveryInfo;
    
    @NotEmpty(message = "주문 상품은 최소 1개 이상이어야 합니다.")
    @Valid  // 리스트의 각 요소도 검증
    private List<OrderItem> items;
}

여기서 @Valid가 중요한 역할을 합니다. OrderRequest를 검증할 때 deliveryInfo와 items가 null이 아닌지만 확인하는 것이 아니라, 그 안의 필드들도 모두 검증하려면 @Valid를 반드시 붙여야 합니다.

 
 
public class DeliveryInfo {
    
    @NotBlank(message = "수령인 이름은 필수입니다.")
    @Size(max = 50, message = "수령인 이름은 50자 이하여야 합니다.")
    private String recipientName;
    
    @NotBlank(message = "배송 주소는 필수입니다.")
    @Size(max = 200, message = "배송 주소는 200자 이하여야 합니다.")
    private String address;
    
    @NotBlank(message = "연락처는 필수입니다.")
    @Pattern(regexp = "^01[0-9]-\\d{3,4}-\\d{4}$", 
             message = "올바른 전화번호 형식이 아닙니다.")
    private String phone;
    
    @Size(max = 500, message = "배송 요청사항은 500자 이하여야 합니다.")
    private String deliveryRequest;
}

public class OrderItem {
    
    @NotNull(message = "상품 ID는 필수입니다.")
    @Positive(message = "상품 ID는 양수여야 합니다.")
    private Long productId;
    
    @NotNull(message = "주문 수량은 필수입니다.")
    @Positive(message = "주문 수량은 양수여야 합니다.")
    @Max(value = 999, message = "주문 수량은 999개 이하여야 합니다.")
    private Integer quantity;
    
    @NotNull(message = "상품 가격은 필수입니다.")
    @PositiveOrZero(message = "상품 가격은 0 이상이어야 합니다.")
    private BigDecimal price;
}

이렇게 구조화하면 각 객체가 자신의 검증 규칙을 명확히 가지게 됩니다. DeliveryInfo는 배송 정보의 유효성만 책임지고, OrderItem은 주문 상품의 유효성만 책임집니다. 이것은 단일 책임 원칙을 따르는 좋은 설계입니다.

검증 에러가 발생하면 스프링은 정확히 어느 중첩 레벨의 어느 필드에서 문제가 발생했는지 알려줍니다. 예를 들어 "deliveryInfo.phone"처럼 경로를 포함한 필드명으로 에러를 보고하므로, 프론트엔드에서도 정확한 위치에 에러 메시지를 표시할 수 있습니다.


9. 커스텀 Validation - 도메인 특화 검증 만들기

제공되는 기본 어노테이션들만으로 모든 검증을 처리할 수는 없습니다. 비즈니스 도메인마다 고유한 검증 규칙이 있기 때문입니다. 이럴 때는 커스텀 어노테이션을 만들어서 사용할 수 있습니다.

예를 들어 한국 주민등록번호를 검증하는 어노테이션을 만들어보겠습니다.

 
 
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = SocialSecurityNumberValidator.class)
public @interface SocialSecurityNumber {
    String message() default "올바른 주민등록번호 형식이 아닙니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

어노테이션은 세 가지 필수 속성을 가져야 합니다. message는 검증 실패 시 표시할 메시지이고, groups는 그룹 검증을 위한 것이며, payload는 검증과 관련된 메타데이터를 전달하기 위한 것입니다.

실제 검증 로직은 ConstraintValidator 인터페이스를 구현하여 작성합니다.

 
 
public class SocialSecurityNumberValidator 
    implements ConstraintValidator<SocialSecurityNumber, String> {
    
    @Override
    public void initialize(SocialSecurityNumber constraintAnnotation) {
        // 초기화 로직 (필요한 경우)
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;  // null은 @NotNull과 함께 사용
        }
        
        // 하이픈 제거
        String ssn = value.replaceAll("-", "");
        
        // 13자리 숫자인지 확인
        if (!ssn.matches("^\\d{13}$")) {
            return false;
        }
        
        // 생년월일 부분 검증
        String birthDate = ssn.substring(0, 6);
        int genderCode = Integer.parseInt(ssn.substring(6, 7));
        
        // 세기 결정
        int year;
        if (genderCode == 1 || genderCode == 2) {
            year = 1900 + Integer.parseInt(birthDate.substring(0, 2));
        } else if (genderCode == 3 || genderCode == 4) {
            year = 2000 + Integer.parseInt(birthDate.substring(0, 2));
        } else {
            return false;
        }
        
        int month = Integer.parseInt(birthDate.substring(2, 4));
        int day = Integer.parseInt(birthDate.substring(4, 6));
        
        // 날짜 유효성 검사
        if (month < 1 || month > 12 || day < 1 || day > 31) {
            return false;
        }
        
        // 체크섬 검증 (주민등록번호의 마지막 자리)
        return validateChecksum(ssn);
    }
    
    private boolean validateChecksum(String ssn) {
        int[] weights = {2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5};
        int sum = 0;
        
        for (int i = 0; i < 12; i++) {
            sum += Character.getNumericValue(ssn.charAt(i)) * weights[i];
        }
        
        int checksum = (11 - (sum % 11)) % 10;
        int lastDigit = Character.getNumericValue(ssn.charAt(12));
        
        return checksum == lastDigit;
    }
}

이제 이 어노테이션을 사용할 수 있습니다.

 
 
 
public class UserRegistrationRequest {
    
    @NotBlank(message = "이름은 필수입니다.")
    private String name;
    
    @SocialSecurityNumber  // 커스텀 어노테이션 사용
    private String ssn;
}

커스텀 어노테이션을 만들면 복잡한 검증 로직을 재사용 가능한 형태로 캡슐화할 수 있습니다. 주민등록번호 검증이 필요한 모든 곳에서 단순히 @SocialSecurityNumber를 붙이기만 하면 됩니다. 검증 로직이 변경되어도 Validator 구현체 하나만 수정하면 모든 곳에 반영됩니다.

 

 

또 다른 예로, 비즈니스 규칙을 검증하는 커스텀 어노테이션도 만들 수 있습니다. 예를 들어 프로모션 코드가 유효한지 검증하는 어노테이션을 만들어보겠습니다.

 
 
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = ValidPromoCodeValidator.class)
public @interface ValidPromoCode {
    String message() default "유효하지 않은 프로모션 코드입니다.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

@Component
public class ValidPromoCodeValidator 
    implements ConstraintValidator<ValidPromoCode, String> {
    
    private final PromoCodeRepository promoCodeRepository;
    
    public ValidPromoCodeValidator(PromoCodeRepository promoCodeRepository) {
        this.promoCodeRepository = promoCodeRepository;
    }
    
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        if (value == null || value.trim().isEmpty()) {
            return true;  // 선택적 필드로 사용할 경우
        }
        
        PromoCode promoCode = promoCodeRepository.findByCode(value)
            .orElse(null);
        
        if (promoCode == null) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("존재하지 않는 프로모션 코드입니다.")
                .addConstraintViolation();
            return false;
        }
        
        if (!promoCode.isActive()) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("만료되거나 비활성화된 프로모션 코드입니다.")
                .addConstraintViolation();
            return false;
        }
        
        if (promoCode.getUsageCount() >= promoCode.getMaxUsage()) {
            context.disableDefaultConstraintViolation();
            context.buildConstraintViolationWithTemplate("사용 횟수가 초과된 프로모션 코드입니다.")
                .addConstraintViolation();
            return false;
        }
        
        return true;
    }
}

이 Validator는 데이터베이스에 의존합니다. 스프링이 Validator를 빈으로 관리하므로, Repository를 주입받아 사용할 수 있습니다. 또한 상황에 따라 다른 에러 메시지를 제공하기 위해 ConstraintValidatorContext를 활용했습니다.

이런 식으로 커스텀 검증을 만들면, 복잡한 도메인 규칙도 선언적인 방식으로 표현할 수 있습니다.


10. 에러 처리 전략 - 사용자에게 친절하게

검증은 단순히 오류를 찾아내는 것으로 끝나지 않습니다. 발견된 오류를 사용자에게 어떻게 전달하느냐가 중요합니다.

 

스프링은 검증 실패 시 MethodArgumentNotValidException을 발생시키는데, 이를 적절히 처리하여 사용자 친화적인 에러 응답을 만들어야 합니다.

글로벌 예외 핸들러를 만들어서 모든 검증 에러를 일관되게 처리할 수 있습니다.

 
 
@RestControllerAdvice
public class GlobalExceptionHandler {
    
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ValidationErrorResponse> handleValidationException(
        MethodArgumentNotValidException ex) {
        
        Map<String, String> fieldErrors = new HashMap<>();
        
        ex.getBindingResult().getFieldErrors().forEach(error -> {
            String fieldName = error.getField();
            String errorMessage = error.getDefaultMessage();
            
            // 첫 번째 에러 메시지만 유지 (중복 방지)
            fieldErrors.putIfAbsent(fieldName, errorMessage);
        });
        
        ValidationErrorResponse response = ValidationErrorResponse.builder()
            .status(HttpStatus.BAD_REQUEST.value())
            .message("입력값 검증에 실패했습니다.")
            .fieldErrors(fieldErrors)
            .timestamp(LocalDateTime.now())
            .build();
        
        return ResponseEntity.badRequest().body(response);
    }
}

응답 객체는 클라이언트가 에러를 쉽게 처리할 수 있도록 구조화합니다.

 
 
@Getter
@Builder
public class ValidationErrorResponse {
    private int status;
    private String message;
    private Map<String, String> fieldErrors;
    private LocalDateTime timestamp;
}

이런 응답은 프론트엔드에서 각 필드 옆에 에러 메시지를 표시하기 쉽게 만들어줍니다.

 
 
 
{
  "status": 400,
  "message": "입력값 검증에 실패했습니다.",
  "fieldErrors": {
    "email": "올바른 이메일 형식이 아닙니다.",
    "password": "비밀번호는 8자 이상이어야 합니다.",
    "name": "이름은 필수입니다."
  },
  "timestamp": "2026-01-29T10:30:00"
}

중첩 객체의 에러도 적절히 처리됩니다.

 
 
{
  "status": 400,
  "message": "입력값 검증에 실패했습니다.",
  "fieldErrors": {
    "deliveryInfo.recipientName": "수령인 이름은 필수입니다.",
    "deliveryInfo.phone": "올바른 전화번호 형식이 아닙니다.",
    "items[0].quantity": "주문 수량은 양수여야 합니다."
  },
  "timestamp": "2026-01-29T10:30:00"
}

필드명에 경로가 포함되어 있어서 정확히 어느 필드에서 문제가 발생했는지 알 수 있습니다.

좀 더 상세한 정보가 필요하다면 에러 응답을 확장할 수 있습니다.

 
 
 
@Getter
@Builder
public class DetailedValidationErrorResponse {
    private int status;
    private String message;
    private List<FieldError> errors;
    private LocalDateTime timestamp;
    
    @Getter
    @Builder
    public static class FieldError {
        private String field;
        private Object rejectedValue;
        private String message;
        private String code;
    }
}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<DetailedValidationErrorResponse> handleValidation(
    MethodArgumentNotValidException ex) {
    
    List<DetailedValidationErrorResponse.FieldError> errors = 
        ex.getBindingResult().getFieldErrors().stream()
            .map(error -> DetailedValidationErrorResponse.FieldError.builder()
                .field(error.getField())
                .rejectedValue(error.getRejectedValue())
                .message(error.getDefaultMessage())
                .code(error.getCode())
                .build())
            .collect(Collectors.toList());
    
    DetailedValidationErrorResponse response = 
        DetailedValidationErrorResponse.builder()
            .status(HttpStatus.BAD_REQUEST.value())
            .message("입력값 검증에 실패했습니다.")
            .errors(errors)
            .timestamp(LocalDateTime.now())
            .build();
    
    return ResponseEntity.badRequest().body(response);
}

이렇게 하면 각 에러에 대해 거부된 값, 에러 코드 등 더 자세한 정보를 제공할 수 있습니다. 에러 코드는 클라이언트에서 다국어 처리를 할 때 유용하게 사용할 수 있습니다.


마치며

이번 글에서는 스프링이 제공하는 두 가지 유효성 검증 메커니즘인 Validator 인터페이스와 Bean Validation에 대해  살펴보았습니다.

 

스프링에서 제공하는 두 가지 유효성 검사 방식은 서로 대체 관계가 아니라, 역할이 다른 도구라고 이해하는 것이 좋습니다.

Bean Validation은 입력값의 기본적인 유효성을 보장하기 위한 도구이며,
Spring Validator는 도메인과 비즈니스 규칙을 보호하기 위한 도구입니다.

검증의 성격에 따라 적절한 방식을 선택하고, 필요하다면 두 방식을 함께 사용하는 것이 안정적이고 유지보수하기 쉬운 애플리케이션을 만드는 데 큰 도움이 됩니다.

 

 

저작자표시 비영리 (새창열림)

'스프링' 카테고리의 다른 글

스프링 트랜잭션(@Transactional) 전파레벨 7가지  (0) 2026.02.13
FIRST 원칙으로 바라본 테스트 코드 작성법  (0) 2026.01.30
스프링에서 스코프(Scope)란 무엇인가?  (0) 2026.01.19
스프링을 스프링답게 만드는 진짜 이유: IoC·AOP·PSA 삼각형의 정체  (0) 2026.01.18
스프링 배치란?  (0) 2025.12.11
'스프링' 카테고리의 다른 글
  • 스프링 트랜잭션(@Transactional) 전파레벨 7가지
  • FIRST 원칙으로 바라본 테스트 코드 작성법
  • 스프링에서 스코프(Scope)란 무엇인가?
  • 스프링을 스프링답게 만드는 진짜 이유: IoC·AOP·PSA 삼각형의 정체
깊은바다속꼬북이
깊은바다속꼬북이
  • 깊은바다속꼬북이
    CodeBlossom
    깊은바다속꼬북이
  • 전체
    오늘
    어제
    • 분류 전체보기 (53) N
      • 라이징 캠프 (4)
      • 객채지향 개발론 (3)
      • 스프링 (10) N
      • 네트워크 (2)
      • 자바 (16)
      • 자료구조 (3)
      • 운영체제 (0)
      • 데이터베이스 (4)
      • 디자인패턴 (7)
      • JSP (1)
      • 개발 알쓸신잡 (2)
      • 일반 교양 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    spring
    자료구조
    GC
    MySQL 옵티마이저
    전략 패턴(Strategy Pattern)
    JUnnit5
    디자인 패턴
    junnit5프레임워크
    스프링
    jit-compiler
    템플릿 메서드 패턴(Template Method Pattern)
    자바 Socket 클래스
    개발 교훈
    트랜잭션 전파레벨
    개발 철학
    어댑터 패턴(Adapter Pattern)
    java 버전별 특징
    디자인패턴
    java
    jvm 클래스 로더
    싱글턴 패턴(Singleton Pattern)
    프로그램밍 언어
    개발자 철학
    백엔드
    MySQL 파서
    java data area
    한국어 검색
    객체지향
    JVM
    mockito라이브러리
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
깊은바다속꼬북이
Spring Validation 완전 정복 - 올바른 유효성 검증 전략
상단으로

티스토리툴바