RE-Heat 개발자 일지

스프링 MVC 2편 - [9] API 예외 처리 본문

백엔드/스프링

스프링 MVC 2편 - [9] API 예외 처리

RE-Heat 2023. 7. 27. 17:58

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

인프런 김영한님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.

 

[1] API 예외처리 - 시작

목표 : API 예외처리는 어떻게 해야 할까?

HTML 페이지는 4xx, 5xx 같은 오류페이지만 있으면 된다. 반면 API는 각 오류 상황에 적합한 응답 스펙을 정하고 JSON으로 데이터를 내려주어야 해 비교적 복잡한 편이다.

 

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

회원 조회 기능을 만들고, id 파라미터에 ex가 들어오면 RuntimeException 예외가 발생하도록 코드 작성

 

POSTMAN으로 테스트

정상호출하면 JSON값으로 반환되나, 예외가 발생하면 html이 호출되는 것을 확인할 수 있다. 하지만 우리가 원하는 건 예외가 발생해도 JSON 형식으로 반환되는 것이다.

 

이 문제를 해결하려면 오류 페이지 컨트롤러도 수정이 필요하다.

 

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

① HTTP Header의 Accept 값이 application/json이면 produces = MediaType.APPLICATION_JSON_VALUE가 붙은 메서드를 우선 호출한다. 

② 응답 데이터를 위해 Map을 만들고 status, message키에 값을 할당. Jackson 라이브러리가 Map을 JSON 구조로 변환해 줌.

③ ResponseEntity를 사용해 응답하기 때문에 메시지 컨버터가 동작, 클라이언트에 JSON이 반환된다.

 

 

POSTMAN - http://localhost:8080/api/members/ex 호출

HTML 페이지가 아닌 JSON 형식으로 응답되는 것을 확인할 수 있다.

 

 

[2] 스프링부트 기본 오류 처리

API 예외 처리도 스프링부트가 제공하는 기본 오류 방식(BasicErrorController)을 사용할 수 있다. 

 

BasicErrorController

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

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

① HTTP 헤더의 Accept에 text/html값이 오면 errorHtml()을 호출해 view를 제공한다.

② 그 외에는 error()가 호출되고 ResponseEntity로 Http Body에 JSON 데이터를 반환한다.

 

POSTMAN 실행화면

Accept가 text/html이면 html페이지를, application/json이면 BasicErrorController에서 제공하는 JSON 형식을 반환하는 것을 확인할 수 있다.

 

POSTMAN - 자세한 오류 정보 추가

다음 옵션들을 설정하면 더 자세한 오류 정보를 추가할 수 있다.

  • server.error.include-binding-errors=always
  • server.error.include-exception=true
  • server.error.include-message=always
  • server.error.include-stacktrace=always

단, 보안상 권장하지 않는다.

 

 

[3] HandlerExceptionResolver 시작

목표 : 예외가 발생했을 때 서블릿을 넘어 WAS까지 전달되면 WAS는 모든 예외의 상태코드를 500으로 처리한다. 그런데 우리는 발생하는 예외에 따라 400, 404 등 다른 상태 코드를 보내고 싶다. 아울러 오류 메시지, 형식 등을 API마다 다르게 처리하는 게 목표다.

 

ApiExceptionController - bad가 넘어올 경우 IllegalArgumentException 터지도록 수정

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

이렇게 다른 예외 상황이 발생해도 POSTMAN을 실행해 보면 상태코드가 500이 뜨는 걸 확인할 수 있다.

 

■ HandlerExceptionResolver(== ExceptionResolver)

스프링 MVC는 컨트롤러 밖으로 예외가 발생할 때 이 예외를 처리할 수 있는 HandlerExceptionResolver 인터페이스를 제공한다. 

 

① ExceptionResolver 적용 전

 

② ExceptionResolver 적용 후

 

 

