[Springboot] 유효성 검증과 예외 처리

종미(미아)·2024년 5월 6일
3

🌱 Spring

목록 보기
5/9
post-thumbnail

들어가며

적절한 예외 처리는 어플리케이션을 개발할 때 필수적이지만 생각보다 어렵다. (^_^a) 세부적인 부분들을 놓치고 지나가기 쉽고 고민할 지점들이 존재한다. Springboot와 함께 예외를 핸들링한 과정과 더불어 우테코 미션을 진행하며 유효성 검증 위치를 고민한 경험을 소개하려 한다.

유효성 검증 위치 선택

방탈출 예약 생성 HTTP API를 만드려고 한다. API 명세는 아래와 같다.

      POST /reservations HTTP/1.1
      content-type: application/json
    
      {
          "date": "2023-08-05",
          "name": "Mia",
          "timeId": 1,
          "themeId": 1
      }

간단하게 아래와 같은 검증이 필요하다.

  1. HTTP 헤더(HTTP 메소드, URL, content-type)가 유효하다.
  2. 모든 필드(date, name, ..)의 타입이 유효하다.
  3. 모든 필드의 값이 존재한다.
  4. 예약 날짜(date)는 현재 날짜 이후이다.
  5. 예약자 이름은 공백과 숫자로만 이루어질 수 없다.
  6. 예약 시간 Id를 가진 예약 시간 데이터(튜플)가 존재한다.
  7. 테마 Id를 가진 테마 데이터(튜플)가 존재한다.
  8. 같은 시간, 같은 테마에 2팀 이상 예약할 수 없다.

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("유효하지 않은 예약 날짜입니다.");
        }
    }
 }

아래와 같은 문제들이 있다.

  1. String에서 LocalDate로의 변환 책임이 도메인 클래스에 있는가? (같은 맥락으로 String에서 Long으로의 변환도)
  2. (예약 객체가 생성되기 전) 서비스 로직에서 예약 날짜는 String으로 사용되어도 되는가?
  3. 이전 날짜를 가진 예약(Reservation)객체는 생성될 수 없어도 되는가?

유효한 타입을 Presentation layer에서 검증

// 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로 예외를 처리할 수 있으니 아래에서 더 살펴보자.

서비스 정책을 Application layer에서 검증

정의하기 나름이지만 서비스 정책, 도메인 규칙들은 도메인 클래스, 서비스 클래스 모두에서 검증 가능하다고 생각한다. 하지만 "예약 날짜(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("이전 날짜 혹은 당일은 예약할 수 없습니다.");
        }
    }

유효성 검증의 위치 선택 기준

유효성 검증 위치 변경을 여러 번 반복하며 생각한 기준이다.

  1. 얼마나 빠르게 검증해야 안전한가?
    '적어도' 어디에 있어야 하는지 고민해보아야 한다.

  2. 해당 검증이 위치한 객체의 역할(책임)인가?
    OOP와 Layered architecture를 고려하면 생각보다 쉽게 검증 위치를 찾을 수 있다.

  3. 경우별로 유효성 검증을 쉽게 테스트할 수 있는가?
    유효성 검증 역시 중요한 로직이고 경우별로 다른 처리를 해야 하기 때문에 쉽게 테스트할 수 있어야 한다.

그래서 앞서 언급한 8개 검증은 나의 어플리케이션에서 이렇게 위치한다.

  1. HTTP 헤더(HTTP 메소드, URL, content-type)가 유효하다.
    ➡️ 요청 시 즉시 검증한다.
  2. 모든 필드(date, name, ..)의 타입이 유효하다.
    ➡️ DTO 클래스의 역직렬화 시
  3. 모든 필드의 값이 존재한다.
    ➡️ DTO 클래스
  4. 예약 날짜(date)는 현재 날짜 이후이다.
    ➡️ Sevivce 클래스의 메소드
  5. 예약자 이름은 공백과 숫자로만 이루어질 수 없다.
    ➡️ Domain 클래스의 생성 시
  6. 예약 시간 Id를 가진 예약 시간 데이터(튜플)가 존재한다.
    ➡️ Sevivce 클래스의 메소드
  7. 테마 Id를 가진 테마 데이터(튜플)가 존재한다.
    ➡️ Sevivce 클래스의 메소드
  8. 같은 시간, 같은 테마에 2팀 이상 예약할 수 없다.
    ➡️ 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)에서 예외가 발생하면 DispatcherServletHandlerExceptionResolver 체인에 예외를 해결 및 처리를 위임한다. 일반적으로 Error response를 반환하는 것이다.

HandlerExceptionResolver 구현체 목록이다.

구현체description
SimpleMappingExceptionResolver예외 클래스 이름과 예외 응답 view 이름을 매핑한다. 브라우저 어플리케이션에서 에러 페이지로 응답할 때 유용하다.
DefaultHandlerExceptionResolverSpring 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

RequestMappingHandlerMappingExceptionHandlerExceptionResolver가 컨트롤러 어드바이스 빈들을 찾아서 런타임에 실행한다. 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);
        }

URL 매개변수 타입 예외 처리하기

엄밀히 말하면 핸들러 메소드의 매개변수 타입 예외를 처리하는 방법이다. 아래와 같이 @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]

Exceptionhandlers

[3]

ControllerAdvice

profile
BE 개발자 지망생 🪐

0개의 댓글