스프링 MVC 2편 - API의 예외처리

링딩·2022년 8월 19일
0

스프링 MVC

목록 보기
16/18

김영한 강사님 강의를 참고하여 작성했습니다.

[서블릿]에서 API 예외처리를!

그런데 API의 경우에는 생각할 내용이 더 많다. 오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만..
API는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.👍🤔


처음으로 돌아가서 서블릿 오류 페이지 방식을 사용해보자.

  • WebServerCustomizer@Component 주석을 다시 제거
    • 그랬더니... HTML 문서가 뜬다..
  • 왜?
    이전에 미리 만들었던 오류 처리 페이지로 HTML이 반환되어서..
  • 어떻게 해야해?
    - JSON응답을 할 수 있도록 컨트롤러를 수정해준다.

그래서..JSON 응답을 하도록 추가한 컨트롤러

 //API 응답 추가
    @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));
    }

이에 대한 [정상 응답]

 "message": "잘못된 사용자",
 "status": 500
}
  • produces = MediaType.APPLICATION_JSON_VALUE
    - (뜻): 클라이언트가 요청하는 HTTP Header의
    Accept값이 application/json 일 때 해당 메서드가 호출된다.
  • HashMap이라 순서는 달라질 수 있음
  • ResponseEntity 를 사용해서 응답하기 때문에 메시지 컨버터가 동작하면서 클라이언트에 JSON이
    반환


그러면 스프링부트는 일 안해요,,?
일 한다..


스프링 부트의 @BasicErrorController

우리가 바로 전 섹션에서 배웠을 때 error/400.html처럼 API도 도움을 받을 수 없을까?

[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에는 HTTP 요청 Header에 있는 Accept를 확인하고 '오류 페이지'를 선택한다.
    => application/json 타입을 통해서...
  • BasicErrorController가 제공하는 기본 정보들을 활용해 오류 API를 생성

=> 결과적으로 이렇게 너무 많은 정보를 제공하는 것은 보안상 위험...
=> Best는 간결한 메시지만 노출하자👍👍✨


+) 이건 HTML 로 만들어주는 코드 참조 _BasicErrorController 발췌

+)HTML 페이지 vs API 오류

BasicErrorController 를 확장하면 JSON 메시지도 변경할 수 있다.

  • 그러나 조금 뒤에 설명할 @ExceptionHandler 가 제공하는 기능을 사용하는 것이 더 나은 방법이므로
  • 현재는...
    BasicErrorController 를 확장해서 JSON 오류 메시지를 변경할 수 있다 정도로만 이해해두자



@ BasicController 만으로는 API 오류 처리에 한계가 있다.
HTML 요청의 오류페이지는 단순하게 404, 500 코드에 따라 다른 오류페이지를 반환해주면 되었다.

하지만 API는 정말 다양한 형태가 나온다.

클라이언트, 서버마다 API 스펙이 다를수도있고, 같다고 하더라도 [Item API, Order API]등

요청마다 다른 오류 JSON 메시지를 전송해야 한다. 이를 어떻게 처리할 수 있을까? 🤔🤔


API 예외처리 - HandlerExceptionResolver

스프링 MVC는 컨트롤러 밖으로 예외가 발생한 경우, 이 예외를 처리할 수 있는 HandlerExceptionResolver 인터페이스를 제공해준다.
-> 만약 예외처리를 WAS로 던지지 않고 중간에 처리하고 싶다면 이를 구현하면 된다.
=> ExceptionResolver를 사용하면 굳이 WAS를 거쳐서 돌아올 필요가 없다. 그냥 예외를 try-catch처럼 잡으면 된다.


구현과정

ExceptionResolver 적용 전

ExceptionResolver 적용 후

ExceptionResolver 로 예외를 해결해도postHandle() 은 호출되지 않는다.



HandlerExceptionResolver 그리고 등록

먼저 이를 쓰기 전에 어떤 인터페이스인지 잠깐 보자

public interface HandlerExceptionResolver {
 ModelAndView resolveException(
 HttpServletRequest request, HttpServletResponse response,
 Object handler, Exception ex);
}

handler : 핸들러(컨트롤러) 정보
Exception ex : 핸들러(컨트롤러)에서 발생한 발생한 예외

1. 구현체 [MyHandlerExceptionResolver]

[결과]
여기서는 IllegalArgumentException 이 발생하면 response.sendError(400) 를 호출해서 HTTP
상태 코드를 400으로 지정하고, 빈 ModelAndView 를 반환한다.

@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;
 
  • ExceptionResolverModelAndView 를 반환하는 이유는
    => 마치 try, catch를 하듯이, Exception 을
    처리해서 정상 흐름 처럼 변경하는 것
    이 목적이다.
    => 이름 그대로 Exception 을 Resolver(해결)하는 것이
    목적이다

반환 값에 따른 동작 방식

HandlerExceptionResolver 의 반환 값에 따른 DispatcherServlet 의 동작 방식은 다음과 같다.

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

