서블릿 API 예외 처리
package hello.exception.sevlet;
import org.springframework.boot.web.server.ConfigurableWebServerFactory;
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@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 errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
}
}
- 이전 포스팅에서 만들었던
WebServerCustomizer
를 다시 사용하기 위해@Component
어노테이션을 다시 적용시키자.response.sendError()
가 호출되면 등록한 경로가 호출된다.
package hello.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;
}
}
- 레포지토리 없이 회원을 조회하는 기능을 사용해보자.
id
값이ex
이면 예외가 발생하도록 하였다.
- 정상요청에는
Json
응답이 오지만 오류에 대한 요청은HTML
이 반환되었다. 즉, 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.
@RequestMapping("/error-page/500")
public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
log.info("errorPage 500");
printErrorInfo(request);
return "error-page/500";
}
@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));
}
- 다음과 같이 기존의 에러 컨트롤러에 Http Header의 Accept 값이
application/json
이면 해당 메서드를 호출하게 하여 Map으로 받아서 JSON으로 구조로 변환할 수 있도록 적용하자.
스프링 부트 API 예외 처리
API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다. 스프링 부트가 제공하는 BasicErrorController
코드를 살펴보자.
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse
response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
BasicErrorController
는/error
를 처리하는 두 가지 메서드가 있다. 클라이언트 요청 Accept 헤더 값이text/html
인 경우errorHtml()
을 호출해서 View를 제공하고 그 외는 JSON 데이터를 반환한다.
server.error.include-binding-errors=always
server.error.include-exception=true
server.error.include-message=always
server.error.include-stacktrace=always
- 다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.
BasicErrorController
를 확장하면 JSON 메시지도 변경할 수 있다. 그런데 API 오류는 발생하는 예외에 따라 그 결과가 달라질 수 있다. 즉,@ExceptionHandler
가 제공하는 기능을 사용하는 것이 더 나은 방법이다.HTML 화면을 처리할 때
BasicErrorController
를 사용하고 API 오류를 처리할 때@ExceptionHandler
를 사용하자.
서블릿 방식 HandlerExceptionResolver
@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에러가 발생한다.
bad
일 때 다른 상태코드를 나타내고 싶을 때를 알아보자.
예외가 발생해서 서블릿을 넘어 WAS까지 예외가 전달되면 HTTP 상태코드가 500으로 처리된다. 발생하는 예외에 따라서 다른 상태코드도 처리하고 싶은 경우 HandlerExceptionResolver
에 대해 알아야한다. 줄여서 ExceptionResolver
라고 한다.
- 예외가 처리되지 않고 그대로 WAS로 전달된다. DispatcherSevlet에서 인터셉터를 통해
preHandle
,postHandle
,afterCompletion
등의 처리를 한다.preHandle
: 핸들러 어댑터 전에 호출postHandle
: 컨트롤러 호출 후에 호출 (예외가 발생하면 호출하지 않는다.)afterCompletion
: 뷰 렌더링 이후 호출 (항상 호출된다.)- https://sasca37.tistory.com/154?category=1218447#%EC%-A%A-%ED%--%--%EB%A-%--%--%EC%-D%B-%ED%--%B-%EC%--%--%ED%--%B- 인터셉터에 대한 자세한 내용은 이전 포스팅을 참고하자.
ExceptionResolver
가 존재하면 중간에 예외 해결을 시도한다. 이 때 해결할 수 있다면 정상처리를 도와준다. 예외를 해결해도postHandle()
은 호출되지 않는다.
package hello.exception.resolver;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
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;
}
}
HandlerExceptionResolver
의 반환 값에 따른DispatcherSevlet
의 동작 방식은 다음과 같다.- 빈 ModelAndView : 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿 리턴
- ModelAndView 지정 : View, Model 등의 정보를 저장해서 반환하면 뷰를 렌더링
- null : 다음
ExceptionResolver
를 찾아서 실행. 처리할 수 없으면 예외처리 실패 후 서블릿 밖으로 던진다.
- 예외 상태 코드 변환
response.sendError()
를 호출해서 오류를 처리하도록 위임. 이 때 WAS는 서블릿 오류 페이지를 찾아서 내부 호출 (/error 등)
- 템플릿 처리 : ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링
- API 응답 처리 :
response.getWriter().println("hello")
처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다. JSON 응답처리도 가능
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
- 다음과 같이 등록해주면 오류 상태코드가 변경되는 것을 확인할 수 있다.
서블릿 방식 HandlerExceptionResolver 활용
package hello.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);
}
}
- 다음과 같이 커스텀 예외처리를
RuntimeException
을 상속받아서 생성하자.
@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);
}
- 사용자 오류에 생성한 커스텀 예외처리를 적용시키자.
package hello.exception.resolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import hello.exception.exception.UserException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
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 to 400");
String acceptHeader = request.getHeader("accept");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
if ("application/json".equals(acceptHeader)) {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result =
objectMapper.writeValueAsString(errorResult);
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
값이 json 이면 JSON 오류를 그 외는 오류 페이지를 반환하도록 설정할 수 있다.
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver>
resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
resolvers.add(new UserHandlerExceptionResolver());
}
- 새로운 HandlerExceptionResolver를 생성했기 때문에 WebConfig에 추가 등록하자.
스프링 방식 ExceptionResolver
스프링 부트가 기본으로 제공하는 ExceptionResolver
는 다음과 같다.
HandlerExceptionResolverComposite
에 다음 순서로 등록
- ExceptionHandlerExceptionResolver :
@ExceptionHandler
를 처리 (대부분 API 예외는 여기서 처리 가능) - ResponseStatusExceptionResolver : Http 상태 코드 지정
- DefaultHandlerExceptionResolver : 스프링 내부 기본 예외를 처리
ResponseStatusExceptionResolver
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
- 다음과 같이
@ResponseStatus
를 등록하여 예외를 400으로 변경하자. 내부적으로response.sendError(statusCode, resolvedReason)
을 호출하고 WAS에서 다시 오류 페이지를 내부 요청한다.
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
- 400 오류 상태코드가 반환되는 것을 확인할 수 있다.
error.bad=잘못된 요청 오류입니다. 메시지 사용
messages.properties
를 생성하고 다음과 같이 적용한 후,@ResponseStatus
에 reason을error.bad
로 설정하면 메시지화가 가능하다.
ResponseStatusException
@ResponseStatus
는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. 즉 , 동적으로 변경하기 어렵기 때문에 이때는 ResponseStatusException
예외를 사용하면 된다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
- 다음과 같이 기존에 있는 예외에서 원하는 오류 상태 코드와 상태 메시지를 담아서 보여줄 수 있다.
DefaultHandlerExceptionResolver
DefaultHandlerExceptionResolver
는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 예를 들어 파라미터 바인딩 시점에 타입이 맞지 않으면 클라이언트에서 정보를 잘못입력한 경우이지만 WAS로 전달이되면서 500에러가 나온다. 이 500 에러를 400 에러로 변경해주는 기능을 담당한다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
- 실행 결과를 보면 반환된 HTTP 상태 코드가 400인 것을 확인할 수 있다.
스프링 방식 @ExceptionHandler
API 예외처리를 적용할 땐 HandlerExceptionResolver
를 사용하면 ModelAndView를 반환해야하고 직접 HttpServletResponse에 응답 데이터를 넣어주는 불편함이 있었다.
@ExceptionHandler
가 사용하기 불편했던 ExceptionHandlerExceptionResolver
를 기본으로 제공해주며 우선 순위도 ExceptionResolver 중에 가장 높다.
package hello.exception.exhandler;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
- 예외 발생 시 API 응답으로 사용하기 위한 객체를 정의하자.
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(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;
}
- 다음과 같이
@ExceptionHandler
어노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
@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);
}
- 어노테이션에 반환 객체를 생략하고 파라미터로 바인딩할 수도 있다.
@ResponseEntity
를 사용해서 원하는 오류 객체를 바인딩해서 반환할 수 있다.
ExceptionHandler에 지정한 부모 클래스는 자식 클래스 까지 처리할 수 있다. 이 때 우선 순위는 작은 단위인 자식이 우선순위를 갖게 된다.
@ControllerAdvice
여러 컨트롤러에 @ExceptionHandler
, @InitBinder
기능을 부여해주는 역할을 한다.
대상을 지정하지 않으면, 모든 컨트롤러에 적용된다. (글로벌 기능)
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1{}
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2{} // 패키지 하위 모두 적용
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3{} // 특정 클래스 지정
- 다음과 같이 지정할 컨트롤러를 여러 방법으로 적용시킬 수 있다.
import hello.exception.exception.UserException;
import hello.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", "내부 오류");
}
}
- 기존에 하던
@ExceptionHandler
와@ControllerAdvice
를 조합하여 글로벌 처리를 할 수 있다.
본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.