사용자가 방탈출 게임 예약을 등록하는 웹 애플리케이션이 있다고 하자.
사용자로부터 예약 데이터를 받아올 때, 올바른 내용(형태)인지 검증하는 과정이 필요하다.
'검증'이란, 사전적 의미 그대로 검사하여 증명하는 것이다.
만약 예약자 이름에 아무런 데이터가 들어있지 않다면? 날짜 데이터의 형태가 잘못되어있다면?
어떤 게임(테마)을 선택했는지 내용이 없다면? 애플리케이션이 기대한대로 동작하지 않아야한다.
이를 위해서는 먼저, 잘못된 데이터임을 검증할 수 있어야 한다.
그리고 잘못된 데이터가 입력되었다는 것을 사용자에게 알려주어야 한다.
이번 글에서는 사용자로부터 요청을 받아온는 DTO를 검증
하고, 검증에 실패했을 때 예외를 처리
하는 과정을 남겨보고자 한다.
DTO 검증하기
검증은 여러 단계에서 수행될 수 있다. DTO를 받아오는 과정에서 바로 검증을 할 수도 있고,
엔티티로 변환하는 과정에서 검증을 할 수도, 서비스나 Dao에서 검증을 수행할수도 있다.
나는 이번 애플리케이션에서 DTO를 검증
하는 방식을 선택했는데, "검증할 수 있는 가장 빠른 단계에서 검사"하고 싶었기 때문이다.
처음에는 도메인 자체의 규칙을 통해 검증을 진행했는데, 그 경우 서비스 레이어까지 로직이 진행된 후 예외가 발생한다.
DTO에서 검증을 진행한다면 뒤의 추가적인 로직이 수행되기 전에 예외가 발생한다.
그래서 빠른 검증이 가능했고, 성능상 이점이 있다고 생각해서 DTO 검증을 선택했다.
스프링에서는 검증을 위한 라이브러리를 제공한다. 이를 활용한 방법을 적용하였다.
1. 의존성을 추가한다
아래 코드는 gradle.build
의 의존성 추가 코드이다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
2. DTO에 검증 조건을 설정한다.
필드 앞에 애너테이션을 붙여 조건을 설정해 줄 수 있다.
@NotBlank
, @Email
, @Min
, @Positive
등 validation 패키지가 제공하는 애너테이션들이 있다. 필요에 맞게 사용하자.
public record ReservationRequest(
@NotBlank(message = "예약자 이름은 필수입니다.") String name,
@NotBlank(message = "예약 날짜는 필수입니다.") @DateValid String date,
@NotNull @Positive Long timeId,
@NotNull @Positive Long themeId
) {
}
그리고 만약 원하는 애너테이션이 없는 경우, 사용자 정의 ConstraintValidator와 Annotation을 만들어 적용시킬수도 있다.
위 코드의 @DateValid
애너테이션은 직접 작성한 애너테이션이다.
ConstraintValidator와 Annotation은 아래와 같이 작성하였다.
(애너테이션 설정에 관한 '메타 에너테이션' 내용은 이전 포스팅 참고)
@Constraint(validatedBy = DateValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface DateValid {
String message() default "올바른 날짜 형태가 아닙니다.";
String format() default "yyyy-MM-dd";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
검증 내용은, ConstraintValidator를 구현한 클래스의 try 문 안에 작성해주면 된다.
public class DateValidator implements ConstraintValidator<DateValid, String> {
private DateTimeFormatter formatter;
@Override
public void initialize(DateValid constraintAnnotation) {
formatter = DateTimeFormatter.ofPattern(constraintAnnotation.format());
}
@Override
public boolean isValid(String strDate, ConstraintValidatorContext context) {
try {
LocalDate.parse(strDate, formatter);
return true;
} catch (Exception e) {
return false;
}
}
}
3. Contoller - 검증을 하고자 하는 곳에 @Valid
를 추가한다.
아래 코드를 보면, @RequestBody
에 @Valid
가 추가되어있다. 이렇게 @Valid
를 추가해주어야 해당 파라미터를 검증하게 된다.
@RestController
@RequestMapping("/reservations")
public class ReservationController {
private final ReservationService reservationService;
public ReservationController(ReservationService reservationService) {
this.reservationService = reservationService;
}
@PostMapping
public ResponseEntity<ReservationResponse> createReservation(@Valid @RequestBody ReservationRequest request) {
ReservationResponse response = reservationService.createReservation(request);
URI uri = URI.create("/reservations/" + response.id());
return ResponseEntity.created(uri).body(response);
}
}
ReservationRequest에 검증 조건을 주었더라도, 호출부에 @Valid
가 없다면 검증 작업이 수행되지 않는다.
@Valid를 붙이기 전과 후 아래 사진은 예약 생성에 잘못된 요청을 보낸 결과이다.
@Valid를 붙이기 전(왼쪽) - 500에러가 발생했다. 비즈니스 로직 수행 단계로 넘어가 로직 수행 중 예외가 발생했다.
@Valid를 붙인 후(오른쪽) - 400에러로 요청 검증 단계에서 잘못된 요청임이 검증되었다.
그러나 검증에 성공했더라도, 사용자가 400 BAD_REQUEST라는 결과만으로는 무엇이 잘못되었는지 알 수 없다.
오류를 처리해 어떤것이 잘못되었는지
친절하게 알려주도록 하자.
예외 처리하기
스프링에서는 예외 처리를 위해 @ExceptionHandler
를 제공한다.
컨트롤러 내부에서 @ExceptionHandler
를 작성해서 발생할 수 있는 예외를 처리할수도 있다.
그러나 동일한 예외가 여러 컨트롤러에서 발생할 수도 있다. 모든 컨트롤러에서 각각 예외처리를 한다면 중복코드가 늘어난다.
AOP 관점에서, 동일한 기능을 분리해서 @ControllerAdvice
로 한번에 처리해줄 수 있다.
세부 내용은 이전 포스팅에 기록되어 있다.
대신, 해당 포스팅은 @ControllerAdvice
가 아닌 @RestControllerAdvice
를 사용하고 있다.
RestControllerAdvice는 ControllerAdvice에 @ResponseBody가 붙은 형태로,
리턴하는 값을 ResponseBody로 전달하는 역할을 한다. (마치 @Controller와 @RestController와 같은 차이)
1. 예외 응답을 위한 클래스 구현
이번 코드를 구현하며 친절한(?) 에러 메세지 전달을 위해 ErrorResponse
, ErrorDetail
이라는 클래스를 정의했다.
(SpringFramework 6.0 이후 버전부터는 ProblemDetail이라는 클래스를 제공한다고하니 사용해봐도 좋을 것 같다.)
ErrorResponse 클래스에는 에러 메세지(단건의 표준 예외 처리 시 사용)와 에러 세부 내용(여러건의 필드 에러 표시)들을 담고 있다.
public record ErrorResponse(String message, List<ErrorDetail> details) {
public ErrorResponse(String message) {
this(message, Collections.emptyList());
}
public ErrorResponse(FieldError[] errors) {
this("", Arrays.stream(errors).map(ErrorDetail::new).toList());
}
}
ErrorDetail에는 어떤 필드에서 어떤 에러가 발생했는지를 나타낸다.
public record ErrorDetail(String field, String message) {
public ErrorDetail(FieldError error) {
this(error.getField(), error.getDefaultMessage());
}
}
마지막으로 예외 처리를 위한 @ControllerAdvice를 작성한다.
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler
public ResponseEntity<ErrorResponse> handle(MethodArgumentNotValidException exception) {
BindingResult bindingResult = exception.getBindingResult();
List<FieldError> fieldError = bindingResult.getFieldErrors();
ErrorResponse errorResponse = new ErrorResponse(fieldError.toArray(FieldError[]::new));
return new ResponseEntity(errorResponse, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public ResponseEntity<ErrorResponse> handle(NoSuchElementException exception) {
return new ResponseEntity(new ErrorResponse(exception.getMessage()), HttpStatus.BAD_REQUEST);
}
...
}
다시 잘못된 예약 요청을 보내면, 아래와 같이 어떤 에러가 발생했는지 예외메세지를 리턴해주고있다.
위 코드들이 완전한 코드는 아니므로 이런 방식으로 예외를 처리해줄 수 있구나 참고만 하면 좋을 것 같다.
참고자료
Baeldung - Java Bean Validation Basics
'우아한테크코스 > 레벨 2 - Spring' 카테고리의 다른 글
[Spring, JUnit5] 테스트 격리: DB 초기화 (InitializingBean, BeforeEachCallback) (0) | 2024.05.25 |
---|---|
[JPA] @Embedded, @Embeddable 개념과 사용법 (0) | 2024.05.16 |
[Spring] HandlerMethodArgumentResolver 사용하기 (0) | 2024.05.09 |
[Spring] Controller, Service 레이어에서 DTO의 사용 (0) | 2024.04.24 |
[Spring] @Controller와 @RestController 차이, 그리고 ResponseEntity (1) | 2024.04.17 |