스프링 #10 예외 처리 2 - API

함형주·2022년 10월 19일
0

spring

목록 보기
10/12

질문, 피드백 등 모든 댓글 환영합니다.

HTTP API 요청에서 예외가 발생하면 단순히 오류페이지를 제공하는 것이 아니라 상황에 맞게 오류 응답 스펙을 정하고 관련 데이터를 JSON으로 응답해야합니다.

예외의 이동 경로와 오류페이지 응답은 이전 블로그를 참고해주세요.

스프링부트가 기본으로 제공하는 BasicErrorController를 확장하여 오류 페이지 뿐만 아니라 API 예외도 처리할 수 있습니다.

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

ResponseEntity를 반환하여 HTTP 메시지 컨버터를 활용한 JSON 응답이 가능한 메서드를 제공하기에 BasicErrorController를 상속 받아 기능을 확장하면 원하는 형식으로 API 예외 처리가 가능합니다.

하지만 API 예외는 요청 방법이나 형식에 따라 다른 응답을 내려주어야 할 경우가 많아 매우 복잡하고 세밀하게 설계해야 합니다. 때문에 보통 API 예외는 BasicErrorController로 처리하지 않고 @ExceptionHandler를 사용하여 처리합니다.

HandlerExceptionResolver

스프링은 예외가 컨트롤러 밖으로 전달된 경우 예외를 WAS까지 전달하지 않고 HandlerExceptionResolver에서 예외를 해결하고 요청 흐름을 새로 정의할 수 있는 기능을 제공합니다.

HandlerExceptionResolver

ModelAndView resolveException(
HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex);

HandlerExceptionResolver를 상속받아 resolveException()에서 예외를 해결하고 ModelAndView를 반환하여 정상 흐름으로 만들 수 있습니다.

반환 값에 따라 DispatcherServlet은 다르게 동작합니다.

  1. ModelAndView : 뷰를 렌더링하지 않고 정상 흐름으로 처리
  2. 값이 있는 ModelAndView : model과 view 정보에 따라 렌더링
  3. null : 다음 순서의 HandlerExceptionResolver으로 이동, 만약 마지막이라면 예외를 그대로 WAS까지 전달

HandlerExceptionResolver에서 response.getWriter() 등을 사용하여 API 형식으로 응답을 제공할 수도 있습니다.

WebMvcConfigurer을 통해 HandlerExceptionResolver 구현체를 등록하여 사용할 수 있습니다.

하지만 막상 HandlerExceptionResolver를 직접 구현해서 사용하려면 굉장히 복잡한 과정을 거쳐야 합니다.

try {
	if(ex instance of XxxException)..
		if ("application/json".equals(request.getHeader("accept")))...
			response.getWriter()...
} catch .....

이 처럼 하나의 예외를 처리하기 위해 많은 코드가 필요합니다.

스프링부트는 3가지의 HandlerExceptionResolver 구현체를 제공합니다.
아래의 순서대로 등록되어 사용됩니다.

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

ExceptionHandlerExceptionResolver는 아래에서 설명할 @ExceptionHandler를 처리합니다.

ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 예외에 HTTP 상태 코드를 지정하는 역할을 합니다.
@ResponseStatus이 달린 예외나 ResponseStatusException을 처리합니다.

@ResponseStatus로 각 예외에 HTTP 상태 코드를 설정할 수 있습니다.

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "xxxx")
public class XxxException extends RuntimeException {
}

ResponseStatusExceptionResolver@ResponseStatus가 적용된 예외가 넘어오면 response.sendError()를 호출하여 WAS에서 오류 페이지를 요청합니다.
@ResponseStatusreason 속성은 메시지 소스에 접근할 수도 있습니다.

@ResponseStatus는 편리하게 상태 코드를 설정할 수 있지만 개발자가 수정 가능한 예외에만 적용 가능하기에 라이브러리에서 제공하는 예외 등에는 적용할 수 없습니다.

이럴 때는 ResponseStatusException를 사용합니다.

public ResponseStatusException(HttpStatus status)
public ResponseStatusException(HttpStatus status, @Nullable String reason)
public ResponseStatusException(HttpStatus status, @Nullable String reason, @Nullable Throwable cause)
public ResponseStatusException(int rawStatusCode, @Nullable String reason, @Nullable Throwable cause)

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 예외를 처리합니다.
예를 들어 HTTP 요청 시 바인딩 과정 중에 TypeMismatchException이 발생하는데 이 경우 원래라면 500 상태 코드가 발생하지만 DefaultHandlerExceptionResolver를 통해 400 오류로 바꿔줍니다.
HTTP 요청에서 TypeMismatchException이 발생한다면 대부분 클라이언트가 잘못 요청한 경우이므로 HTTP 스펙에 맞게 400을 반환해야 합니다.

스프링은 일반적으로 발생하는 예외에 대한 수많은 내용을 DefaultHandlerExceptionResolver에 등록해 두었고 별도의 설정없이 자동으로 적용되어 사용할 수 있습니다.

ExceptionHandlerExceptionResolver

지금까지 HandlerExceptionResolver에 대한 내용을 정리했는데 사실 이를 직접 구현하여 사용하기는 매우 복잡합니다. response에 직접 API 데이터를 생성하거나 필요없는 ModelAndView를 반환하는 것은 매우 비효율적입니다.

스프링은 @ExceptionHandler을 통해 API 예외를 처리하는 기능을 제공합니다.

@ExceptionHandler를 처리하는 리졸버가 ExceptionHandlerExceptionResolver 입니다.

