Bean Validation(@Valid)과 서비스 계층 Validator의 역할 분리를 다룹니다.
API를 만들다 보면 검증 코드를 어디에 넣어야 할지 헷갈리는 순간이 옵니다. DTO에 @NotNull을 붙이면 되는 건지, 서비스에서 따로 검증해야 하는 건지, 아니면 둘 다 해야 하는 건지. 결론부터 말하면 둘 다 쓰되, 각자 다른 질문에 답하게 만들어야 합니다.
검증의 두 가지 질문
모든 검증은 결국 두 가지 질문 중 하나에 답합니다.
- "형식이 맞는가?" — null인가, 빈 문자열인가, 이메일 포맷인가
- "비즈니스 규칙에 맞는가?" — 설정값 기반 길이 제한, 중복 여부, 권한 조건
이 두 질문을 한 곳에서 다 처리하려고 하면 코드가 뒤엉킵니다. 그래서 계층을 나눕니다.
1계층. Controller — Bean Validation
컨트롤러는 형식 검증만 담당합니다. "이 요청이 처리할 가치가 있는가?"를 판단하는 문지기 역할이죠.
public record UserCreateRequest(
@NotBlank(message = "아이디는 필수입니다.")
String username,
@NotBlank(message = "비밀번호는 필수입니다.")
String password,
@NotNull
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email
) {}
@NotBlank,@NotNull— null이나 빈 값 차단@Email— 이메일 포맷 검증@Size(min = 1, max = 2000)— 고정된 길이 제한
컨트롤러에서 @Valid를 붙이면 Spring이 자동으로 검증하고, 실패 시 MethodArgumentNotValidException을 던집니다.
@PostMapping
public ResponseEntity<UserDto> create(
@Valid @RequestBody UserCreateRequest request) { ... }
값 자체의 형식을 검증하는 일. null 체크, 포맷 체크, 고정 범위 체크처럼 요청 데이터만 보고 판단할 수 있는 검증입니다.
2계층. Service — 비즈니스 Validator
서비스 계층은 비즈니스 규칙 검증을 담당합니다. "이 값이 우리 시스템의 정책에 맞는가?"를 판단합니다.
@Component
@RequiredArgsConstructor
public class UserValidator {
private final ValidationProperties props;
public void validateId(String id) {
if (id.length() < props.id().minLength()
|| id.length() > props.id().maxLength()) {
throw new ValidationException(
String.format("아이디는 %d~%d자여야 합니다.",
props.id().minLength(), props.id().maxLength()));
}
if (!id.matches("^[a-zA-Z0-9_]+$")) {
throw new ValidationException(
"아이디는 영문자, 숫자, 언더바만 사용 가능합니다.");
}
}
}
- 길이 제한이
@ConfigurationProperties에서 오는 경우 →@Size로는 불가능 (SpEL 미지원) - 정규식 패턴이 설정 파일에서 오는 경우 →
@Pattern으로는 불가능 - DB 조회가 필요한 검증 (중복 체크 등) → Bean Validation으로 절대 불가능
서비스 메서드에서 비즈니스 로직 실행 전에 호출합니다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserValidator validator;
@Transactional
public UserDto signUp(UserCreateRequest dto) {
validator.validateId(dto.username());
validator.validatePassword(dto.password());
// ... 비즈니스 로직
}
}
경계선을 긋는 기준
어떤 검증을 어디에 넣을지 헷갈릴 때, 이 기준으로 판단하면 됩니다.
"요청 데이터만 보고 판단할 수 있는가?"
Yes → DTO + Bean Validation
- null/빈 값 체크
- 이메일, URL 등 포맷 체크
- 고정된 길이 제한 (
@Size(max = 2000))
No → Service + Validator
- 설정값(
application.yml) 기반 길이 제한 - 설정값 기반 정규식 패턴
- DB 조회 필요 (중복 아이디, 존재하는 채널인지 등)
- 엔티티 간 관계 검증 (이 사용자가 이 채널에 참여 중인가?)
- 비즈니스 정책 (관리자만 가능, 하루 5회 제한 등)
DTO에 @NotBlank를 붙여놓고, Validator에서도 if (id == null || id.isBlank())를 또 하는 건 중복입니다. DTO가 null/빈 값을 막아주니까, Validator는 "값이 있다는 전제 하에" 규칙만 검증하면 됩니다.
예외 타입 통일
검증 실패 시 던지는 예외도 계층별로 다릅니다.
Bean Validation 실패
Spring이 자동으로 MethodArgumentNotValidException을 던집니다. GlobalExceptionHandler에서 잡아서 400 응답을 만들면 됩니다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e) {
String message = e.getBindingResult()
.getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(new ErrorResponse(message));
}
비즈니스 Validator 실패
커스텀 예외를 던져야 합니다. IllegalArgumentException을 쓰면 다른 라이브러리가 던지는 것과 구분이 안 되기 때문입니다.
// 전용 예외 클래스
public class ValidationException extends DiscodeitException {
public ValidationException(String message) {
super(/* ... */);
}
}
이렇게 하면 GlobalExceptionHandler에서 Bean Validation 실패와 비즈니스 검증 실패를 각각 다른 방식으로 처리할 수 있습니다.
심화. 커스텀 제약 어노테이션
설정값 기반 검증도 어노테이션처럼 쓰고 싶다면, 커스텀 ConstraintValidator를 만들 수 있습니다.
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UsernameValidator.class)
public @interface ValidUsername {
String message() default "올바르지 않은 아이디입니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
@Component
public class UsernameValidator
implements ConstraintValidator<ValidUsername, String> {
@Autowired
private ValidationProperties props;
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
if (value == null) return true; // @NotBlank가 담당
return value.length() >= props.id().minLength()
&& value.length() <= props.id().maxLength()
&& value.matches(props.emailRegex());
}
}
이렇게 하면 DTO에서 @ValidUsername으로 선언적으로 쓸 수 있습니다. 하지만 단순한 프로젝트에서는 오버엔지니어링이 될 수 있으니, Validator 컴포넌트 방식으로 충분하다면 굳이 만들 필요는 없습니다.
자주 하는 실수
DTO의 @NotBlank가 이미 null/빈 값을 막아줍니다. Validator에서는 "값이 넘어왔다"는 전제로 비즈니스 규칙만 검증하세요.
[!WARNING] [실수] IllegalArgumentException 남용
IllegalArgumentException은 Java 표준 라이브러리도 던집니다. 비즈니스 검증 실패에 이걸 쓰면 GlobalExceptionHandler에서 의도치 않은 예외까지 잡게 됩니다. 커스텀 예외를 사용하세요.
[!WARNING] [실수] @Size에 설정값 넣으려고 하기
@Size(min = ${props.minLength})는 동작하지 않습니다. Bean Validation 어노테이션의 속성은 컴파일 타임 상수만 받습니다. 설정값 기반 길이 제한은 서비스 계층 Validator나 커스텀 ConstraintValidator로 처리하세요.
정리
- 1계층 (DTO): null, 빈 값, 포맷 →
@Valid+ Bean Validation - 2계층 (Service): 설정값 기반, DB 조회 필요, 정책 → Validator 컴포넌트
두 계층이 서로 다른 질문에 답하도록 역할을 나누면, 검증 로직이 깔끔하게 정리됩니다.