[Spring] 스프링 전역 예외 처리 - @ExceptionHandler와 @RestControllerAdvice 사용법

개발을 하다보면 예외는 반드시 발생합니다. 이때 아무 처리도 하지 않으면 클라이언트는 스프링의 기본 에러 응답이나 스택 트레이스가 섞인 응답을 받게 되죠. 그렇다고 컨트롤러마다 try-catch를 넣으면 코드가 금방 지저분해집니다.

 

스프링은 이 문제를 해결하기 위해 @ExceptionHandler와 @ControllerAdvice를 제공합니다. 이 기능은 예외 처리 로직을 한곳에 모아두고, 어떤 예외가 발생하든 일관된 형태로 응답할 수 있게 해 주는 도구인데요. 이번 포스팅에서는 자바 예외의 기본 구조부터 시작해 두 어노테이션의 사용법과 우선순위까지 정리해보도록 하겠습니다.

 

자바 예외 구조 먼저 짚고 가기

스프링의 예외 처리를 이해하려면 자바 예외 클래스의 계층 구조를 먼저 알면 좋습니다. 어떤 핸들러가 선택되는지가 이 상속 관계에 따라 결정되기 때문입니다.

 

위의 이미지처럼 자바에서 모든 예외는 Throwable을 상속받고, 우리가 다루는 예외는 대부분 Exception 아래에 있습니다. 여기서 RuntimeException과 그 하위 클래스는 Unchecked Exception, 그 외의 Exception 하위 클래스는 Checked Exception으로 나뉩니다.

 

Checked Exception은 컴파일러가 "이 예외는 처리하고 넘어가야 한다"고 미리 알려줍니다. 파일을 읽는 코드에서 IOException 처리를 빠뜨리면 컴파일 자체가 안 되는 것이 예시라고 할 수 있겠습니다.

 

반면 Unchecked Exception은 실행 중에야 드러나게 되는데요. 메서드가 특정 타입의 값이 들어온다고 가정하고 만들어졌는데, 실제로는 null이 들어오거나 예상과 다른 값이 들어오는 경우를 예로 들 수 있겠습니다. 이런 상황은 코드만 봐서는 컴파일러가 알 수 없기 때문에 처리를 강제하지 않습니다.

 

예외 처리의 두 가지 방향

본격적으로 들어가기 전에 짚고 갈 것이 있습니다. 예외를 처리한다는 말에는 사실 두 가지 의미가 섞여 있습니다. 하나는 예외를 복구할 수 있는 경우입니다. 외부 API 호출이 실패하면 캐시된 값을 대신 쓴다거나, 값이 잘못 들어오면 기본값으로 대체하는 상황입니다. 이런 경우는 해당 로직 안에서 try-catch로 직접 처리하는 것이 맞습니다.

 

다른 하나는 복구할 수 없어서 클라이언트에게 알려야 하는 경우입니다. 존재하지 않는 회원을 조회했다면 서버가 할 수 있는 일은 "그런 회원은 없습니다"라고 응답하는 것뿐입니다. 이때 중요한 것은 에러 응답을 어떤 형태로, 얼마나 일관되게 내려주느냐인데요.이번 글에서 다루는 @ExceptionHandler와 @ControllerAdvice는 두 번째 경우를 위한 도구입니다. 예외를 복구하는 것이 아니라, 처리할 수 없는 예외를 받아서 정해진 형식의 에러 응답으로 바꿔주는 역할을 합니다.

 

@ExceptionHandler

@ExceptionHandler는 스프링에서 "이 예외가 발생하면 이 메서드로 처리하겠다"고 지정하는 어노테이션입니다. 메서드에 붙이고, 괄호 안에 처리할 예외 클래스를 적습니다.

@RestController
@RequestMapping("/api/v1/hello")
public class HelloController {

    private final Logger log = LoggerFactory.getLogger(HelloController.class);

    @PostMapping("/exception")
    public void exceptionTest() throws Exception {
        throw new Exception();   // 테스트용으로 예외를 그대로 던짐
    }

    @ExceptionHandler(value = Exception.class)
    public ResponseEntity<Map<String, String>> handleException(Exception e) {
        HttpHeaders headers = new HttpHeaders();
        HttpStatus status = HttpStatus.BAD_REQUEST;

        log.error("컨트롤러 내 ExceptionHandler 호출, {}", e.getMessage());

        Map<String, String> body = new HashMap<>();
        body.put("error type", status.getReasonPhrase());
        body.put("code", "400");
        body.put("message", "에러 발생");

        return new ResponseEntity<>(body, headers, status);
    }
}

/api/v1/hello/exception으로 요청을 보내면 exceptionTest()에서 예외가 던져지고, 같은 컨트롤러 안에 있는 handleException()이 그 예외를 받아 처리합니다. 클라이언트는 다음과 같은 JSON 응답을 받습니다.

 

