[스프링] API 예외 처리

gyeol·2024년 1월 10일

스프링

목록 보기
37/50
post-thumbnail

김영한 님의 '스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술'을 듣고 적은 글입니다.

HTML 페이지의 경우 앞의 글처럼 오류 페이지만 있으면 되지만 API의 경우에는 생각할 내용이 더 많다. API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려주어야 한다.

서블릿 사용한 예외 처리

앞서 주석 처리해놓은 WebServerCustomizer.java@Component 애노테이션의 주석 처리를 풀어준다. 이제 WAS에 예외가 전달되거나 response.sendError()가 호출되면 위에 등록한 예외 페이지 경로가 호출된다.

단순히 회원을 조회할 수 있는 ApiExceptionController.java를 만든다.

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이면 예외가 발생하도록 했다. Postman을 통해 테스트하면 http://localhost:8080/api/members/ex 이때에 오류가 발생하는 걸 확인할 수 있다. 하지만 우리가 전에 만들어 놓은 HTML 페이지가 반환된다.
이를 해결하기 위해서는 오류 페이지 컨트롤러도 JSON 응답을 할 수 있도록 수정해야 한다.

ErrorPageController.java에 JSON응답을 위해 코드를 추가해준다.

@RequestMapping(value = "/error-page/500", produces =MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequestrequest, 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));
}

produces = MediaType.APPLICATION_JSON_VALUE의 뜻은 클라이언트가 요청하는 HTTP Header의 Accept의 값이 application/json 일 때 해당 메서드가 호출된다는 것이다.

응답 데이터를 위해 Map을 만들고 statusmessage 키에 값을 할당해 우리가 지정한 상태 코드와 오류 메시지를 넣어준다. ResponseEntity를 이용해 응답하기에 메시지 컨버터가 동작하면서 클라이언트에 JSON이 반환된다.

스프링 부트 기본 오류 처리

API 예외 처리에도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있다.
스프링 부트가 제공하는 BasicErrorController를 살펴보면 HTML로 데이터가 넘어올때와 아닐 때에 처리 로직이 다른 걸 확인할 수 있다.

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE)
public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponseresponse) {}

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {}

우리는 BasicErrorController 사용을 위해 주석 처리를 풀어놓았던 @Component에 다시 주석 처리를 해줘야 한다.

그 후 다시 코드를 실행해 Postman을 통해 테스트하면

{
  "timestamp": "2021-04-28T00:00:00.000+00:00",
  "status": 500,
  "error": "Internal Server Error",
  "exception": "java.lang.RuntimeException",
  "trace": "java.lang.RuntimeException: 잘못된 사용자\n\tat
  hello.exception.web.api.ApiExceptionController.getMember(ApiExceptionController.java:19...,
  "message": "잘못된 사용자",
  "path": "/api/members/ex"
}

이런 식으로 BasicErrorController가 기본으로 제공해주는 정보들을 확인할 수 있다.

HTML 페이지 VS API 오류

BasicErrorController를 사용하면 HTML 페이지를 제공하는 경우에 매우 편리하다. 그런데 API 오류 처리는 다르다. API마다 각각의 컨트롤러나 예외마다 서로 다른 응답 결과를 출력해야할 수도 있다. 예를 들어 회원 관련 API에서 발생하는 예외와 상품 관련 API에서 발생하는 예외에 따라 그 결과가 달라질 수 있다.
그렇기에 API오류 처리는 @ExceptionHandler을 사용하는게 좋다.

HandlerExceptionResolver

예를 들어 IllegalArgumentException을 처리하지 못해 컨트롤러 밖으로 넘어가는 일이 발생하면 HTTP 상태 코드를 400으로 처리하고 싶을 때 어떻게하면 좋을까.

처리를 위해 ApiExceptionController.java에 코드를 추가해준다.

@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인 것을 확인할 수 있다.

스프링 MVC는 컨트롤러 밖으로 예외가 던져진 경우 예외를 해결하고 동작을 새로 정의할 수 있는 방법을 제공한다. 컨트롤러 밖으로 던져진 예외를 해결하고 동작 방식을 변경하고 싶으면 HandlerExceptionResolver를 사용하면 된다. (ExceptionResolver)

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;
  }
}

그리고 등록을 위해 WebConfig.java에 코드를 추가해준다.

@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
	resolvers.add(new MyHandlerExceptionResolver());
}

이렇게 해주면 기존 오류를 먹고 빈 ModelAndView를 반환하여 정상 흐름으로 처리해주고 상태 코드는 400으로 바뀐다.

