Spring에서 API 예외 처리는 일반 웹 페이지 예외 처리와는 다른 접근이 필요하다.
API는 오류 발생시 JSON 형태로 적절한 오류 응답을 제공해야 하기 때문이다.
Spring에선 @ExceptionHandler와 @ControllerAdvice를 사용해서 이 문제를 해결한다.
@ExceptionHandler의 필요성
HTML과 API 오류 처리의 차이를 생각해보자.
HTML 페이지는 단순히 오류 페이지(4XX, 5XX)를 보여주면 된다. 반면에 API는 각 오류 상황에 맞는 JSON 응답과 HTTP 상태 코드를 제공해야 한다.
기존 예외 처리 방식인 BasicErrorController는 HTML 오류 페이지 중심으로 동작하고 API처럼 예외마다 다른 JSON 응답 포맷이 필요한 경우에는 유연성이 부족하다.
따라서 HandlerExceptionResolver를 직접 구현하는 방식이 있다.
그러나 이 방식은 몇 가지 문제가 있다.
- ModelAndView 반환이 API에 불필요하다.
- API는 JSON 응답이 필요하므로 ModelAndView 자체가 쓸모없음
- HttpServletResponse에 직접 데이터 입력이 불편하다.
- JSON 응답을 주기 위해선 response.getWriter().write() 등 서블릿 수준으로 직접 처리해야 한다.
- API 개발자 입장에서 번거로움.
- 컨트롤러별 예외 처리가 어렵다.
- HandlerExceptionResolver는 전역적으로 동작해서 특정 컨트롤러에서만 예외를 다르게 처리하는 게 어려움.
- 회원 컨트롤러와 주문 컨트롤러가 같은 예외를 다르게 응답해야 할 때 유연성이 떨어진다.
@ExceptionHandler 동작 원리
SpringMVC는 예외 처리를 위해서 여러 ExceptionResolver를 제공하는데 이 중에서 가장 우선순위가 높은 것이 ExceptionHandlerExceptionResolver이다.
동작 흐름
- 컨트롤러에서 예외 발생 시 DispatcherServlet이 예외를 잡음
- DispatcherServlet은 등록된 ExceptionResolver 목록을 순회
- ExceptionHandlerExceptionResolver는 해당 컨트롤러에 예외를 처리할 수 있는 @ExceptionHandler 메서드가 있는지 확인
- 적절한 메서드를 찾으면 해당 메서드 호출
- 반환값을 기반으로 응답 생성(상태 코드, 헤더, 본문 etc..)
Controller 예외 발생 → DispatcherServlet → ExceptionHandlerExceptionResolver → @ExceptionHandler 메서드 실행 → 응답 생성
핵심 포인트를 정리해보면
- ExceptionHandlerExceptionResolver는 특정 컨트롤러 내에서 발생한 예외와 그 컨트롤러의 @ExceptionHandler 메서드를 매핑한다.
- 예외 타입 계층 구조를 고려하여 가장 구체적인 예외 핸들러를 선택한다.
- @ResponseStatus 또는 ResponseEntity를 통해 HTTP 상태 코드 제어
- @RestController에선 반환값이 자동으로 JSON으로 변환
@ExceptionHandler 사용해보기
상황을 쉽게 이해하기 위해 Custom 예외 네이밍을 썼다.
@ExceptionHandler(ProductNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleProductNotFound(ProductNotFoundException ex) {
return new ErrorResponse("PRODUCT_NOT_FOUND", ex.getMessage());
}
ProductNotFoundException 예외가 발생했을 때 HTTP 상태 코드 404(Not Found)로 JSON 에러 응답을 보내는 예제이다.
@ResponseStatus(HttpStatus.NOT_FOUND) → HTTP 응답 코드 404로 자동 지정
ErrorResponse 객체를 리턴 → JSON으로 자동 변환됨(@RestController이니까)
@ExceptionHandler({InvalidProductException.class, ProductValidationException.class})
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationExceptions(Exception ex) {
return new ErrorResponse("VALIDATION_ERROR", ex.getMessage());
}
여러 종류의 검증 관련 예외를 한 번에 처리하는 예제이다.
@ExceptionHandler({...}) 배열로 다중 예외 처리가 가능하다.
응답 상태는 항상 400이다.
@ExceptionHandler(ProductServiceException.class)
public ResponseEntity<ErrorResponse> handleServiceException(ProductServiceException ex) {
ErrorResponse response = new ErrorResponse("SERVICE_ERROR", ex.getMessage());
return new ResponseEntity<>(response, ex.getStatusCode());
}
서비스 로직에서 발생한 ProductServiceException 예외를 처리하고 동적으로 HTTP 상태 코드를 결정하는 예제이다.
ResponseEntity를 반환하므로 응답 바디와 상태 코드를 동시에 조절할 수 있다.
- ResponseEntity는 Spring에서 HTTP 응답을 직접 조작할 수 있게 해주는 클래스
- 상태 코드가 상황마다 다를 때 유용하다.
@ControllerAdvice를 통한 분리
@ExceptionHandler를 사용하면 효과적인 예외 처리가 가능하지만 컨트롤러마다 동일한 예외 처리 코드를 반복하거나 컨트롤러에 비즈니스 로직과 예외 로직이 함께 존재한다는 문제가 있다.
이럴 때 @ControllerAdvice나 @RestControllerAdvice를 사용하면 예외 처리 로직을 별도의 클래스로 분리할 수 있다.
@RestControllerAdvice // @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleProductNotFound(ProductNotFoundException ex) {
return new ErrorResponse("PRODUCT_NOT_FOUND", ex.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleGlobalException(Exception ex) {
return new ErrorResponse("SERVER_ERROR", "서버 내부 오류가 발생했습니다.");
}
}
@ControllerAdvice는 모든 컨트롤러의 예외를 공통으로 처리하는 기능을 제공한다.
거기에 @ResponseBody 기능을 합친 @RestControllerAdvice는 모든 예외 응답을 JSON으로 자동 반환해준다.
여기서 handleGlobalException을 중점적으로 보자.
- Exception.class는 모든 예외의 최상위 부모이다.
- 이 핸들러는 예상치 못한 모든 예외를 안전하게 처리해준다.
- 서버에서 오류가 발생했을 때 클라이언트에 에러 메시지 500으로 통일해서 응답한다.
-> 어떤 예외든 마지막에라도 잡아서 서버 오류가 그대로 노출되지 않게 방어한다.
요약하자면
1. @RestControllerAdvice는 모든 컨트롤러의 예외를 한 곳에서 전역 처리하기 위해 사용.
2. 반복적으로 @ExceptionHandler를 각 컨트롤러에 남발하지 않기 위해 구조를 분리.
3. 모든 예외에 대해 일관된 JSON 형식의 응답과 상태 코드를 보장하기 위해 씀.
@ControllerAdvice는 적용 범위를 지정할 수 있어 더 세밀한 제어가 가능하다.
// 특정 패키지에만 적용
@RestControllerAdvice("com.example.api.order")
public class OrderExceptionHandler { ... }
// 특정 애노테이션이 있는 컨트롤러에만 적용
@RestControllerAdvice(annotations = AdminApi.class)
public class AdminExceptionHandler { ... }
// 특정 클래스 타입에만 적용
@RestControllerAdvice(assignableTypes = {ProductController.class, OrderController.class})
public class ProductOrderExceptionHandler { ... }
예시 상황
- 사용자가 로그인 없이 게시글 작성 시도를 함 -> 인증 실패 (401 Unauthorized)
- 로그인했지만 관리자 전용 기능을 일반 유저가 호출 -> 권한 부족 (403 Forbidden)
이런 상황에선 Spring Security가 자동으로 예외를 던진다.
상황 | 발생 예외 | HTTP 상태 |
로그인 안 함 | AuthenticationException | 401 Unauthorized |
로그인 했지만 권한 없음 | AccessDeniedException | 403 Forbidden |
@RestControllerAdvice
public class SecurityExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleAccessDeniedException(AccessDeniedException ex) {
return new ErrorResponse("ACCESS_DENIED", "이 리소스에 접근할 권한이 없습니다.");
}
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleAuthenticationException(AuthenticationException ex) {
return new ErrorResponse("AUTHENTICATION_FAILED", "인증에 실패했습니다. 다시 로그인해주세요.");
}
}
이 코드는 보안 예외를 전역에서 통일되게 처리하기 위한 코드이다.
권한 없음 처리
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ErrorResponse handleAccessDeniedException(AccessDeniedException ex) {
return new ErrorResponse("ACCESS_DENIED", "이 리소스에 접근할 권한이 없습니다.");
}
사용자는 로그인했지만 ROLE_USER론 ROLE_ADMIN 기능을 쓸 수 없다.
Spring Security가 AccessDeniedException을 던진다.
-> 이 핸들러가 잡아서 "ACCESS_DENIED" 에러코드와 메시지를 JSON으로 반환한다.
{
"code": "ACCESS_DENIED",
"message": "이 리소스에 접근할 권한이 없습니다."
}
인증 실패 처리
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleAuthenticationException(AuthenticationException ex) {
return new ErrorResponse("AUTHENTICATION_FAILED", "인증에 실패했습니다. 다시 로그인해주세요.");
}
로그인 없이 보호된 서비스에 접근한 경우이다.
UsernamePasswordAuthenticationFilter에서 AuthenticationException이 발생한다.
-> 이 핸들러가 잡아서 "AUTHENTICATION_FAILED" 응답을 만들어준다.
{
"code": "AUTHENTICATION_FAILED",
"message": "인증에 실패했습니다. 다시 로그인해주세요."
}
전체 흐름을 보면
요청 → SecurityFilterChain → 인증/인가 실패 → 예외 발생 → DispatcherServlet → SecurityExceptionHandler → JSON 응답 반환
이렇게 된다. API의 보안 실패 처리도 REST API답게 통일된 구조로 관리 가능하다.
'Backend > Spring Boot' 카테고리의 다른 글
@RequestParam에서 400 BadRequest가 발생하는 이유 (1) | 2025.05.20 |
---|---|
쿠키(Cookie)와 세션(Session) (1) | 2025.04.29 |
HTTP 응답 처리 방법의 발전 (0) | 2025.03.28 |
스프링 부트 계층형 아키텍처 (0) | 2025.03.27 |
빈이 존재하는 범위를 이해하자 (0) | 2025.02.19 |