Spring Boot의 Bean Validation을 이용한 Validation (@Valid, @NotBlank, …)
1. 개요
REST API를 구축할 때, 항상 입력 데이터를 확인해야 합니다: 이메일이 올바른 형식인지, 이름이 비어 있지는 않은지, 나이가 양수인지 등… Bean Validation (JSR 380, 구현은 Hibernate Validator)를 사용하면 annotation을 통해 이 작업을 할 수 있으며, 수많은 if-else 코드를 직접 작성할 필요가 없습니다.
이 글에서는 설정 방법, @Valid와 일반적인 annotation 사용법, 중첩된 객체 validation, 커스텀 validator 작성, 그리고 클라이언트에 친화적인 response를 반환하는 오류 처리 방법을 다룹니다. 이 글을 읽은 후에는 Spring Boot 프로젝트의 모든 DTO에 자신 있게 validation을 적용할 수 있을 것입니다.
2. Dependency 추가
Spring Boot는 기본적으로 Bean Validation을 포함하지 않으므로, spring-boot-starter-validation을 추가해야 합니다:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
이 dependency는 Bean Validation API의 가장 일반적인 구현인 Hibernate Validator를 함께 가져옵니다. spring-boot-starter-web을 사용하면 validation이 이미 포함되어 있다고 생각하는 사람들이 많지만, 실제로는 Spring Boot 2.3 이후부터 별도로 분리되었습니다. 이것을 빠뜨리면 @Valid가 작동하지 않으며 오류도 발생하지 않아서, 잘못된 데이터도 통과하게 됩니다.
3. Request body를 @Valid로 검증하기
3.1. constraint annotation을 포함한 DTO 선언
public record CreateOrderRequest(
@NotBlank(message = "고객 이름은 필수입니다")
String customerName,
@Email(message = "이메일 형식이 올바르지 않습니다")
@NotBlank
String email,
@Positive(message = "수량은 0보다 커야 합니다")
Integer quantity,
@DecimalMin(value = "0.0", inclusive = false, message = "가격은 0보다 커야 합니다")
BigDecimal unitPrice
) {}
각 필드에 하나 이상의 constraint annotation을 붙입니다: @NotBlank는 null/비어있음/공백만 포함하는 경우를 허용하지 않고, @Email은 이메일 형식을 검사하며, @Positive는 양수를 요구하고, @DecimalMin은 최소값을 제한합니다. message 파라미터는 위반할 때 반환할 오류 메시지입니다.
3.2. Controller에서 @Valid 적용
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@PostMapping
public ResponseEntity<String> createOrder(@Valid @RequestBody CreateOrderRequest request) {
// 이 지점에서 request는 검증되었음이 보장됨
return ResponseEntity.ok("Order created for " + request.customerName());
}
}
@Valid는 @RequestBody 앞에 붙이며, 이는 Spring MVC에 JSON에서 deserialize된 객체에 대해 validation을 실행하도록 지시합니다. 어떤 필드가 constraint를 위반하면 Spring은 메서드 본문이 실행되기 전에 MethodArgumentNotValidException을 던집니다. 즉, 비즈니스 로직에서 null 검사나 빈 문자열 검사를 할 필요가 없습니다.
customerName이 없는 요청을 보냅니다:
POST /api/orders
{
"email": "a@b.com",
"quantity": 2,
"unitPrice": 10.5
}
기본적으로 (exception을 처리하지 않으면), Spring은 HTTP 400을 길고 읽기 어려운 본문과 함께 반환합니다. 섹션 5에서 이를 더 깔끔하게 처리하는 방법을 다룹니다.
4. 자주 사용되는 annotation
| Annotation | 의미 |
|---|---|
@NotNull |
값이 null이 아니어야 함 |
@NotBlank |
문자열이 null이 아니어야 하고, 비어있지 않으며, 공백만으로 이루어져 있지 않아야 함 |
@NotEmpty |
Collection/String/Array가 null이 아니고 비어있지 않아야 함 |
@Size(min, max) |
문자열의 길이 또는 Collection의 크기 제한 |
@Min / @Max |
정수 범위 제한 |
@Positive / @Negative |
양수/음수 |
@Email |
이메일 형식 |
@Pattern(regexp) |
정규식 매칭 |
@Past / @Future |
과거/미래 날짜 |
여러 constraint을 한 필드에 조합하는 예시:
public class Customer {
@Size(min = 3, max = 50, message = "이름은 3자 이상 50자 이하여야 합니다")
private String name;
@Pattern(regexp = "^0\\d{9}$", message = "전화번호는 0으로 시작하는 10자리 숫자여야 합니다")
private String phone;
@Past(message = "생년월일은 과거 날짜여야 합니다")
private LocalDate birthDate;
}
한 필드에 여러 annotation을 붙일 수 있습니다. 예를 들어 @NotBlank와 @Size를 함께 사용하여 빈 문자열도 막고 길이도 제한할 수 있습니다. Validator는 모든 constraint를 실행하고 오류를 모아서 반환하며, 첫 번째 오류에서 멈추지 않습니다.
5. @ExceptionHandler를 이용한 검증 오류 처리
Spring의 기본 응답 대신, @RestControllerAdvice를 사용하여 재포맷합니다:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.put(error.getField(), error.getDefaultMessage());
}
return ResponseEntity.badRequest().body(errors);
}
}
MethodArgumentNotValidException은 BindingResult를 포함하며, 여기에 FieldError 목록이 있습니다. 각 오류는 필드 이름과 메시지를 가지고 있습니다. 위의 코드는 모든 오류를 Map<field, message>로 모아서 HTTP 400과 함께 반환합니다.
위의 customerName이 없는 요청을 다시 호출하면:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"customerName": "고객 이름은 필수입니다"
}
이제 응답이 간결하고, 클라이언트가 오류를 폼의 각 필드에 쉽게 매핑할 수 있습니다.
6. 중첩된 객체와 컬렉션 검증
6.1. @Valid를 사용한 중첩 객체
public record AddressRequest(
@NotBlank String city,
@NotBlank String street
) {}
public record CustomerRequest(
@NotBlank String name,
@Valid
@NotNull
AddressRequest address
) {}
만약 CustomerRequest.address에 @NotNull만 붙이고 @Valid를 빠뜨리면, AddressRequest 내부의 constraint(city의 @NotBlank 등)는 검사되지 않습니다. 이는 Bean Validation을 처음 사용할 때 매우 흔한 실수입니다. @Valid는 validator에게 객체 내부까지 검사하도록 지시합니다.
6.2. List의 각 요소 검증
public record BulkOrderRequest(
@NotEmpty(message = "항목 목록은 비어있을 수 없습니다")
@Valid
List<@Valid OrderItem> items
) {}
public record OrderItem(
@NotBlank String productCode,
@Positive Integer quantity
) {}
@Valid를 List<...> 바로 앞에(또는 type argument에) 붙이면, 리스트의 각 요소를 검증하며, 단순히 리스트가 null이나 비어있는지만 확인하지 않습니다. 만약 리스트의 두 번째 항목에 quantity = -1이 있으면, 오류는 items[1].quantity 형태로 반환됩니다.
7. 커스텀 validator 작성
기존 constraint로 충분하지 않으면, 직접 annotation을 만들 수 있습니다. 예를 들어 주문 코드는 ORD-로 시작해야 한다고 하겠습니다:
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = OrderCodeValidator.class)
public @interface ValidOrderCode {
String message() default "주문 코드는 ORD-xxxxx 형식이어야 합니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class OrderCodeValidator implements ConstraintValidator<ValidOrderCode, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) {
return true; // @NotNull이 따로 처리하도록 하고, 오류 중복 방지
}
return value.matches("^ORD-\\d{5}$");
}
}
@Constraint(validatedBy = ...)는 annotation을 처리할 클래스를 연결합니다. ConstraintValidator<A, T>는 A는 annotation을, T는 적용될 필드의 타입을 나타냅니다. isValid 메서드는 유효하면 true를 반환합니다. 주의할 점: value == null일 때 true를 반환하는 것이 좋습니다. 필수성 검사는 @NotNull이 담당해야 하며, 한 필드가 서로 다른 두 개의 메시지로 두 번 오류를 보고하는 것을 피하기 위함입니다.
DTO에서 사용:
public record OrderLookupRequest(
@NotBlank
@ValidOrderCode
String orderCode
) {}
8. JUnit 5 + AssertJ로 검증 테스트
Spring MVC context를 시작하지 않고 직접 Validator를 테스트할 수 있으며, 이는 integration 테스트보다 빠릅니다:
class CreateOrderRequestValidationTest {
private static Validator validator;
@BeforeAll
static void setUp() {
try (ValidatorFactory factory = Validation.buildDefaultValidatorFactory()) {
validator = factory.getValidator();
}
}
@Test
void shouldRejectBlankCustomerName() {
CreateOrderRequest request = new CreateOrderRequest(
"", "a@b.com", 2, BigDecimal.TEN);
Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);
assertThat(violations)
.extracting(v -> v.getPropertyPath().toString())
.contains("customerName");
}
@Test
void shouldPassWhenAllFieldsValid() {
CreateOrderRequest request = new CreateOrderRequest(
"Nguyen Van A", "a@b.com", 2, BigDecimal.TEN);
Set<ConstraintViolation<CreateOrderRequest>> violations = validator.validate(request);
assertThat(violations).isEmpty();
}
}
Validation.buildDefaultValidatorFactory()는 Spring context가 필요 없는 독립적인 Validator를 생성합니다. validator.validate(request)는 Set<ConstraintViolation>을 반환하며, 비어있으면 유효합니다. 이 방식의 테스트는 controller 로직과 분리하여 DTO를 단위 테스트하기에 적합합니다.
9. Path variable과 query parameter 검증
@Valid를 @RequestBody에 붙이는 것이 가장 일반적이지만, controller 클래스에 @Validated를 추가하면 개별 파라미터도 검증할 수 있습니다:
@RestController
@RequestMapping("/api/orders")
@Validated
public class OrderQueryController {
@GetMapping("/{id}")
public ResponseEntity<String> getOrder(
@PathVariable @Positive(message = "ID는 0보다 커야 합니다") Long id) {
return ResponseEntity.ok("Order " + id);
}
}
@Validated (Bean Validation의 @Valid와는 다른 Spring의 annotation)는 메서드 파라미터 수준의 검증을 활성화합니다. id = -1이면 Spring은 ConstraintViolationException을 던집니다(섹션 5의 MethodArgumentNotValidException과는 다릅니다). 이 exception을 처리하기 위해 별도의 @ExceptionHandler가 필요합니다:
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<String> handleConstraintViolation(ConstraintViolationException ex) {
return ResponseEntity.badRequest().body(ex.getMessage());
}
주의: 이 두 exception은 혼동하기 쉽습니다.
MethodArgumentNotValidException은@Valid @RequestBody/@ModelAttribute에 사용되고,ConstraintViolationException은@Validatedpath variable/query parameter에 사용됩니다. 둘 중 하나의 handler가 없으면 오류가 기본 500 응답으로 처리되어 매우 안 좋습니다.
10. 흔한 실수와 언제 다른 방식을 고려할지
@Valid빠뜨림: constraint annotation은 자동으로 활성화되지 않으며, controller 파라미터, 중첩 객체의 필드, generic의 type argument 등 올바른 위치에@Valid를 붙여야 합니다.- starter-validation dependency 누락: constraint annotation이 조용히 무시되며 exception도 발생하지 않습니다. 테스트를 작성하지 않으면 탐지하기 매우 어렵습니다.
- 여러 필드를 걸쳐 복잡한 검증 로직 (예: “종료 날짜가 시작 날짜보다 늦어야 함”): Bean Validation으로도 가능하지만 class-level constraint를 사용해야 합니다. 그러나 검증이 데이터베이스 데이터에 의존하는 경우 (예: “이메일이 이미 존재하지 않아야 함”), 이를 service layer에서 처리하는 것이 좋습니다. Validator가 repository를 호출하면 테스트하기 어려워지고 단일 책임 원칙을 위반합니다.
- 오류 메시지의 다국어 지원: annotation에서 하드코딩된 메시지 대신
message = "{order.code.invalid}"와messages.properties파일을 사용하면, 프로젝트가 i18n이 필요한 경우에 좋습니다.
11. 결론
Bean Validation을 사용하면 DTO에 annotation으로 데이터 검증 규칙을 선언할 수 있으며, 산재된 if-else 코드를 작성할 필요가 없습니다. @Valid/@Validated와 @RestControllerAdvice를 조합하면, 전체 API에서 일관된 검증 및 오류 처리 흐름을 구축할 수 있습니다. 기존 constraint로 충분하지 않으면 커스텀 validator를 통해 유연하게 확장할 수 있으며, 여전히 선언적이고 읽기 쉬우며 테스트하기 좋은 스타일을 유지합니다.