[Spring MVC 2편] 9. API 예외 처리

HJ·2023년 1월 22일
0

Spring MVC 2편

목록 보기
9/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. API 예외 처리

  • 예외가 발생한 경우, HTML 페이지를 보여주는 경우 단순 오류 페이지만 있으면 처리할 수 있었지만 API는 단순하지 않다

  • API는 서버 - 서버, 앱 - 서버 등과 같이 여러 상황에서 주고 받을 수 있기 때문에 예외가 발생하면 정확한 데이터를 전달해야한다

  • 그러므로 API를 호출하는 쪽과 응답하는 쪽이 서로 합의하여 4XX나 5XX 오류처럼 어떤 오류가 발생하면 어떤 형식으로 보낼지, 어떤 데이터를 보낼지와 같은 것을 정해야한다

  • 즉, API 예외 처리는 각 오류 상황에 맞는 오류 응답 스펙을 정하고 JSON으로 데이터를 내려주어야함




2. WebServerCustomizer

2-1. 수정 사항

  • 이전에 WebServerCustomizer를 이용해 오류 페이지를 전달했던 방식처럼 API 응답하기

  • 기존 방식

    • WebServerCustomizer 생성하고 예외 종류에 따라 ErrorPage 추가

    • ErrorPage를 추가할 때 등록한 URL을 처리( 오류 페이지를 처리 )하는 Controller 작성

  • 변화 내용

    • API 통신을 하는 경우, JSON으로 데이터를 전달해야하기 때문에 Controller가 JSON 응답을 하도록 메서드 추가

2-2. Controller 메서드 추가

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> errorPage500Api(
        HttpServletRequest request, HttpServletResponse response) {

    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(ERROR_STATUS_CODE);
    return new ResponseEntity<>(result, HttpStatus.valueOf(statusCode));
}
  • produces

    • 미디어 타입 조건 매핑 ( Accept )

    • 클라이언트가 보내는 Accept 타입에 따라서 어떤 메서드가 호출될 것인지를 지정

    • 즉, 같은 URL을 처리하는 메서드가 있을 때, produces로 미디어 타입을 지정하면 Accept 헤더에 따라 호출되는 메서드가 다름

    • 클라이언트가 요청하는 HTTP Header의 Accept 의 값이 application/json 일 때 해당 메서드가 호출

  • ResponseEntity

    • HTTP 응답 메세지의 body에 JSON 데이터를 직접 넣을 때 사용

    • ResponseEntity 를 반환하면 HTTP 메시지 컨버터를 통해서 JSON 형식으로 변환되어서 반환

    • MVC 1편 6번 게시글의 8-2번 참고




3. 스프링부트 기본 오류 처리

@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) {
		// html 오류 페이지 반환
        ...
	}

	@RequestMapping
	public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
		HttpStatus status = getStatus(request);
		if (status == HttpStatus.NO_CONTENT) {
			return new ResponseEntity<>(status);
		}
		Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
		return new ResponseEntity<>(body, status);
	}
}
  • BasicErrorController는 스프링부트가 제공하는 기본 오류 처리 방식

    • 위의 두 메서드는 /error 라는 동일한 경로를 처리

    • 오류가 발생했을 때 Accept가 text/html이면 errorHTML()을 호출해 오류 페이지 반환

    • 그 외의 경우에는 error()를 호출하면 Http 메세지 컨버터가 동작해 ResponseEntity로 JSON 데이터를 반환

  • BasicErrorController는 기본 정보들을 활용해서 오류 API를 생성해서 반환

    • 위에서 말하는 기본 정보란 MVC 2편 8번 게시물의 5-4에서 언급한 정보

    • 마찬가지로 application.properties에서 오류 정보 포함 여부를 설정 가능

  • BasicErrorController는 오류 페이지를 제공하는 경우에 편리하지만 API는 각 상황에 따라 다른 응답 결과를 출력해야할 수도 있기 때문에 @ExceptionHandler를 사용하는 것이 더 좋은 방법

    • ex> 회원과 관련된 API에서 예외가 발생했을 때와 상품과 관련된 API에서 예외가 발생헀을 때의 JSON 데이터를 다르게 구성해야할 수도 있음



