관리 메뉴

Rootable의 개발일기

API 예외 처리 본문

Spring

API 예외 처리

dev-rootable 2023. 8. 1. 17:00

📌 BasicErrorController의 한계

 

🔎 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 상태 코드를 커스터마이징(변경)할 수 있다.

 

🔎 적용 전과 후 동작

 

ExceptionResolver 적용 전

 

ExceptionResolver 적용 후

 

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을 처리해서 정상 흐름처럼 변경하는 것이 목적이다. 이름 그대로 예외를 해결하는 것이 목적이다.

 

위 코드는 다음과 같이 동작한다.

 

  1. ApiExceptionController 호출 후 IllegalArgumentException 예외가 컨트롤러 밖으로 던져짐
  2. 예외가 발생했으므로 DispatcherServlet은 ExceptionResolver를 작동시켜 해결 시도
  3. IllegalArgumentException 예외를 처리하는 MyHandlerExceptionResolver를 실행
  4. 400 응답 코드를 담은 sendError를 호출하여 서블릿 컨테이너까지 에러 전달
  5. 서블릿 컨테이너에서 BasicErrorController를 호출
  6. BasicErrorController는 클라이언트의 Accept에 따라 뷰 또는 JSON 응답

 

Accept = application/json

 

Accept = text/html

 

🔎 반환 값에 따른 동작 방식

 

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

 

JSON 결과

 

HTML 결과

 

클라이언트 Accept에 따라 2가지 결과가 나왔다. 그리고 HTTP 응답 스펙을 직접 정의하여 ExceptionResolver에서 예외 처리가 끝나고, WAS 입장에서는
자신이 예외를 받지 않았으므로 정상 처리된 것으로 본다.

 

📌 스프링이 제공하는 ExceptionResolver

 

스프링 부트가 기본으로 제공하는 ExceptionResolver는 3가지가 있다. (우선순위는 숫자가 낮을수록 높다)

 

  1. ExceptionHandlerExceptionResolver
  2. ResponseStatusExceptionResolver
  3. 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()와 매칭되어 동작한다.

 

@ResponseStatus 결과

 

✔ 메시지 기능 활용

 

reason을 MessageSource에서 찾는 기능도 제공한다.

 

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

 

@ResponseStatus(value = HttpStatus.BAD_REQUEST, reason = "error.bad")
public class BadRequestException extends RuntimeException {
}

 

@ResponseStatus 메시지 기능

 

✔ 조건에 따라 동적 변경

 

@ResponseStatus는 애노테이션을 사용하기 때문에 특정 조건에 따라 로직을 작성하기 어렵다. 이때는 ResponseStatusException 예외를 사용하면 된다.

 

    @GetMapping("/api/response-status-ex2")
    public String responseStatusEx2() {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());
    }

 

위 코드를 통해 특정 예외(IllegalArgumentException)가 발생했을 때, 특정 상태 코드(NOT_FOUND)로 변경하고 원하는 메시지를 출력할 수 있다.

 

ResponseStatusException 사용

 

🔎 DefaultHandlerExceptionResolver

 

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

 

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않아 내부에서 TypeMismatchException이 발생하는 경우다.

 

DefaultHandlerExceptionResolver.handleTypeMismatch 내부 코드를 보면 response.sendError(HttpServletResponse.SC_BAD_REQUEST)가 있다. 따라서 sendError()를 통해 상태 코드를 변경할 수 있다.

 

    @GetMapping("/api/default-handler-ex")
    public String defaultException(@RequestParam Integer data) {
        return "ok";
    }

 

DefaultHandlerExceptionResolver 결과

 

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

 

  1. 컨트롤러를 호출하여 IllegalArgumentException예외가 컨트롤러 밖으로 던져진다.
  2. 예외가 발생했으므로 ExceptionResolver가 동작한다.
  3. 가장 우선순위가 높은 ExceptionHandlerExceptionResolver가 실행된다.
  4. ExceptionHandlerExceptionResolver는 해당 컨트롤러에서 IllegalArgumentException을 처리할 수 있는 @ExceptionHandler가 있는지 확인한다.
  5. illegalExHandle()을 실행
  6. @RestController이므로 @ResponseBody가 적용되어 HTTP 컨버터가 동작하여 JSON 형태로 반환한다.
  7. @ResponseStatus(HttpStatus.BAD_REQUEST)를 지정했으므로, HTTP 상태 코드 400으로 응답한다.

 

illegalExHandle 결과

 

✔ 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 응답 코드를 로직을 통해 동적으로 변경할 수 있다.

 

userExHandle 결과

 

✔ 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으로 발생한 예외를 처리한다.

 

exHandle 결과

 

🔎 @ExceptionHandler 장점

 

  1. 컨트롤러에 바로 사용할 수 있어 그 자리에서 처리 흐름이 끝난다. (WAS로 예외를 전달하고, 재호출하는 과정 생략)
  2. @ResponseStatus와 함께 사용할 수 있어 상태 코드 변경이 가능하다.
  3. @ExceptionHandler에 특정 예외를 넣을 수 있어 예외마다 API 응답 처리가 가능하다.
  4. 반환 타입이 자유롭다.
  5. 여러 예외를 한 번에 처리할 수 있다.
  6. @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