MyHandlerExceptionResolver

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {

        log.info("call resolver", ex);

        try {
            if (ex instanceof IllegalArgumentException){
                log.info("IllegalAtrumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e){
            log.error("resolver ex", ex);
        }

        return null;
    }
}

① HandlerExceptionResolver를 상속받아 구현

    handler : 핸들러(컨트롤러) 정보

    Exception ex : 핸들러에서 발생한 예외

② Exception이 IllegalArgumentException인지 확인하고 맞으면 상태코드를 500 대신 400으로 지정한다. 이후 빈 ModelAndView를 반환한다. 

※ ExceptionResolver가 ModelAndView를 반환하는 건 try, catch 하듯 Exception을 처리해 정상 흐름처럼 변경하는 게 목적이다.

 

Webconfig - MyHandlerExceptionResolver 등록

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

 

■ 반환 값에 따른 동작방식

  • 빈 ModelAndView : 위 구현체처럼 반환하면 뷰를 렌더링하지 않고 정상 흐름으로 서블릿이 리턴됨.
  • ModelAndView 지정 : 뷰를 렌더링한다.
  • null : null을 반환하면 ExceptionResolver를 찾아 실행. 처리할 수 없으면 기존에 발생한 예외를 서블릿 밖으로 보낸다.

 

■ ExceptionResolver 활용법

① 예외 상태 코드 변환

    - 예외를 response.sendError() 호출로 변경해 서블릿에서 상태 코드에 다른 오류 처리하도록 위임

    - WAS는 서블릿 오류 페이지를 찾아서 내부 호출. 스프링부트가 기본 설정한 /error가 호출됨.

② 뷰 템플릿 처리 

    - ModelAndView에 값을 채워서 예외에 따른 새로운 오류 화면 뷰를 렌더링해 고객에게 제공

③ API 응답처리 

    - response.getWriter(). println("hello");처럼 HTTP 응답 바디에 직접 데이터를 넣어주는 것도 가능하다.

    - JSON으로 응답하면 API 응답 처리를 할 수 있다.

 

 

[4] HandlerExceptionResolver 활용

예외가 발생할 때 WAS까지 예외가 던져지고 WAS에서 /error를 호출하는 과정은 복잡하다. ExceptionResolver를 활용하면 예외가 발생했을 때 WAS까지 가지 않고 문제를 해결할 수 있다.

 

사용자정의 예외를 추가해 확인해 보자.

 

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 - 예외추가

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

 

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");//템플릿 에러에 500.html 호출
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}

HTTP 헤더의 Accept값이 application/json이면 JSON, 아니면 HTML 오류 페이지를 보여준다.

 

WebConfig - UserHandlerExceptionResolver 추가

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

 

POSTMAN 실행화면

 

ExceptionResolver를 사용하면 서블릿 컨테이너까지 예외가 전달되지 않고 스프링 MVC에서 예외 처리가 끝이 난다. 

but 이를 구현하려면 상당히 복잡한 과정을 거쳐야 한다.

 

스프링은 이런 기능을 좀 더 쉽게 구현할 수 있도록 3가지 ExceptionResolver를 제공한다.

 

 

[5] 스프링이 제공하는 ExceptionResolver1

■ 스프링부트가 기본으로 제공하는 ExceptionResolver

① ExceptionHandlerExceptionResolver

    @ExceptionHandler를 처리한다. API 예외처리는 대부분 이 기능을 쓴다.

② ResponseStatusExceptionResolver

    HTTP 상태 코드를 지정해 준다.

    ex) @ReseponseStatus(value = HttpStatus.NOT_FOUND)

③ DefaultHandlerExceptionResolver

    스프링 내부 기본 예외를 처리한다.

3이 우선순위가 가장 낮다.

 

ResponseStatusExceptionResolver

① @ResponseStatus가 달려있는 예외

 

BadRequestException

@ResponseStatus(code= HttpStatus.BAD_REQUEST)//400
public class BadRequestException extends RuntimeException{ //500

}

1] BadRequestException 예외가 컨트롤러 밖으로 넘어가면 ResponseStatusExceptionResolver가 해당 애노테이션을 확인해 오류 코드를 HttpStatus.BAD_REQUEST(==400)으로 변경하고 메시지도 담는다.

 

2] ResponseStatusExceptionResolver 코드를 확인하면 response.sendError(statusCode, 
resolvedReason)를 호출하는 것을 확인할 수 있다.

 

3] 메시지 기능도 제공한다.

@ResponseStatus의 reason에 메시지를 담을 수 있다.

 

messages.properties도 사용 가능

 

② ResponseStatusException 예외

@ResponseStatus는 개발자가 직접 변경할 수 없는 예외엔 적용할 수 없다. 우리가 만든 Exception엔 붙일 수 있지만, 내가 코드를 수정할 수 없는 라이브러리의 예외 코드엔 적용하기 어렵다는 뜻이다.

 