resolveExceptionIllegalArgumentException일 때만 적용되도록 했지만 다른 예외일때도 resolveException이 호출되긴 한다.

ExceptionResolver 적용 전


컨트롤러에서 예외 발생시 postHandle는 호출되지도 않고 예외도 전달되지 않는다.

ExceptionResolver 적용 후


만약 ExceptionResolver이 존재한다면 예외를 해결하려 시도한다. 해결 시 View를 렌더링하고 정상적인인 로직이 된다.
하지만 이때에도 postHandle은 호출되지 않는다.

ExceptionResolverModelAndView를 반환하는 이유는 예외를 처리해 정상 흐름처럼 변경하는 것이 목적이다.

HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식

  • 빈 ModelAndView : new ModelAndView() 처럼 빈 ModelAndView를 반환하면 뷰를 렌더링하지 않고 정상 흐름으로 서블릿 리턴
  • ModelAndVie 지정 : ModelAndViewView, Model등의 정보를 지정해 반환하면 뷰를 렌더링 함
  • null : null 반환 시 다음 ExceptionResolver를 찾아 실행. 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고 기존에 발생한 예외를 서블릿 밖으로 던짐.

ExceptionResolver 활용

  • 예외 상태 코드 변환 : 예외를 response.sendError(xxx) 호출로 변경해 서블릿에서 상태 코드에 따른 오류를 처리하도록 함. 이후 WAS는 서블릿 오류 페이지를 찾아 내부 호출
  • 뷰 템플릿 처리 : ModelAndView에 값을 채워 예외에 따른 새로운 오류 화면 뷰 렌더링해서 제공
  • API 응답 처리 : response.getWriter().println(...) 처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능.

HandlerExceptionResolver 활용

예외가 발생하면 WAS까지 예외가 던져지고 WAS에서 오류페이지 정보를 찾아 다시 /error를 호출하는 과정은 너무 복잡하다. ExceptionResolver를 활용하면 예외가 발생했을 때 이런 복잡한 과정 없이 해결할 수 있다. 하지만 우리가 직접 구현하려고 하면 너무 복잡하기에 우리는 스프링이 제공한은 ExceptionResolver를 제공하면 된다.

스프링이 제공하는 ExceptionResolver

스프링 부트가 기본으로 제공하는 ExceptionResolvrer는 다음과 같다
HandlerExceptionResolverComposite 에 등록
1. ExceptionHandlerExceptionResolver :@ExceptionHandler처리
2. ResponseStatusExceptionResolver : HTTP 상태 코드 지정
ex) @ResponseStatus(value = HttpStatus.NOT_FOUND)
3. DefaultHandlerExceptionResolver : 스프링 내 부 기본 예외 처리

ResponseStatusExceptionResolver

예외에 따라 HTTP 상태 코드를 지정해주는 역할을 한다.

  • @ResponseStatus가 달려 있는 예외
  • ResponseStatusException 예외
package hello.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가 해당 애노테이션을 확인해 오류 코드를 400으로 변경하고 메시지도 담는다.

ResponseStatusExceptionResolver를 들어가 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason)를 호출한다.

그리고 reasonMessageSource에서 찾는 기능도 제공한다.

package hello.exception.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

//@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {}

messages.properties를 따로 만들어 error.bad를 추가해주면 된다.

error.bad=잘못된 요청 오류입니다. 메시지 사용

그 후 실행하면 우리가 지정한 메시지가 오류 메시지로 뜨는 것을 확인할 수 있다. 이때 application.properties에서 server.error.include-message=always 이렇게 바꾸어 줘야 한다.

ResponseStatuseException

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다. 추가로 애노테이션을 사용하기에 조건에 따라 동적으로 변경하는 것도 어렵기에 이때에는 ResponseStatusException 예외를 사용하면 된다.

DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결한다. 대표적으로 파라미터 바인딩 시점에 타입이 틀리면 내부에서 TypeMismatchException이 발생하는데 이때 예외가 발생했기에 그냥 두면 서블릿 컨테이너까지 오류가 전달되고 500 오류가 발생한다.
그런데 파라미터 바인딩은 대부분 클라이언트 잘못이기에 HTTP에서는 상태 코두 400을 사용하도록 되어 있다.
DefaultHandlerExceptionResolver는 이것을 500 오류가 아닌 400 오류로 변경한다.

DefaultHandlerExceptionResolver.handleTypeMismatch를 살펴보면 response.sendError(HttpServletResponse.SC_BAD_REQUEST)를 사용하고 있다. 결국 response.sendError()를 통해 문제를 해결한다.

@ExceptionHandler

