Spring @Valid 입력 검증: null·빈 문자열·날짜·@Validated 구분

반응형

Spring Boot 2.4.5로 회원 가입 API를 만들 때, 생년월일 field에 null 또는 YYYY-MM-DD 형식만 허용하고 싶었다. 당시에는 정규식 끝에 |를 붙여 빈 문자열까지 허용하는 방법을 적었다.

다시 살펴보니 요구사항과 구현이 섞여 있었다. optional 값은 null, 형식 검증은 @Pattern, 실제 날짜의 유효성은 LocalDate 같은 type과 날짜 constraint로 나누는 편이 명확하다. 2022-02-31은 날짜처럼 보이지만 실제 달력에는 없는 값이므로 정규식만으로 충분하지 않다.

먼저 null과 빈 문자열을 구분한다

Jakarta Validation constraint는 각각 처리 범위가 다르다.

constraint null "" 주된 용도
@NotNull 실패 통과 값 존재 여부
@NotEmpty 실패 실패 비어 있지 않은 문자열·collection
@NotBlank 실패 실패 공백뿐인 문자열까지 거부
@Pattern 통과 정규식에 따라 결정 문자열 형식
@PastOrPresent 통과 해당 없음 현재 또는 과거 날짜

특히 @Patternnull을 유효한 값으로 본다. optional field라면 별도의 | 없이도 null을 허용할 수 있다. 값이 필수라면 @NotBlank@NotNull을 함께 붙여야 한다.

Spring Boot 3에서는 jakarta.validation을 쓴다

당시 Spring Boot 2.4.5 code는 javax.validation package를 사용했다. Spring Boot 3부터는 Jakarta EE 9 기반이라 import가 jakarta.validation으로 바뀌었다. starter와 validation API의 version을 따로 고정하기보다 Spring Boot dependency management에 맡긴다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-validation'
}

기존 Spring Boot 2 project를 그대로 유지한다면 javax.validation을 사용하고, Boot 3 이상으로 migration할 때 import와 관련 library 호환성을 함께 바꿔야 한다.

날짜는 String보다 LocalDate로 받기

생년월일을 optional로 받으며 미래 날짜만 막는다면 DTO에서 의미가 드러나는 type을 쓰는 편이 낫다.

import jakarta.validation.constraints.PastOrPresent;
import java.time.LocalDate;

public record RegisterRequest(
        @PastOrPresent(message = "생년월일은 오늘보다 미래일 수 없습니다.")
        LocalDate birthday
) {}
@PostMapping("/users")
public ResponseEntity<Void> register(
        @Valid @RequestBody RegisterRequest request
) {
    userService.register(request);
    return ResponseEntity.status(HttpStatus.CREATED).build();
}

JSON에서는 값을 생략하거나 null로 보내면 optional 값이 된다. 2022-02-31처럼 존재하지 않는 날짜는 LocalDate 변환 단계에서 거부되고, 미래의 실제 날짜는 @PastOrPresent가 거부한다. 빈 문자열을 null처럼 취급할지는 serializer 설정에 따라 달라질 수 있으므로 API 계약에서는 생략 또는 null 중 하나로 정하는 편이 안전하다.

필수 생년월일이라면 @NotNull을 추가한다.

public record RegisterRequest(
        @NotNull(message = "생년월일은 필수입니다.")
        @PastOrPresent(message = "생년월일은 오늘보다 미래일 수 없습니다.")
        LocalDate birthday
) {}

String으로 받아야 한다면 정규식의 한계를 적는다

외부 protocol 때문에 반드시 String이어야 한다면 @Pattern으로 모양을 제한할 수 있다.

public record RegisterRequest(
        @Pattern(
                regexp = "\\d{4}-\\d{2}-\\d{2}",
                message = "생년월일은 YYYY-MM-DD 형식이어야 합니다."
        )
        String birthday
) {}

이 constraint는 null을 허용하지만 빈 문자열은 거부한다. 다만 월을 01부터 12까지 제한하는 더 긴 정규식을 쓰더라도 윤년과 월별 말일까지 완전하게 검증하기는 어렵다. 형식을 통과한 뒤 LocalDate.parse로 calendar validity를 확인하거나, 처음부터 DTO type을 LocalDate로 두는 쪽이 낫다.

@Valid와 @Validated의 역할

둘은 같은 상황에서 무조건 바꿔 쓰는 annotation이 아니다.

  • @Valid: Jakarta Validation 표준 annotation이다. request body와 nested object의 validation을 cascade할 때 사용한다.
  • @Validated: Spring annotation이다. validation group을 선택하거나 method validation을 적용할 때 사용한다.

nested DTO에는 field에 @Valid가 있어야 내부 constraint까지 따라간다.

public record OrderRequest(
        @NotNull @Valid CustomerRequest customer
) {}

Spring MVC의 method validation 동작은 controller parameter 구성과 Spring version에 따라 달라질 수 있다. 단순한 request body 검증은 @Valid @RequestBody로 시작하고, group이나 service method constraint가 필요할 때 @Validated를 추가하는 순서가 이해하기 쉽다.

검증 오류 응답도 API 계약이다

validation을 붙이는 것으로 작업이 끝나지는 않는다. client가 어느 field를 고쳐야 하는지 알 수 있도록 field, 안정적인 error code, 안전한 message를 반환해야 한다. password나 token 같은 rejected value를 error log와 response에 그대로 넣어서는 안 된다.

검증 exception을 HTTP response로 통일하는 방법은 try-catch와 @RestControllerAdvice 역할에 이어서 정리했다.

당시의 요구사항을 지금 표현하면 “빈 값도 통과하는 정규식”이 아니라 생년월일은 optional이고, 값이 있다면 실제 과거 또는 오늘 날짜여야 한다가 된다. 먼저 업무 규칙을 문장으로 쓴 뒤 그 규칙에 맞는 type과 constraint를 고르는 것이 핵심이다.

참고 자료

반응형
KEEP READING
카테고리 전체 보기 →

댓글