자바 백엔드 입문 34편 — Bean Validation @Valid @NotNull

2026-05-16자바 백엔드 입문

자바 백엔드 입문 34편. @Valid·@NotNull·@Email·@Min·@Pattern으로 DTO 검증을 자동화하는 Bean Validation을 공항 보안 검색대 비유로 풀어쓴 Phase 5 시작 글.

📚 자바 백엔드 입문 · 34편 — Bean Validation @Valid @NotNull

이 글은 자바 백엔드 입문 시리즈 59편 중 34편이에요. Phase 4 Web MVC 5편이 끝났고, 이번 34편부터 Phase 5 Validation 2편으로 들어갑니다. 입력 검증은 모든 백엔드의 첫 방어선이에요. "클라이언트가 보낸 데이터가 정상 범위인지" 를 어떻게 자동으로 확인하느냐 — 그 표준이 Bean Validation.

Bean Validation이 헷갈리는 이유

처음 29편에서 @Valid 가 짧게 등장했는데, 사실 그 한 줄 뒤에 10종 넘는 검증 어노테이션과 Hibernate Validator 라이브러리가 있어요. 처음 봤을 때 "왜 검증 어노테이션이 이렇게 많지?" 가 헷갈려요.

이 글에서는 공항 보안 검색대 비유로 풀어요. DTO = 캐리어, @Valid = 검색대 통과 명령, @NotNull·@Email·@Min 등 = 검사 항목 체크리스트. 끝까지 따라오시면 "클라이언트 입력을 어떻게 한 줄씩 자동 검사하나" 가 한 그림에 들어와요.

Bean Validation이 뭐고 왜 필요한가

Bean Validation 은 자바 표준 검증 규격(Java EE / Jakarta EE 표준, JSR-380)이에요. 실제 구현체로 Hibernate Validator 가 사실상 표준. Spring Boot의 spring-boot-starter-web 또는 spring-boot-starter-validation 에 자동 포함돼요.

핵심 발상 — 검증 규칙을 DTO 필드 옆에 어노테이션으로 박아두면, Spring이 자동으로 실행. 컨트롤러나 서비스에서 if-else 박지 않아요.

// 검증 없을 때 (나쁜 패턴)
@PostMapping("/orders")
public Order create(@RequestBody OrderRequest req) {
    if (req.getProductId() == null) throw new IllegalArgumentException("상품 ID 필수");
    if (req.getQuantity() < 1 || req.getQuantity() > 100) throw new IllegalArgumentException("수량 1~100");
    if (req.getEmail() == null || !req.getEmail().contains("@")) throw new IllegalArgumentException("이메일 형식 오류");
    // ... 본격적인 비즈니스 로직은 이 아래
}

// Bean Validation 사용 (표준 패턴)
@PostMapping("/orders")
public Order create(@RequestBody @Valid OrderCreateRequest req) {
    return orderService.create(req);   // 검증은 Spring이 자동
}

@Getter @Setter @NoArgsConstructor
public class OrderCreateRequest {
    @NotNull(message = "상품 ID는 필수입니다")
    private Long productId;

    @Min(value = 1, message = "수량은 1 이상")
    @Max(value = 100, message = "수량은 100 이하")
    private int quantity;

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

DTO에 어노테이션만 박으면 — @Valid 한 줄로 모든 검증이 자동 실행. 컨트롤러 본문은 비즈니스 로직에만 집중 가능.

자주 쓰는 검증 어노테이션 10종

Bean Validation 표준 어노테이션 + Hibernate Validator 확장. 자주 만나는 10개.

어노테이션검사 항목적용 타입
@NotNullnull이 아니어야 함모든 타입
@NotEmptynull + 빈 컬렉션·문자열 XString·Collection·Map·Array
@NotBlanknull + 빈 문자열 + 공백만 XString 전용
@Size(min, max)길이 범위String·Collection
@Min·@Max숫자 최소·최대숫자형
@Positive·@Negative양수·음수숫자형
@Email이메일 형식String
@Pattern(regex)정규식 매칭String
@Past·@Future과거·미래 날짜날짜·시간 타입
@AssertTrue·@AssertFalseboolean 필드 또는 메서드boolean

@NotNull@NotEmpty·@NotBlank 의 차이가 처음엔 헷갈리는데, 공항 보안 비유로 풀면: - @NotNull — 짐 가방이 "있긴 있어야 함" (안은 비어 있어도 OK) - @NotEmpty — 가방 안에 뭐라도 "내용물이 있어야 함" - @NotBlank — 가방 안에 뭐가 있는데 "공백·먼지 같은 의미없는 것 말고 진짜 내용물"

문자열 검증에는 거의 항상 @NotBlank 가 정답.

실전 예 — 회원 가입 DTO

자주 보는 회원 가입 요청 DTO 예시. 모든 어노테이션이 자연스럽게 합쳐져요.

@Getter @Setter @NoArgsConstructor
public class UserSignupRequest {