{
  "error type": "Bad Request",
  "code": "400",
  "message": "에러 발생"
}

핸들러 메서드의 반환 타입으로 ResponseEntity를 쓰는 이유는 HTTP 상태 코드와 헤더, 본문을 모두 직접 지정할 수 있기 때문입니다. 예외 상황에서는 상태 코드가 특히 중요하므로 ResponseEntity로 명확하게 내려주는 것이 좋습니다.

 

참고로 위 예제에서 로그를 찍어 보면 e.getMessage()가 null로 나옵니다. new Exception()처럼 메시지 없이 예외를 생성했기 때문입니다. 실제로는 new Exception("회원을 찾을 수 없습니다")처럼 메시지를 담아서 던지는 것이 디버깅에 훨씬 도움이 됩니다.

 

괄호 안에 예외 클래스를 꼭 적어야 할까요?

필수는 아닙니다. 괄호를 생략하면 스프링은 메서드 파라미터의 타입을 보고 어떤 예외를 처리할지 판단합니다.

@ExceptionHandler                        // 괄호 생략
public ResponseEntity<?> handle(IllegalArgumentException e) { ... }

하지만 파라미터 타입과 실제로 처리하고 싶은 예외가 다르거나, 여러 예외를 한 메서드에서 처리하고 싶을 때 혼란이 생길 수 있습니다. 그래서 대부분의 프로젝트에서는 괄호 안에 예외 클래스를 명시하는 방식을 씁니다. 

 

@ExceptionHandler({IllegalArgumentException.class, IllegalStateException.class})
public ResponseEntity<?> handleBadRequest(RuntimeException e) { ... }

여러 개를 처리하려면 이렇게 배열로 적으면 됩니다.

 

컨트롤러 안에 두면 생기는 한계

위 예제처럼 @ExceptionHandler를 컨트롤러 안에 두면 그 컨트롤러에서 발생한 예외만 처리합니다. 다른 컨트롤러에서 같은 예외가 발생해도 이 핸들러는 동작하지 않습니다. 컨트롤러가 열 개라면 같은 핸들러를 열 번 복사해야 하는 셈입니다. 이 문제를 해결하는 것이 @ControllerAdvice입니다.

 

@ControllerAdvice와 @RestControllerAdvice

@ControllerAdvice는 여러 컨트롤러에서 발생하는 예외를 한곳에서 관리할 수 있게 해 주는 어노테이션입니다. 이 어노테이션을 붙인 클래스 안에 @ExceptionHandler 메서드를 정의하면, 별도 설정이 없는 한 모든 컨트롤러의 예외를 처리합니다.

@RestControllerAdvice
public class GlobalExceptionHandler {

    private final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(value = Exception.class)
    public ResponseEntity<Map<String, String>> handleException(Exception e) {
        HttpHeaders headers = new HttpHeaders();
        HttpStatus status = HttpStatus.BAD_REQUEST;

        log.error("Advice 내 ExceptionHandler 호출, {}", e.getMessage());

        Map<String, String> body = new HashMap<>();
        body.put("error type", status.getReasonPhrase());
        body.put("code", "400");
        body.put("message", "에러 발생");

        return new ResponseEntity<>(body, headers, status);
    }
}

핸들러 메서드 자체는 앞의 예제와 똑같습니다. 다른 점은 위치뿐입니다. 컨트롤러 안이 아니라 @RestControllerAdvice가 붙은 별도의 클래스에 있기 때문에, 어느 컨트롤러에서 예외가 발생하든 이 핸들러가 처리합니다.

 

@ControllerAdvice vs @RestControllerAdvice

이름에서 짐작할 수 있듯이 차이는 @Controller와 @RestController의 차이와 같습니다.

REST API를 만든다면 @RestControllerAdvice를 쓰면 됩니다. 핸들러가 반환한 객체가 JSON으로 변환되어 응답 본문에 담깁니다. 반환 타입이 ResponseEntity라면 두 어노테이션 모두 본문으로 처리되지만, 일반 객체를 반환하는 핸들러까지 고려하면 REST API에서는 @RestControllerAdvice가 의도에 맞습니다.

 

적용 범위 지정

기본적으로는 모든 컨트롤러에 적용되지만, 속성을 지정해서 범위를 좁힐 수 있습니다.

// 특정 패키지의 컨트롤러에만 적용
@RestControllerAdvice(basePackages = "com.example.api.order")

// 특정 컨트롤러 클래스에만 적용
@RestControllerAdvice(assignableTypes = {OrderController.class, PaymentController.class})

// 특정 어노테이션이 붙은 컨트롤러에만 적용
@RestControllerAdvice(annotations = RestController.class)

관리자용 API와 사용자용 API의 에러 응답 형식이 다르다면, 패키지를 나눠 두고 각각 다른 Advice를 적용하는 식으로 활용할 수 있습니다.

 

 핸들러가 여러 개라면 누가 처리할까요? 

