적절한 예외 처리는 어플리케이션을 개발할 때 필수적이지만 생각보다 어렵다. (^_^a) 세부적인 부분들을 놓치고 지나가기 쉽고 고민할 지점들이 존재한다. Springboot와 함께 예외를 핸들링한 과정과 더불어 우테코 미션을 진행하며 유효성 검증 위치를 고민한 경험을 소개하려 한다.
방탈출 예약 생성 HTTP API를 만드려고 한다. API 명세는 아래와 같다.
POST /reservations HTTP/1.1
content-type: application/json
{
"date": "2023-08-05",
"name": "Mia",
"timeId": 1,
"themeId": 1
}
간단하게 아래와 같은 검증이 필요하다.
date
, name
, ..)의 타입이 유효하다.date
)는 현재 날짜 이후이다.1번은 도메인 로직에서 처리할 부분이 아니고 나머지만 우선 생각해보자.
2, 4, 5번에 대한 검증이다. (6, 7, 8번은 데이터 조회가 필요하므로 자동 생략)
public class Reservation {
// 생략
public Reservation(Long id, Reservation reservation) {
this.id = id;
this.name = reservation.name;
this.date = reservation.date;
this.time = reservation.time;
this.theme = reservation.theme;
}
public Reservation(Long id, String name, String dateInput, ReservationTime time, Theme theme) {
validateName(name);
LocalDate date = convertToLocalDate(dateInput);
validateDate(date);
this.id = id;
this.name = name;
this.date = date;
this.time = time;
this.theme = theme;
}
private void validateName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("예약자 이름은 비어있을 수 없습니다.");
}
Matcher matcher = NAME_PATTERN.matcher(name);
if (matcher.matches()) {
throw new IllegalArgumentException("예약자 이름은 숫자로만 구성될 수 없습니다.");
}
}
private void validateDate(LocalDate date) {
if (date.isBefore(LocalDate.now()) || date.equals(LocalDate.now())) {
throw new IllegalArgumentException("이전 날짜 혹은 당일은 예약할 수 없습니다.");
}
}
private LocalDate convertToLocalDate(String date) {
if (date == null || date.isEmpty()) {
throw new IllegalArgumentException("예약 날짜가 비어있습니다.");
}
try {
return LocalDate.parse(date);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("유효하지 않은 예약 날짜입니다.");
}
}
}
아래와 같은 문제들이 있다.
String
에서 LocalDate
로의 변환 책임이 도메인 클래스에 있는가? (같은 맥락으로 String
에서 Long
으로의 변환도)String
으로 사용되어도 되는가?Reservation
)객체는 생성될 수 없어도 되는가?// ReservationController.java
@PostMapping
public ResponseEntity<ReservationResponse> createReservation(@RequestBody ReservationSaveRequest request) {
// 생략
Reservation reservation = request.toModel(themeResponse, timeResponse);
return ResponseEntity.status(HttpStatus.CREATED).body(reservationService.create(reservation));
}
// ReservationSaveRequest.java
public record ReservationSaveRequest(
String name,
String date,
String timeId,
String themeId) {
public Reservation toModel(ThemeResponse themeResponse, ReservationTimeResponse timeResponse) {
LocalDate convertedDate = convertToLocalDate(date);
// 생략
return new Reservation(name, convertedDate, time, theme);
}
private LocalDate convertToLocalDate(String date) {
if (date == null || date.isEmpty()) {
throw new IllegalArgumentException("예약 날짜가 비어있습니다.");
}
try {
return LocalDate.parse(date);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException("유효하지 않은 예약 날짜입니다.");
}
}
// 생략
}
Domain보다 Presentation layer가 더 적절해보이긴 하지만, 모든 필드를 String
으로 받고 유효한 타입으로 변환해주는 과정이 번거로워 보인다. 에러 메세지와 검증 과정을 세세하게 작성하기 위해서 너무 많은 노력이 든다.
가장 빠른 검증이다. 잘못된 타입으로 요청해 변환할 수 없다면 모든 비즈니스 로직을 수행할 수 없는데, 빠른 검증이 안전하고 편하다. 그리고 사실 Jackson 라이브러리를 사용하면 직렬화, 역직렬화를 해주어서 타입 변환을 직접하지 않아도 된다. 아래처럼 DTO를 사용할 수 있다.
public record ReservationSaveRequest(
String name,
LocalDate date,
Long timeId,
Long themeId) {}
하지만 아무런 예외 처리를 해주지 않으면 잘못된 요청을 보냈을 때 상태코드 400(Bad Request)만 반환해줄 뿐 메세지가 없으니 클라이언트 입장에서 대처할 수 없다.
@Test
@DisplayName("올바르지 않은 타입의 필드로 예약 POST 요청 시 상태코드 400을 반환한다.")
void createReservationWithInvalidDateFormat() throws Exception {
// given
String invalidDateFormatRequest = "{\"date\": \"dfdf\"}";
// when & then
mockMvc.perform(post("/reservations")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidDateFormatRequest))
.andDo(print())
.andExpect(status().isBadRequest());
}
발생한 예외는 HttpMessageNotReadableException
이다. ExceptionHandler
로 예외를 처리할 수 있으니 아래에서 더 살펴보자.
정의하기 나름이지만 서비스 정책, 도메인 규칙들은 도메인 클래스, 서비스 클래스 모두에서 검증 가능하다고 생각한다. 하지만 "예약 날짜(date
)는 현재 날짜 이후이다."라는 정책은 예약 도메인 객체를 생성할 때마다 검증할 경우 이미 지나간 이전 예약은 조회할 수 없게 된다. 이 정책은 "예약을 생성할 때" 적용해야 하는 규칙이기 때문이다.
따라서 예약 생성 관련 서비스 메소드에서 검증해주었다.
// ReservationService.java
@Transactional
public ReservationResponse create(Reservation reservation) {
validateReservationDate(reservation);
// 생략
Reservation savedReservation = reservationRepository.save(reservation);
return ReservationResponse.from(savedReservation);
}
private void validateReservationDate(Reservation reservation) {
if (reservation.isBeforeOrOnToday()) {
throw new IllegalArgumentException("이전 날짜 혹은 당일은 예약할 수 없습니다.");
}
}
유효성 검증 위치 변경을 여러 번 반복하며 생각한 기준이다.
그래서 앞서 언급한 8개 검증은 나의 어플리케이션에서 이렇게 위치한다.
date
, name
, ..)의 타입이 유효하다.DTO
클래스의 역직렬화 시DTO
클래스date
)는 현재 날짜 이후이다.Sevivce
클래스의 메소드Domain
클래스의 생성 시Sevivce
클래스의 메소드Sevivce
클래스의 메소드Sevivce
클래스의 메소드ControllerAdvice
로 전역 예외 처리하기커스텀 예외 혹은 spring boot의 예외를 내부에서 해결할 수 없고, 클라이언트에게 재요청 혹은 요청 불가를 알리려면 적절한 응답을 주어야 한다. Spring MVC container에서 발생한 예외는 @ExceptionHandler
와 @ControllerAdvice
로 핸들링할 수 있다.
아래의 커스텀 예외를
// ReservationService.java
public ReservationTimeResponse findById(Long id) {
ReservationTime reservationTime = reservationTimeRepository.findById(id)
.orElseThrow(() -> new NotFoundException("해당 ID의 예약 시간이 없습니다."));
return ReservationTimeResponse.from(reservationTime);
}
해당 서비스에 의존하는 컨트롤러에서 ExceptionHandler
로 잡아서 핸들링할 수 있다.
@RestController
public class SimpleController {
// ...
@ExceptionHandler
public ResponseEntity<ErrorResponse> handle(NotFoundException ex) {
// ...
}
}
하지만 커스텀 예외는 ReservationService
뿐 아니라 어플리케이션의 여러 곳에서 발생할 수 있다. @RestControllerAdvice
(@ControllerAdvice
)의 ExceptionHandler 메소드로 @Controller
혹은 다른 핸들러의 예외를 처리할 수 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFoundException(NotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getMessage()));
}
}
참고로 @RestControllerAdvice
는 예외에 대한 응답을 HTML view가 아니라 response body message로 하겠다는 의미이며, @ControllerAdvice
에 @ResponseBody
를 붙였다고 보면 된다.
DispatcherServlet
에서 예외가 처리되는 과정request mapping 중 혹은 request handler(@Controller
)에서 예외가 발생하면 DispatcherServlet
은 HandlerExceptionResolver
체인에 예외를 해결 및 처리를 위임한다. 일반적으로 Error response를 반환하는 것이다.
HandlerExceptionResolver
구현체 목록이다.
구현체 | description |
---|---|
SimpleMappingExceptionResolver | 예외 클래스 이름과 예외 응답 view 이름을 매핑한다. 브라우저 어플리케이션에서 에러 페이지로 응답할 때 유용하다. |
DefaultHandlerExceptionResolver | Spring MVC에 의해 발생한 예외를 해결하고(resolve) HTTP 상태 코드를 매핑한다. |
ResponseStatusExceptionResolver | @ResponseStatus 애너테이션을 사용한 메소드에서 발생한 예외를 해결하고 해당 애너테이션으로 HTTP 상태 코드를 매핑한다. |
ExceptionHandlerExceptionResolver | @Controller 혹은 @ControllerAdvice 클래스의 @ExceptionHanlder 메소드를 실행해서 예외를 해결한다. |
위에 언급했던 아래 테스트 코드 실행 결과 상태코드 400이 응답되었던 건 HandlerExceptionResolver
체인이 예외를 처리했기 때문이다. (@ControllerAdvice
를 사용하지 않았을 때)
@Test
@DisplayName("올바르지 않은 타입의 필드로 예약 POST 요청 시 상태코드 400을 반환한다.")
void createReservationWithInvalidDateFormat() throws Exception {
// given
String invalidDateFormatRequest = "{\"date\": \"dfdf\"}";
// when & then
mockMvc.perform(post("/reservations")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidDateFormatRequest))
.andDo(print())
.andExpect(status().isBadRequest());
}
사진에서 볼 수 있듯 DispatcherServlet
에서 리졸버들을 가지고 있다.
ResponseEntityExceptionHandler
RequestMappingHandlerMapping
과 ExceptionHandlerExceptionResolver
가 컨트롤러 어드바이스 빈들을 찾아서 런타임에 실행한다. spring boot에서 자주 발생하는 예외를 모두 컨트롤러 어드바이스에 처리해 주는 건 힘들다.
이 때 ResponseEntityExceptionHandler
라는 기본 클래스를 사용할 수 있는데, spring boot의 예외들에 대해 @ExceptionHandler
메소드들을 정의해놓은 클래스들이다. 그 메소드들을 hadleException
메소드에서 호출한다.
아래처럼 기본적으로 세팅해놓고 필요할 때 오버라이딩하면 편하다.
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
다시 되돌아 가서, 역직렬화할 때 발생하는 HttpMessageNotReadableException
를 전역적으로 처리해보자.
// GlobalExceptionHandler.java
@Override
public ResponseEntity<Object> handleHttpMessageNotReadable(HttpMessageNotReadableException e, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
}
ResponseEntityExceptionHandler
에 ExceptionHandler 메소드가 이미 정의되어 있기 때문에 오버라이딩하였다. 에러 메세지 응답은 아래와 같다.
{
"message":"JSON parse error: Cannot deserialize value of type `java.time.LocalDate` from String \"dfdf\": Failed to deserialize java.time.LocalDate: (java.time.format.DateTimeParseException) Text 'dfdf' could not be parsed at index 0"
}
클라이언트에게 불친절하다. 조금 더 디테일하게 줄 수 있지 않을까? stack trace를 출력해보았다.
AbstractJackson2HttpMessageConverter.readJavaType
에서 InvalidFormatException
이 발생하였다.
예외가 발생한 메소드를 디버깅 모드로 확인해보니 자바 타입으로 변환하지 못한 DTO 필드명과 타입을 InvalidFormatException
에서 가지고 있었다. 따라서 아래와 같이 에러 메세지를 수정하였다.
// handleHttpMessageNotReadable 메소드
if (e.getCause() instanceof InvalidFormatException invalidFormatException) {
String errorMessage = getMismatchedInputExceptionMessage(invalidFormatException);
return ResponseEntity.badRequest()
.body(new ErrorResponse(errorMessage));
}
private String getMismatchedInputExceptionMessage(InvalidFormatException e) {
String type = e.getTargetType().toString();
String fieldNames = e.getPath()
.stream()
.map(Reference::getFieldName)
.collect(Collectors.joining(", "));
return fieldNames + String.format("(type: %s) 필드의 값이 잘못되었습니다.", type);
}
엄밀히 말하면 핸들러 메소드의 매개변수 타입 예외를 처리하는 방법이다. 아래와 같이 @RequestParam
(혹은 @PathVariable
이나 커스텀 애너테이션)을 사용할 때 MethodArgumentTypeMismatchException
를 처리해줄 수 있다.
@GetMapping("/available")
public ResponseEntity<List<AvailableReservationTimeResponse>> findAllByDateAndThemeId(
@RequestParam LocalDate date, @RequestParam Long themeId) {
return ResponseEntity.ok(reservationTimeService.findAvailableReservationTimes(date, themeId));
}
ResponseEntityExceptionHandler
에 따로 정의된 메소드는 없어 직접 만들어주었다.
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
String errorMessage = getMethodArgumentTypeMismatchExceptionMessage(e);
return ResponseEntity.badRequest()
.body(new ErrorResponse(errorMessage));
}
private String getMethodArgumentTypeMismatchExceptionMessage(MethodArgumentTypeMismatchException e) {
String type = e.getParameter().getParameterType().toString();
String parameterName = e.getParameter().getParameterName();
return parameterName + String.format("(type: %s) 파라미터의 타입이 올바르지 않습니다.", type);
}
부끄럽지만.. 예외 처리에 대해 깊게 고민한 적이 없었던 것 같다. 하지만 항상 API를 만들 때마다 클라이언트 개발자들에게 "이거 왜 안돼?"는 종종 들어왔다. 내 API의 불친절한(미숙한) 에러 응답도 불필요한 소통을 만든 원인이었을지도 모른다. 구글, 유튜브 API의 예외 응답을 구경하다 보니 정말 디테일하고 다양한 상황에 대해 잘 정리되어 있어 새삼 멋졌고(?) 다음 프로젝트 때 적용해보고 싶다고 생각했다.
또한 (과거에 했듯이) 글로벌 예외 핸들러를 한 번에 만든게 아니라 하나씩 추가하며 디버깅을 해보니 spring boot의 예외 처리를 이해하기 쉬웠다. 👍
[1]
ResponseEntityExceptionHandler
[2]
[3]