4. HandlerExceptionResolver

4-1. 개요

  • Controller 내부에서 Exception이 발생하고 WAS까지 전달되면 서버 내부 오류로 판단하여 HTTP 상태코드 500으로 처리하는데

  • 이렇게 500으로 처리하지 않고 발생하는 예외에 따라서 다른 상태코드로 처리하거나 오류 메시지, 형식 등을 API마다 다르게 처리하도록 할 수 있음

  • ➡️HandlerExceptionResolver ( 줄여서 ExceptionResolver )


4-2. 동작 흐름

  • Controller에서 발생한 예외를 잡아서 정상적으로 처리할 수 있도록 해줌

  • but> 예외를 해결해도 postHandle()은 호출되지 않는다


4-3. ExceptionResolver 활용 방식

  1. 예외 상태 코드 변환 ( 500 ➜ 4xx )

  2. ModelAndView에 값을 채워 예외에 따른 새로운 오류 화면을 렌더링 해서 제공

  3. response.getWriter().println(); 처럼 HTTP 응답 메세지의 body에 직접 데이터를 넣는 것도 가능하며, JSON으로 응답하면 API 응답 처리를 할 수 있다


4-4. ExceptionResolver의 반환값에 따른 DispatcherServlet의 동작

  1. 빈 ModelAndView를 반환 : 뷰를 렌더링하지 않고 WAS까지 정상적인 흐름으로 서블릿이 반환

  2. ModelAndView에 정보를 지정해서 반환 : ModelAndView에 View , Model 등의 정보를 지정해서 반환하면 뷰를 렌더링한다

  3. null 반환 : null 을 반환하면 다음 ExceptionResolver 를 찾아서 실행, 없으면 기존에 발생한 예외를 서블릿 밖으로 던진다


4-5. 상태 코드 변경

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에 만든 ExceptionHandler등록
@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}
  • Exception ex : 핸들러( Controller )에서 발생한 예외

  • ExceptionResolver가 발생한 예외를 먹어버리고 sendError()로 400에러를 보냄

  • 빈 ModelAndView를 반환했기 때문에 WAS까지 정상적인 반환값이 전달

  • ➡️ sendError()로 400 에러가 넘어왔기 때문에 WAS가 500 에러가 아닌 400 에러로 인식

    • 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출

    • ex> 스프링부트가 기본으로 설정한 /error 호출




5. 예외 처리 과정 간소화

5-1. 개요

  • 지금까지는 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error 를 호출

  • ExceptionResolver를 활용하면 WAS까지 예외가 전달되지 않고 ExceptionResolver에서 예외를 처리할 수 있다

    • ExceptionResolver에서 예외를 처리하기 때문에 WAS 입장에서는 정상적인 처리가 이루어진 것

    • WAS가 /error 와 같은 경로를 다시 호출하는 일이 발생하지 않는다


5-2. 직접 ExceptionResolver 구현하기

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) {
                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 {
                    return new ModelAndView("error/500");  // 에러 페이지 지정
                }        
            }
        } 
        ...
    }
}
  • 지정한 예외가 발생하면 Accept 헤더를 보고 처리 방식을 나눈다 ( json과 html로 )

  • 응답 메세지에 HTTP 상태 코드를 담는다

  • json인 경우, 데이터를 담고 objectMapper를 이용해 변환해서 응답 메세지의 body에 담는다 ( MVC 1편 참고 )

  • 빈 ModelAndView를 반환하기 때문에 WAS까지 response가 전달되고, body에 담은 데이터가 클라이언트에게 전달된다

  • WebConfig에 등록 필요

    • resolvers.add(new UserHandlerExceptionResolver());



6. 스프링이 제공하는 ExceptionResolver

  • 스프링이 제공하는 ExceptionResolver가 존재하기 때문에 여러 기능들을 편리하게 사용할 수 있다

    • ex> 직접 ExceptionResolver를 구현해서 상태코드를 변경하거나 하지 않아도 스프링이 제공하는 ExceptionResolver를 이용해 간단하게 변경할 수 있다
  • HandlerExceptionResolverComposite에 아래 순서로 등록되어 있다

    • ExceptionHandlerExceptionResolver : @ExceptionHandler를 처리

    • ResponseStatusExceptionResolver : HTTP 상태 코드를 지정

    • DefaultHandlerExceptionResolver : 스프링 내부 기본 예외를 처리

  • 첫 번째 ExceptionResolver에서 처리되지 않고 null이 반환되면 두 번째에서 처리, 두 번째에서 안되면 세 번째에서 처리하는 흐름



