@NotNull과 @Email만 쓰고 있다면, Bean Validation의 10%도 활용하지 못하고 있는 거다. Jakarta Bean Validation은 30개 이상의 제약 어노테이션을 제공하고, 조합과 커스텀까지 더하면 대부분의 형식 검증을 선언적으로 처리할 수 있다. 검증 계층을 어떻게 나눌지는 Spring 검증 전략 가이드에서 다뤘으니, 이 문서에서는 어노테이션 자체를 깊이 파고든다.
ModelAttribute/RequestBody} D --> E[Data Binding] E --> F{Validation 수행
@Valid / @Validated} F -->|제약 조건 위반| G[BindingResult에
FieldError 기록] F -->|통과| H[Controller Method 실행] G --> I[GlobalExceptionHandler
MethodArgumentNotValidException] I --> J[Error Response]
Null 검증의 세 가지 선택지
Bean Validation에서 가장 먼저 부딪히는 질문이다. @NotNull, @NotBlank, @NotEmpty — 이름이 비슷해서 아무거나 골라 쓰기 쉽지만, 각각 거부하는 범위가 다르다.
| 어노테이션 | null | "" (빈 문자열) | " " (공백만) | 적용 타입 |
|---|---|---|---|---|
@NotNull | X | O | O | 모든 타입 |
@NotEmpty | X | X | O | String, Collection, Map, Array |
@NotBlank | X | X | X | String만 |
O는 통과, X는 거부다.
핵심은 적용 타입과 거부 범위가 반비례한다는 점이다. @NotNull은 모든 타입에 쓸 수 있지만 null만 잡고, @NotBlank는 String 전용이지만 공백까지 잡는다.
타입별 선택 기준
- String →
@NotBlank를 쓴다. 사용자 입력에서 빈 문자열이나 공백만 들어오는 건 null이나 다름없다. - Integer, Long, Enum 등 래퍼 타입 →
@NotNull을 쓴다.@NotBlank는 컴파일은 되지만 런타임에ConstraintDeclarationException이 터진다. - List, Set, Map →
@NotEmpty를 쓴다. 빈 컬렉션을 거부하면서 null도 같이 잡아준다.
public record FeedCreateRequest(
@NotBlank(message = "제목은 필수입니다.")
String title,
@NotBlank(message = "내용은 필수입니다.")
String content,
@NotNull(message = "카테고리는 필수입니다.")
Category category,
@NotEmpty(message = "태그를 하나 이상 입력해주세요.")
List<String> tags
) {}
빈 문자열 ""이 검증을 통과한다. 프론트엔드에서 빈 input을 전송하면 ""이 넘어오는데, @NotNull은 이걸 유효한 값으로 판단한다. String 필드에는 반드시 @NotBlank를 쓴다.
문자열 검증 어노테이션
String에 @NotBlank로 존재 여부를 확인했다면, 다음은 값의 형식을 검증할 차례다.
@Size — 길이 제한
문자열, 컬렉션, 배열의 크기를 제한한다. min과 max를 조합해서 범위를 지정한다.
@NotBlank
@Size(min = 2, max = 20, message = "이름은 2~20자여야 합니다.")
String name,
@Size(max = 500, message = "자기소개는 500자 이하여야 합니다.")
String bio
@Size는 null을 통과시킨다. "null이면 크기를 잴 수 없으니 유효하다"는 Bean Validation의 설계 철학이다. 필수 필드라면 @NotBlank와 함께 쓴다.
거의 모든 제약 어노테이션은 null을 유효한 값으로 취급한다. "null 검증은 @NotNull 계열의 책임"이라는 원칙 때문이다. 각 어노테이션이 null을 개별적으로 처리하면 중복 검증이 되고, @NotNull의 존재 의미가 사라진다. 그래서 @Size, @Pattern, @Email, @Min 등은 전부 null이면 통과한다.
@Pattern — 정규식 매칭
정규식으로 문자열 형식을 검증한다. 비밀번호 규칙, 전화번호, 아이디 형식 등에 유용하다.
@NotBlank
@Pattern(
regexp = "^[a-zA-Z0-9_]{4,20}$",
message = "아이디는 영문, 숫자, 언더바만 사용 가능하며 4~20자여야 합니다."
)
String username,
@NotBlank
@Pattern(
regexp = "^01[016789]-\\d{3,4}-\\d{4}$",
message = "올바른 전화번호 형식이 아닙니다."
)
String phoneNumber
@Pattern으로 길이까지 검증하는 것은 가능하지만, @Size와 역할이 겹치게 된다. 정규식은 형식 패턴에, 길이 제한은 @Size에 맡기는 편이 의도가 명확하다.
@Email — 이메일 검증의 함정
@Email은 이름과 달리 매우 관대한 검증을 한다. RFC 5322 스펙을 느슨하게 따르기 때문에, "a@b" 같은 값도 통과시킨다.
// 이것만으로는 부족하다
@Email
String email; // "a@b" 통과, "" 통과, null 통과
실무에서는 @NotBlank과 함께 쓰고, 필요하면 regexp로 더 엄격하게 제한한다.
@NotBlank(message = "이메일은 필수입니다.")
@Email(
regexp = "^[\\w.+-]+@[\\w-]+\\.[\\w.]+$",
message = "올바른 이메일 형식이 아닙니다."
)
String email
@Email의 regexp 속성을 지정하면 기본 검증 로직 대신 해당 정규식으로 검증한다. 너무 엄격하게 만들면 유효한 이메일을 거부할 수 있으니, 도메인 부분에 점(.)이 있는지 정도만 추가 확인하는 수준이 적당하다.
숫자와 시간 검증
숫자 범위
숫자 타입에 사용할 수 있는 어노테이션은 용도별로 세분화되어 있다.
| 어노테이션 | 의미 | 예시 |
|---|---|---|
@Min(value) | 최솟값 이상 | @Min(1) → 1 이상 |
@Max(value) | 최댓값 이하 | @Max(100) → 100 이하 |
@Positive | 양수, 0 제외 | 가격, 수량 |
@PositiveOrZero | 0 이상 | 재고, 포인트 |
@Negative | 음수, 0 제외 | — |
@NegativeOrZero | 0 이하 | — |
@Digits(integer, fraction) | 자릿수 제한 | @Digits(integer=5, fraction=2) → 99999.99까지 |
@DecimalMin / @DecimalMax | BigDecimal 범위 | 금융 정밀 연산 |
@NotNull(message = "온도 민감도는 필수입니다.")
@Min(value = 1, message = "온도 민감도는 1 이상이어야 합니다.")
@Max(value = 5, message = "온도 민감도는 5 이하여야 합니다.")
Integer temperatureSensitivity,
@Positive(message = "가격은 양수여야 합니다.")
Integer price,
@PositiveOrZero(message = "재고는 0 이상이어야 합니다.")
Integer stock
@Min/@Max vs @Positive 중 어떤 걸 쓸지 고민된다면 — 의미가 명확한 쪽을 고른다. "1 이상"이라는 규칙이면 @Min(1), "양수여야 한다"는 개념이면 @Positive. 결과는 같지만 코드를 읽는 사람에게 전달하는 의도가 다르다.
시간 범위
LocalDate, LocalDateTime, Instant 등 Java 시간 타입에 사용한다.
| 어노테이션 | 의미 | 용도 |
|---|---|---|
@Past | 과거 | 생년월일 |
@PastOrPresent | 과거 또는 현재 | 기록 일시 |
@Future | 미래 | 예약 날짜 |
@FutureOrPresent | 미래 또는 현재 | 마감일 |
@Past(message = "생년월일은 과거 날짜여야 합니다.")
LocalDate birthDate,
@FutureOrPresent(message = "예약 날짜는 오늘 이후여야 합니다.")
LocalDate reservationDate
중첩 객체와 컬렉션 검증
DTO 안에 다른 DTO가 있을 때, 안쪽 객체의 검증을 자동으로 실행하려면 @Valid를 붙여야 한다. 이걸 빠뜨리면 안쪽 객체에 아무리 어노테이션을 붙여도 전부 무시된다.
public record ProfileUpdateRequest(
@NotBlank(message = "이름은 필수입니다.")
String name,
@Valid // 이걸 빠뜨리면 LocationRequest의 검증이 실행되지 않는다
@NotNull(message = "위치 정보는 필수입니다.")
LocationRequest location
) {
public record LocationRequest(
@NotNull(message = "위도는 필수입니다.")
@DecimalMin(value = "-90.0") @DecimalMax(value = "90.0")
Double latitude,
@NotNull(message = "경도는 필수입니다.")
@DecimalMin(value = "-180.0") @DecimalMax(value = "180.0")
Double longitude
) {}
}
@Valid와 @NotNull이 각각 다른 일을 한다는 점에 주의한다. @NotNull은 location 자체가 null인지 확인하고, @Valid는 location이 null이 아닐 때 안쪽 필드를 검증한다.
컬렉션 요소 검증
컬렉션 자체가 아니라 안에 들어있는 각 요소를 검증하고 싶으면, 타입 파라미터 앞에 @Valid를 붙인다.
public record OrderCreateRequest(
@NotEmpty(message = "주문 항목은 하나 이상이어야 합니다.")
List<@Valid OrderItemRequest> items
) {}
각 주문 항목의 내부 필드도 검증되어야 하니, OrderItemRequest에도 제약을 선언한다.
public record OrderItemRequest(
@NotNull(message = "상품 ID는 필수입니다.")
Long productId,
@Positive(message = "수량은 양수여야 합니다.")
Integer quantity
) {}
List<@Valid OrderItemRequest>에서 @Valid는 Java의 타입 어노테이션 문법이다. 리스트의 각 요소에 대해 OrderItemRequest의 제약을 검증하라는 뜻이다. @Valid 없이 List<OrderItemRequest>만 쓰면, 리스트에 잘못된 요소가 들어와도 아무 경고 없이 통과한다.
검증 그룹으로 상황별 분리
같은 DTO를 생성과 수정에 재사용하면서, 상황에 따라 다른 검증 규칙을 적용하고 싶을 때 사용한다.
그룹 정의
그룹은 빈 인터페이스로 정의한다. 마커 인터페이스와 같은 원리다.
public interface OnCreate {}
public interface OnUpdate {}
DTO에 그룹 지정
public record UserRequest(
@NotBlank(groups = OnCreate.class, message = "이름은 필수입니다.")
String name,
@NotBlank(groups = OnCreate.class, message = "이메일은 필수입니다.")
@Email(groups = {OnCreate.class, OnUpdate.class})
String email,
@NotBlank(groups = OnCreate.class, message = "비밀번호는 필수입니다.")
@Size(min = 8, groups = {OnCreate.class, OnUpdate.class})
String password
) {}
groups를 지정하지 않은 제약은 Default 그룹에 속한다. 특정 그룹을 활성화하면 Default 그룹은 자동으로 비활성화되니, 의도적으로 그룹을 관리해야 한다.
컨트롤러에서 그룹 활성화
여기서 중요한 점이 있다. 그룹을 활성화하려면 @Valid가 아닌 @Validated를 써야 한다.
@PostMapping
public ResponseEntity<UserDto> create(
@Validated(OnCreate.class) @RequestBody UserRequest request) { ... }
@PatchMapping("/{id}")
public ResponseEntity<UserDto> update(
@Validated(OnUpdate.class) @RequestBody UserRequest request) { ... }
@Valid는 Jakarta 표준이고, @Validated는 Spring 확장이다. @Valid는 그룹 지정을 지원하지 않는다.
검증 그룹은 강력하지만, DTO가 복잡해지면 어떤 필드가 어떤 상황에서 검증되는지 추적하기 어렵다. 필드 차이가 크다면 CreateRequest와 UpdateRequest로 DTO를 분리하는 편이 가독성이 좋다. 그룹은 대부분의 필드가 겹치고 한두 개만 다를 때 유용하다.
커스텀 제약 어노테이션 작성
기본 제공 어노테이션으로 표현하기 어려운 검증 규칙이 있다. "비밀번호에 대문자, 소문자, 숫자가 각각 하나 이상 포함되어야 한다"처럼, 하나의 @Pattern으로는 가독성이 떨어지는 경우다.
커스텀 제약 어노테이션은 두 부분으로 구성된다. 어노테이션 정의와 검증 로직이다.
어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {
String message() default "비밀번호는 대문자, 소문자, 숫자를 각각 하나 이상 포함해야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
message, groups, payload — 이 세 속성은 모든 제약 어노테이션이 반드시 가져야 하는 필수 속성이다. Bean Validation 스펙이 요구하는 것이므로 하나라도 빠지면 런타임 에러가 발생한다.
검증 로직
public class StrongPasswordValidator
implements ConstraintValidator<StrongPassword, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null 검증은 @NotBlank의 몫
return value.chars().anyMatch(Character::isUpperCase)
&& value.chars().anyMatch(Character::isLowerCase)
&& value.chars().anyMatch(Character::isDigit);
}
}
null일 때 true를 반환하는 건 앞서 설명한 Bean Validation의 null 처리 원칙을 따르는 거다. null 체크는 @NotNull 계열에 위임한다.
사용
public record SignUpRequest(
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, max = 30, message = "비밀번호는 8~30자여야 합니다.")
@StrongPassword
String password
) {}
@NotBlank → @Size → @StrongPassword 각 어노테이션이 하나의 규칙만 담당하니 조합과 재사용이 쉽다.
@Pattern의 정규식이 한 줄을 넘기거나, 같은 검증 로직을 세 곳 이상에서 반복하고 있다면 커스텀 어노테이션으로 추출할 때다. 한 곳에서만 쓰는 검증이라면 @Pattern이 더 실용적이다.
형식적 검증"] end subgraph "Domain Layer" Entity[Entity] Entity_V["비즈니스 로직 기반
상태 검증"] end DTO -->|Data Transfer| Entity DTO_V -.->|1차 필터링| DTO Entity_V -.->|데이터 무결성 보장| Entity
자주 하는 실수
@NotNull은 null만 거부한다. 빈 문자열 ""과 공백 " "은 유효한 값으로 통과한다. 프론트엔드에서 빈 input을 전송하면 ""이 넘어오는데, 이걸 잡지 못한다.
String 필드에는 @NotBlank를 쓴다. 진짜 null만 체크하면 되는 String 필드는 거의 없다.
[!DANGER] @Email 단독 사용
@Email은 null과 빈 문자열을 모두 통과시킨다. 이메일이 필수 필드라면 반드시 @NotBlank와 함께 써야 한다.
```java
// 잘못된 사용 — null, "" 모두 통과
String email;
// 올바른 사용
@NotBlank @Email
String email;
```
[!DANGER] 중첩 객체에 @Valid 누락
중첩된 객체에 @Valid를 빠뜨리면, 안쪽 객체에 붙인 모든 제약 어노테이션이 무시된다. 컴파일 에러도, 런타임 경고도 없이 조용히 검증을 건너뛴다. 디버깅하기 가장 까다로운 실수다.
```java
// 잘못 — LocationRequest의 @NotNull이 동작하지 않음
LocationRequest location;
// 올바름
@Valid LocationRequest location;
```
[!WARNING] 엔티티에 Bean Validation 붙이기
DTO에 붙여야 할 검증 어노테이션을 엔티티에 붙이는 실수다. 엔티티와 DTO는 검증 기준이 다르다. 엔티티의 name 필드는 DB에서 읽어올 때 이미 값이 있지만, DTO의 name은 사용자 입력이라 null일 수 있다. 엔티티에 @NotBlank를 붙이면 DB에서 정상적으로 읽어온 데이터도 검증 대상이 된다.
Bean Validation은 요청 데이터의 형식 검증이 목적이다. 검증의 대상은 DTO다.
[!WARNING] @Valid와 @Validated 혼동
@Valid는 Jakarta 표준이고, @Validated는 Spring 확장이다. 검증 그룹을 사용할 때 @Valid를 쓰면 그룹 지정이 무시되고 모든 제약이 실행된다.
- 그룹 없이 단순 검증 → @Valid 또는 @Validated 둘 다 가능
- 그룹 지정 필요 → 반드시 @Validated(Group.class)
[!WARNING] message 속성 생략
기본 메시지는 "must not be blank" 같은 영어 문장이다. API 응답으로 그대로 내보내면 사용자가 알아볼 수 없다. message를 항상 명시하는 습관을 들인다.
```java
// 기본 메시지: "must not be blank"
@NotBlank
String name;
// 명시적 메시지
@NotBlank(message = "이름은 필수입니다.")
String name;
```
[!WARNING] 비즈니스 규칙을 Bean Validation으로 해결하려 하기
"이메일이 이미 등록되어 있는지"를 커스텀 Validator에서 DB 조회로 확인하려는 시도가 있다. 기술적으로 가능하지만, Bean Validation은 값 자체의 형식을 검증하는 도구다. DB 조회, 외부 API 호출 같은 비즈니스 로직은 서비스 계층에서 처리해야 한다.
검증 계층 분리에 대해서는 Spring 검증 전략 가이드를 참고한다.
어노테이션 선택 빠른 참조
어떤 어노테이션을 쓸지 바로 결정하고 싶을 때 이 표를 참고한다.
| 상황 | 어노테이션 |
|---|---|
| String 필수값 | @NotBlank |
| Enum, Integer 등 필수값 | @NotNull |
| 컬렉션 최소 1개 | @NotEmpty |
| 문자열 길이 | @Size(min, max) |
| 문자열 형식 | @Pattern(regexp) |
| 이메일 | @NotBlank + @Email |
| 숫자 범위 | @Min / @Max |
| 양수만 | @Positive |
| 과거 날짜 | @Past |
| 미래 날짜 | @Future |
| 중첩 객체 | @Valid + 내부 제약 |
| 컬렉션 요소 | List<@Valid Dto> |