이럴 땐 ResponseStatusException의 예외를 사용하면 된다.

 => ResponseStatusException()에 status, reason, Throwable cause를 넣어 줌.

 

 

[6] 스프링이 제공하는 ExceptionResolver2

목표 : DefaultHandlerExceptionResolver에 대해 알아보자.

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

 

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, 이 경우 그냥 두면 서블릿 컨테이너까지 오류가 올라가 결과적으로 500 오류가 발생한다. 그런데 파라미터 바인딩은 대부분 클라이언트가 HTTP 요청 정보를 잘못 호출해서 발생하는 문제이다. HTTP에서는 이런 경우엔 HTTP 상태 코드 400을 사용하도록 되어있다.


DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태코드 400 오류로 변경한다.

원래 500이 나와야 하나, DefaultHandlerExceptionResolver가 400으로 바꿔준 것을 확인할 수 있다.

 

[5], [6]과 같은 방식으로 HandlerExceptionResolver를 직접 사용하는 건 매우 번거로운 일이다.

이에 스프링은 개발자가 API 예외 처리하기 쉽도록 @ExceptionHandler라는 기능을 제공한다.

 

 

[7] @ExceptionHandler

 

■ API 예외처리의 어려운 점

  • HandlerExceptionResolver는 ModelAndView를 반환해야 했는데, 이는 API 응답엔 필요하지 않은 일이다.
  • API 응답을 위해 HttpServletResponse에 직접 응답 데이터를 넣어주는 건 번거롭다.
  • 특정 컨트롤러에서 발생하는 예외를 별도로 처리하기 어렵다.
    • ex) 회원관리와 상품관리에서 발생한 RuntimeException을 다르게 처리하기 어렵다.

 

스프링은 위와 같은 문제를 해결하기 위해 @ExceptionHandler를 사용하는 편리한 예외 처리 기능을 제공한다. 이게 ExceptionHandlerExceptionResolver이며 이 기능은 스프링이 제공하는 ExceptionResolver 중에서도 우선순위가 가장 높다. 실무에선 대부분 이 기능을 사용한다.

 

ErrorResult

@Data
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

예외가 발생했을 때 API 응답으로 사용하는 객체 정의

 

 

ApiExceptionV2Controller

@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 ApiExceptionController.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 ApiExceptionController.MemberDto(id, "hello " + id);
    }

    @Data
    @AllArgsConstructor
    static class MemberDto {
        private String memberId;
        private String name;
    }
}

① @ExceptionHandler 선언 후 해당 메서드에서 처리하고 싶은 예외를 지정하면 된다.

    ex) @ExceptionHandler(IllegalArgumentException.class) : IllegalArgumentException 또는 하위 자식 클래스 모두 처리

② ErrorResult를 리턴하면 그 값에 맞게 JSON으로 반환됨

③ 상태코드를 변경하지 않으면 WAS로 정상응답이 나가므로 상태코드는 200. 잘못된 입력 값에 맞는 400 에러를 보내고 싶으면 @ResponseStatus(HttpStatus.BAD_REQUEST)를 붙여주면 된다.

④ UserException 처리 : @ExceptionHandler에 예외를 지정하지 않으면 해당 메서드의 파라미터 예외를 사용한다.

 

■ 우선순위

스프링은 자세한 것이 항상 우선권을 가진다. 자식클래스 > 부모클래스

ex) 자식예외 발생() -> 부모예외처리(), 자식예외처리() 둘 다 호출 대상 -> 둘 중 더 자세한 자식예외처리가 호출

 

■ 다양한 예외

@ExceptionHandler({AException.class, BException.class})
public String ex(Exception e) {
    log.info("exception e", e);
}

A, B Exception 등 다양한 예외를 한 번에 처리할 수도 있다.

 

 

[8] @ControllerAdvice

목표: 정상 코드와 예외처리 코드 분리 및 원하는 컨트롤러에 예외처리 적용하는 방법을 알아보자.

 

ExControllerAdvice & ApiExceptionV2Controller

① 예외처리 코드를 ExControllerAdvice로 분리

② @ControllerAdvice는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder 기능을 부여하는 역할

    @RestControllerAdivce는 @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 {
}

① @RestController가 붙은 컨트롤러를 대상으로 예외처리

② 그 패키지에 들어있는 모든 컨트롤러를 대상으로 예외처리

③ 특정 클래스를 지정. 단, 특정 클래스를 지정하지 않으면 모든 컨트롤러에 적용된다.