7. DefaultHandlerExceptionResolver

  • DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 해결

  • ex> 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생한 경우, 예외 처리를 하지 않고 두면 서블릿 컨테이너까지 오류가 올라가고 500 오류가 발생

  • but> 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이기 때문에 DefaultHandlerExceptionResolver가 500 오류가 아니라 400 오류로 변경해준다




8. ResponseStatusExceptionResolver

8-1. 설명

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

  • @ResponseStatus가 붙어 있는 예외나 ResponseStatusException 예외를 처리한다

  • Exception에 @ResponseStatus 어노테이션을 붙여 상태 코드를 지정할 수 있다

    • 그럼 Exception이 발생했을 때 500으로 처리되는 것이 아닌 @ResponseStatus에 명시된 상태 코드가 반환

    • 이전에 sendError()를 통해 수동으로 상태 코드를 변환했던 과정을 어노테이션을 통해 손쉽게 구현 가능

  • @ResponseStatus를 사용할 수 없는 상황에 ResponseStatusException를 사용한다


8-2. @ResponseStatus

@ResponseStatus(code= HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{ }
  • 위의 BadRequestException을 터치면 Exception에 @ResponseStatus가 있기 때문에 ResponseStatusExceptionResolver가 동작

  • 어노테이션에 지정된 code와 reason 값을 꺼내서 response.sendError(code, reason)를 실행하고 빈 ModelAndView를 반환

  • 즉, ResponseStatusExceptionResolver는 내부에서 response.sendError()를 호출해서 상태 코드 변환을 수행한다

    • 위의 예시는 400을 반환했기 때문에 정상적으로 처리되지 않고 WAS에서 다시 오류 페이지(/error)를 요청한다

8-3. @ResponseStatus의 메세지 기능

  • 위에서 reason은 예외 메세지인데 이를 코드와 시켜서 예외 메세지를 MessageSource에서 찾게 할 수 있다

  • reason에 명시된 메세지 코드를 찾지 못하면 텍스트가 그대로 반환

  • messages.properties에 error.bad=잘못된 요청 오류입니다. 메시지 사용가 있고

  • @ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")를 사용하면

  • messages.properties에서 error.bad라는 메세지 코드를 찾고, 찾아진 메세지가 반환


8-4. ResponseStatusException

  • 개발자가 직접 변경하거나 수정할 수 없는 예외에는 @ResponseStatus를 붙일 수 없다

  • 어노테이션을 사용하기 때문에 예외 발생 시, 조건에 따라 동적으로 변경하는 것도 어렵다

  • ➡️ ResponseStatusException을 사용

  • throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());




9. @ExceptionHandler

9-1. 개요

  • BasicErrorController나 ExceptionHandler를 직접 구현해서 API 예외 처리하는 것은 좋은 방식이 아님

    • BasicErrorControleller는 HTML 오류 페이지를 제공할 때 더 좋은 방식

    • ExceptionHandler를 구현하기에는 ModelAndView를 반환하기 때문에 response를 이용해 message body에 직접 데이터를 넣어야함

    • 서로 다른 Controller에서 발생하는 동일한 예외를 다른 방식으로 처리하기 ( 다른 형식의 데이터를 응답하기 ) 어렵다

  • ➡️ @ExceptionHandler를 사용

  • @ExceptionHandler를 사용하면 ExceptionHandlerExceptionResolver가 동작한다

  • @ExceptionHandler는 Controller 내부에서만 적용된다

  • ExceptionHandler에서 처리하고 WAS까지 오류가 전달되지 않는다


