[Spring] API 예외처리하기 (@ExceptionHandler, @ControllerAdvice)

🔥Log·2023년 1월 24일
3

스프링

목록 보기
9/18

☕ 시작


어떠한 서비스든 경우에 맞게 예외를 클라이언트에게 적절히 제공해야한다.
그래서, 이번 글에서는 API서버를 개발할 때, 예외처리 방법을 알아보도록 하겠다.


🤔 예외를 처리하는 방법


예외를 처리하는 방법은 여러가지가 있다.
포인트는 어딘가에서 발생한 예외를 잡아서 이 예외를 처리한 후, 원하는 메세지와 상태코드와 함께 클라이언트 측에 정상응답으로 전달해야한다는 것이다.

이렇게 할 수 있는 방법이 어떤 것들이 있는지 하나 하나 짚고 넘어가 보겠다.


1) HandlerExceptionResolver 구현


Exception resolver를 직접 한땀 한땀 구현하는 가장 원시(?)적인 방법이다. ㅎㅎ

먼저, IllegalArgumentException이 터졌을 때, 특정 처리를 해주는 상황이라고 가정하자.

구현 방법 자체는 간단하다.
1. HandlerExceptionResolver를 상속받은 구현체를 만든다.
2. 이 클래스를 Exception resolver로 등록한다.

방법 자체는 직관적이고 쉽지만,,, 코드양이 굉장히 방대하다 ㅎㅎ

1. Exception resolver 구현

public class CustomExceptionResolver implements HandlerExceptionResolver {
	
    private final ObjectMapper objMapper = new ObjectMapper();

    @Override
    public ModelAndView resolveException(
    	HttpServletRequest request, 
        HttpServletResponse response, 
        Object handler, 
        Exception exception
    ) {
        try {
            if (exception instanceof IllegalArgumentException) {
                handleException(response, exception);
                return new ModelAndView();
            }
        } catch (IOException e) {}
        return null;
    }
    
    // 실제로 예외를 처리할 메서드
    private handleException(HttpServletResponse response, Exception exception) {
    	response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

		// 클라이언트에 보여질 json객체 만들기
    	Map<String, Object> result = new HashMap<>();
        result.put("exception", exception.getClass());
        result.put("message", exception.getMessage());

        String result = objMapper.writeValueAsString(result); // Object -> String

        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(result);
    }
}

2. Exception resolver 등록

@Configuration
public class AppConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new CustomExceptionResolver());
    }
}

이렇게 하면, 서버에서 IllegalArgumentException이 발생했을 때 이 Resolver가 대신 처리를 해주게 되는 것이다.
또, 클라이언트에게 보여질 에러 내용은 아래와 같은 Json객체가 보여지게 된다.

{
	"exception": 에러의 클래스 이름,
  	"message": "에러 메세지"
}

직접 HandlerExceptionResolver를 구현하는 방식은 개발자가 해줘야할 게 참 많고, 장황하다.
사실은 이런 반복적이고 귀찮은 작업을 스프링에서는 미리 다 구현을 해놓았다. ㅎㅎ

바~로 알아보자 🤭


2) 스프링의 ExceptionResolver


기본적으로 스프링에서는 3가지의 기본 Exception resolver들을 제공하고 있다.

  1. ExceptionHandlerExceptionResolver ⭐
  2. ResponseStatusExceptionResolver
  3. DefaultHandlerExceptionResolver

1, 2, 3 순서대로 우선순위를 갖고 있다.
즉, 1번에서 처리가 안되면 2번, 3번 순서대로 처리의 주체가 넘어가게 된다.
이것들이 어떻게 활용하는 지 알아보자.


2-1) @ExceptionHandler


@ExceptionHandler를 활용하여 아주 편리하게 예외를 처리할 수 있다.
API서버에서 예외를 처리할 때, 가장 많이 쓰이기 때문에 아주 중요하다.

아래와 같은 컨트롤러가 있다고 가정해보자.
클라이언트에서 /api/ex-handler에 api요청을하면, IllegalArgumentexception이 발생하는 간단한 컨트롤러이다.

@RestController
public class TestController {

    @GetMapping("/api/ex-handler")
    public String exHandler() {
        throw new IllegalArgumentException();
    }