    @NotBlank(message = "사용자 이름은 필수입니다")
    @Size(min = 2, max = 20, message = "사용자 이름은 2~20자")
    private String username;

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

    @NotBlank(message = "비밀번호는 필수입니다")
    @Pattern(
        regexp = "^(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$",
        message = "비밀번호는 대문자·숫자·특수문자 포함 8자 이상"
    )
    private String password;

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

    @AssertTrue(message = "이용약관 동의가 필요합니다")
    private boolean termsAccepted;
}

이 한 DTO가 "회원 가입 입력 검증의 모든 룰" 을 담아요. 컨트롤러는 그저 @Valid 한 줄.

검증 실패 시 — 자동 응답

@Valid 검증이 실패하면 — Spring이 MethodArgumentNotValidException 을 자동으로 던져요. 그러면 두 가지 흐름.

1. 기본 동작 — 400 Bad Request

별도 처리기가 없으면 Spring Boot 기본 핸들러가 400 응답 + 검증 오류 메시지를 JSON으로 반환.

2. 글로벌 핸들러로 커스터마이즈 (33편 패턴)

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        List<FieldErrorDetail> fieldErrors = e.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(err -> new FieldErrorDetail(err.getField(), err.getDefaultMessage()))
                .toList();

        ErrorResponse body = new ErrorResponse(
                "VALIDATION_FAILED",
                "입력 검증 실패",
                fieldErrors
        );
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
    }
}

응답 예시:

{
  "code": "VALIDATION_FAILED",
  "message": "입력 검증 실패",
  "fieldErrors": [
    {"field": "username", "message": "사용자 이름은 2~20자"},
    {"field": "email", "message": "올바른 이메일 형식이 아닙니다"}
  ]
}

클라이언트가 "어느 필드가 왜 잘못됐는지" 한눈에 알 수 있어요. 한국 회사 백엔드 표준 패턴.

중첩 객체 검증 — @Valid 연쇄

DTO 안에 다른 DTO가 들어 있을 때, 자식 DTO까지 검증하려면 필드에 @Valid 박기.

@Getter @Setter @NoArgsConstructor
public class OrderRequest {
    @NotNull
    private Long productId;

    @Valid                      // ← 이거 필수, 안 박으면 자식 검증 안 됨
    @NotNull
    private AddressRequest shippingAddress;
}

@Getter @Setter @NoArgsConstructor
public class AddressRequest {
    @NotBlank private String street;
    @NotBlank private String city;
    @Pattern(regexp = "\\d{5}") private String zipCode;
}

@Valid 필드에 박으면 — 부모 DTO 검증 시 자식 DTO 안의 어노테이션도 다 실행. 안 박으면 "자식은 그냥 통과".

리스트 안 객체 검증도 동일.

@Valid
private List<OrderItem> items;   // 리스트 안 각 OrderItem이 검증됨

groups — 검증 시나리오 분기

가끔 "생성할 때는 비밀번호 필수, 수정할 때는 선택" 같이 같은 DTO에 다른 검증 룰이 필요해요. groups 가 답.

public interface CreateGroup {}
public interface UpdateGroup {}

public class UserRequest {
    @NotBlank(groups = CreateGroup.class)
    private String password;     // 생성 시에만 필수

