단독 가이드

Bean Validation(@Valid)과 서비스 계층 Validator의 역할 분리를 다룹니다.

API를 만들다 보면 검증 코드를 어디에 넣어야 할지 헷갈리는 순간이 옵니다. DTO에 @NotNull을 붙이면 되는 건지, 서비스에서 따로 검증해야 하는 건지, 아니면 둘 다 해야 하는 건지. 결론부터 말하면 둘 다 쓰되, 각자 다른 질문에 답하게 만들어야 합니다.


검증의 두 가지 질문

모든 검증은 결국 두 가지 질문 중 하나에 답합니다.

  1. "형식이 맞는가?" — null인가, 빈 문자열인가, 이메일 포맷인가
  2. "비즈니스 규칙에 맞는가?" — 설정값 기반 길이 제한, 중복 여부, 권한 조건

이 두 질문을 한 곳에서 다 처리하려고 하면 코드가 뒤엉킵니다. 그래서 계층을 나눕니다.

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) { ... }
Bean Validation이 잘하는 것

값 자체의 형식을 검증하는 일. 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 컴포넌트 방식으로 충분하다면 굳이 만들 필요는 없습니다.

자주 하는 실수

[실수] Validator에서 null 체크를 중복으로 하기

DTO의 @NotBlank가 이미 null/빈 값을 막아줍니다. Validator에서는 "값이 넘어왔다"는 전제로 비즈니스 규칙만 검증하세요.

[!WARNING] [실수] IllegalArgumentException 남용

IllegalArgumentException은 Java 표준 라이브러리도 던집니다. 비즈니스 검증 실패에 이걸 쓰면 GlobalExceptionHandler에서 의도치 않은 예외까지 잡게 됩니다. 커스텀 예외를 사용하세요.

[!WARNING] [실수] @Size에 설정값 넣으려고 하기

@Size(min = ${props.minLength})는 동작하지 않습니다. Bean Validation 어노테이션의 속성은 컴파일 타임 상수만 받습니다. 설정값 기반 길이 제한은 서비스 계층 Validator나 커스텀 ConstraintValidator로 처리하세요.

정리

flowchart LR A[요청 진입] --> B{형식이 맞는가?} B -- "No" --> C["MethodArgumentNotValidException\n(400 Bad Request)"] B -- "Yes" --> D{비즈니스 규칙에 맞는가?} D -- "No" --> E["ValidationException\n(400 Bad Request)"] D -- "Yes" --> F[비즈니스 로직 실행]
  • 1계층 (DTO): null, 빈 값, 포맷 → @Valid + Bean Validation
  • 2계층 (Service): 설정값 기반, DB 조회 필요, 정책 → Validator 컴포넌트

두 계층이 서로 다른 질문에 답하도록 역할을 나누면, 검증 로직이 깔끔하게 정리됩니다.