
오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
WebServerCustomizer가 다시 사용되도록 @Component 애노테이션 주석을 풀어주었다.
ApiExceptionController
package com.example.exception.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
Postman 정상 테스트

Postman 예외 발생 테스트

ErrorPageController API 응답 추가
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletResponse response, HttpServletRequest request){
log.info("API errorPage 500");
Map<String, Object> result = new HashMap<>();
Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
result.put("status", request.getAttribute(ERROR_STATUS_CODE));
result.put("message", ex.getMessage());
Integer statusCode = (Integer) request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
오류
코드대로 실행 했는데 오류 메시지가 안뜨는 오류가 있었다.
ex.getMessage가 null이다. request.getAttribute(ERROR_EXCEPTION); 요 부분에서 request를 못 받는 것 같았다.//RequestDispatcher 상수로 정의되어 있음 public static final String ERROR_EXCEPTION = "javax.servlet.error.exception"; public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type"; public static final String ERROR_MESSAGE = "javax.servlet.error.message"; public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri"; public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name"; public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";Spring Boot 3.xx 버전 이상부터는 javax -> jakarta 로 변경해야 된다는 것을 알았다. 변경 후 테스트 해보니 잘 실행되었다.
POST 요청

WebServerCustomizer에 @Component를 다시 주석 처리하고 스프링 부트의 기본 오류처리를 어떻게하나 확인해보자.

스프링 부트가 제공하는 BasicErrorController코드를 보자
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
errorHtml(): produces = MediaType.TEXT_HTML_VALUE : 클라이언트 요청의 Accept해더 값이 text/html인 경우에는 errorHtml()을 호출해서 view를 제공한다.error(): 그외 경우에 호출되고 ResponseEntity로 HTTP Body에 JSON 데이터를 반환한다.다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.
오류 메시지는 이렇게 막 추가하면 보안상 위험할 수 있다. 간결한 메시지만 노출하고, 로그를 통해서 확인하자.
목표
ApiExceptionController - 수정
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
return new MemberDto(id, "hello " + id);
}

상태 코드가 500번이다
HandlerExceptionResolver

직접 적용해보자
MyHandlerExceptionResolver
package com.example.exception.resolver;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import java.io.IOException;
@Slf4j
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 resolver to 400");
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
IllegalArgumentException이 발생하면 response.sendError(400)를 호출해서 HTTP 상태 코드를 400으로 지정하고, 빈 ModelAndView를 반환한다.WebConfig 등록
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
POSTMAN 확인

-> 500 error 에서 400 error로 변경 된 것을 확인가능
반환 값에 따른 동작 방식
HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다.
new ModelAndView()처럼 빈 ModelAndView를 반환하면 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.ModelAndView에 View,Model 등의 정보를 지정해서 반환하면 뷰를 렌더링 한다.null을 반환하면, 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있으면ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error를 호출하는 과정은 생각해보면 너무 복잡하다. ExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 여기에서 문제를 깔끔하게 해결할 수 있다.
UserException
package com.example.exception.exception;
public class UserException extends RuntimeException {
public UserException() {
super();
}
public UserException(String message) {
super(message);
}
public UserException(String message, Throwable cause) {
super(message, cause);
}
public UserException(Throwable cause) {
super(cause);
}
protected UserException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}
ApiExceptionController - 예외 추가
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
예외 처리하는 UserHandlerExceptionResolver를 만들어보자
UserHandlerExceptionResolver
package com.example.exception.resolver;
import com.example.exception.exception.UserException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
try {
if(ex instanceof UserException){
log.info("UserException resolver 400");
String accetpHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST); // 400
if("application/json".equals(accetpHeader)){ // json 이면
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result = objectMapper.writeValueAsString(errorResult); // json를 문자열로
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
return new ModelAndView();
}else {
// TEXT / HTML 넘어오면
return new ModelAndView("error/500");
}
}
}catch (IOException e) {
log.error("resolver ex ", e);
}
return null;
}
}
HTTP 요청 해더의 ACCEPT 값이 application/json 이면 JSON으로 오류를 내려주고, 그 외 경우에는 error/500에 있는 HTML 오류 페이지를 보여준다.
WebConfig도 추가해줬다.
POSTMAN 확인
ACCEPT : application/json

ACCEPT : text/html

