본문 바로가기
Spring

[Spring] 스프링의 예외 처리

by 감자b 2024. 12. 27.

스프링은 예외 처리를 위한 다양한 방법을 제공한다.

1. BasicErrorController

다음과 같은 컨트롤러가 있다고 하자.

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    @GetMapping("/login")
    public String loginException() {
        throw new LoginRequiredException("인증이 필요한 서비스입니다.");
    }
}

기본적으로 요청이 들어오면 다음과 같은 호출 과정을 거친다.

WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러

 

근데 만약 컨트롤러에서 예외를 잡지 않고 throw 한다면 어떻게 될까?

위에서 컨트롤러는 호출 시 직접 만든 LoginRequiredException이라는 RuntimeException을 던진다.

이렇게 되면 컨트롤러에서 발생한 예외가 다시 WAS까지 전파된다.

그런데 예외를 전달받은 WAS는 오류 페이지 출력을 위해 다시 컨트롤러에 요청을 날리고 컨트롤러는 오류 페이지를 반환한다.

즉 WAS부터 컨트롤러까지의 요청이 2번 발생하게 된다.

스프링 부트는 BasicErrorController라는 스프링 컨트롤러를 자동으로 등록하고, 예외가 발생하거나, response.sendError()가 호출되면 해당 컨트롤러의 /error라는 경로의 기본 오류 페이지를 설정하고 호출한다.

 

자주 볼 수 있는 Whitelabel Error Page가 스프링이 BasicErrorController를 이용해 기본적으로 예외를 처리한 것이다. (Accept: text/html 인 경우에 해당)

 

JSON으로 요청을 보낸다면 아래와 같은 응답을 보내줌.

{
    "timestamp": "2024-10-21T21:29:33.787+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/exception/login"
}

만약 화면이 마음에 들지 않는다면 BasicErrorController는 우선 순위에 따라 뷰를 지정할 수도 있다.

  1. 뷰 템플릿
    • /resources/templates/error/500.html
    • /resources/templates/error/5xx.html
    • 구체적인 코드 우선 실행하고 에러코드에 해당하는 뷰가 없다면 5xx를 실행하여 500번대 에러를 처리
  2. 정적 리소스
    • /resources/static/error/5xx.html
  3. 적용 대상이 없다면 error라는 이름의 뷰
    • /resources/templates/error.html

하지만 기본적으로 제공하는 이 방식은 컨트롤러를 한 번 더 호출한다는 문제가 있다. 이 과정에서 등록한 필터, 인터셉터가 호출될 수 있는데 서블릿 필터의 경우 필터 등록 시에 dispatcherType으로 구분하거나, 인터셉터의 경우 등록 시에 excludePathPatterns를 사용해서 오류 페이지 경로를 제외하는 식의 추가 작업이 필요하다.

또한 예외가 WAS까지 전달되는 경우에는 HTTP 상태 코드가 500으로 처리된다는 문제가 있다.


2. HandlerExceptionResolver

스프링 MVC는 HandlerExceptionResolver을 이용해 컨트롤러 밖으로 예외가 던져졌을 때 해당 예외를 해결하고, 동작을 새로 정의할 수 있도록 한다.

public interface HandlerExceptionResolver {
    @Nullable
    ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, @Nullable Object handler, Exception ex);
}

HandlerExceptionResolver를 적용하면 다음과 같이 처리된다.

컨트롤러에서 발생한 예외는 ExceptionResolver에서 해결할 기회가 주어지며, 예외를 정상 흐름으로 변경하도록 한다.

위 인터페이스를 보면 resolveException 메서드는 ModelAndView를 반환하는데, 반환 값에 따라 동작 방식이 다르게 진행된다.

  • 빈 ModelAndView 반환 시 : new ModelAndView()를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴.
  • ModelAndView 지정 : ModelAndView에 View, Model 등 정보를 지정해서 반환하면 뷰를 렌더링.
  • null : 다음 ExceptionResolver를 찾아서 실행하며, 만약 처리할 수 있는 ExceptionResolver가 없다면 예외 처리를 못하고, 서블릿 밖으로 전달.

따라서 요청이 정상 흐름으로 진행될 수 있도록 하기 때문에 ExceptionResolver를 이용하면 BasicErrorController에서 하지 못했던 예외 상태 코드 변환이 가능하다.

Response.sendError() 메서드로 상태 코드를 변환할 수 있는데 이를 사용하면 WAS가 dispatcherType == ERROR 형식으로 다시 요청을 한다. (BasicErrorController 처리)

하지만 sendError()가 아닌 ModelAndView를 리턴한다면, WAS의 추가 호출 없이 바로 Http 응답을 리턴한다.

 

스프링 부트는 기본적으로 ExceptionResolver를 제공하고 다음 순서로 등록한다.

  1. ExceptionHandlerExceptionResolver
    • @ExceptionHandler 처리하는 리졸버
    • ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver의 경우 특정 컨트롤러에 따른 별도의 예외 처리가 힘들고 Response에 직접 응답을 해야하는 등 불편한 점이 존재했다. (JSON 응답 반환 시) 이러한 문제를 해결하기 위해 제공하는 기능으로 우선 순위가 가장 높고 API 응답 시에 대부분 해당 기능을 사용한다.
public class ErrorResult {
	private String code;
	private String message;
}

 

다음 컨트롤러는 3개의 예외를 던지는 메서드를 갖는다.

“/bad”에 매핑 할 때는 ResponseStatusException, “/login”에 매핑 시엔 LoginRequiredException, 마지막으로 “/server-error”에 매핑 시엔 Exception을 던진다.