실무에서는 핸들러가 하나만 있는 경우가 거의 없습니다. 예외 종류별로 핸들러가 있고, 컨트롤러 안과 Advice 양쪽에 핸들러가 있기도 합니다. 이때 어떤 핸들러가 선택되는지는 두 가지 규칙으로 정해집니다.

 

규칙 1. 더 구체적인 예외가 우선합니다

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public ResponseEntity<?> handleException(Exception e) { ... }

    @ExceptionHandler(NullPointerException.class)
    public ResponseEntity<?> handleNpe(NullPointerException e) { ... }
}

여기서 NullPointerException이 발생하면 어떤 핸들러가 호출될까요? NullPointerException은 Exception의 하위 클래스이므로 두 핸들러 모두 처리할 자격이 있습니다. 이때 스프링은 상속 관계상 더 가까운(더 구체적인) 예외를 선언한 핸들러를 선택합니다. 그래서 handleNpe()가 호출됩니다.

 

반대로 IllegalStateException처럼 별도 핸들러가 없는 예외가 발생하면 handleException()이 받아서 처리합니다. 그래서 보통 Exception.class 핸들러를 하나 두어 "나머지 모든 예외"를 받는 최후의 안전망으로 씁니다.

 

규칙 2. 컨트롤러 안의 핸들러가 Advice보다 우선합니다

같은 예외에 대한 핸들러가 컨트롤러 안에도 있고 @RestControllerAdvice에도 있다면, 컨트롤러 안의 핸들러가 먼저 선택됩니다. 특정 컨트롤러를 위해 따로 정의한 처리 방식이 전역 설정보다 우선한다고 이해하면 자연스럽습니다.

 

앞의 두 예제를 동시에 두고 /api/v1/hello/exception을 호출해 보면 로그에 컨트롤러 내 ExceptionHandler 호출이 찍힙니다. 컨트롤러 안의 핸들러를 주석 처리하고 다시 호출하면 그때서야 Advice 내 ExceptionHandler 호출이 찍힙니다. 응답 내용은 같지만 처리한 주체가 달라진 것입니다.

 

정리하면 스프링은 먼저 예외가 발생한 컨트롤러 안에서 맞는 핸들러를 찾고, 없으면 Advice로 넘어갑니다. 각 단계에서는 가장 구체적인 예외 타입의 핸들러를 고릅니다.

 

@ExceptionHandler가 잡지 못하는 예외

앞서 필터와 인터셉터 글을 읽으셨다면 한 가지 궁금증이 생길 수 있습니다. 필터나 인터셉터에서 발생한 예외도 @ExceptionHandler가 처리해 줄까요?

 

답은 위치에 따라 다릅니다. @ExceptionHandler는 DispatcherServlet 안에서 발생한 예외만 처리합니다.

  • 인터셉터의 preHandle()에서 던진 예외는 DispatcherServlet 안이므로 처리됩니다.
  • Argument Resolver에서 던진 예외도 컨트롤러 호출 과정이므로 처리됩니다.
  • 필터에서 던진 예외는 DispatcherServlet에 들어가기 전이므로 처리되지 않습니다.

그래서 인터셉터에서 인증에 실패했을 때 response.setStatus(401)로 직접 응답을 만드는 대신 throw new UnauthorizedException()처럼 예외를 던지고, Advice에서 일괄 처리하는 방식도 많이 씁니다. 그러면 인증 실패 응답도 다른 에러와 같은 형식으로 나갑니다.

 

반면 필터에서 발생한 예외는 Advice까지 도달하지 못하므로, 필터 안에서 직접 응답을 작성하거나 필터 체인 앞쪽에 예외를 잡는 전용 필터를 두는 방식으로 처리해야 합니다.

 

정리하면

  • 자바 예외는 컴파일 시점에 처리를 강제하는 Checked Exception과, 런타임에 드러나는 Unchecked Exception(RuntimeException 계열)으로 나뉩니다.
  • 복구할 수 있는 예외는 try-catch로, 클라이언트에게 알려야 하는 예외는 @ExceptionHandler로 처리합니다.
  • @ExceptionHandler는 지정한 예외를 처리하는 메서드를 정의하며, 괄호 안에 예외 클래스를 명시하는 것이 좋습니다.
  • @ControllerAdvice는 여러 컨트롤러의 예외를 한곳에서 처리하고, REST API라면 @RestControllerAdvice를 씁니다.
  • 핸들러 우선순위는 컨트롤러 안 → Advice 순이며, 각 단계에서 더 구체적인 예외의 핸들러가 선택됩니다.
  • 에러 응답은 DTO로 형식을 통일하고, 예외 성격에 맞는 상태 코드를 내려줍니다.
  • 인터셉터와 Argument Resolver의 예외는 처리되지만, 필터의 예외는 처리되지 않습니다.