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 |
통과 | 해당 없음 | 현재 또는 과거 날짜 |
특히 @Pattern은 null을 유효한 값으로 본다. 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를 고르는 것이 핵심이다.
참고 자료
'배움과 성장 > 백엔드·데이터' 카테고리의 다른 글
| Spring Boot JSON 날짜 형식: @JsonFormat과 전역 설정의 경계 (0) | 2022.10.11 |
|---|---|
| REST API URI 설계: DB 테이블이 아니라 도메인 리소스를 이름 짓는 법 (1) | 2022.10.02 |
| SQL 학습 메모: SARGable 조건·LEFT JOIN·GROUP BY 순서 이해하기 (0) | 2022.07.15 |
| SQL 학습 메모: NULL 안전한 안티 조인과 날짜 범위 조건 (0) | 2022.07.14 |
| Django QuerySet 학습노트: Lazy Evaluation·조회·Slicing·F Expression (0) | 2022.07.05 |
댓글