
예외 처리를 분명히 했다고 생각했는데, 왜 클라이언트는 500 에러를 받았을까?
@ControllerAdvice로는 못 잡는 예외도 있고,
Filter, Interceptor, 비동기 처리에서는 전혀 다른 방식으로 대응해야 한다.실무에서 안정적이고 일관된 예외 응답을 위해
어디서, 어떻게 처리해야 하는지를 Spring 구조 중심으로 정리해봤다.
Spring MVC 요청 흐름은 아래처럼 진행됨.
[Filter] → [Interceptor] → [DispatcherServlet] → [Controller]
↓
[HandlerExceptionResolver]
↓
@ControllerAdvice / ViewResolver
예외가 어디서 발생했는지에 따라 처리 방식이 달라진다.
때문에, 이를 알지 못하면 @ControllerAdvice 써도 잡히지 않는 상황이 생긴다.
| 발생 위치 | 처리 가능 방식 |
|---|---|
| Controller 내부 | @ExceptionHandler / @ControllerAdvice |
| Interceptor 내부 | ❌ Advice로는 불가 → 직접 처리하거나 Resolver 등록 필요 |
| Filter 내부 | ❌ Advice로는 불가 → 직접 응답 처리해야 함 |
| Async / Executor 내부 | ❌ Advice로는 불가 → 별도 설정 필요 |
내가 처음에 착각했던 부분!
그런데, ControllerAdvice는 DispatcherServlet 이후에 동작하는 구조이기 때문에
그 전에 발생한 예외는 잡지 못한다.
@WebFilter("/*")
public class AuthFilter implements Filter {
public void doFilter(...) {
if (token == null) {
throw new RuntimeException("토큰 없음");
}
chain.doFilter(request, response);
}
}
이렇게 Filter에서 예외가 터지면 ControllerAdvice는 아예 호출도 안되는 것!
| 항목 | @ControllerAdvice | @RestControllerAdvice |
|---|---|---|
| 응답 타입 | View 렌더링 (기본값) | JSON (@ResponseBody 포함) |
| 사용 목적 | 서버 렌더링 웹 (ex. 타임리프) | REST API 전용 |
| Spring 4.3 이상 | @ControllerAdvice + @ResponseBody 조합 | RestControllerAdvice가 동일한 효과 |
실무에서는 당연히 @RestControllerAdvice를 써야 API 응답으로 예외를 처리할 수 있다.
예외가 발생하면 DispatcherServlet이 잡고,
내부에 등록된 ExceptionResolver들을 순회하면서 누가 처리할지 결정한다.
그 중 하나가 예외를 처리하면 그걸로 끝!
ControllerAdvice도 사실은 이 Resolver 중 하나일 뿐이다.
→ Advice는 Controller 이후 단계에서만 작동한다는 걸 기억하자.
| 상황 | 이유 | 해결 방법 |
|---|---|---|
| Filter에서 예외 발생 | DispatcherServlet 도달 전 | Filter 안에서 직접 response 작성 |
| Interceptor의 preHandle 중 예외 | Controller 진입 전 | try-catch 처리하거나 ExceptionResolver 직접 등록 |
| @Async 내부 예외 | 별도 스레드 → 메인 쓰레드로 전달 안 됨 | AsyncUncaughtExceptionHandler 설정 필요 |
| CompletableFuture | 예외가 get() 시점에야 드러남 | .exceptionally, .handle로 처리 필요 |
public class AuthFilter implements Filter {
public void doFilter(...) {
try {
chain.doFilter(request, response);
} catch (Exception e) {
response.setStatus(401);
response.setContentType("application/json");
response.getWriter().write("{\"message\":\"인증 실패\"}");
}
}
}
Advice는 여기서 아무 역할도 하지 못한다.
다시 말 해, 무조건!!! 직접 잡아줘야한다는 것!
public class BaseException extends RuntimeException {
private final ErrorCode errorCode;
...
}
public class UserNotFoundException extends BaseException {
public UserNotFoundException() {
super(ErrorCode.USER_NOT_FOUND);
}
}
public enum ErrorCode {
USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다."),
INVALID_TOKEN(401, "유효하지 않은 토큰입니다."),
INTERNAL_ERROR(500, "서버 오류");
private final int status;
private final String message;
}
Enum으로 상태코드, 메시지를 관리하면 일관성도 좋고, 유지보수도 쉽다.
에러 응답 포맷을 미리 정해두는 것이 매우 중요!!!!
public class ErrorResponse {
private final int status;
private final String message;
private final String code;
private final String path;
private final LocalDateTime timestamp;
}
프론트에서 이 구조를 그대로 기대하고 있을 가능성 높음.
사전에 API 계약서에 명시해두고, 일관되게 이 포맷을 지켜야 한다.

→ DispatcherServlet 안에서 발생한 예외는 Advice로 처리
→ 그 이전(예: Filter)이나 이후(Async)는 직접 처리 필요
| 실수 | 권장 방식 |
|---|---|
| 모든 예외를 catch(Exception)로 한꺼번에 처리 | 예외 타입별로 핸들러 나누기 |
| 500 상태코드 하나로 전부 응답함 | 상황에 맞는 HTTP 상태코드 사용 |
| 에러 메시지를 하드코딩함 | Enum으로 메시지 관리 |
| ControllerAdvice 하나만 등록해놓고 다 처리되길 바람 | Filter, Interceptor, Async는 따로 처리 구조 구성해야 함 |
Spring에서 예외 처리는 그냥 try-catch로 끝나는 게 아니다.
예외가 어디서 발생했는지, 요청 흐름의 어느 위치인지, 동기인지 비동기인지에 따라
처리 방식이 완전히 달라진다.
Advice는 내부 Controller에서 발생한 예외만 잡는다.
Filter나 비동기 흐름에선 직접 처리하거나 별도 설정이 필요하다.
결국 예외 처리는
✔️ 일관된 구조,
✔️ 클라이언트와의 API 계약,
✔️ 디버깅 가능한 서비스 흐름을 만드는 게 핵심이다.