ExceptionResolver 활용

  • 예외 상태 코드 변환
    - 예외를 response.sendError(xxx) 호출로 변경해서 서블릿에서 상태 코드에 따른 오류를 처리하도록 위임
    - 이후 WAS는 서블릿 오류 페이지를 찾아서 내부 호출, 예를 들어서 스프링 부트가 기본으로 설정한 /error 가 호출됨
  • 뷰 템플릿 처리
    ModelAndView 에 값을 채워서 예외에 따른 새로운 오류 화면 뷰 렌더링 해서 고객에게 제공
  • API 응답 처리
    response.getWriter().println("hello");
    -> HTTP 응답 바디에 직접 데이터를 넣어주는
    것도 가능하다.
    -> 여기에 JSON 으로 응답하면 API 응답 처리를 할 수 있다.

2. WebConfig에 등록까지 해줘야 작동한다.



HandlerExceptionResolver 활용

1. 예외상황에 따라 다르게 처리해야하므로 서비스에서 사용할 Exception을 정의한다.

[UserException] _코드 참조

2. 해당 예외를 처리하기 위해 ExceptionResolver를 생성 및 WebConfig에 추가

[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 {
                    //TEXT/HTML
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

WebConfig에 내가 쓸 Resolver를 추가함

/**
     * 기본 설정을 유지하면서 추가
     */
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
        resolvers.add(new UserHandlerExceptionResolver());
    }

정리

예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리는 끝이 난다.
결과적으로 WAS 입장에서는 정상 처리가 된 것이다. 이렇게 예외를 이곳에서 모두 처리할 수 있다는 것이 핵심이다.

그런데 직접 ExceptionResolver 를 구현하려고 하니 상당히 복잡하다.


이젠 좀 스프링 맛 좀 보자... 스프링이 제공한 ExceptionResolver 들을 알아보자.🤪🤪




API 예외 처리 - 스프링부트의 ExceptionResolver

스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다.

HandlerExceptionResolverComposite 에 다음 순서로 등록
1. ExceptionHandlerExceptionResolver
2. ResponseStatusExceptionResolver
3. DefaultHandlerExceptionResolver
로.... 우선 순위가 가장 낮다.


이 들에 대한 추가적인 설명

  • ExceptionHandlerExceptionResolver
    - @ExceptionHandler 을 처리한다.
    - API 예외 처리는 대부분 이 기능으로 해결한다.
    -> 조금 뒤에 자세히 설명한다.
  • ResponseStatusExceptionResolver
    - HTTP 상태 코드를 지정해준다.
    예) @ResponseStatus(value = HttpStatus.NOT_FOUND)
  • DefaultHandlerExceptionResolver
    - 스프링 내부 기본 예외를 처리한다.

1. ResponseStatusExceptionResolver

다음 두 가지 경우를 처리
1. @ResponseStatus 가 달려있는 예외
2. ResponseStatusException 예외


1) @ResponseStatus 예외

[BadRequestException]

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

[ApiExceptionController 추가]

@GetMapping("/api/response-status-ex1")
public String responseStatusEx1() {
 throw new BadRequestException();
}
  • ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason) 를 호출하는 것을 확인
  • sendError(400) 를 호출했기 때문에 WAS에서 다시 오류 페이지( /error )를 내부 요청

+) 메시지 기능

  • messages.properties 에 error.bad를 추가해서 그 메시지를 사용할 수 있다.
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}

2) ResponseStatusException

[이럴 때 쓴다.]

  1. 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없는 경우..
  2. 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.

[ApiExceptionController 추가]

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {
 throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
IllegalArgumentException());
}
  • 컨트롤러에서 한 번에 다 써준다.... 상태코드랑, 메시지 전부 다!!!

2. DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 스프링 예외를 해결한다.

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면

  • 내부에서 TypeMismatchException
    발생하는데, 이 경우 예외가 발생했기 때문에
    -> 그냥 두면 서블릿 컨테이너까지 오류가 올라가고, 결과적으로 500 오류가 발생한다.🤦‍♂️
그런데 '파라미터 바인딩'은 대부분 클.라.이.언.트.가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. 🤔

우리 서버의 잘못이 아니란 것이다..!!
HTTP 에서는 이런 경우 HTTP 상태 코드 400을 사용하도록 되어 있다!

DefaultHandlerExceptionResolver 는 이것을 500 오류가 아닌, -> HTTP 상태 코드 400 오류로 변경해준다! 👍✨


어떻게?

[ApiExceptionController에 추가]

  • Integer data 에 문자를 입력하면 내부에서 TypeMismatchException 이 발생한다. -> 타입 오류!!!

결과

그러나 결과는 500이 아닌 400에러가 떴다!!
이것이 바로 스프링의 힘 아니겠느냐!!!! 😁✌✌✌


지금까지 HTTP 상태 코드를 변경하고, 스프링 내부 예외의 상태코드를 변경하는 기능도 알아보았다.