API 예외 처리 어려운 점

  • HandlerExceptionResolver를 떠올려 보면 ModelAndView를 반환해야 했는데 이것은 API 응답에 필요 없음
  • API 응답을 위해 HttpServletResponse에 직접 응답 데이터를 넣어주어야 하는데 번거로움.
  • 특정 컨트롤러에서마나 발생하는 예외를 별도로 처리하기 어려움

스프링 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 매우 편리한 기능을 제공하는데 이것이 바로 ExceptionHandlerExceptionResolver이다. 기본으로 제공하는 ExceptionResolver 중에서도 우선순위가 가장 높다.

컨트롤러에서 예외 발생시 @ExceptionHandler가 있으면 지정된 메서드가 호출된다. 참고로 지정하나 예외의 자식 클래스까지 모두 잡을 수 있다.

우선 순위

스프링의 우선순위는 항상 자세한 것이 우선순위를 가지는데 예를 들어 부모와 자식클래스가 있고 다음과 같이 예외 처리가 된다.

@ExceptionHandler(부모예외.class)
public String 부모예외처리()(부모예외 e) {}

@ExceptionHandler(자식예외.class)
public String 자식예외처리()(자식예외 e) {}

@ExceptionHandler에 지정한 부모 클래스는 자식 클래스 까지 처리가능하다. 따라서 자식예외가 발생하면 둘 다 호출 대상이 되지만 둘 중 더 자세한 것이 우선순위를 가지기에 자식예외처리()가 호출된다.

예외 생략

@ExceptionHandler에 예외를 생략할 수 있다. 생략하면 메서드 파라미터의 예외가 지정된다.

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

예제

package hello.exception.exhandler.advice;

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(basePackages = "hello.exception.api")
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class) //해당 오류 발생시 이 로직으로 처리됨.
    public ErrorResult illegalExHandler(IllegalArgumentException e){ //illegal의 자식까지 처리
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("BAD", e.getMessage()); //restcontroller이기에 그대로 json으로 반환됨
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e){ //userexception의 자식까지 처리
        log.error("[exceptionHandler] 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 exHandler(Exception e){ //위의 예외 두개가 처리하지 못하는 예외까지 처리 (exception이 최상위 예외이기에)
        log.error("[exceptionHandler] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
}
  • 컨트롤러 호출 시 IllegalArgumentException 예외가 컨트롤러 밖으로 던져지면 ExceptionResolver 중 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다. 해당 컨트롤러에 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있기에 illegalExHandler() 메서드가 실행된다. @ResponseBody가 적용되기에 HTTP 컨버터가 사용되가 응답이 JSON형태로 반환된다. 이때 @ResponseStatus(HttpStatus.BAD_REQUEST)로 지정했기에 상태코드 400으로 응답한다.
  • @ExceptionHandler에 예외를 지정하지 않았기에 해당 메서드 파라미더 예외인 UserExeption 예외를 사용한다.
    ResponseEntity를 사용해 HTTP 메시지 바디에 직접 응답한다. ResponseEntity를 사용하면 @ResponseStatus와는 다르게 HTTP 응답 코드를 프로그래밍하여 동적으로 변경할 수 있다.
  • 위의 두 메서드에서 잡지 못하는 예외들을 exHandler() 에서 처리한다. 예를 들어 RuntimeExceptionException의 자식 클래스이기에 exHandler()가 호출된다. 그리고 @ResponseStatus(HttpStatus.INTERNAL_SEVER_ERROR)로 HTTP 상태 코드를 500으로 응답한다.

@ControllerAdvice

@ExceptionHandler를 사용해 예외를 깔끔하게 처리할 수 있었지만 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여있다. @ControllerAdvice또는 @RestControllerAdvice를 사용하면 예외 처리 코드와 정상 코드 부분을 따로 분리할 수 있다.

@ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해주는 역할을 한다. @RestControllerAdvice@ControllerAdvice와 동일하고 @ResponseBody가 추가되어 있다.

대상 컨트롤러 지정 방법

// 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 {}
  • 특정 애노테이션이 존재하는 컨트롤러를 지정할 수 있음
  • 특정 패키지를 직접 지정할 수도 있음. 패키지 지정의 경우 해당 패키지와 그 하위에 있는 컨트롤러가 대상이 됨
  • 특정 클래스 지정 가능. 대상 컨트롤러 지정 생략 시 모든 컨트롤러에 적용됨

실무에선 예외를 공통적으로 처리하는 것이 중요하다는 걸 기억하자.

profile
공부 기록 공간 '◡'

0개의 댓글