@ExceptionHandler를 사용하면 ModelAndView도 반환하지 않고, 다른 컨트롤러에서 발생하는 같은 종류의 예외를 서로 다른 방식으로 HttpServletResponse를 사용하지 않고 API 예외를 처리할 수 있습니다.

때문에 API 예외를 처리할 때는 거의 @ExceptionHandler를 사용하여 처리합니다.

@ExceptionHandler

HTTP API 요청을 처리할 컨트롤러에서 @ExceptionHandler이 지정된 메서드가 있으면 예외 발생 시 해당 메서드가 실행됩니다.

일단 글 보단 코드로 살펴 본 뒤 자세히 설명하겠습니다.

예제) /ex/{id}로 요청 시 User를 JSON 형식으로 반환, id 1, 2는 요청 불가하며 ErrorResponse를 응답


@Data, @AllArgsConstructor는 롬복이 제공하는 어노테이션

User

@Data @AllArgsConstructor
public class User {
    int id;
    String name;
}

예외 시 응답 결과로 제공할 객체

@Data @AllArgsConstructor
public class ErrorResponse {
    int id;
    String message;
}

예외 처리 컨트롤러

@RestController
public class ExController {

    @GetMapping("/ex/{id}")
    public User ex(@PathVariable int id) {
        if (id == 1)
            throw new RuntimeException("1 예외");
        if (id == 2)
            throw new IllegalArgumentException("2 예외");

        return new User(id, "user "+id);
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(RuntimeException.class)
    public ErrorResponse ex1(RuntimeException e) {
        return new ErrorResponse(1, e.getMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResponse ex2(IllegalArgumentException e) {
        return new ErrorResponse(2, e.getMessage());
    }
}

@RestController는 @Controller와 @ResponseBody를 합친 어노테이션

응답 결과

/ex/1

{
    "id": 1,
    "message": "1 예외"
}

/ex/2

{
    "id": 2,
    "message": "2 예외"
}

/ex/3

{
    "id": 3,
    "name": "user 3"
}

@ExceptionHandler 사용법

@ExceptionHandler
@ExceptionHandler(Exception.class)
@ExceptionHandler({AException.class, BException.class})

@ExceptionHandler에서 지정된 예외가 발생하면 해당 메서드가 실행됩니다.
해당 예외와 그 자식 타입의 예외를 모두 해결할 수 있고 만약 둘 다 각각 지정되어 있다면 자식 타입의 예외 발생 시 부모 타입의 예외가 지정된 메서드는 실행되지 않습니다.

위의 예제처럼 @ExceptionHandlerRuntimeExceptionIllegalArgumentException을 지정했을 때 각각 따로 예외 처리가 가능합니다.
(IllegalArgumentExceptionRuntimeException을 상속 받았기에 RuntimeException만 지정해도 두 예외를 모두 처리할 수 있지만 IllegalArgumentException을 따로 지정하면 우선 순위에 따라 더 자세한 IllegalArgumentException이 호출됩니다.)

@ExceptionHandler에 여러 예외를 지정하여 한 번에 처리할 수도 있습니다.

예외를 지정하지 않으면 해당 메서드의 파라미터로 지정됩니다.

@ExceptionHandler 
public ErrorResponse ex(RuntimeException e)

RuntimeException을 예외로 지정합니다.

컨트롤러에서 다양한 파라미터와 반환 값 사용이 가능하듯이 @ExceptionHandler에서도 여러 파라미터와 반환 값 사용이 가능합니다.

@ExceptionHandler에서 사용 가능한 파라미터, 반환 값 목록은 공식 문서 참조

만약 예외를 처리할 때 HTTP 상태 코드를 동적으로 사용하고 싶다면 ErrorResponse 대신 ResponseEntity<ErrorResponse>를 반환하여 사용합니다.

@ControllerAdvice

@ExceptionHandler를 이용해서 컨트롤러에 예외 처리 로직을 작성하게 되면 해당 파일에서 정상 처리 로직과 예외 처리 로직이 공존하여 복잡도가 올라갑니다.

때문에 일반적으로 이를 분리해서 사용하는데 @ControllerAdvice를 이용하여 쉽게 분리 가능합니다.

위의 예제를 기반으로

@RestController
public class ExController {

    @GetMapping("/ex/{id}")
    public User ex(@PathVariable int id) {
        if (id == 1)
            throw new RuntimeException("1 예외");
        if (id == 2)
            throw new IllegalArgumentException("2 예외");

        return new User(id, "user "+id);
    }
}

예외 처리 로직 분리

@RestControllerAdvice
public class ExControllerAdvice {    

	@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler(RuntimeException.class)
    public ErrorResponse ex1(RuntimeException e) {
        return new ErrorResponse(1, e.getMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResponse ex1(IllegalArgumentException e) {
        return new ErrorResponse(2, e.getMessage());
    }
}

이처럼 @ControllerAdvice를 이용하여 정상 처리 클래스와 예외 처리 클래스를 분리할 수 있습니다. (@RestControllerAdvice@ResponseBody@ControllerAdvice가 합쳐진 어노테이션입니다.)

@ControllerAdvice는 클래스가 적용될 범위 지정이 가능합니다.

@ControllerAdvice // 1.
@ControllerAdvice(annotations = RestController.class) // 2.
@ControllerAdvice("org.example.controllers") // 3.
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class}) // 4.
  1. : 모든 Controller에 적용
  2. : @RestController가 적용된 클래스에 적용
  3. : 해당 패키지와 하위 패키지의 Controller에 적용
  4. : 적용할 컨트롤러를 직접 지정
profile
평범한 대학생의 공부 일기?

0개의 댓글