API를 설계하다 보면 예외 상황을 처리해야 하는 순간이 반드시 찾아온다.
웹 서비스라면 단순히 예쁜 오류 페이지 하나 보여주면 끝나겠지만,
API 서버는 다르다.
클라이언트와는 오직 JSON 형태의 응답으로 통신하기 때문에,
예외가 발생했을 때도 단순히 페이지를 보여주는 대신,
그에 맞는 JSON 형태의 오류 응답을 반환해야 한다.
오늘은 그래서, API 예외 처리의 전체 흐름을 이해하고
- HandlerExceptionResolver를 직접 구현해보며 원리를 살펴보고,
- 스프링이 기본 제공하는 예외 리졸버를 통해 자동 처리 방식을 확인하고,
- 마지막으로 @ExceptionHandler와 @ControllerAdvice를 사용해
실무에서 가장 효율적으로 예외를 처리하는 방법을 알아볼 것이다.
1. HandlerExceptionResolver 직접 구현하기
스프링 MVC는 예외가 발생하면 내부적으로 여러 단계를 거쳐 처리한다.
컨트롤러 밖으로 던져진 예외는 DispatcherServlet까지 전달되고,
그 시점에서 예외를 해결(resolver) 해줄 객체가 있다면 그 결과로 HTTP 응답을 만들어 반환한다.
이때 사용하는 인터페이스가 HandlerExceptionResolver다.
직접 구현해보면 다음과 같다.
@Slf4j
@Component
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
try {
if (ex instanceof IllegalArgumentException) {
log.info("IllegalArgumentException 발생");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
String result = "{ \"code\": \"BAD_REQUEST\", \"message\": \"" + ex.getMessage() + "\" }";
response.getWriter().write(result);
return new ModelAndView(); // 예외를 여기서 처리했으므로 정상 종료
}
} catch (IOException e) {
log.error("resolver error", e);
}
return null; // 처리 불가 시 다음 리졸버로 전달
}
}
//등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver>
resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
이 코드는 IllegalArgumentException이 발생했을 때 직접 상태 코드와 JSON 응답을 만들어 클라이언트로 반환한다.
이렇게 하면 WAS까지 예외가 올라가지 않고, 스프링 MVC 레벨에서 처리가 끝난다.
하지만 예외가 늘어날수록 이런 코드를 계속 작성해야 하므로,
현실적으로 유지보수가 어렵다.
즉, 예외 하나 처리하려고 매번 이런 리졸버를 만드는 것은 비효율적이다.
2. 스프링이 제공하는 기본 ExceptionResolver
스프링은 이런 불편함을 줄이기 위해 여러 기본 리졸버를 내장하고 있다.
대표적으로 다음 세 가지가 있다.
- ExceptionHandlerExceptionResolver
- @ExceptionHandler 애노테이션을 인식해서 예외를 처리한다.
- 이 부분은 다음에서 자세히 살펴볼 것이다.
- ResponseStatusExceptionResolver
- @ResponseStatus 또는 ResponseStatusException을 해석해 HTTP 상태 코드를 설정한다.
@ResponseStatus(HttpStatus.BAD_REQUEST)
class BadRequestException extends RuntimeException {}
@GetMapping("/api/test2")
public String test2() {
throw new BadRequestException(); // 400 Bad Request 반환
}
3. DefaultHandlerExceptionResolver
- 스프링 내부 예외를 적절한 상태 코드로 자동 변환한다.
@GetMapping("/api/test3")
public String test3(@RequestParam("age") int age) {
return "나이: " + age; // 숫자 대신 문자 입력 시 400 Bad Request
}
이처럼 기본 리졸버만으로도 여러 예외가 자동으로 처리되지만,
실제 서비스에서는 도메인별로 세밀한 JSON 응답이 필요하기 때문에
다음에서는 @ExceptionHandler를 이용한 명시적인 방법을 살펴보겠다.
3. @ExceptionHandler – 예외 처리의 핵심
실무에서 가장 자주 사용되는 방법이 바로 @ExceptionHandler다.
이 애노테이션은 특정 예외 타입이 발생했을 때, 해당 메서드가 자동으로 호출되어 응답을 만든다.
@Slf4j
@RestController
@RequestMapping("/api")
public class ApiExceptionController {
@GetMapping("/members/{id}")
public Member getMember(@PathVariable("id") String id) {
if (id.equals("ex")) throw new RuntimeException("잘못된 사용자입니다.");
if (id.equals("bad")) throw new IllegalArgumentException("입력이 잘못되었습니다.");
return new Member(id, "memberA");
}
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult handleIllegalArgument(IllegalArgumentException e) {
log.error("[exceptionHandler] ex", e);
return new ErrorResult("BAD_REQUEST", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> handleRuntime(RuntimeException e) {
log.error("[exceptionHandler] ex", e);
ErrorResult error = new ErrorResult("INTERNAL_ERROR", "서버 내부 오류");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
@Data
@AllArgsConstructor
class ErrorResult {
private String code;
private String message;
}
예외 발생 시 결과는 다음과 같이 내려간다.
{
"code": "BAD_REQUEST",
"message": "입력이 잘못되었습니다."
}
핵심 포인트는 다음과 같다.
- @ResponseStatus : 정적인 상태 코드를 지정할 때 사용한다.
- ResponseEntity : 상황에 따라 동적으로 상태 코드와 응답 바디를 조합할 수 있다.
- @ExceptionHandler는 해당 컨트롤러 내부에서만 적용된다.
즉, 현재 컨트롤러에서 발생한 예외만 처리한다는 제한이 있다.
만약 여러 컨트롤러에서 공통적으로 같은 예외를 처리하고 싶다면 어떻게 해야 할까?
4. @ControllerAdvice – 전역 예외 처리
@ControllerAdvice는 컨트롤러 전역에서 예외를 처리하기 위한 기능이다.
하나의 클래스에 공통 예외 처리를 모아두면,
모든 컨트롤러에서 같은 방식으로 예외가 처리된다.
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult handleIllegal(IllegalArgumentException e) {
log.error("[globalExceptionHandler] ex", e);
return new ErrorResult("BAD_REQUEST", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> handleException(Exception e) {
log.error("[globalExceptionHandler] ex", e);
ErrorResult error = new ErrorResult("INTERNAL_ERROR", "예기치 못한 오류가 발생했습니다.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
이 클래스는 전역적으로 등록되기 때문에,
모든 컨트롤러에서 동일한 예외가 발생하면 이 핸들러가 작동한다.
덕분에 각 컨트롤러에 같은 코드를 반복해서 작성할 필요가 없다.
필요에 따라 @ControllerAdvice에 특정 패키지나 애노테이션을 지정하면
특정 영역의 컨트롤러에만 적용할 수도 있다.
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
마무리하며
정리하면,
우리는 처음에 HandlerExceptionResolver를 직접 구현하면서 예외 처리의 원리를 이해했고,
그 과정이 얼마나 번거로운지 확인했다.
이후 스프링이 제공하는 기본 리졸버들이
자동으로 예외를 변환해주는 과정을 살펴보았고,
@ExceptionHandler와 @ControllerAdvice를 통해
실무에서 가장 깔끔하게 예외를 처리하는 방법을 배웠다.
결국 우리는 어노테이션 기반 예외 처리만으로 대부분의 문제를 해결할 수 있지만,
그 내부의 동작 원리는 처음에 구현했던 HandlerExceptionResolver 방식과 크게 다르지 않다.
즉, 내부 구조를 이해하고 있으면
나중에 예외가 처리되지 않거나 중복 호출되는 문제도 훨씬 빠르게 해결할 수 있다.
감사합니다.
'스프링' 카테고리의 다른 글
| [스프링] 파일 업로드 (0) | 2025.10.09 |
|---|---|
| [스프링] 타입 컨버터 (0) | 2025.10.09 |
| [스프링] 예외 처리와 오류 페이지 (0) | 2025.10.01 |
| [스프링] 로그인 처리 3 - 스프링 인터셉터(Interceptor) (0) | 2025.09.29 |
| [스프링] 로그인 처리 2 - 서블릿 필터 (0) | 2025.09.29 |
