기본 콘텐츠로 건너뛰기

Spring MVC에서 `@ExceptionHandler`를 제대로 쓰는 방법: 예외 흐름부터 전역 처리까지

Spring MVC에서 @ExceptionHandler를 제대로 쓰는 방법: 예외 흐름부터 전역 처리까지

Spring MVC에서 예외 처리는 부가 기능이 아니라 API 설계의 일부입니다. 같은 실패 상황인데 어떤 엔드포인트는 400을 주고, 어떤 곳은 500을 주고, 어떤 응답은 메시지 형식조차 다르다면 클라이언트 코드도 복잡해지고 운영 중 원인 파악도 어려워집니다.

@ExceptionHandler는 이런 혼란을 줄이기 위한 핵심 도구입니다. 컨트롤러 실행 과정에서 발생한 예외를 잡아, 애플리케이션이 의도한 HTTP 상태 코드와 응답 본문으로 바꿔 주는 역할을 합니다. 단순히 예외를 숨기는 기능이 아니라, 예외를 API 계약에 맞는 응답으로 번역하는 장치라고 이해하면 훨씬 실무적입니다.

이 글에서는 @ExceptionHandler가 정확히 어디서 동작하는지, @ControllerAdvice와는 어떻게 역할을 나누는지, 그리고 실제로 어떤 응답 구조를 만들어야 유지보수가 쉬운지까지 한 번에 정리해보겠습니다.

왜 컨트롤러 예외 처리를 따로 설계해야 할까

예외는 없어지지 않습니다. 잘못된 요청 본문, 존재하지 않는 리소스, 현재 상태에서는 허용되지 않는 비즈니스 요청, 예상하지 못한 런타임 오류는 서비스가 살아 있는 한 계속 발생합니다. 중요한 것은 예외 발생 자체가 아니라, 그 예외가 어떤 HTTP 응답으로 변환되느냐입니다.

예외 처리를 설계하지 않으면 보통 다음 문제가 나타납니다.

  • 사용자 입력 오류와 서버 내부 오류가 모두 500으로 보입니다.
  • 프론트엔드가 엔드포인트마다 다른 실패 형식을 처리해야 합니다.
  • 컨트롤러마다 try-catch가 늘어나서 비즈니스 로직이 흐려집니다.
  • 운영 로그에는 스택 트레이스만 남고, 클라이언트에게는 맥락 없는 오류 메시지만 전달됩니다.

실무에서는 예외를 “없애는 것”보다 “의미 있게 분류하는 것”이 더 중요합니다. 예를 들어 리소스 없음은 404, 요청 값 오류는 400, 상태 충돌은 409, 미처 고려하지 못한 장애는 500처럼 구분해야 합니다. 이 구분을 일관되게 구현하는 데 @ExceptionHandler가 매우 유용합니다.

@ExceptionHandler는 무엇을 하는가

@ExceptionHandler는 특정 예외 타입을 처리할 메서드를 지정하는 애너테이션입니다. 보통 두 위치에서 사용합니다.

  • 특정 컨트롤러 내부
  • @ControllerAdvice 또는 @RestControllerAdvice 클래스 내부

컨트롤러 내부에 두면 해당 컨트롤러에 가까운 예외 정책을 표현하기 좋고, advice에 두면 여러 컨트롤러에 공통으로 적용할 수 있습니다.

몇 가지 중요한 특성을 먼저 짚고 가면 좋습니다.

  • 서비스 계층에서 던진 예외라도 컨트롤러 호출 흐름을 타고 올라오면 처리할 수 있습니다.
  • @ExceptionHandler는 MVC 예외 해석기 위에서 동작하므로, 필터나 Spring Security 앞단에서 발생한 예외는 같은 방식으로 처리되지 않을 수 있습니다.
  • 더 구체적인 예외 타입의 핸들러가 더 넓은 타입보다 우선합니다.
  • JSON API라면 @RestControllerAdvice가 대체로 더 자연스럽습니다.
  • Exception.class를 받는 광범위한 핸들러는 마지막 안전망으로 두는 편이 좋습니다.

즉, @ExceptionHandler는 예외를 잡는 기술이라기보다 예외를 해석해서 응답을 만드는 기술에 가깝습니다.

