레벨2 방탈출 미션에서 요청 DTO 검증을 도메인 검증과 동일하게 validate() 메서드로 직접 처리했다. 리뷰어분이 Spring Validation을 키워드로 제시해줬고, 이를 계기로 Bean Validation을 도입하게 됐다.
아래는 Bean Validation을 적용하면서 겪은 시행착오와 오해를 솔직하게 정리한 내용이다.
Bean Validation이란 무엇인가?
Bean Validation은 JavaBean 유효성 검증을 위한 메타데이터 모델과 API에 대한 정의이며 여기서 언급하고 있는 JavaBean은 직렬화 가능하고 매개변수가 없는 생성자를 가지며, Getter 와 Setter Method를 사용하여 프로퍼티에 접근이 가능한 객체를 의미합니다
자바에서 객체의 필드 값이나 상태에 대해서 제약 조건(Constraint)를 애노테이션 통해서 간편하게 검증할 수 있도록 지원하는 기술이다.
Bean Validation 없이도 문제없는 거 아냐?
미션을 진행하면서 느낀 기존 방식의 불편함
Bean Validation 없이도 필드마다 검증 메서드를 직접 작성해 호출하는 방식으로 충분히 구현할 수 있다. 지금은 필드가 4개뿐이라 괜찮아 보이지만, 필드가 늘어나거나 요구사항이 바뀐다면 어떨까? 검증 메서드가 그만큼 늘어나고, 변경할 곳도 많아진다. 결국 코드의 가독성과 유지보수성이 눈에 띄게 떨어진다.
public record ReservationRequest(
String name,
LocalDate date,
Long timeId,
Long themeId) {
public ReservationRequest {
validateName(name);
validateDate(date);
validateTimeId(timeId);
validateThemeId(themeId);
}
private static void validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("예약자 이름은 빈값일 수 없습니다.");
}
if (name.length() > 20) {
throw new IllegalArgumentException("예약자 이름은 20자 이하여야 합니다.");
}
}
private static void validateDate(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("예약 날짜는 필수입니다.");
}
}
private static void validateTimeId(Long timeId) {
if (timeId == null) {
throw new IllegalArgumentException("예약 시간은 필수입니다.");
}
}
private static void validateThemeId(Long themeId) {
if (themeId == null) {
throw new IllegalArgumentException("예약 테마는 필수입니다.");
}
}
}
Bean Validation을 적용함으로서 간편해진 코드
public record ReservationRequest(
@NotBlank(message = "예약자 이름은 빈값일 수 없습니다.")
@Size(max = 20, message = "예약자 이름은 20자 이하여야 합니다.")
String name,
@NotNull(message = "예약 날짜는 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate date,
@NotNull(message = "예약 시간은 필수입니다.")
Long timeId,
@NotNull(message = "예약 테마는 필수입니다.")
Long themeId) {
}
제약 선언과 검증 실행은 다르다
미션을 진행하면서 @NotNull, @Size 등의 애노테이션을 추가하면 Spring이 알아서 검증을 수행해줄 것이라고 생각했다. 하지만 실제 테스트에서는 검증이 동작하지 않았고, Controller 파라미터에 @Valid를 추가해야만 검증이 수행됐다.
@PostMapping
public ResponseEntity<ReservationResponse> register(@Valid @RequestBody ReservationRequest reservationRequest) {
ReservationResponse reservationResponse = reservationService.register(reservationRequest);
return ResponseEntity.created(URI.create("/reservations/" + reservationResponse.id()))
.body(reservationResponse);
}
@NotNull, @Size 같은 애노테이션은 "이 필드는 이런 제약이 있다"고 선언만 할 뿐, 실제로 검증을 실행하지는 않는다.
@Valid를 컨트롤러 파라미터에 선언해야 Spring이 "아, 이 객체는 검증이 필요하구나" 를 인식하고, 선언된 제약 조건들을 실제로 실행시킨다.
즉, 제약 조건 애노테이션은 규칙서이고, @Valid는 "이 규칙서를 실제로 검사해라"는 명령인 셈이다.
(i+1) 그렇다면 @Valid는 어떤식으로 검증이 진행되는가?`
@RequestBody JSON 역직렬화
↓
ReservationRequest 객체 생성 (DTO 생성 완료)
↓
@Valid 감지 (ArgumentResolver)
↓
검증 위임 (LocalValidatorFactoryBean)
↓
@Constraint 애노테이션 탐색 (Validator)
↓
실제 검증 수행 (ConstraintValidator)
↓
실패 시 MethodArgumentNotValidException 발생
@RequestBody에 의해 JSON이 역직렬화되어 DTO 객체가 먼저 생성된다.
이후 컨트롤러 파라미터에 @Valid가 선언되면, Spring MVC의 ArgumentResolver가 이를 감지해 LocalValidatorFactoryBean에게 검증을 위임한다.
Validator는 생성된 DTO 객체의 필드에 선언된 @Constraint 애노테이션들을 탐색하고, 각 애노테이션에 연결된 ConstraintValidator가 실제 검증 로직을 수행한다.
검증에 실패하면 MethodArgumentNotValidException이 발생한다.
(i+1) 호기심 많은 개발자라면 궁금할 점
DTO가 아닌 서비스 레이어에서도 Bean Validation을 적용할 수 있을까?
지금은 컨트롤러에서 @Valid로 DTO를 검증했는데, 서비스 메서드 파라미터에도 동일하게 적용할 수 있는지 궁금했다.
결론부터 말하면, @Valid는 기본적으로 컨트롤러 계층에서만 동작한다.
모든 요청은 프론트 컨트롤러인 DispatcherServlet을 통해 컨트롤러로 전달된다. 이 과정에서 ArgumentResolver가 컨트롤러 메서드의 객체를 만들어주는데, @Valid 역시 ArgumentResolver에 의해 처리된다.
@RequestBody의 경우 ArgumentResolver의 구현체인 RequestResponseBodyMethodProcessor가 JSON을 객체로 변환하며, 이 내부에서 @Valid로 시작하는 애노테이션이 있을 경우 유효성 검사를 진행한다.
즉, @Valid는 ArgumentResolver가 동작하는 컨트롤러 계층에 종속되어 있기 때문에, 서비스나 레포지토리 같은 다른 계층에서는 기본적으로 검증이 수행되지 않는다.
더 알아볼 수 있는 키워드
- ArgumentResolver
- LocalValidatorFactoryBean
- ConstraintValidator
'Spring' 카테고리의 다른 글
| MethodArgumentNotValidException 발생 조건부터 핸들링까지 (0) | 2026.05.30 |
|---|---|
| @Valid vs @Validate 차이는 무엇인가? (0) | 2026.05.24 |