[Spring Boot][5] 9. API 예외처리

sorzzzzy·2021년 9월 26일
0

Spring Boot - RoadMap 1

목록 보기
46/46
post-thumbnail

🏷 시작

API의 경우 어떻게 예외처리를 할까❓

➡️ 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다!


✔️ 스프링 부트 기본 오류 처리

BasicErrorController

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
  
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
  • errorHtml()
    • produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept 해더 값이 text/html 인 경우에는 errorHtml() 을 호출해서 view를 제공한다.
  • error()
    • 그 외 경우에 호출되고 ResponseEntity 로 HTTP Body에 JSON 데이터를 반환한다.

✔️ Html 페이지 vs API 오류

  • 스프링 부트가 제공하는 BasicErrorController 는 HTML 페이지를 제공하는 경우에는 매우 편리하다.
    ➡️ 4xx, 5xx 등등 모두 잘 처리해준다.

  • 그런데 API 오류 처리는 다른 차원의 이야기❗️❗️
    ➡️ API 마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야 할 수도 있다.
    (예를 들어서 회원과 관련된 API에서 예외가 발생할 때 응답과, 상품과 관련된 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있음)
    ➡️ 매우 세밀하고 복잡함.
    ➡️ 따라서 이 방법은 HTML 화면을 처리할 때 사용하고, API는 오류 처리는 뒤에서 설명할 @ExceptionHandler 를 사용하자!!

📌 BasicErrorController 를 확장해서 JSON 오류 메시지를 변경할 수 있다 정도로만 이해해두고 넘어가자!



🏷 HandlerExceptionResolver 활용

스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있는 방법을 제공한다.
컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다❗️ (줄여서 ExceptionResolver)

📌 참고
ExceptionResolver 로 예외를 해결해도 postHandle() 은 호출되지 않는다.



🏷 @ExceptionHandler

지금까지 살펴본 BasicErrorController 를 사용하거나 HandlerExceptionResolver 를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다☹️


✔️ API 예외처리의 어려운 점

  • HandlerExceptionResolver 를 떠올려 보면 ModelAndView 를 반환해야 했다. 이것은 API 응답에는 필요하지 않다.
  • API 응답을 위해서 HttpServletResponse 에 직접 응답 데이터를 넣어주었는
    데 이것은 매우 불편하다🤔
  • 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다.
    (예를 들어서 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까❓)

✔️ @ExceptionHandler

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver 이다❗️

  • 스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다.
  • 실무에서 API 예외 처리는 대부분 이 기능을 사용한다!

✔️ ErrorResult 객체 정의

package hello.exception.exhandler;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

✔️ ApiExceptionV2Controller 컨트롤러 생성

package hello.exception.api;

import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@Slf4j
// api 는 @RestController 사용
@RestController
public class ApiExceptionV2Controller {

    // @ResponseStatus(HttpStatus.BAD_REQUEST) : 상태코드도 바꿔주고 싶을 때 사용
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    // 서블릿 컨테이너까지 지저분하게 가지 않고, 정상 흐름으로 끝날 수 있음
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    // ResponseEntity<ErrorResult> 사용
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    // 상태 코드 지정
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    // 그냥 exception 사용
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }


    @GetMapping("/api2/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {

        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        if (id.equals("user-ex")) {
            throw new UserException("사용자 오류");
        }

        return new MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }

}

@ExceptionHandler 예외 처리 방법

  • @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
  • 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다.
  • 참고로 지정한 예외 또는 그 예외의 자식 클래스는 모두 잡을 수 있다.

📌 스프링의 우선순위는 항상 자세한 것이 우선권을 가진다.
예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.


✔️ 실행 결과

{
	"code": "BAD",
	"message": "잘못된 입력 값" 
}

✔️ 실행 흐름 정리

1️⃣ 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
2️⃣ 예외가 발생했으로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.
3️⃣ ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리할 수 있는 @ExceptionHandler 가 있는지 확인한다.
4️⃣ illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다.
5️⃣ 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.
6️⃣ @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.



🏷 @ControllerAdvice

@ExceptionHandler 를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice 를 사용하면 둘을 분리할 수 있다❗️


✔️ ExControllerAdvice

ApiExceptionV2Controller 에서 다룬 3가지 경우(@ExceptionHandler)를 이곳에 붙여넣기 하고, 기존 코드는 삭제한다!

package hello.exception.exhandler.advice;

import hello.exception.exception.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {

    // @ResponseStatus(HttpStatus.BAD_REQUEST) : 상태코드도 바꿔주고 싶을 때 사용
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    // 서블릿 컨테이너까지 지저분하게 가지 않고, 정상 흐름으로 끝날 수 있음
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandle(IllegalArgumentException e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("BAD", e.getMessage());
    }

    @ExceptionHandler
    // ResponseEntity<ErrorResult> 사용
    public ResponseEntity<ErrorResult> userExHandle(UserException e) {
        log.error("[exceptionHandle] ex", e);
        ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
        return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
    }

    // 상태 코드 지정
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    // 그냥 exception 사용
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }


}

@ControllerAdvice

  • @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다.
  • @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
  • @RestControllerAdvice@ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.
    @Controller , @RestController 의 차이와 같다.

📌 대상 컨트롤러 지정 방법

  // Target all Controllers annotated with @RestController
  @ControllerAdvice(annotations = RestController.class)
  public class ExampleAdvice1 {}
  // Target all Controllers within specific packages
  @ControllerAdvice("org.example.controllers")
  public class ExampleAdvice2 {}
  // Target all Controllers assignable to specific classes
  @ControllerAdvice(assignableTypes = {ControllerInterface.class,
  AbstractController.class})
  public class ExampleAdvice3 {}

자세한 부분은 [스프링 공식 문서](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann- controller-advice) 참고 🌟

지금까지 알아본 것처럼, @ExceptionHandler@ControllerAdvice 를 조합하면 예외를 깔끔하게 해결할 수 있다👍🏻


고지가 보인다...! 보...일까....? 아.....닌....가...^_^?

profile
Backend Developer

0개의 댓글