Spring MVC에서 예외가 처리되는 내부 흐름

@ExceptionHandler를 제대로 쓰려면 Spring MVC 내부 흐름을 알고 있어야 합니다. 요청이 들어오면 DispatcherServlet이 적절한 핸들러 메서드를 찾아 실행합니다. 이 과정에서 예외가 발생하면, DispatcherServlet은 곧바로 WAS로 예외를 던지지 않고 등록된 HandlerExceptionResolver들에게 처리를 위임합니다.

일반적으로 많이 보게 되는 순서는 다음과 같습니다.

  • ExceptionHandlerExceptionResolver
  • ResponseStatusExceptionResolver
  • DefaultHandlerExceptionResolver

가장 먼저 보는 것은 ExceptionHandlerExceptionResolver입니다. 여기서 현재 컨트롤러의 @ExceptionHandler를 찾고, 없으면 @ControllerAdvice 계열 전역 핸들러를 조회합니다. 이 단계에서 처리되면 예외는 애플리케이션 내부에서 응답으로 바뀌고 더 이상 위로 전파되지 않습니다.

그다음 ResponseStatusExceptionResolver@ResponseStatus가 붙은 예외나 ResponseStatusException을 상태 코드 기반으로 처리합니다. 마지막으로 DefaultHandlerExceptionResolver는 HTTP 메서드 불일치, 파라미터 바인딩 오류 같은 Spring MVC 기본 예외를 표준 상태 코드에 매핑합니다.

여기서 가장 많이 헷갈리는 지점은 “모든 예외가 @ExceptionHandler로 잡히는가?”입니다. 답은 그렇지 않다는 것입니다. @ExceptionHandler는 컨트롤러 실행 흐름 안에서 특히 강력합니다. 하지만 다음 영역은 별도 전략이 필요할 수 있습니다.

  • 서블릿 필터
  • Spring Security 인증/인가 체인
  • 컨트롤러 진입 전 단계의 저수준 예외
  • 이미 응답이 커밋된 뒤 발생한 예외

이 경계를 이해하면, 왜 어떤 예외는 잘 잡히고 어떤 예외는 기대대로 동작하지 않는지 훨씬 빠르게 파악할 수 있습니다.

컨트롤러 단위 처리와 @RestControllerAdvice 전역 처리는 어떻게 나눌까

실전에서는 “어디에 두는가”보다 “무엇을 어디까지 공통화할 것인가”를 먼저 정하는 편이 좋습니다. 보통은 응답 형식을 먼저 결정하고, 그다음 예외의 범위를 나눕니다.

아래 예시는 공통 에러 응답과 도메인 예외, 그리고 컨트롤러 내부의 로컬 핸들러를 함께 보여줍니다. OrderNotFoundException처럼 주문 컨텍스트에 강하게 묶인 예외는 컨트롤러에 두고, 여러 API에서 공통으로 발생할 수 있는 예외는 전역 advice로 올리는 방식입니다.

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.time.LocalDateTime;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

public record ApiError(
        String code,
        String message,
        String path,
        LocalDateTime timestamp
) {}

public record CreateOrderRequest(
        @NotBlank(message = "상품명은 필수입니다.")
        String itemName,
        @Min(value = 1, message = "수량은 1 이상이어야 합니다.")
        int quantity
) {}

public record OrderResponse(Long id, String status) {}

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("주문을 찾을 수 없습니다. id=" + id);
    }
}

public class InvalidOrderStateException extends RuntimeException {
    public InvalidOrderStateException(String message) {
        super(message);
    }
}

