이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Spring의
Validator와 Jakarta Bean Validation은 어떤 관계인가?@NotNull,@NotBlank,@Valid같은 제약 조건은 어느 계층의 규칙을 표현해야 하는가?- 커스텀 제약 조건을 만들기 전에 어떤 설계 판단을 먼저 해야 하는가?
개요
Spring의 검증 인프라는 크게 두 축으로 이해하면 쉽다. 하나는 Spring이 오래전부터 제공한 Validator 인터페이스이고, 다른 하나는 Jakarta Bean Validation 표준이다. Spring 공식 문서는 Bean Validation 제공자를 LocalValidatorFactoryBean으로 감싸 Spring의 Validator와 Bean Validation의 ValidatorFactory 양쪽에서 사용할 수 있게 설명한다.
현대 Spring MVC와 Spring Boot 애플리케이션에서는 Bean Validation을 가장 자주 만난다. 요청 DTO에 @NotBlank, @Email, @Size를 붙이고 Controller 파라미터에 @Valid를 붙이는 방식이 대표적이다. 하지만 검증의 본질은 애너테이션 사용법이 아니라 경계에서 들어오는 값이 애플리케이션의 계약을 깨지 않는지 확인하는 것이다.
원리
Bean Validation은 객체의 필드, 프로퍼티, 메서드 파라미터, 반환값, 객체 그래프에 제약 조건을 선언한다. 검증기는 이 선언을 읽고 위반 사항을 ConstraintViolation으로 돌려준다. Spring MVC는 이 과정을 요청 바인딩 흐름에 연결하고, 서비스 계층에서는 Method Validation을 통해 메서드 호출 계약에 연결할 수 있다. 현대 Spring에서는 jakarta.validation.* 패키지 기준으로 이해하는 것이 좋다.
가장 중요한 설계 기준은 검증 규칙의 성격이다. 이메일 형식, 문자열 길이, 필수 입력 여부는 입력 DTO의 계약으로 표현하기 좋다. 반면 주문 가능 여부, 회원 등급별 할인 정책, 재고 차감 가능성은 단순 필드 제약이 아니라 도메인 의사결정이다. 이런 규칙을 애너테이션으로 억지로 밀어 넣으면 테스트와 변경이 어려워진다.
객체 그래프 검증에는 @Valid가 필요하다. 예를 들어 주문 요청 안에 배송지 객체가 들어 있다면 바깥 DTO만 검증해서는 내부 객체의 제약 조건이 실행되지 않을 수 있다. 다만 @Valid 자체는 @NotNull처럼 값을 제한하는 constraint가 아니라 cascade 검증을 연결하는 표식이다. 컬렉션 요소까지 검증할 때도 중첩 구조를 의식해야 한다.
코드 예제
public record SignupRequest(
@NotBlank String email,
@Size(min = 8, max = 64) String password,
@Valid AddressRequest address
) {
}
public record AddressRequest(
@NotBlank String city,
@NotBlank String street,
@NotBlank String zipCode
) {
}이 예제에서 SignupRequest는 HTTP 요청 계약을 표현한다. 이메일이 비어 있지 않아야 하고 비밀번호 길이가 맞아야 하며 배송지 객체도 함께 검증되어야 한다. 다만 이메일 중복 여부나 비밀번호가 과거 유출 목록에 있는지 여부는 외부 조회가 필요한 정책이므로 서비스나 별도 정책 객체에서 다루는 편이 낫다.
@Component
public class SignupPolicy {
public void validate(SignupCommand command) {
if (command.email().endsWith("@example.invalid")) {
throw new InvalidSignupException("unsupported email domain");
}
}
}애너테이션 검증과 정책 검증을 나누면 DTO는 입력 형태를 지키고, 정책 객체는 유스케이스의 의미 있는 판단을 담당한다.
커스텀 제약 조건
커스텀 제약 조건은 반복되는 입력 규칙을 표준 검증 흐름에 태울 때 유용하다. 예를 들어 여러 DTO에서 같은 코드 형식을 검증한다면 @ProductCode 같은 애너테이션을 만들 수 있다. 하지만 커스텀 제약 조건 안에서 Repository를 조회하거나 복잡한 유스케이스 결정을 수행하면 검증 계층과 비즈니스 계층이 섞인다.
좋은 커스텀 제약 조건은 값 하나 또는 객체 하나의 안정적인 형태 규칙을 다룬다. 나쁜 커스텀 제약 조건은 실행 시점, 트랜잭션, 외부 API, 현재 사용자 권한에 크게 의존한다. 후자는 서비스 정책으로 드러내는 편이 읽기 쉽다.
실전 팁
- 요청 DTO에는 외부 계약을, 도메인 객체에는 불변식을, 서비스에는 유스케이스 진입 조건을 둔다.
@NotNull은 null만 막는다. 문자열 필수 입력에는@NotBlank가 더 의도에 맞는 경우가 많다.- 중첩 객체와 컬렉션은
@Valid가 빠지면 내부 제약이 실행되지 않는지 반드시 확인한다. 동시에 null 금지가 필요하다면@NotNull같은 constraint를 따로 둔다. - 검증 메시지는 화면 문구와 내부 로그 문구를 분리한다. 메시지 국제화나 오류 코드 체계를 나중에 붙이기 쉬워진다.
- 커스텀 제약 조건을 만들기 전에 같은 규칙을 DTO 구조 변경이나 명시적 정책 객체로 표현할 수 있는지 먼저 본다.
위험 신호!
- DTO 애너테이션 안에 도메인 정책을 모두 넣으려 한다.
- 같은 제약 조건이 Controller, Service, Entity에 의미 없이 반복된다.
@Pattern정규식이 길어져 팀원이 읽을 수 없다.- 검증 실패 메시지가 내부 클래스명이나 예외 메시지를 그대로 노출한다.
- 중첩 객체 검증이 필요한데
@Valid여부를 테스트하지 않는다. @Valid만 붙이고 실제 제약 조건을 선언하지 않았는데 값이 제한될 것이라고 기대한다.
확인 질문
확인 질문
- Spring에서 Bean Validation 구현체를 Spring
Validator로 연결하는 대표 컴포넌트는 무엇인가?
LocalValidatorFactoryBean이다.- 입력 DTO의 Bean Validation이 잘 맞는 규칙은 어떤 규칙인가?
- 필수 여부, 길이, 형식, 단일 객체의 값 범위처럼 외부 입력 계약을 표현하는 규칙이다.
- 커스텀 제약 조건을 만들 때 피해야 할 것은 무엇인가?
- Repository 조회, 권한 판단, 트랜잭션 의존 같은 유스케이스 결정을 검증 애너테이션 안에 숨기는 것이다.