직접 ExceptionResolver를 구현하려고 하니 상당히 복잡하다. 지금부터 스프링이 제공하는 ExceptionResolver들을 알아보자.
스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같다.
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver -> 우선순위가 가장 낮음
ResponseStatusExceptionResolver는 예외에 따라서 HTTP 상태 코드를 지정해주는 역할을 한다.
@ResponseStatus가 달려있는 예외ResponseStatusException 예외BadRequestException
package com.example.exception.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
BadRequestException예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST(400)으로 변경하고, 메시지도 담는다.
ApiExceptionController - 추가
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}

상태 코드가 400으로 바뀌어있다.
reason을 MessageSource에서 찾는 기능도 제공한다.
messages.properties
error.bad=잘못된 요청 오류입니다. 메시지 사용
reason = "error.bad"로 변경

ApiExceptionController - 추가
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. (애노테이션을 직접 넣어야 하는데, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드 같은 곳에는 적용할 수 없다.) 추가로 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다. 
DefaultHandlerExceptionResolver에 대해서 알아보자
DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다.TypeMismatchException이 발생하는데, 이 경우 예외가 발생했기 때문에 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경한다.ApiExceptionController - 추가
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}

TypeMismatchException가 발생한다.HTML 화면 오류 vs API 오류
BasicErrorController를 사용하는게 편하다.BasicErrorController를 사용하거나 HandlerExceptionResolver를 직접 구현하는 방식으로 API 예외를 다루기는 쉽지 않다.(예외 상황에 단순히 오류 화면을 보여주는 것이 아니라, 예외에 따라서 각각 다른 데이터를 출력해야 할 수도 있기 때문에)@ExceptionHandler
@ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공하는데, 이것이 바로 ExceptionHandlerExceptionResolver이다.ExceptionHandlerExceptionResolver를 기본으로 제공하고, 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다. 실무에서 API 예외 처리는 대부분 이 기능을 사용한다.예제로 알아보자
ErrorResult
package com.example.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
ApiExceptionV2Controller (ApiController와 비슷)
package com.example.exception.exhandler;
import com.example.exception.exception.UserException;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
public class ApiExceptionV2Controller {
// 이 컨트롤러 안에서 IllegalArgumentException 오류가 나타나면 잡아줌.
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e){
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
POSTMAN 테스트

실행 흐름
IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.ExceptionResolver가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.ExceptionHandlerExceptionResolver는 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.illegalExHandle()를 실행한다. @RestController이므로 illegalExHandle()에도 @ResponseBody가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 JSON으로 반환된다.@ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로 HTTP 상태 코드 400으로 응답한다.ApiExceptionV2Controller 코드 추가
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler에 예외를 지정하지 않으면 해당 메서드 파라미터 예외를 사용한다. 여기서는 UserException을 사용한다.ResponseEntity를 사용해서 HTTP 메시지 바디에 직접 응답한다. 물론 HTTP 컨버터가 사용된다.POSTMAN 테스트

ApiExceptionV2Controller 코드 추가
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
throw new RuntimeException("잘못된 사용자") 이 코드가 실행되면서, 컨트롤러 밖으로 RuntimeException이 던져진다.RuntimeException은 Exception의 자식 클래스이다. 따라서 이 메서드가 호출된다.@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)로 HTTP 상태 코드를 500으로 응답한다.POSTMAN 테스트

우선순위
스프링의 우선순위는 항상 자세한 것이 우선권을 가진다. 예를 들어서 부모, 자식 클래스가 있고 다음과 같이 예외가 처리된다.
@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}
@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}
@ExceptionHandler를 사용해서 예외를 깔끔하게 처리할 수 있게 되었지만, 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 둘을 분리할 수 있다.
ExControllerAdvice
package com.example.exception.exception.advice;
import com.example.exception.exception.UserException;
import com.example.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class ExControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
}
ApiExceptionV2Controller에 있는 @ExceptionHandler를 모두 ExControllerAdvice로 옮겼고 ApiExceptionV2Controller에 @ExceptionHandler를 모두 제거 하였다.
-> POSTMAN 테스트 결과 모두 동일하게 적용되었다.
@ControllerAdvice
@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을@ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)@RestControllerAdvice는 @ControllerAdvice와 같고, @ResponseBody가 추가되어 있다. @Controller, @RestController 의 차이와 같다.대상 컨트롤러 지정 방법
// Target all Controllers annotated with @RestController
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}
// Target all Controllers within specific packages
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}
// Target all Controllers assignable to specific classes
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 {}
https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-ann-controller-advice (스프링 공식 문서 참고)
참고
김영한: 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술(인프런)
Github - https://github.com/b2b2004/Spring_MVC