    @Email(groups = {CreateGroup.class, UpdateGroup.class})
    private String email;        // 둘 다 검증
}

@PostMapping
public User create(@RequestBody @Validated(CreateGroup.class) UserRequest req) { ... }

@PatchMapping("/{id}")
public User update(@PathVariable Long id, @RequestBody @Validated(UpdateGroup.class) UserRequest req) { ... }

@Valid 대신 @Validated 를 써야 groups가 동작해요. @Validated 는 Spring 확장.

@Validated vs @Valid — 헷갈리는 둘

이 둘이 자주 헷갈리는데 정확한 차이를 정리.

구분 @Valid @Validated
출처 자바 표준 (Jakarta Bean Validation) Spring 확장
groups 지원 X O
클래스 레벨 박기 X O (Spring AOP로 메서드 매개변수 검증)
메서드 매개변수 단일 검증 X O (@Min·@Max 직접 박을 수 있음)
필드·매개변수 O O

평범한 DTO 검증은 @Valid, groups 분기·메서드 매개변수 직접 검증은 @Validated. 실무에서는 둘 다 자주 만나요.

⚠️ 검증은 첫 방어선일 뿐

Bean Validation은 "입력 형식이 올바른가" 만 검사해요. 비즈니스 룰 검증(중복 이메일·재고 부족 등)은 서비스 레이어에서 별도로 처리. "이메일 형식 맞음""이미 가입된 이메일이 아님" 은 다른 차원의 검증이에요.

메시지 국제화 — properties 파일

검증 메시지를 코드에 박지 않고 messages.properties 에서 관리할 수도 있어요.

# src/main/resources/messages.properties
NotBlank.username=사용자 이름은 필수입니다
Size.username=사용자 이름은 {2}~{1}자
Email.email=올바른 이메일 형식이 아닙니다
@NotBlank
private String username;   // properties에서 자동 매칭

다국어 지원이 필요한 글로벌 서비스에 유용. 한국 회사 백엔드는 보통 한국어 단일이라 코드에 직접 박는 경우가 더 많아요.

한 줄 정리 — Bean Validation = DTO 필드에 어노테이션 박으면 @Valid 한 줄로 자동 검증. @NotBlank·@Email·@Size·@Pattern 10종이 90% 케이스 처리. 실패 시 MethodArgumentNotValidException 자동 → 글로벌 핸들러로 일관 응답.

시험 직전 한 번 더 — Bean Validation 입문자가 매번 헷갈리는 것

  • Bean Validation = 자바 표준 검증 규격 (JSR-380, Jakarta EE)
  • 실제 구현 = Hibernate Validator (사실상 표준)
  • Spring Boot 자동 포함 (spring-boot-starter-validation)
  • @Valid 한 줄 = DTO 검증 자동 실행
  • 검증 어노테이션 = DTO 필드 옆에 박음
  • @NotNull = null 아님 (모든 타입)
  • @NotEmpty = null + 빈 컬렉션·문자열 X (String·Collection)
  • @NotBlank = null + 빈 + 공백만 X (문자열 검증 표준)
  • @Size(min, max) = 길이 범위 (String·Collection)
  • @Min·@Max = 숫자 범위
  • @Email / @Pattern(regex) = 형식 검사
  • @Past·@Future = 날짜 검증
  • @AssertTrue = boolean이 true여야 함 (이용약관 동의 등)
  • 검증 실패MethodArgumentNotValidException 자동
  • 글로벌 핸들러로 400 + 필드별 오류 메시지 응답
  • 중첩 객체 검증 = 자식 필드에 @Valid 박아야 동작
  • 리스트 안 객체도 마찬가지 — @Valid private List<Item> items
  • @Validated = Spring 확장, groups 지원
  • groups = 시나리오별 검증 룰 분기 (Create / Update 등)
  • 평범한 DTO 검증 = @Valid, 시나리오 분기·메서드 단일 = @Validated
  • 검증은 첫 방어선 — 비즈니스 룰은 서비스에서 별도 처리
  • 메시지 국제화 = messages.properties 활용

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!