HTML 페이지의 경우 오류 페이지를 보여주면 문제를 해결할 수 있으나 기업과 기업 간 통신, 백엔드와 프론트엔드의 API를 이용한 통신에서는 각 오류 상황에 맞는 오류 응답 스펙을 정하고 Json으로 데이터를 뿌려주어야 한다.
API를 이용한 통신에서 예외가 발생하면 Json이 아닌 Text 형태로 결과가 반환되는데 이 Text를 직접 받아서 할 수 있는 것이 별로 없다. 따라서 문제를 자세하게 짚어보려면 오류 페이지 컨트롤러도 Json 응답을 줄 수 있도록 변경해야 한다.
@Slf4j
@RestController
public class ApiExceptionController {
public static final String ERROR_EXCEPTION = "jakarta.servlet.error.exception";
public static final String ERROR_EXCEPTION_TYPE = "jakarta.servlet.error.exception_type";
public static final String ERROR_MESSAGE = "jakarta.servlet.error.message";
public static final String ERROR_REQUEST_URI = "jakarta.servlet.error.request_uri";
public static final String ERROR_SERVLET_NAME = "jakarta.servlet.error.servlet_name";
public static final String ERROR_STATUS_CODE = "jakarta.servlet.error.status_code";
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable(name = "id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자입니다!");
}
return new MemberDto(id, "hello " + id);
}
@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPAge500Api(HttpServletRequest request, HttpServletResponse response) {
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));
}
@Data
static class Member {
private String memberId;
private String name;
}
@Data
@NoArgsConstructor
static class MemberDto {
private String memberId;
private String name;
public MemberDto(Member member) {
this.memberId = member.getMemberId();
this.name = member.getName();
}
public MemberDto(String memberId, String message) {
this.memberId = memberId;
this.name = message;
}
}
}
포스트맨으로 테스트 시 → Accept
: application/json
으로 형식 변경
실행 흐름 정리
HttpServeltResponse
의 sendError()
가 호출되면 등록한 페이지 경로가 호출되도록 설정@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
@Override
public void customize(ConfigurableWebServerFactory factory) {
ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
ErrorPage errorPage = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage, errorPage404, errorPage500);
}
}
http://localhost:8080/api/members/ex
→ RuntimeException
발생@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable(name = "id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자입니다!");
}
return new MemberDto(id, "hello " + id);
}
RuntimeException
예외에 대해서 /error-page/500
오류 페이지가 사용자에게 보여진다.@RequestMapping({"${server.error.path:${error.path:/error}}"}) // 경로 확인
@RequestMapping(produces = {"text/html"})
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
HttpStatus status = this.getStatus(request);
Map<String, Object> model = Collections.unmodifiableMap(this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
response.setStatus(status.value());
ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);
return modelAndView != null ? modelAndView : new ModelAndView("error", model);
}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
HttpStatus status = this.getStatus(request);
if (status == HttpStatus.NO_CONTENT) {
return new ResponseEntity(status);
} else {
Map<String, Object> body = this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.ALL));
return new ResponseEntity(body, status);
}
}
/error
동일한 경로를 처리하는 errorHtml()
, error()
두 메서드를 확인할 수 있다.
errorHtml()
: 클라이언트 요청의 Accept 헤더 값이 text/html
인 경우 errorHtml()
을 호출해서 오류 페이지(View)를 제공한다.error()
: 그 외 경우에 호출되고 ResponseEntity
로 HTTP Body에 Json 데이터를 반환한다.스프링 MVC는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고 동작을 새로 정의할 수 있는 방법을 제공한다. 컨트롤러 밖으로 던져진 예외를 해결하고 동작 방식을 변경하고 싶으면 HandlerExceptionResolver
를 사용하면 된다.
ExceptionResolver 적용
public interface HandlerExceptionResolver {
ModelAndView resolveException(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex);
}
// 👉HandlerExceptionResolver 인터페이스를 구현한 커스텀 HandlerExceptionResolver
@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");
// 👉sendError() → WAS는 서블릿 오류 페이지를 찾아서 내부 호출
// WAS → 필터 → 서블릿 → 인터셉터 → 컨트롤러(오류 페이지)
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
return new ModelAndView();
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
// 👉구현한 커스텀 HandlerExceptionResolver를 사용하기 위해서 등록
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
// ..
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
ExceptionResolver 적용
DispatchServlet
이 예외를 감지HandlerExceptionResolver
을 순차적으로 호출HandlerExceptionResolver
→ 여기선, UserHandlerExceptionResolver
extendHandlerExceptionResolvers
→ 등록 HandlerExceptionResolver
가 예외를 처리HandlerExceptionResolver
→ 여기선, UserHandlerExceptionResolver
text/html
반환 타입인 경우 → /error
하위에 있는 커스텀 오류 페이지 application/json
반환 타입인 경우 → 반환할 뷰가 없고 WAS로 정상 응답을 보낸다.@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable(name = "id") String id) {
if (id.equals("ex")) {
throw new RuntimeException("잘못된 사용자입니다!");
}
if (id.equals("bad")) {
throw new IllegalArgumentException("잘못된 입력 값");
}
// [1]. 컨트롤러에서 예외
if (id.equals("user-ex")) {
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
// [2]. DispatchServlet이 예외를 감지, 등록된 HandlerExceptionResolver로 예외를 그 자리에서 바로 잡기
@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.error("UserException resolver to 400");
String a = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
if ("application/json".equals(a)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
// errorResult(객체) → 문자
String result = objectMapper.writeValueAsString(errorResult);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
// [3]. ModelAndView 반환
return new ModelAndView();
} else {
ModelAndView modelAndView = new ModelAndView();
// ❗Thymeleaf 의존성 있어야 ModelAndView로 뷰 네임을 찾을 수 있음
modelAndView.setViewName("error/404");
return modelAndView;
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
❗
HttpServletResponse
의sendError()
를 호출하면 서블릿 컨테이너에서 오류를 처리
❗HandlerExceptionResolver
는 그 자리에서 바로 오류를 처리
스프링 부트가 제공하는 ExceptionResolver
ExceptionHandlerExceptionResolver
ResponseStatusExceptionResolver
DefaultHandlerExceptionResolver
→ 우선순위가 가장 낮음@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청")
public class BadRequestException extends RuntimeException{
}
BadRequestException
예외가 컨트롤러에서 발생하면 ResponseStatusExceptionResolver
예외가 해당 어노테이션(@ResponseStatus
)을 확인해서 오류 코드를 400
으로 변경하고 메시지에도 담는다.
또한 위와 같이 sendError()
를 호출했기 때문에 WAS에서 /error
오류 페이지를 요청한다.
@GetMapping("/api/default-handler-ex")
public String defaultHandler(@RequestParam(name = "data") Integer data) {
return "ok";
}
@RequestParam
을 타입은 Integer
인데 이 때, 요청 타입을 잘못 넣게 되면 TypeMismatchException
예외가 발생하는데 이 때, 코드를 타고 들어가서 보면 handleTypeMismatch()
메서드가 호출된다. 메서드를 보면 ModelAndView
를 반환하는데 이 때, HttpServletResponse
의 sendError()
를 호출하는 것을 볼 수 있다.
예외 상태 코드는 400이고 sendError()
를 호출했기 때문에 WAS 내부 호출을 통해 400 오류 페이지를 찾고 이를 사용자에게 보여주게 된다.
API 예외 처리의 어려운 점
HandlerExceptionResolver
를 사용하면 오류 페이지를 보여줄 수 있지만 백엔드와 프론트엔드 간의 API를 이용한 통신에서는 이 HandlerExceptionResolver
가 반환하는 ModelAndView
가 필요가 없다.@ExceptionHandler
를 사용한다.(추가로 읽은 블로그)일관적인 에러응답을 달라! - G마켓 기술 블로그
❗@RestControllerAdvice + @ExceptionHandler 에러 처리 시 유의할 점
커스텀하게 정의한 ErrorResult 객체를 일괄 리턴하도록 코드를 작성, 클라이언트 측에서 잘못된 요청을 보냈을 때 이를 판단할 수 있는 오류 코드와 오류 메시지만을 리턴하려는 의도가 담겨 있다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
@Slf4j
@RestController
public class ApiExceptionControllerV2 {
@GetMapping("/api/v2/members/{id}")
public ApiExceptionController.MemberDto getMember(@PathVariable(name = "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 ApiExceptionController.MemberDto(id, "hello " + id);
}
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExceptionHandler(IllegalArgumentException e) {
log.error("exceptionHandler", e);
return new ErrorResult("BAD_REQUEST", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExceptionHandler(UserException e) {
log.error("exceptionHandler", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public ErrorResult exceptionHandler(Exception e) {
log.error("exceptionHandler", e);
return new ErrorResult("EX", "내부 오류");
}
}
❗
@ExceptionHandler
예외 처리 방법
@ExceptionHandler
어노테이션을 선언하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다. 해당 컨트롤러에서 예외가 발생하면 예외를 지정한 메서드가 호출된다. 참고로 지정한 예외 또는 그 자식 클래스는 모두 잡을 수 있다.
Ex.RuntimeException
예외가 발생한 것을Exception
으로 잡은 것
@ExceptionHandler
실행 흐름 정리
ExceptionResolver
가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver
가 실행된다.@ExceptionHandler
가 있는지 확인한다.❗
@ExceptionHandler
은 해당 컨트롤러에 국한되어 처리하는데 이렇게 되면 프로그램 규모에 비례하여 증가하는 컨트롤러의 숫자에 맞춰 그만큼 별도로 작성을 해야 한다는 단점이 있다. 또한 컨트롤러에 예외를 처리하는 핸들러가 같이 포함되어 코드의 성격이 명확하게 드러나지 않을 수 있다. 이를 전역적으로 처리할 수 있는 것이 바로@ControllerAdvice
이다.
@Slf4j
@RestControllerAdvice
public class ExRestControllerAdvice {
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExceptionHandler(IllegalArgumentException e) {
log.error("exceptionHandler", e);
return new ErrorResult("BAD_REQUEST", e.getMessage());
}
@ExceptionHandler
public ResponseEntity<ErrorResult> userExceptionHandler(UserException e) {
log.error("exceptionHandler", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler
public ErrorResult exceptionHandler(Exception e) {
log.error("exceptionHandler", e);
return new ErrorResult("EX", "내부 오류");
}
}
❗
@ControllerAdvice
는 대상으로 지정한 여러 컨트롤러에@ExceptionHandler
+@InitBinder
기능을 부여해주는 역할을 한다.
@ControllerAdvice
에 대상을 지정하지 않으면 모든 컨트롤러에 전역적으로 적용된다.(글로벌 적용)
@RestControllerAdvice
는@ControllerAdvice
와 같고@ResponseBody
가 추가되어 있다. Json 형태의 응답을 내보낼 때 사용한다.