자바 백엔드 입문 34편. @Valid·@NotNull·@Email·@Min·@Pattern으로 DTO 검증을 자동화하는 Bean Validation을 공항 보안 검색대 비유로 풀어쓴 Phase 5 시작 글.
이 글은 자바 백엔드 입문 시리즈 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개.
| 어노테이션 | 검사 항목 | 적용 타입 |
|---|---|---|
@NotNull | null이 아니어야 함 | 모든 타입 |
@NotEmpty | null + 빈 컬렉션·문자열 X | String·Collection·Map·Array |
@NotBlank | null + 빈 문자열 + 공백만 X | String 전용 |
@Size(min, max) | 길이 범위 | String·Collection |
@Min·@Max | 숫자 최소·최대 | 숫자형 |
@Positive·@Negative | 양수·음수 | 숫자형 |
@Email | 이메일 형식 | String |
@Pattern(regex) | 정규식 매칭 | String |
@Past·@Future | 과거·미래 날짜 | 날짜·시간 타입 |
@AssertTrue·@AssertFalse | boolean 필드 또는 메서드 | 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활용
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 29편 — RequestParam PathVariable RequestBody
- 30편 — ArgumentResolver와 @LoginUser
- 31편 — 파일 업로드 @RequestPart MultipartFile
- 32편 — CORS 설정
- 33편 — @ExceptionHandler @ControllerAdvice
다음 글: