Spring Validation이란?
입력값 검증은 애플리케이션 보안과 안정성의 첫 번째 방어선입니다. Spring Boot는 Bean Validation(JSR 380)을 기반으로 @Valid, @Validated 어노테이션과 다양한 제약 조건 어노테이션을 제공합니다. 하지만 기본 어노테이션만으로는 실전의 복잡한 검증 요구사항을 충족할 수 없습니다. 이 글에서는 커스텀 Validator 작성, 그룹 검증, 크로스필드 검증, 컬렉션 검증, 에러 응답 표준화까지 심화 패턴을 다룹니다.
기본 검증 어노테이션 정리
| 어노테이션 | 대상 | 설명 |
|---|---|---|
@NotNull |
모든 타입 | null 불허 (빈 문자열 허용) |
@NotBlank |
String | null, “”, ” ” 모두 불허 |
@NotEmpty |
String, Collection | null, 빈 컬렉션 불허 |
@Size(min, max) |
String, Collection | 길이/크기 범위 |
@Min / @Max |
숫자 | 최소/최대값 |
@Email |
String | 이메일 형식 |
@Pattern |
String | 정규식 매칭 |
@Past / @Future |
날짜 | 과거/미래 날짜 |
@Positive |
숫자 | 양수만 허용 |
public class CreateUserRequest {
@NotBlank(message = "이름은 필수입니다")
@Size(min = 2, max = 50, message = "이름은 2~50자여야 합니다")
private String name;
@NotBlank
@Email(message = "올바른 이메일 형식이 아닙니다")
private String email;
@NotBlank
@Pattern(
regexp = "^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&]).{8,}$",
message = "비밀번호는 8자 이상, 대소문자·숫자·특수문자를 포함해야 합니다"
)
private String password;
@NotNull
@Min(value = 1, message = "나이는 1 이상이어야 합니다")
@Max(value = 150)
private Integer age;
@Past(message = "생년월일은 과거 날짜여야 합니다")
private LocalDate birthDate;
// 중첩 객체 검증: @Valid 필수
@Valid
@NotNull
private AddressRequest address;
// 컬렉션 내부 요소 검증
@Valid
@Size(max = 5, message = "태그는 최대 5개까지 가능합니다")
private List<@NotBlank String> tags;
}
커스텀 Validator 작성
비즈니스 규칙에 맞는 커스텀 검증 어노테이션을 만드는 패턴입니다.
// 1. 어노테이션 정의
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PhoneNumberValidator.class)
@Documented
public @interface ValidPhoneNumber {
String message() default "올바른 전화번호 형식이 아닙니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String region() default "KR"; // 커스텀 속성
}
// 2. Validator 구현
public class PhoneNumberValidator implements ConstraintValidator<ValidPhoneNumber, String> {
private static final Map<String, Pattern> PATTERNS = Map.of(
"KR", Pattern.compile("^01[016789]-?\d{3,4}-?\d{4}$"),
"US", Pattern.compile("^\+?1?[-.]?\(?\d{3}\)?[-.]?\d{3}[-.]?\d{4}$"),
"JP", Pattern.compile("^0\d{1,4}-?\d{1,4}-?\d{4}$")
);
private String region;
@Override
public void initialize(ValidPhoneNumber annotation) {
this.region = annotation.region();
}
@Override
public boolean isValid(String value, ConstraintValidatorContext context) {
if (value == null) return true; // null 허용은 @NotNull로 처리
Pattern pattern = PATTERNS.get(region);
if (pattern == null) return false;
return pattern.matcher(value).matches();
}
}
// 3. 사용
public class ContactRequest {
@ValidPhoneNumber(region = "KR")
private String phone;
@ValidPhoneNumber(region = "US", message = "미국 전화번호 형식이 아닙니다")
private String usPhone;
}
크로스필드 검증 (클래스 레벨)
두 개 이상의 필드를 비교하는 검증은 클래스 레벨 어노테이션으로 구현합니다. Spring 전역 예외 처리와 결합하여 일관된 에러 응답을 반환할 수 있습니다.
// 비밀번호 확인 검증
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchValidator.class)
public @interface PasswordMatch {
String message() default "비밀번호가 일치하지 않습니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String password();
String confirmPassword();
}
public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {
private String passwordField;
private String confirmField;
@Override
public void initialize(PasswordMatch annotation) {
this.passwordField = annotation.password();
this.confirmField = annotation.confirmPassword();
}
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
try {
Object password = BeanUtils.getPropertyDescriptor(obj.getClass(), passwordField)
.getReadMethod().invoke(obj);
Object confirm = BeanUtils.getPropertyDescriptor(obj.getClass(), confirmField)
.getReadMethod().invoke(obj);
boolean valid = Objects.equals(password, confirm);
if (!valid) {
// 에러를 confirmPassword 필드에 바인딩
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
.addPropertyNode(confirmField)
.addConstraintViolation();
}
return valid;
} catch (Exception e) {
return false;
}
}
}
// 사용
@PasswordMatch(password = "password", confirmPassword = "confirmPassword")
public class SignUpRequest {
@NotBlank
private String password;
@NotBlank
private String confirmPassword;
}
// 날짜 범위 검증
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "시작일은 종료일보다 이전이어야 합니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
String startDate();
String endDate();
}
@ValidDateRange(startDate = "startDate", endDate = "endDate")
public class SearchRequest {
@NotNull
private LocalDate startDate;
@NotNull
private LocalDate endDate;
}
검증 그룹 (Validation Groups)
생성과 수정 시 다른 검증 규칙을 적용해야 할 때 그룹을 사용합니다.
// 그룹 인터페이스 정의
public interface OnCreate {}
public interface OnUpdate {}
// DTO: 그룹별 다른 규칙
public class ProductRequest {
@Null(groups = OnCreate.class, message = "생성 시 ID를 지정할 수 없습니다")
@NotNull(groups = OnUpdate.class, message = "수정 시 ID는 필수입니다")
private Long id;
@NotBlank(groups = {OnCreate.class, OnUpdate.class})
@Size(max = 200)
private String name;
@NotNull(groups = OnCreate.class)
@Positive
private BigDecimal price;
@NotBlank(groups = OnCreate.class)
private String category; // 수정 시에는 선택적
}
// 컨트롤러: @Validated로 그룹 지정 (@Valid는 그룹 미지원)
@RestController
@RequestMapping("/products")
public class ProductController {
@PostMapping
public ProductResponse create(
@Validated(OnCreate.class) @RequestBody ProductRequest request) {
return productService.create(request);
}
@PutMapping("/{id}")
public ProductResponse update(
@PathVariable Long id,
@Validated(OnUpdate.class) @RequestBody ProductRequest request) {
return productService.update(id, request);
}
}
// 그룹 시퀀스: 순서대로 검증 (앞 그룹 실패 시 뒤 그룹 미검증)
@GroupSequence({BasicCheck.class, AdvancedCheck.class, ProductRequest.class})
public class ProductRequest {
@NotBlank(groups = BasicCheck.class)
private String name;
@ValidSku(groups = AdvancedCheck.class) // DB 조회가 필요한 비싼 검증
private String sku;
}
서비스 레이어 검증: @Validated
컨트롤러뿐 아니라 서비스 메서드의 파라미터와 반환값도 검증할 수 있습니다.
@Service
@Validated // 클래스 레벨에 선언
public class UserService {
// 파라미터 검증
public UserDto findByEmail(@NotBlank @Email String email) {
return userRepository.findByEmail(email)
.map(UserDto::from)
.orElseThrow(() -> new NotFoundException("User not found"));
}
// 반환값 검증
@NotNull
public UserDto createUser(@Valid CreateUserRequest request) {
// ...
return UserDto.from(savedUser);
}
// 컬렉션 파라미터 검증
public void deleteUsers(
@NotEmpty @Size(max = 100, message = "한 번에 최대 100명까지 삭제 가능")
List<@Positive Long> userIds) {
userRepository.deleteAllById(userIds);
}
}
DB 연동 검증: Unique 체크
데이터베이스와 연동하여 중복 검사를 수행하는 커스텀 Validator입니다. Spring MapStruct DTO 매핑과 함께 사용하면 입력 → 검증 → 변환 파이프라인이 완성됩니다.
// 범용 Unique 검증 어노테이션
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueValidator.class)
public @interface Unique {
String message() default "이미 사용 중인 값입니다";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
Class<?> entity();
String field();
}
@Component
public class UniqueValidator implements ConstraintValidator<Unique, Object> {
@Autowired
private EntityManager entityManager;
private Class<?> entityClass;
private String fieldName;
@Override
public void initialize(Unique annotation) {
this.entityClass = annotation.entity();
this.fieldName = annotation.field();
}
@Override
public boolean isValid(Object value, ConstraintValidatorContext context) {
if (value == null) return true;
String jpql = String.format(
"SELECT COUNT(e) FROM %s e WHERE e.%s = :value",
entityClass.getSimpleName(), fieldName
);
Long count = entityManager.createQuery(jpql, Long.class)
.setParameter("value", value)
.getSingleResult();
return count == 0;
}
}
// 사용
public class CreateUserRequest {
@Unique(entity = User.class, field = "email", message = "이미 등록된 이메일입니다")
@Email
private String email;
@Unique(entity = User.class, field = "nickname", message = "이미 사용 중인 닉네임입니다")
@Size(min = 2, max = 20)
private String nickname;
}
에러 응답 표준화
@RestControllerAdvice
public class ValidationExceptionHandler {
// @Valid/@Validated 실패 → MethodArgumentNotValidException
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
List<FieldError> fieldErrors = ex.getBindingResult().getFieldErrors()
.stream()
.map(error -> new FieldError(
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue()
))
.toList();
return ResponseEntity.badRequest().body(
new ErrorResponse("VALIDATION_ERROR", "입력값이 올바르지 않습니다", fieldErrors)
);
}
// @Validated 서비스 레이어 → ConstraintViolationException
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraint(
ConstraintViolationException ex) {
List<FieldError> errors = ex.getConstraintViolations().stream()
.map(v -> new FieldError(
extractFieldName(v.getPropertyPath()),
v.getMessage(),
v.getInvalidValue()
))
.toList();
return ResponseEntity.badRequest().body(
new ErrorResponse("VALIDATION_ERROR", "입력값이 올바르지 않습니다", errors)
);
}
private String extractFieldName(Path path) {
String fullPath = path.toString();
return fullPath.contains(".")
? fullPath.substring(fullPath.lastIndexOf('.') + 1)
: fullPath;
}
}
// 표준 에러 응답
public record ErrorResponse(
String code,
String message,
List<FieldError> errors
) {}
public record FieldError(
String field,
String message,
Object rejectedValue
) {}
정리
Spring Validation은 입력값 검증을 선언적으로 표현하는 강력한 프레임워크입니다. 기본 어노테이션으로 단순 검증을 처리하고, 커스텀 Validator로 비즈니스 규칙을 표현하며, 클래스 레벨 어노테이션으로 크로스필드 검증을 구현합니다. 검증 그룹으로 생성/수정 시 다른 규칙을 적용하고, @Validated로 서비스 레이어까지 검증을 확장하세요. 에러 응답을 @RestControllerAdvice로 표준화하면 프론트엔드와의 협업이 훨씬 매끄러워집니다.