@NotNull@Email만 쓰고 있다면, Bean Validation의 10%도 활용하지 못하고 있는 거다. Jakarta Bean Validation은 30개 이상의 제약 어노테이션을 제공하고, 조합과 커스텀까지 더하면 대부분의 형식 검증을 선언적으로 처리할 수 있다. 검증 계층을 어떻게 나눌지는 Spring 검증 전략 가이드에서 다뤘으니, 이 문서에서는 어노테이션 자체를 깊이 파고든다.

graph TD A[Client Request] --> B[DispatcherServlet] B --> C[HandlerAdapter] C --> D{ArgumentResolver
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"" (빈 문자열)" " (공백만)적용 타입
@NotNullXOO모든 타입
@NotEmptyXXOString, Collection, Map, Array
@NotBlankXXXString만

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
) {}
String에 @NotNull을 쓰면

빈 문자열 ""이 검증을 통과한다. 프론트엔드에서 빈 input을 전송하면 ""이 넘어오는데, @NotNull은 이걸 유효한 값으로 판단한다. String 필드에는 반드시 @NotBlank를 쓴다.

문자열 검증 어노테이션

String에 @NotBlank로 존재 여부를 확인했다면, 다음은 값의 형식을 검증할 차례다.

@Size — 길이 제한

문자열, 컬렉션, 배열의 크기를 제한한다. minmax를 조합해서 범위를 지정한다.

@NotBlank
@Size(min = 2, max = 20, message = "이름은 2~20자여야 합니다.")
String name,

@Size(max = 500, message = "자기소개는 500자 이하여야 합니다.")
String bio

@Sizenull을 통과시킨다. "null이면 크기를 잴 수 없으니 유효하다"는 Bean Validation의 설계 철학이다. 필수 필드라면 @NotBlank와 함께 쓴다.

Bean Validation의 null 처리 원칙

거의 모든 제약 어노테이션은 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를 쓸 때

@Emailregexp 속성을 지정하면 기본 검증 로직 대신 해당 정규식으로 검증한다. 너무 엄격하게 만들면 유효한 이메일을 거부할 수 있으니, 도메인 부분에 점(.)이 있는지 정도만 추가 확인하는 수준이 적당하다.

숫자와 시간 검증

숫자 범위

숫자 타입에 사용할 수 있는 어노테이션은 용도별로 세분화되어 있다.

어노테이션의미예시
@Min(value)최솟값 이상@Min(1) → 1 이상
@Max(value)최댓값 이하@Max(100) → 100 이하
@Positive양수, 0 제외가격, 수량
@PositiveOrZero0 이상재고, 포인트
@Negative음수, 0 제외
@NegativeOrZero0 이하
@Digits(integer, fraction)자릿수 제한@Digits(integer=5, fraction=2) → 99999.99까지
@DecimalMin / @DecimalMaxBigDecimal 범위금융 정밀 연산
@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는 그룹 지정을 지원하지 않는다.

classDiagram class Valid { <> +Standard JSR-303/JSR-380 +Method Argument Validation +Recursive Validation } class Validated { <> +Spring Specific +Group Validation 지원 +Class Level Validation } Valid <|-- Validated : 기능 확장
실무에서의 선택

검증 그룹은 강력하지만, DTO가 복잡해지면 어떤 필드가 어떤 상황에서 검증되는지 추적하기 어렵다. 필드 차이가 크다면 CreateRequestUpdateRequest로 DTO를 분리하는 편이 가독성이 좋다. 그룹은 대부분의 필드가 겹치고 한두 개만 다를 때 유용하다.

커스텀 제약 어노테이션 작성

기본 제공 어노테이션으로 표현하기 어려운 검증 규칙이 있다. "비밀번호에 대문자, 소문자, 숫자가 각각 하나 이상 포함되어야 한다"처럼, 하나의 @Pattern으로는 가독성이 떨어지는 경우다.

커스텀 제약 어노테이션은 두 부분으로 구성된다. 어노테이션 정의와 검증 로직이다.

sequenceDiagram participant U as User/Dev participant A as @Annotation participant V as ConstraintValidator participant S as Spring/Hibernate U->>A: 어노테이션 정의 (@Constraint 선언) A->>V: Validator 구현 클래스 지정 U->>V: isValid() 로직 구현 S->>V: Validator 인스턴스 생성 및 주입 S->>V: isValid(value, context) 호출 V-->>S: boolean 결과 반환

어노테이션 정의

@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이 더 실용적이다.

graph LR subgraph "Presentation Layer" DTO[Request DTO] DTO_V["@NotBlank, @Min 등
형식적 검증"] end subgraph "Domain Layer" Entity[Entity] Entity_V["비즈니스 로직 기반
상태 검증"] end DTO -->|Data Transfer| Entity DTO_V -.->|1차 필터링| DTO Entity_V -.->|데이터 무결성 보장| Entity

자주 하는 실수

String에 @NotNull 사용

@NotNull은 null만 거부한다. 빈 문자열 ""과 공백 " "은 유효한 값으로 통과한다. 프론트엔드에서 빈 input을 전송하면 ""이 넘어오는데, 이걸 잡지 못한다.

String 필드에는 @NotBlank를 쓴다. 진짜 null만 체크하면 되는 String 필드는 거의 없다.

[!DANGER] @Email 단독 사용

@Email은 null과 빈 문자열을 모두 통과시킨다. 이메일이 필수 필드라면 반드시 @NotBlank와 함께 써야 한다.

```java

// 잘못된 사용 — null, "" 모두 통과

@Email

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 검증 전략 가이드를 참고한다.

stateDiagram-v2 [*] --> Validation_Failed Validation_Failed --> Throw_Exception: BindingResult 보유 여부 확인 state Throw_Exception { direction LR @RequestBody --> MethodArgumentNotValidException @ModelAttribute --> BindException Method_Params --> ConstraintViolationException } MethodArgumentNotValidException --> GlobalExceptionHandler BindException --> GlobalExceptionHandler ConstraintViolationException --> GlobalExceptionHandler GlobalExceptionHandler --> ErrorResponse: ErrorCode 매핑 ErrorResponse --> [*]: JSON 응답

어노테이션 선택 빠른 참조

어떤 어노테이션을 쓸지 바로 결정하고 싶을 때 이 표를 참고한다.

상황어노테이션
String 필수값@NotBlank
Enum, Integer 등 필수값@NotNull
컬렉션 최소 1개@NotEmpty
문자열 길이@Size(min, max)
문자열 형식@Pattern(regexp)
이메일@NotBlank + @Email
숫자 범위@Min / @Max
양수만@Positive
과거 날짜@Past
미래 날짜@Future
중첩 객체@Valid + 내부 제약
컬렉션 요소List<@Valid Dto>