@RestController
@RequestMapping("/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderResponse getOrder(@PathVariable Long id) {
        if (id < 1) {
            throw new InvalidOrderStateException("주문 ID는 1 이상이어야 합니다.");
        }
        if (id == 9999L) {
            throw new OrderNotFoundException(id);
        }
        return new OrderResponse(id, "READY");
    }

    @PostMapping
    public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
        return new OrderResponse(1001L, "CREATED");
    }

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ApiError> handleOrderNotFound(
            OrderNotFoundException ex,
            HttpServletRequest request
    ) {
        ApiError body = new ApiError(
                "ORDER_NOT_FOUND",
                ex.getMessage(),
                request.getRequestURI(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

이 코드는 컨트롤러에 가까운 정책을 명확하게 드러낸다는 장점이 있습니다. 주문 조회 API에서 발생하는 “없음” 예외는 그 자리에서 404로 변환됩니다. 반면 검증 실패나 예기치 못한 일반 예외까지 이 안에 모두 넣으면 컨트롤러가 무거워지므로, 공통 정책은 아래처럼 전역 advice에 두는 편이 낫습니다.

import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(basePackages = "com.example")
public class GlobalApiExceptionHandler {

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

    @ExceptionHandler(InvalidOrderStateException.class)
    public ResponseEntity<ApiError> handleInvalidState(
            InvalidOrderStateException ex,
            HttpServletRequest request
    ) {
        ApiError body = new ApiError(
                "INVALID_ORDER_STATE",
                ex.getMessage(),
                request.getRequestURI(),
                LocalDateTime.now()
        );
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiError> handleValidation(
            MethodArgumentNotValidException ex,
            HttpServletRequest request
    ) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .findFirst()
                .orElse("요청 값을 다시 확인해 주세요.");

        ApiError body = new ApiError(
                "VALIDATION_ERROR",
                message,
                request.getRequestURI(),
                LocalDateTime.now()
        );
        return ResponseEntity.badRequest().body(body);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiError> handleUnexpected(
            Exception ex,
            HttpServletRequest request
    ) {
        log.error("Unhandled exception", ex);

        ApiError body = new ApiError(
                "INTERNAL_SERVER_ERROR",
                "일시적인 오류가 발생했습니다.",
                request.getRequestURI(),
                LocalDateTime.now()
        );
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
    }
}

이 구성이 좋은 이유는 관심사가 분리되기 때문입니다. 특정 API 문맥에 가까운 예외는 로컬에서 처리하고, 여러 컨트롤러에 걸친 공통 정책은 전역에서 관리합니다. 모든 예외를 전역 하나에 몰아넣는 방식도 가능하지만, 시간이 지나면 도메인 의미가 흐려지고 조건 분기가 커지기 쉽습니다.

API 에러 응답은 어떤 형태로 설계하면 좋을까

에러 응답은 화려할 필요가 없습니다. 대신 규칙이 분명해야 합니다. 최소한 다음 정도는 유지하는 편이 실용적입니다.

  • code: 클라이언트가 분기 처리하기 위한 안정적인 식별자
  • message: 사람이 읽을 수 있는 설명
  • path: 어느 요청에서 발생했는지 확인할 경로
  • timestamp: 문제 추적 시점 확인용 정보

이 구조의 장점은 프론트엔드와 백엔드가 같은 실패 언어를 쓰게 된다는 점입니다. 예를 들어 프론트엔드는 code=VALIDATION_ERROR면 입력 폼 강조를 하고, code=INTERNAL_SERVER_ERROR면 일반 에러 토스트를 띄우는 식으로 단순하게 대응할 수 있습니다.

Spring 6 이상에서는 ProblemDetail도 좋은 선택지입니다. 다만 이미 팀에서 공통 포맷을 운영 중이거나, 에러 코드를 별도로 강하게 관리하고 싶다면 DTO 방식이 더 명시적일 수 있습니다. 중요한 것은 어떤 방식을 쓰든 응답 구조를 엔드포인트마다 바꾸지 않는 것입니다.

또 하나 실무에서 중요한 원칙은 500 응답에 내부 구현 세부사항을 노출하지 않는 것입니다. 예외 메시지를 그대로 내려보내면 디버깅에는 편해 보이지만, 운영 환경에서는 오히려 불필요한 정보 노출이 됩니다. 자세한 내용은 서버 로그에 남기고, 외부 응답은 안전하고 일관되게 유지하는 편이 맞습니다.

curl로 바로 확인하는 테스트 예시

예외 처리는 코드를 쓰는 것보다 실제 응답을 확인하는 과정이 더 중요합니다. 아래 예시는 각각 다른 종류의 실패를 빠르게 검증하는 데 유용합니다.

curl -i http://localhost:8080/orders/9999

curl -i http://localhost:8080/orders/0

curl -i -X POST http://localhost:8080/orders \
  -H 'Content-Type: application/json' \
  -d '{"itemName":"","quantity":0}'

첫 번째 요청은 존재하지 않는 주문이므로 404와 함께 ORDER_NOT_FOUND를 기대할 수 있습니다. 두 번째는 비즈니스 규칙상 잘못된 상태이므로 400과 INVALID_ORDER_STATE가 나오는 것이 자연스럽습니다. 세 번째는 Bean Validation 실패이므로 MethodArgumentNotValidException이 발생하고, 전역 핸들러가 400과 VALIDATION_ERROR를 반환하게 됩니다.

예상 응답은 다음처럼 일정한 구조를 유지해야 합니다.

{
  "code": "VALIDATION_ERROR",
  "message": "itemName: 상품명은 필수입니다.",
  "path": "/orders",
  "timestamp": "2026-04-04T14:20:30"
}

이처럼 상태 코드는 상황에 따라 달라도, 응답 바디의 형식이 유지되면 클라이언트 입장에서 훨씬 다루기 쉬워집니다. 결국 좋은 예외 처리는 “실패했을 때도 예측 가능한 API”를 만드는 일입니다.

예외가 기대대로 잡히지 않을 때 확인할 디버깅 포인트

@ExceptionHandler를 붙였는데도 동작하지 않는다고 느껴질 때는, 애너테이션 자체를 의심하기보다 먼저 예외의 발생 위치를 확인해야 합니다. 실제 원인은 대체로 구조와 흐름에 있습니다.

로그를 조금만 보강해도 원인을 찾는 속도가 크게 올라갑니다. 아래 설정은 DispatcherServlet과 예외 해석기의 동작을 추적할 때 유용합니다.

logging:
  level:
    org.springframework.web.servlet.DispatcherServlet: DEBUG
    org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver: DEBUG

이 상태에서 아래 항목을 차례로 점검하면 대부분의 문제를 정리할 수 있습니다.

  • 예외가 정말 컨트롤러 실행 중에 발생했는지 확인합니다.
  • 실제 던져진 예외 타입이 핸들러의 파라미터 타입과 일치하는지 봅니다.
  • @RestControllerAdvice가 컴포넌트 스캔 범위 안에 있는지 확인합니다.
  • basePackages를 사용했다면 대상 패키지가 정확한지 확인합니다.
  • 로컬 @ExceptionHandler가 먼저 매칭되어 전역 핸들러가 호출되지 않는 상황인지 봅니다.
  • 검증 실패 예외가 MethodArgumentNotValidException인지, 다른 예외로 들어오는 상황인지 확인합니다.
  • 응답이 이미 커밋된 뒤 예외가 발생한 것은 아닌지 살펴봅니다.

특히 자주 나오는 오해가 두 가지 있습니다. 첫째, 서비스에서 던진 예외는 못 잡는다고 생각하는 경우입니다. 실제로는 컨트롤러 호출 흐름 안에서 올라온다면 충분히 처리할 수 있습니다. 둘째, 웹 계층에서 발생한 예외는 모두 advice가 잡는다고 생각하는 경우입니다. 필터 체인이나 보안 계층은 별도 처리 지점이 필요할 수 있습니다.

디버깅의 핵심은 “어디서 예외가 났는가”와 “누가 그것을 처리했는가”를 분리해서 보는 것입니다. 이 관점을 잡으면 @ExceptionHandler 관련 문제는 대부분 빠르게 수습할 수 있습니다.

정리

@ExceptionHandler는 Spring MVC 예외 처리를 예쁘게 꾸미는 기능이 아니라, 예외를 HTTP 응답 계약으로 연결하는 핵심 구성 요소입니다. 컨트롤러 내부에서는 도메인에 가까운 예외를 처리하고, @RestControllerAdvice에서는 여러 API에 공통인 정책을 관리하면 구조가 자연스럽습니다.

핵심은 세 가지입니다. 예외를 HTTP 의미로 분류할 것, 응답 형식을 일관되게 유지할 것, 그리고 예외가 발생하는 계층 경계를 정확히 이해할 것입니다. 이 세 가지만 지켜도 API 품질은 눈에 띄게 안정됩니다.

원문 참고

https://www.maeil-mail.kr/question/8

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...