	@ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalAccessException.class)
    public ErrorResponse handler(IllegalArgumentException e) {
        // ... 예외 처리 ...
        return new ErrorResponse("Bas request", e.getMessage());
    }

    private static class ErrorResponse {
        String code;
        String message;

        public ErrorResponse(String code, String message) {
            this.code = code;
            this.message = message;
        }
    }
}

이 코드를 보면, @ExceptionHandler로 이 컨트롤러에서 발생할 IllegalArgumentException을 잡아서 처리해주고 있다.
이런식으로 각 컨트롤러에 맞게 에러를 처리해줄 수 있다.

근데, 컨트롤러마다 에러 처리 코드를 구현해야하는 게 어떤 상황에서는 귀찮고 불필요할 수가 있다.
그럴때는 서버 전체에 적용되는 에러처리 핸들러가 필요하다.
이런 경우엔 AOP가 녹아있는 @ControllerAdvice를 사용한다.


@ControllerAdvice ⭐

이 기능은 스프링 예외 처리의 화룡정점이라고 할 수 있다.
비지니스 로직과 예외 처리를 완전히 분리 시켜줌과 동시에 원하는 곳에 원하는 방식으로 예외를 처리할 수 있게 해준다.
사용방법은 사실 매우 간단하다.
예외 처리를 담당할 클래스를 만들고 그 클래스에 @RestControllerAdvice또는 @ControllerAdvice를 붙여주면 된다.
위의 예제 코드에 이를 적용해보자.
그러면, 아래와 같이 컨트롤러와 예외 처리 로직이 완전히 분리되게 된다.

컨트롤러

@RestController
public class TestController {

    @GetMapping("/api/ex-handler")
    public String exHandler() {
        throw new IllegalArgumentException();
    }
}

예외처리 클래스

@RestControllerAdvice
public class TestController {

	@ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalAccessException.class)
    public ErrorResponse handler(IllegalArgumentException e) {
        // ... 예외 처리 ...
        return new ErrorResponse("Bas request", e.getMessage());
    }

    private static class ErrorResponse {
        String code;
        String message;

        public ErrorResponse(String code, String message) {
            this.code = code;
            this.message = message;
        }
    }
    
}

이렇게만 해주면 앱을 빌드할 때, 스프링에서 @RestControllerAdvice를 보고, 이 클래스를 예외처리 클래스로 들고 있게 된다.

에러 처리의 범위는 @ControllerAdvice에 인자로 넘겨서 설정할 수 있고, 특정 클래스부터 특정 패키지까지 원하는 대로 설정할 수 있다.


2-2) @ResponseStatus


이 Resolver가 처리할 수 있는 경우는 아래 2가지이다.

  1. @ResponseStatus가 붙어있는 예외가 발생한 경우
  2. ResponseStatusException이 발생할 경우

즉, @ResponseStatus를 붙인 에러를 구현하여 코드 상에서 사용하거나, ResponseStatusException에 상태코드와 메세지같은 것을 인자로 넘겨서 예외를 발생시키면 된다.

예시로 알아보자.

예시

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "사용자를 찾을 수가 없어!")
public class UserNotFoundException extends RuntimeException {}

먼저 @ResponseStatus를 붙인 에러를 만들어준다.

@RestController
public class TestController {
	@GetMapping("/api/user-not-found")
    public String userNotFound() {
        throw new UserNotFoundException();
    }
    
    // ResponseStatusException을 사용하는 예시 (위 api와 동일하게 동작)
    @GetMapping("/api/user-not-found/2")
    public String userNotFound2() {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "사용자를 찾을 수 없어!", new AttributeNotFoundException());
    }
}

그리고 이 예외를 특정 api에서 바로 발생시켜보자.
그러면 아래와 같이 정상적으로 예외가 보여지는 것을 확인할 수 있다.


2-3) 기본 처리


DefaultHandlerExceptionResolver는 그냥 두면 알아서(?) 특정 에러들을 처리하는 Resolver이다.
어떠한 에러들을 알아서 처리해주는지 공식문서를 참고하자 ㅎㅎ


☕ 마무리

예외를 처리하는 다양한 방식들을 알아보았다.
이 글이 조금이나마 도움이 되었길 바라면서 마치도록 하겠다 🤭

0개의 댓글