그런데 HandlerExceptionResolver 를 직접 사용하기는 복잡하다.

  • API 오류 응답의 경우 response 에 직접 데이터를 넣어야 해서 매우 불편하고 번거롭다.
  • ModelAndView 를 반환해야 하는 것도 API에는 잘 맞지 않는다.
  • 스프링은 이 문제를 해결하기 위해 @ExceptionHandler 라는 매우 혁신적인 예외 처리 기능을 제공한다. ✨✨




@ExceptionHandler

API의 경우...Item-API, Order-API등 서비스 종류에 따라 오류 메시지를 다르게 처리해줘야 하고..
심지어는 클라이언트에 따라 API의 스펙 자체가 달라지기도 한다.
물론 필요하다면 HTML 화면도 리졸버로 처리해도 된다.

API 예외처리의 어려운 점

  1. HandlerExceptionResolver 를 떠올려 보면 ModelAndView 를 반환해야 했다.
    -> 이것은 API 응답에는 필요하지 않다.
  2. API 응답을 위해서 HttpServletResponse직접 응답 데이터를 넣어주었다.😥
    -> 이것은 매우 불편하다.
    -> 스프링 컨트롤러에 비유하면 마치 과거 서블릿을 사용하던 시절로 돌아간 것 같다.😓
  3. '특정 컨트롤러에서만' 발생하는 예외를 별도로 처리하기 어렵다.

Q. 문제 발생...

1) 회원을 처리하는 컨트롤러에서 발생하는 RuntimeException 예외와 2) 상품을 관리하는 컨트롤러에서 발생하는 동일한 RuntimeException 예외를 서로 다른 방식으로 처리하고 싶다면 어떻게 해야할까?

A. @ExceptionHandler을 써라...

스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler 라는 애노테이션을 사용하는 매우 편리한 예외 처리 기능을 제공

@ExceptionHandler란..
1. 스프링은 ExceptionHandlerExceptionResolver 를 기본으로 제공
2. 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높다


@ExceptionHandler 어노테이션 사용

  • ExceptionResolver는 예외가 발생하면, 컨트롤러 안에 @ExceptionHandler 를 사용하는 메서드가 있는지 확인
    => 이 예외를 해당 메서드가 처리하도록 만들어주고, HTTP 요청에 정상 응답(200)한다.
  • 만약 다른 HTTP 오류코드를 반환하고 싶다면 예외처리 메서드에 @ResponseStatus를 추가로 사용


@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());
    }

    //UserExcpeiton 을 예외하는 곳임.
    //매개변수에 들어오는 애 확인
    @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;
    }
}
  1. @ExceptionHandler 애노테이션을 선언하고,
  2. 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
  • 해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식
    클래스는 모두 잡을 수 있다.

@ExceptionHandler 기능들..

우선순위

  • 스프링의 우선순위는 항상 자세한 것이 우선권을 가진다
  • @ExceptionHandler지정한 부모 클래스는 자식 클래스까지 처리할 수 있다.
    -> 따라서 자식예외 가 발생하면 부모예외처리() , 자식예외처리() 둘다 호출 대상이 된다.
    => 그런데 둘 중 더 자세한 것이 우선권을
    가지므로 '자식예외처리()' 가 호출

다양한 예외

다음과 같이 다양한 예외를 한번에 처리할 수 있다.

예외 생략

@ExceptionHandler예외를 생략할 수 있다.
=> (생략하면) 메서드 파라미터의 예외로 지정된다.



과정 설명

실행 흐름

  • 컨트롤러를 호출한 결과 IllegalArgumentException 예외가 컨트롤러 밖으로 던져진다.
  1. 예외가 발생했으로 ExceptionResolver 가 작동한다.
    -> 가장 우선순위가 높은
    ExceptionHandlerExceptionResolver 가 실행된다.

  2. ExceptionHandlerExceptionResolver 는 '해당 컨트롤러'에 IllegalArgumentException처리할
    수 있는 ' @ExceptionHandler '가 있는지
    확인
    한다.

  3. illegalExHandle() 를 실행한다. @RestController 이므로 illegalExHandle() 에도 @ResponseBody 가 적용된다.

  4. 따라서 HTTP 컨버터가 사용되고, 응답이 다음과 같은 'JSON'으로 반환된다.
    -> @ResponseStatus(HttpStatus.BAD_REQUEST) 를 지정했으므로 HTTP 상태 코드 400으로 응답한다.




@ControllerAdvice

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

ExControllerAdvice


@Slf4j
@RestControllerAdvice//(basePackages = "hello.exception.api")
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", "내부 오류");
    }
}

ApiExceptionV2Controller


@Slf4j
@RestController
public class ApiExceptionV2Controller { //이제 더 예외에 대해 처리한 부분이 x

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

}

@ControllerAdvice

  • @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할
  • @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
  • @RestControllerAdvice@ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.
    -> @Controller , @RestController 의 차이와 같다.
profile
초짜 백엔드 개린이

0개의 댓글