9-2. 동작 과정

  • 호출 과정

    • Controller에서 예외가 발생하면 DispatcherServlet까지 전달

    • DispatcherServlet이 ExceptionHandler로 예외 해결 시도

    • ExceptionHandlerExceptionResolver가 실행됨

    • Controller 내부에 발생한 예외를 처리하는 @ExceptionHandler가 있는지 찾아본다

    • 있으면 @ExceptionHandler가 붙은 메서드를 호출한다

  • 동작 이후

    • ExceptionResolver에서 예외 처리가 끝이 난 것이기 때문에 서블릿 컨테이너로 예외가 전달되지 않는다

    • ExceptionResolver에서 정상적으로 JSON을 만들어 반환했기 때문에 HTTP 상태 코드가 200이 되고 WAS가 재호출 하는 일이 발생하지 않는다

    • 200이 아닌 다른 상태 코드를 반환하고 싶으면 해당 메서드 위에 @ResponseStatus를 추가한다

    • ex> @ResponseStatus(HttpStatus.BAD_REQUEST)

    • 상태 코드를 400이나 500으로 변경해도 WAS가 재호출하는 일은 발생하지 않는다

  • 주의!!

    • @ResponseStatus로 상태 코드를 변경하는 것은 내부적으로 sendError()를 호출하기 때문에 WAS에서 /error로 다시 요청을 한다고 했는데 위에서는 WAS가 재호출하는 일이 없다고 했다

    • @ResponseStatus를 예외에서 사용할 때는 sendError()가 동작하여 WAS가 다시 요청하게 되는게 맞지만

    • Controller나 @ExceptionHandler에서 @ResponseStatus를 사용할 때는 단순히 상태 코드만 변하는 방식으로만 동작 ( WAS 재호출 없음 )


9-3. 코드

@RestController
public class ApiExceptionV2Controller {

    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        return new ErrorResult("BAD", e.getMessage());
    }

    ...
}
  • Controller 내부에서 IllegalArgumentException이 발생하면 어노테이션이 붙은 메서드가 수행된다

  • Controller가 @RestController이기 때문에 JSON 형식으로 반환

    • 단> 반환형으로 사용한 클래스는 미리 선언되어 있어야함
  • IllegalArgumentException 뿐만 아니라 그 자식 예외들도 위의 메서드로 처리한다


@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandler(UserException e) {
    ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
    return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);
}
  • 메서드의 파라미터에 예외 종류를 적어주면 @ExceptionHandler에서 생략할 수 있다

  • UserException 뿐만 아니라 그 자식 예외들도 위의 메서드로 처리한다


@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
@ExceptionHandler
public ErrorResult exHandler(Exception e) {
    return new ErrorResult("EX", "내부 오류");
}
  • Exception의 자식 예외들을 다 처리해준다

  • Exception이 최상위 예외이기 때문에 위의 두 가지 메서드에서 처리하지 못한 예외를 처리해준다

  • 부모와 자식에 해당하는 예외가 둘 다 있으면 자식에 해당하는 예외가 먼저 호출된다 ( 자세한 것이 먼저 )


@ExceptionHandler(ViewException.class)
public ModelAndView ex(ViewException e) {
    return new ModelAndView("error");
}
  • ModelAndView 를 사용해서 오류 화면( HTML )을 응답하는데 사용할 수도 있다

  • @RestController가 아닌 @Controller를 붙이고 메서드의 반환형을 String으로 view의 이름을 반환하면 뷰 템플릿을 찾아서 렌더링된다




10. @ControllerAdvice

10-1. 개요

  • @ExceptionHandler를 Controller 내부에서만 사용 가능해서 다른 곳에 사용할 수 없다

  • 하나의 Controller에 정상 코드와 예외 처리 코드가 섞여있다

  • ➡️ @ControllerAdvice, @RestControllerAdvice를 사용


10-2. 설명

  • @ControllerAdvice는 대상으로 지정한 Controller에 @ExceptionHandler, @InitBinder 기능을 부여하는 역할

  • 대상을 지정하지 않으면 모든 Controller에 적용된다

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody


10-3. 대상 지정 코드

// Target all Controllers annotated with @RestController
// 특정 어노테이션이 있는 Controller
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 { }


// Target all Controllers within specific packages
// 특정 패키지에 있는 Controller
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 { }


// Target all Controllers assignable to specific classes
// 부모 Controller를 지정하거나 특정 Controller를 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class, AbstractController.class})
public class ExampleAdvice3 { }
profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글