Rootable의 개발일기
API 예외 처리 본문
📌 BasicErrorController의 한계
🔎 BasicErrorController 코드
- errorHtml() : 클라이언트 요청의 Accept 헤더 값이 text/html인 경우, error 디렉터리의 뷰를 반환
- error() : 그 외 경우에 호출되고 ResponseEntity로 HTTP body에 JSON 데이터를 반환
스프링 부트는 BasicErrorController가 제공하는 기본 정보들을 활용해서 오류 API를 생성해 준다.
🔎 한계
스프링 부트가 제공하는 BasicErrorController는 HTML 응답을 하는 경우에는 매우 편리한 방법이다. 하지만 API 오류 처리는 매우 세밀하고 복잡하다.
각각의 컨트롤러마다 또는 예외마다 API 오류 스펙을 정의해야 할 수도 있다. 예를 들어, 회원과 관련된 API에서 예외가 발생했을 때 응답과 상품과 관련된 API에서
발생하는 예외에 따라 그 결과가 달라질 수 있다.
HTML 화면을 처리할 때 BasicErrorController를 사용하고, API 오류 처리는 @ExceptionHandler를 사용하자
📌 HandlerExceptinoResolver(ExceptionResolver)
스프링 MVC는 컨트롤러(핸들러) 밖으로 예외가 던져진 경우 예외를 해결하고, 동작을 새로 정의할 수 있도록 HandlerExceptionResolver(ExceptionResolver)를
제공한다.
특정 예외를 해결하고, JSON 결과 또는 HTTP 상태 코드를 커스터마이징(변경)할 수 있다.
🔎 적용 전과 후 동작
postHandle이 호출되지 않는 것은 동일하다. 다만, DispatcherServlet에서 예외를 해결하기 위해 ExceptionResolver를 호출하는 시도가 추가된 점이 다르다.
🔎 활용
1. 예외 상태 코드 변환
- sendError()를 이용하여 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임하고, 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출한다.
2. 뷰 템플릿 처리
- ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰를 렌더링
3. API 응답 처리
- response.getWriter(). println("...");처럼 HTTP 응답 바디에 직접 데이터를 넣는 것도 가능하다. 여기에 JSON으로 응답하면 API 응답 처리도 할 수 있다.
📌 예제1 - IllegalArgumentException 잡기
MyHanlerExceptionResolver를 통해 IllegalArgumentException을 잡아 원하는 상태 코드로 응답이 오도록 한다.
@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);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String memberId;
private String name;
}
}
위 컨트롤러는 id 값에 "bad"가 들어가면 IllegalArgumentException을 발생시킨다.
@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;
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new MyHandlerExceptionResolver());
}
}
HandlerExceptionResolver의 resolveException()을 구현하여 ExceptionResolver를 사용할 수 있다.
리턴 값으로 ModelAndView를 반환하는 것은 마치 try ~ catch를 하듯이, Exception을 처리해서 정상 흐름처럼 변경하는 것이 목적이다. 이름 그대로 예외를 해결하는 것이 목적이다.
위 코드는 다음과 같이 동작한다.
- ApiExceptionController 호출 후 IllegalArgumentException 예외가 컨트롤러 밖으로 던져짐
- 예외가 발생했으므로 DispatcherServlet은 ExceptionResolver를 작동시켜 해결 시도
- IllegalArgumentException 예외를 처리하는 MyHandlerExceptionResolver를 실행
- 400 응답 코드를 담은 sendError를 호출하여 서블릿 컨테이너까지 에러 전달
- 서블릿 컨테이너에서 BasicErrorController를 호출
- BasicErrorController는 클라이언트의 Accept에 따라 뷰 또는 JSON 응답
🔎 반환 값에 따른 동작 방식
HandlerExceptionResolver의 반환 값에 따른 DispatcherServlet의 동작 방식은 다음과 같다.
- 빈 ModelAndView : 뷰를 렌더링하지 않고, 정상 흐름으로 서블릿이 리턴된다.
- ModelAndView 지정 : ModelAndView에 View, Model 등의 정보를 지정해서 반환하면 뷰를 렌더링
- null : null을 반환하면 다음 ExceptionResolver를 찾아서 실행한다. 만약 처리할 수 있는 ExceptionResolver가 없으면 예외 처리가 안되고, 기존에 발생한 예외를 서블릿 밖으로 던진다.
📌 예제2 - 커스텀 예외 처리
사용자가 직접 생성한 예외를 처리하고, sendError 없이 직접 응답 스펙을 정의하여 서블릿 컨테이너에 예외가 전달되지 않고 스프링 MVC에서 예외 처리가 끝난다.
@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("잘못된 입력 값");
}
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;
}
}
id 값이 "user-ex"가 들어오면 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);
}
}
@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");
//Accept Header 값 가져오기
String acceptHeader = request.getHeader("accept");
//HTTP 400에러로 설정
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
//Accept == JSON 인 경우
if ("application/json".equals(acceptHeader)) {
//JSON 응답에 담을 데이터
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("ex", ex.getClass());
errorResult.put("message", ex.getMessage());
String result = objectMapper.writeValueAsString(errorResult); //객체 -> JSON -> String
//응답 형식 지정
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(result);
return new ModelAndView(); //정상 흐름 처리
} else { //Accept == HTML 인 경우
return new ModelAndView("error/500"); //뷰 렌더링
}
}
} catch (IOException e) {
log.error("resolver ex", e);
}
return null;
}
}
"ex"라는 이름의 예외 클래스와 "message"라는 이름의 예외 메시지를 담아 JSON 형식으로 출력한다.
응답 형식을 지정하는 부분을 보면 직접 HTTP 응답 스펙을 정의하고 이것을 body에 넣었다. Accept가 JSON이라면 빈 ModelAndView를 반환하여
정상 흐름 처리하고, HTML이라면 "error/500.html"을 렌더링 한다.
마찬가지로 WebConfig에 등록한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
resolvers.add(new UserHandlerExceptionResolver());
resolvers.add(new MyHandlerExceptionResolver());
}
}
클라이언트 Accept에 따라 2가지 결과가 나왔다. 그리고 HTTP 응답 스펙을 직접 정의하여 ExceptionResolver에서 예외 처리가 끝나고, WAS 입장에서는
자신이 예외를 받지 않았으므로 정상 처리된 것으로 본다.
📌 스프링이 제공하는 ExceptionResolver
스프링 부트가 기본으로 제공하는 ExceptionResolver는 3가지가 있다. (우선순위는 숫자가 낮을수록 높다)
- ExceptionHandlerExceptionResolver
- ResponseStatusExceptionResolver
- DefaultHandlerExceptionResolver
🔎 ExceptionHandlerExceptionResolver
스프링에서 기본 제공하는 애노테이션을 사용하는 예외 처리 기능으로 @ExceptionHandler의 사용을 지원한다.
🔎 ResponseStatusExceptionResolver
예외에 따라서 HTTP 상태 코드를 지정해 주는 역할을 한다. 다음 두 가지 경우를 처리한다.
- @ResponseStatus가 달려 있는 예외
- ResponseStatusException 예외
개발자가 직접 변경할 수 없는 예외(라이브러리의 예외 코드)에는 적용 불가
✔ 기본 사용법
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
위 코드는 @ResponseStatus가 달려 있는 예외로 HTTP 상태 코드와 메시지를 작성할 수 있다.
@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
throw new BadRequestException();
}
위 컨트롤러에서 BadRequestException 예외를 발생시키면 ResponseStatusExceptionResolver 가 해당 애노테이션을 찾아 옵션에 따라 상태 코드를 변경하거나
메시지를 추가한다.
✔ 내부 코드 일부
protected ModelAndView applyStatusAndReason(int statusCode, @Nullable String reason,
HttpServletResponse response) throws IOException {
if (!StringUtils.hasLength(reason)) {
response.sendError(statusCode);
}
else {
String resolvedReason = (this.messageSource != null ?
this.messageSource.getMessage(reason, null, reason, LocaleContextHolder.getLocale()) :
reason);
response.sendError(statusCode, resolvedReason);
}
return new ModelAndView();
}
위 코드를 보면 결국 response.sendError()를 호출하는 것을 볼 수 있다. 그래서 WAS에서 다시 /error를 내부 요청하는 식으로 동작한다.
또한, @ResponseStatus의 옵션은 바로 response.sendError()와 매칭되어 동작한다.
✔ 메시지 기능 활용
reason을 MessageSource에서 찾는 기능도 제공한다.
error.bad=잘못된 요청 오류입니다. 메시지 사용
@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}
✔ 조건에 따라 동적 변경
@ResponseStatus는 애노테이션을 사용하기 때문에 특정 조건에 따라 로직을 작성하기 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.
@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
}
위 코드를 통해 특정 예외(IllegalArgumentException)가 발생했을 때, 특정 상태 코드(NOT_FOUND)로 변경하고 원하는 메시지를 출력할 수 있다.
🔎 DefaultHandlerExceptionResolver
스프링 내부에서 발생하는 스프링 예외를 해결한다.
대표적으로 파라미터 바인딩 시점에 타입이 맞지 않아 내부에서 TypeMismatchException이 발생하는 경우다.
DefaultHandlerExceptionResolver.handleTypeMismatch 내부 코드를 보면 response.sendError(HttpServletResponse.SC_BAD_REQUEST)가 있다. 따라서 sendError()를 통해 상태 코드를 변경할 수 있다.
@GetMapping("/api/default-handler-ex")
public String defaultException(@RequestParam Integer data) {
return "ok";
}
📌 @ExceptionHandler
🔎 필요성
- BasicErrorController ➡ 컨트롤러마다 또는 예외마다 각각 다른 응답을 내려줄 수 없다.
- HandlerExceptionResolver ➡ 직접 HTTP 응답 스펙을 정의하는 것은 상당히 복잡하고 번거롭다는 단점
- ModelAndView를 반환해야 하는 스펙은 API 응답과 잘 맞지 않는 문제
스프링은 API 예외 처리 문제를 해결하기 위해 ExceptionHandlerExceptionResolver를 제공하여 @ExceptionHandler를 사용하여 문제를 해결할 수 있도록 했다.
🔎 예제
@ExceptionHandler를 통해 컨트롤러와 Resolver로 나누지 않고, 컨트롤러에서 특정 예외에 대한 처리를 한다.
@Data
@AllArgsConstructor
public class ErrorResult {
private String code;
private String message;
}
ErrorResult는 API 응답으로 사용할 객체다.
@Slf4j
@RestController
public class ApiExceptionV2Controller {
@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", "내부 오류");
}
@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;
}
}
✔ illegalExHandle 분석
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult illegalExHandle(IllegalArgumentException e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("BAD", e.getMessage());
}
- 컨트롤러를 호출하여 IllegalArgumentException예외가 컨트롤러 밖으로 던져진다.
- 예외가 발생했으므로 ExceptionResolver가 동작한다.
- 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
- ExceptionHandlerExceptionResolver는 해당 컨트롤러에서 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
- illegalExHandle()을 실행
- @RestController이므로 @ResponseBody가 적용되어 HTTP 컨버터가 동작하여 JSON 형태로 반환한다.
- @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로, HTTP 상태 코드 400으로 응답한다.
✔ userExHandle 분석
@ExceptionHandler에 예외를 생략할 수 있다. 생략하면 파라미터인 UserException 예외가 지정된다.
@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를 사용한 것이다. 따라서, HTTP 응답 바디를 직접 작성할 수 있고, HTTP 컨버터가 동작하여 JSON으로 반환된다.
ResponseEntity의 장점은 @ResponseStatus처럼 애노테이션이 아니므로 HTTP 응답 코드를 로직을 통해 동적으로 변경할 수 있다.
✔ exHandle 분석
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandle(Exception e) {
log.error("[exceptionHandle] ex", e);
return new ErrorResult("EX", "내부 오류");
}
@ResponseStatus를 통해 응답 코드를 변경했다. 처리하는 예외는 Exception이다. @ExceptionHandler는 예외를 처리하는 우선순위가 있다.
- 부모는 자식 예외를 해결할 수 있다.
- 자식은 부모 예외를 해결할 수 없다.
- 부모, 자식 클래스가 같이 있을 때, 우선순위는 더 자세한 것인 자식 클래스가 높다.
결과적으로 자식 예외가 발생하면 둘 다 호출 대상이지만 우선권에 따라 자식 예외만 호출되고, 부모 예외가 발생하면 부모 예외만 호출된다.
따라서, 위 코드는 throw new RuntimeException으로 발생한 예외를 처리한다.
🔎 @ExceptionHandler 장점
- 컨트롤러에 바로 사용할 수 있어 그 자리에서 처리 흐름이 끝난다. (WAS로 예외를 전달하고, 재호출하는 과정 생략)
- @ResponseStatus와 함께 사용할 수 있어 상태 코드 변경이 가능하다.
- @ExceptionHandler에 특정 예외를 넣을 수 있어 예외마다 API 응답 처리가 가능하다.
- 반환 타입이 자유롭다.
- 여러 예외를 한 번에 처리할 수 있다.
- @ControllerAdvice와 함께 사용하면 예외 처리 코드를 분리하고, 컨트롤러마다 예외를 적용할 수 있다.
📌 @ControllerAdvice
예외 처리 부분만 따로 분리할 수 있고, 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여해 주는 역할을 한다.
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
🔎 대상 컨트롤러 지정 방법
대상 컨트롤러 지정을 생략하면 모든 컨트롤러에 적용된다.
Reference:
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard
스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의
웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있
www.inflearn.com
'Spring' 카테고리의 다른 글
@Transactional (0) | 2023.08.08 |
---|---|
스프링 파일 업로드/다운로드 (0) | 2023.08.03 |
예외 처리와 오류 페이지 (0) | 2023.07.31 |
필터와 인터셉터 (0) | 2023.07.28 |
서블릿 HTTP 세션 (0) | 2023.07.27 |