[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션9 API 예외 처리
◼️ API 예외 처리
WebServerCustomizer @Component를 재등록하고 아래 Api 예외 컨트롤러 api를 실행해본다.
@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;
}
}
ex가 아닌경우 정상적으로 MemberDto를 반환하지만 오류가 발생하면 오류페이지 HTML이 반환된다.
클라이언트에게 JSON이 반환되도록 해보자.
// ErrorPageController에 아래 추가...
@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");
HashMap<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가 null이 뜨는문제가 생긴다면, RequestDispatcher 상수 네임스페이스를 확인해보자! 스프링 부트 3.0부터는 네임스페이스가 변경되었기때문에, javax 를 jakarta 로 변경해야한다.
자세한 내용은 여기를 참고
◼️ API 예외 처리 - 스프링 부트 기본 오류 처리
스프링 부트는 BasicErrorController를 제공한다. 이 컨트롤러는 오류 발생시 /error를 기본 오류 페이지로 요청한다.
동일한 경로에 대해(/error) 두 메서드가 존재한다.
- 클라이언트 요청 Accept 헤더 값이 text/html이면 errorHtml() 호출해서 view 제공
- 그 외 경우 ResponseEntity로 HTTP Body에 JSON 데이터 반환
/*@Controller
@RequestMapping("${server.error.path:${error.path:/error}}")
public class BasicErrorController extends AbstractErrorController {...
*/
@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {}
@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}
다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.
- server.error.include-binding-errors=always
- server.error.include-exception=true
- server.error.include-message=always
- server.error.include-stacktrace=always
🟢 Html 페이지 vs API 오류
- HTML 화면을 처리할 때(4xx,5xx...) BasicErrorController를 사용하자.
- API 오류 처리는 세밀하고 복잡한 처리를 요하므로 @ExceptionHandler를 사용하자.
◼️ API 예외 처리 - HandlerExceptionResolver 시작
발생하는 예외에 따라 다른 상태코드를 처리해보자. 예를들어 IllegalArgumentException 을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태코드를 400으로 처리해보자.
먼저 아래와 같이 수정후 /bad 호출하면 500에러가 뜬다.
@Slf4j
@RestController
public class 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);
}
🟢 HandlerExceptionResolver
- 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고 동작을 새로 정의할 수 있는 방법
- 참고로, 이때에도 postHandle은 호출되지않는다.
🟢 HandlerExceptionResolver 활용
- 예외 상태 코드 변환
- 예외를 sendError()호출로 변경해서 서블릿에서 상태 코드에 따른오류 처리하도록 위임
- 이후 WAS는 서블릿 오류 페이지 찾아 내부 호출 ex_스프링부트 기본설정 /error 호출
- 뷰 템플릿 처리
- ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
- API 응답 처리
- HTTP 응답 바디에 직접 데이터를 넣기. ex_response.getWriter().println("hello");
- JSON 으로 응답하면 API 응답 처리 가능
MyHandlerExceptionResolver 작성
@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
@Override
public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
if (ex instanceof IllegalArgumentException){
log.info("IllegalArgumentException resolver to 400");
try { // Exception을 처리해 정상 흐름처럼 변경하는 것이 목적!
// HTTP 상태코드 400으로 지정
response.sendError(HttpServletResponse.SC_BAD_REQUEST,ex.getMessage());
return new ModelAndView(); // 빈 ModelAndView 반환
} catch (IOException e) {
log.error("resolver ex",e);
}
}
return null;
}
}
WebConfig를 통해 MyHandlerExceptionResolver 등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
//...
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
🪄 configureHandlerExceptionResolvers(..) 를 사용하면 스프링이 기본으로 등록하는 ExceptionResolver 가 제거되므로 주의, extendHandlerExceptionResolvers 를 사용하자.
🟢 MyHandlerExceptionResolver 반환 값에 따른 동작 방식
- 빈 ModelAndView: new ModelAndView()를 반환하면 뷰 렌더링X, 정상 흐름으로 서블릿이 리턴됨
- ModelAndView 지정: ModelAndView 에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링
- null: null 을 반환하면, 다음 ExceptionResolver 를 찾아서 실행. 만약 처리할 수 있는 ExceptionResolver 가 없으면 예외 처리X, 예외를 서블릿 밖으로 던짐.
◼️ API 예외 처리 - HandlerExceptionResolver 활용
예외 발생시 WAS까지 예외가 던져지고,WAS에서 오류페이지를 찾아 다시 /error를 호출한다.
ExceptionResolver는 위의 복잡한 과정없이 여기에서 문제를 깔끔히 해결한다.
ExceptionResolver 활용 실습 진행 순서는 다음과 같다.
- UserException 추가
- ApiExceptionController에 UserException 예외를 추가
- UserException 예외 처리 UserHandlerExceptionResolver 등록
- Config에 위 리졸버 추가
먼저 사용자 정의 예외 UserException 를 하나 추가해보자.
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에 예외를 추가한다.
@Slf4j
@RestController
public class ApiExceptionController {
@GetMapping("/api/members/{id}")
public MemberDto getMember(@PathVariable("id") String id){
...
if (id.equals("user-ex")){
throw new UserException("사용자 오류");
}
return new MemberDto(id, "hello " + id);
}
이제 이 예외를 처리하는 UserHandlerExceptionResolver를 만들자.
@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 { // acceptHeader가 json 이 아닌경우
//TEXT/HTML
return new ModelAndView("error/500");
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
위 Resolver를 Config에 추가해준다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
//...
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
resolvers.add(new UserHandlerExceptionResolver()); // 추가한부분!
}
🟢 실습 정리
예외가 발생해도 서블릿 컨테이너까지 예외전달이 되지않고 ExceptionResolver에서 예외를 모두 처리한다.
그래서 WAS입장에서는 정상 처리가 된다.
서블릿 컨테이너까지 예외가 올라가면 복잡,지저분,추가프로세스가 실행된다.
반면에 ExceptionResolver 를 사용하면 예외처리가 상당히 깔끔해진다.
그런데 직접 ExceptionResolver를 구현하는건 복잡하다. 지금부터 스프링이 제공하는 ExceptionResolver 들을 알아보자.
◼️ API 예외 처리 - 스프링이 제공하는 ExceptionResolver1
🟢 스프링 부트가 기본으로 제공하는 ExceptionResolver 우선순위
- ExceptionHandlerExceptionResolver : @ExceptionHandler 을 처리. API 예외 처리는 대부분 이 기능으로 해결
- ResponseStatusExceptionResolver : HTTP 상태 코드 지정
- DefaultHandlerExceptionResolver : 스프링 내부 기본 예외 처리
🟢 ResponseStatusExceptionResolver 처리 예외
- @ResponseStatus 가 달려있는 예외
- ResponseStatusException 예외
BadRequestException가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver 예외가 해당 애노테이션을 확인해서 오류 코드를 HttpStatus.BAD_REQUEST (400)으로 변경하고, 메시지도 담는다.
ResponseStatusExceptionResolver 코드는 내부적으로 response.sendError(statusCode, resolvedReason) 를 호출한다. sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청한다
◼️ @ResponseStatus 가 달려있는 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
▪️ 메시지 적용
아래와 같이 messages.properties에 등록해 메시지 기능을 이용할수 있다.
// error.bad를 messages.properties에 등록
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
◼️ ResponseStatusException 예외
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,"error.bad",new IllegalArgumentException());
}
◼️ API 예외 처리 - 스프링이 제공하는 ExceptionResolver2
🟢 DefaultHandlerExceptionResolver
- 스프링 내부 발생 스프링 예외 해결
- 예를들어 파라미터바인딩 오류는 클라이언트에러 400으로 변경
따라서 아래 pram값으로 문자를 입력하면 TypeMismatchException이 발생하고 HTTP 상태코드 400을 반환한다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
◼️ API 예외 처리 - @ExceptionHandler
웹 브라우저에 HTML 화면을 제공할 때는 오류가 발생하면 BasicErrorController 를 사용하는게 편하다.
하지만 API 예외는 다루기 쉽지 않다.
🟢 API 예외 처리 어려운 점
- 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어렵다. 세밀한 제어가 필요.예를 들어서 상품 API와 주문 API는 오류가 발생했을 때 응답의 모양이 완전히 다를 수 있다.
- HandlerExceptionResolver에서 API 응답에는 필요없는 ModelAndView 를 반환해야 했다.
- API 응답을 위해서 HttpServletResponse 에 직접 응답 데이터를 넣어주었다. 이것은 매우 불편하다. 스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같다.
🟢 @ExceptionHandler
- 스프링은 ExceptionHandlerExceptionResolver를 기본으로 제공하며, 이는 @ExceptionHandler 애노테이션을 사용하는 매우 편리한 예외처리 기능이다.
- 실무에서 API 예외 처리는 대부분 이 기능을 사용,우선순위도 가장 높다.
- 스프링은 항상 자세한 것이 우선순위를 가진다. 자식예외 발생시 부모,자식예외처리 모두 호출되지만 더 자세한 자식이 호출된다.
참고로,다양한 예외를 한번에 처리할 수 있다.
@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
log.info("exception e", e);
}
@ExceptionHandler 에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}
다음과 같이 ModelAndView 를 사용해서 오류 화면(HTML)을 응답하는데 사용할 수도 있다.
@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
log.info("exception e", e);
return new ModelAndView("error");
}
@ExceptionHandler 에는 마치 스프링의 컨트롤러의 파라미터 응답처럼 다양한 파라미터와 응답을 지정할 수 있다. 자세한 파라미터와 응답은 다음 공식 메뉴얼을 참고하자.
먼저 예외발생시 API 응답으로 사용하는 객체를 정의한다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
@Slf4j
@RestController
public class ApiExceptionV2Controller {
// IllegalArgumentException 또는 그 하위 자식 클래스를 모두 처리
@ResponseStatus(HttpStatus.BAD_REQUEST) // HTTP 상태 코드 400으로 응답
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandler(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
// 예외지정X->해당 메서드 파라미터 예외 사용 ex_UserException
@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e){
log.error("[exceptionHandle] ex", e);
ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
// HTTP 메시지 바디에 직접 응답
return new ResponseEntity<>(errorResult,HttpStatus.BAD_REQUEST);
}
//throw new RuntimeException("잘못된 사용자") 이 코드 실행시,메서드 호출
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e){
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@GetMapping("/api2/members/{id}")
public MemberDto getMember(@PathVariable("id") String id) {...
🟢 illegalExHandle() 동작방식
- 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
- 예외가 발생했으로 ExceptionResolver 가 작동한다. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver 가 실행된다.
- ExceptionHandlerExceptionResolver 는 해당 컨트롤러에 IllegalArgumentException 을 처리 할 수 있는 @ExceptionHandler 가 있는지 확인한다.
- illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다. 따라서 HTTP 컨버터가 사용되고, 응답이 JSON으로 반환된다.
◼️ API 예외 처리 - @ControllerAdvice
정상 코드와 예외 처리 코드를 분리해보자.
ApiExceptionV2Controller 코드에 있는 @ExceptionHandler를 모두 제거후 아래에 해당코드를 추가해준다.
@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", "내부 오류");
}
}
🟢 @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 {}
'Spring' 카테고리의 다른 글
[Spring] Spring Type Conversion 문서 정리 및 요약 (0) | 2024.05.18 |
---|---|
[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션10 스프링 타입 컨버터 (0) | 2024.05.18 |
[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션8 예외 처리와 오류 페이지 (0) | 2024.05.12 |
[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션7 로그인 처리2 - 필터, 인터셉터 (0) | 2024.05.08 |
[Spring] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 섹션6 로그인 처리1 - 쿠키, 세션 (0) | 2024.05.08 |