해당 컨트롤러에서 처리하고 싶은 예외를 @ExceptionHandler를 선언한 메서드를 통해 지정해주면 조건에 맞는 예외가 발생했을 때 이 메서드가 호출된다.

예를 들어 /login 호출 시 LoginRequiredException이 발생한다. 이는 RuntimeException을 상속받아 직접 만든 Exception이다. 해당 예외가 컨트롤러 밖으로 던져지게 되고, 예외가 발생하여 ExceptionResolver가 작동하는데 우선 순위가 가장 높은 ExceptionHandlerExceptionResolver가 해당 예외를 처리할 수 있는 @ExceptionHandler를 가진 메서드가 있기 때문에 runtimeExHandle()를 실행하게 되고 @RestController이므로 반환하는 ErrorResult가 HTTP 메세지 컨버터에 의해 JSON으로 반환된다.

즉 @ExceptionHandler(RuntimeException.class)라고 지정한 runtimeExHandle() 메서드는 “/login”, “ResponseStatusException”이 발생했을 때 호출된다. (지정한 예외 포함 자식 클래스까지 처리 가능)

그리고 “/server-error”의 경우 최상위 Exception이 터지므로 exHandle() 메서드를 통해 처리된다.

@RestController
@RequestMapping("/exception")
public class ExceptionController {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(RuntimeException.class)
    public ErrorResult runtimeExHandle(RuntimeException e) {
        return new ErrorResult("BAD", e.getMessage());
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        return new ErrorResult("EX", "내부 오류");
    }

    @GetMapping("/bad")
    public String exception() {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.", new IllegalStateException());
    }

    @GetMapping("/server-error")
    public String exception2() throws Exception {
        throw new Exception("서버 에러");
    }

    @GetMapping("/login")
    public String loginException() {
        throw new LoginRequiredException("인증이 필요한 서비스입니다.");
    }
}

만약 부모, 자식 예외를 모두 처리할 수 있는 @ExceptionHandler 애노테이션이 붙은 메서드가 있다면 자식예외가 붙은 메서드가 우선 순위를 갖는다.

또한 exHandle() 메서드의 경우 @ExceptionHandler 옆에 예외가 생략되었는데 생략 시 파라미터로 넘어온 예외 타입이 지정된다.

 

2. ResponseStatusExceptionResolver

  • HTTP 상태 코드를 지정하는 @ResponseStatus, ResponseStatusException 처리
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청입니다.") 
public class BadRequestException extends RuntimeException {
}

 

위와 같은 예외가 컨트롤러 밖으로 던져지면 ResponseStatusExceptionResolver가 상태 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고 메시지 역시 담음.

이 때 response.sendError(400, reason)을 호출하므로 WAS에서 다시 /error로 요청 발생.

 

@ResponseStatus는 외부 라이브러리의 예외에는 적용할 수 없다. (코드 변경을 할 수 없으므로)

이 때는 ResponseStatusException을 이용해서 처리 가능.

@GetMapping
public String exception() {
	throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잘못된 요청입니다.", new IllegalStateException());
}

 

 

3. DefaultHandlerExceptionResolver (우선 순위 가장 낮으며 스프링 내부의 기본 예외를 처리)

대표적으로 파라미터 바인딩 시점에 타입이 다르다면 TypeMismatchException이 터지는데 이 경우 500에러가 아닌 400을 반환한다.이 때 response.sendError(HttpServletResponse.SC_BAD_REQUEST)을 통해 400을 반환하므로 WAS에서 요청이 한 번 더 일어난다.


3. @ControllerAdvice, @RestControllerAdvice

위 방법을 통해 예외를 편리하게 처리할 수 있지만, 여전히 컨트롤러에 예외, 정상 코드가 섞여있다는 문제가 존재한다. 이는 @ControllerAdvice, @RestControllerAdvice 사용해서 구분할 수 있다.

 

위의 예제 컨트롤러에서 예외를 처리하는 부분을 다음과 같이 ExControllerAdvice에 옮기고 해당 클래스에 @RestControllerAdvice 애노테이션을 적용했다.

@RestControllerAdvice는 어떤 컨트롤러에 해당 예외를 적용할 것인지 지정할 수 있는데 아래와 같이 지정하지 않는다면 모든 컨트롤러에 전역적으로 예외를 처리해준다.

@RestControllerAdvice
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(RuntimeException.class)
    public ErrorResult runtimeExHandle(RuntimeException e) {
        return new ErrorResult("BAD", e.getMessage());
    }

    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        return new ErrorResult("EX", "내부 오류");
    }
}

참고로 RestControllerAdvice는 전역적으로 예외를 처리할 수 있는 @ControllerAdvice에 @ResponseBody가 붙어있어 응답을 json으로 내려주는 애노테이션이다.

즉 ControllerAdvice는 여러 컨트롤러에 대해 ExceptionHandler를 적용하여 전역적으로 에러를 핸들링하는 클래스를 만들고 에러 처리를 위임한다. 이로 인해 예외, 정상 코드를 분리하여 가독성이 높아지고, 직접 정의한 에러 응답을 일관되게 클라이언트에게 전달할 수 있게 된다.

추가로 해당 어노테이션이 선언된 클래스는 스프링 빈으로 등록된다.


참고

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

'Spring' 카테고리의 다른 글

[Spring] JDBC, 커넥션 풀, 트랜잭션 추상화  (0) 2024.12.27
[Spring] MultipartFile 바인딩  (0) 2024.12.27
[Spring] 필터와 인터셉터  (0) 2024.12.27
[Spring] Bean Validation  (0) 2024.12.27
[Spring] 메시지, 국제화  (0) 2024.12.27