-> 블로그 이전

[Spring] API 예외 처리

2022. 7. 12. 17:40Language`/Spring

 

[Spring] 예외 처리 & 오류 페이지

Servlet 예외 처리 1. Exception Exception은 중간에서 "catch"를 통해서 잡을 수 있고, 아니면 계속 "throws"를 통해서 던질 수 있다 웹 애플리케이션은 Request마다 별도의 thread가 할당되고 이 thread는 서블..

cs-ssupport.tistory.com

이전에는 예외가 발생했을 때 "오류 페이지"를 Client에게 내려주는 여러 방법들을 알아보았다

예외가 터지면 WAS에서 다시 "오류 페이지를 위한 REQUEST"를 보낸다
이 때 Request의 DispatcherType은 "ERROR"

 

하지만 서버가 앱 개발자와 통신을 하거나, 프론트엔드 API형식의 통신을 할 경우는 예외 처리가 달라진다

왜냐하면 이러한 API 통신의 경우 단순히 오류페이지를 내려주는게 아니라 정해진 규약에 맞춰서 에러 메시지를 파싱해서 API로 응답해야 하기 때문이다

예를 들어서 안드로이드 앱 개발자가 어떤 API를 Request했는데 서버측에서 처리하던 도중 예외가 터졌다고 하자
>> 여기서 서버에서 단순히 오류 페이지를 내려준다는 것은 말이 안되고 이 오류 페이지를 받은 앱 개발자 또한 왜 이러한 response가 왔는지 의문이 생길 것이다.

이전에 스프링 부트의 예외 처리를 위한 ErrorMvcAutoConfiguration클래스는 위와 같은 2가지 메소드를 가지고 있다고 하였다

errorHtml은 말그대로 HTML을 오류 페이지로써 응답해주는 것이고, error는 HTML이 아닌 API 통신에 대해서 오류를 ResponseEntity에 담아서 내려주는 것이다

 

1. 서블릿 오류 페이지 & JSON 응답

@RequestMapping("/api/member/{memberId}")
public Member api(@PathVariable String memberId){
    if(memberId.equals("exception")){
        throw new RuntimeException("멤버 ID 오류");
    }

    return new Member(memberId, "홍길동");
}
@GetMapping("/error-page/500")
public String error500(){
    return "error-page/500";
}

간단하게 @RestController를 통해서 정상적인 API 통신과 예외가 발생한 API 통신의 response를 비교해보자

정상적인 API 통신은 서버에서 JSON을 내려주었지만, 예외가 터지면 WAS에서 다시 오류 페이지를 Request하는 것을 확인할 수 있다

  • 스프링 부트를 통한 오류 페이지가 아니라 우리가 직접 구현한 서블릿 오류 페이지를 기준으로 설계
@Component
public class ErrorConfig implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404"); // response.sendError
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500"); // response.sendError
        ErrorPage errorPageException = new ErrorPage(RuntimeException.class, "/error-page/500"); // Exception

        factory.addErrorPages(errorPage404, errorPage500, errorPageException);
    }
}

>> JSON에 대한 응답이 HTML인 문제를 해결하려면 오류 페이지에 대한 컨트롤러도 JSON으로 Response 하도록 설계해야 한다

@GetMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> error500(HttpServletRequest request){
    Map<String, Object> result = new HashMap<>();

    Object statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
    Exception exception = (Exception) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION);

    result.put("statusCode", statusCode);
    result.put("message", exception.getMessage());

    return new ResponseEntity<>(result, HttpStatus.valueOf((int) statusCode));
}

JSON에 대한 Request를 JSON으로 response해주기 위해서 @GetMapping내부 속성 중 하나인 "produces"를 application/json으로 설정해주었다


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

스프링 부트에서는 위의 BasicErrorController의 두 메소드가 담당하게 된다

그리고 스프링부트의 default 설정은 "오류 발생시 /error를 오류페이지로 요청"하는 것이다

  • server.error.path로 수정이 가능하며 default는 "/error"이다

그러면 이제 "/api/member/exception"으로 request를 보내고 의도적으로 오류를 발생시켜보자

이 response가 BasicErrorController의 error()메소드의 기본 API 응답이다

<옵션 추가 설정>
sever.error.include-binding-errors=always
sever.error.include-exception=true
sever.error.include-message=always
sever.error.include-stacktrace=always

>> 이러한 옵션은 더 자세한 예외 정보를 보여줄 수 있지만 보안상 위험하기 때문에 로그로만 확인하자

 

HandlerExceptionResolver

기본적으로 예외가 터져서 서블릿밖으로 넘어가서 최종적으로 WAS에 전달되면 서버내부에서 어떤 종류의 예외이든 상관없이 WAS는 500으로 예외를 처리하게 된다

 

각 예외(400, 404, 502, ...)마다 서로 다른 상태코드로 처리하고 싶으면 어떻게 해야 할까?
@GetMapping("/api/member/{memberId}")
public Member api(@PathVariable String memberId) {
    if(memberId.equals("no")){
        throw new NoSuchElementException("없는 사용자");
    }
    if(memberId.equals("parameterException")){
        throw new IllegalArgumentException("잘못된 파라미터");
    }

    return new Member(memberId, "홍길동");
}

IllegalArgumentException에 대해서 Client가 처음부터 데이터를 잘못보냈기 때문에 서버측에서는 400에러로 처리하고 싶다

하지만 response로 보내진 API는 500에러로 처리된 것을 알 수 있다

>> Spring에서는 예외를 해결하고 동작 방식을 변경하기 위한 "HandlerExceptionResolver"를 제공한다

ExceptionResolver 적용 전
ExceptionResolver 적용 후


HandlerExceptionResolver 인터페이스는 내부에 resolveException이라는 메소드 하나를 가지고 있고 이 메소드는 "ModelAndView"를 return한다

 

이를 토대로 customExceptionResolver를 만들어보자

public class CustomExceptionResolver implements HandlerExceptionResolver {
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try{
            if(ex instanceof IllegalArgumentException){
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        return null;
    }
}

resolveException에서 ModelAndView를 return할때는 총 3가지 방식으로 동작할 수 있다

1. 빈 ModelAndView → new ModelAndView()

빈 ModelAndView를 반환한다는 것은 그냥 "예외 흐름 ~> 정상 흐름"으로 흐름을 바꿔서 response하는 것이다

 

2. ModelAndView 지정 → new ModelAndView([실제 View Template])

실제 View를 반환하는 것은 Model, View등의 정보를 지정해서 "뷰를 렌더링"한다

 

3. null

null을 반환하면 "다음 ExceptionResolver"를 찾아서 실행한다

 

물론 API응답에 대한 처리를 하려면 response의 getWriter를 통해서 직접 데이터를 작성해서 응답해주는 방법도 있다
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void extendHandlerExceptionResolvers(List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new CustomExceptionResolver());
    }
}

직접 구현한 HandlerException은 WebMvcConfigurer의 "extendHandlerExceptionResolvers"를 Override해서 resolvers에 add해주면 된다

 

이제 IllegalArgument에 대해서는 400으로 Response하는 것을 확인하였다

 

원래대로라면 예외가 터지면 WAS까지 예외가 던져지고 WAS에서는 예외를 위한 "예외 페이지"를 따로 Reqeust해서 보여주었다
하지만 "ExceptionResolver"를 활용하면 예외가 발생했을 때 흐름이 역으로 오는동안 처리할 수 있기 때문에 여기서 끝낼 수 있다

스프링이 제공해주는 ExceptionResolver

개발자에게 수많은 편리함을 제공해주는 Spring은 ExceptionResolver까지 제공해준다

1순위 : ExceptionHandlerExceptionResolver
2순위 : ResponseStatusExceptionResolver
3순위 : DefaultHandlerExceptionResolver
....

1. ResponseStatusExceptionResolver

ResponseStatusExceptionResolver는 예외에 따라서 "HTTP StatusCode"를 지정해주는 역할을 한다

그리고 다음 2가지 경우를 처리한다

  1. @ResponseStatus가 달려있는 예외
  2. ResponseStatusException 예외
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청입니다 - 404")
public class BadRequestException extends RuntimeException{
}
@GetMapping("/bad")
public String bad(){
    throw new BadRequestException();
}

하지만 @ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다

  • IllegalArgument, NoSuchElement, ...

이럴 때는 @ResponseStatus가 아닌 ResponseStatusException예외를 사용해야 한다

@GetMapping("/parameterException")
public String pE(){
    throw new ResponseStatusException(
            HttpStatus.BAD_REQUEST,
            "잘못된 파라미터 요청입니다",
            new IllegalArgumentException()
    );
}

 

 

2. DefaultHandlerExceptionResolver

DefaultHandlerExceptionResolver는 스프링 내부에서 발생하는 스프링 예외를 처리한다

 

예를들어서 POST로 어떤 데이터가 도착했을 때 이를 바인딩하는 DTO에서 typemiss에 의해서 바인딩이 안될 때 TypeMissmatchException이 발생한다.

이 경우 예외가 발생했으니까 예외를 처리하지 않으면 WAS까지 올라가게 된다. 그런데 WAS에서는 Client가 데이터를 잘못보내서 typemiss 예외가 발생한 것을 "이거는 서버에서부터 올라온 예외니까 서버 오류네"라고 생각해서 500 에러를 던지게 된다

>> 이 경우 DefaultHandlerExceptionResolver는 알아서 400 오류로 변경해서 처리해준다

이렇게 스프링 내부에서 발생하는 스프링 예외에 대한 수많은 처리가 명시되어 있다

 

3. ExceptionHandlerExceptionResolver → @ExceptionHandler

ExceptionHandlerExceptionResolver는 스프링에서 제공해주는 최후의 API 예외 처리 기능이다

Spring은 API 예외 처리를 위해 @ExceptionHandler라는 애노테이션을 제공해준다 

  • 거의 모든 API 예외 처리는 @ExceptionHandler를 통해서 처리한다
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ErrorResult {
    private String code;
    private String message;
}

먼저 API 예외 처리 Response 규약을 만들어주었다

 

@ExceptionHandler는 특정 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다

@ResponseStatus(code = HttpStatus.BAD_REQUEST)
@ExceptionHandler(IllegalArgumentException.class)
public ErrorResult error1(IllegalArgumentException e) {
    return new ErrorResult("BAD-PARAMETER", "잘못된 파라미터 요청입니다");
}

이렇게 @ExceptionHandler 내부에다가 처리할 예외 유형을 적어주면 된다

그리고 @ResponseStatus와 함께 사용해서 상태코드도 지정해줄 수 있다

 

@ResponseStatus(code = HttpStatus.BAD_REQUEST)
@ExceptionHandler
public ErrorResult error2(BadRequestException e) {
    return new ErrorResult("BAD-REQUEST", "잘못된 요청입니다");
}

@ExceptionHandler 내부에 처리할 예외 유형을 지정해주지 않으면 해당되는 메소드의 파라미터에 존재하는 예외를 자동으로 처리해준다

 

@ExceptionHandler
public ResponseEntity<ErrorResult> error3(ArrayIndexOutOfBoundsException e) {
    ErrorResult errorResult = new ErrorResult("BAD-INDEX", "인덱스 초과");
    return new ResponseEntity<>(
            errorResult,
            HttpStatus.BAD_REQUEST
    );
}

@ResponseStatus를 사용하지 않고 ResponsEntity를 반환할때 직접 상태코드도 지정할 수 있다

 

@ExceptionHandler에 지정한 예외는 해당 예외 뿐만 아니라 자식 예외까지 전부 처리해준다
그리고 예외는 내부에 여러개를 지정해서 한번에 처리하게 할 수도 있다

@GetMapping("/{error}")
public void errorController(@PathVariable String error){
    if(error.equals("parameter")){
        throw new IllegalArgumentException();
    }

    if(error.equals("request")){
        throw new BadRequestException();
    }

    if(error.equals("index")){
        throw new ArrayIndexOutOfBoundsException();
    }
}

이제 원하는 형식대로 JSON Error Response가 도착한 것을 확인할 수 있다

 

※ @ExceptionHandler 동작 과정

1. 컨트롤러에서 예외가 터짐

2. 예외가 터졌으므로 흐름이 역행되다가 ExceptionResolver가 작동된다

3. ExceptionResolver는 터진 예외를 처리할 수 있는 @ExceptionHandler가 있는지 확인한다

  • ExceptionHandlerExceptionResolver가 1순위이므로

4. 처리 가능한 @ExceptionHandler를 찾으면 해당 메소드를 실행한다

  • 위에서는 @RestController로 설정했기 때문에 JSON이 그대로 return되고, 만약에 @Controller로 설정했으면 View가 return될 것이다
  • 그리고 @RestController이므로 내부적으로 HTTP Message Converter도 동작한다

5. @ResponseStatus에 지정된 코드나 ResponseEntity에 지정된 상태코드로 최종 Response한다


※ @ControllerAdvice & @RestControllerAdvice

위에서는 하나의 컨트롤러내부에 예외를 처리하는 로직까지 추가적으로 작성해주었다

>> 예외 처리를 담당하는 클래스로 분리해보자

  • @Controller의 예외를 처리하면 해당 클래스에 @ControllerAdvice를 붙이기
  • @RestController의 예외를 처리하면 해당 클래스에 @RestControllerAdvice를 붙이기
@RestControllerAdvice
public class ErControllerAdvice {
    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult error1(IllegalArgumentException e) {
        return new ErrorResult("BAD-PARAMETER", "잘못된 파라미터 요청입니다");
    }

    @ResponseStatus(code = HttpStatus.BAD_REQUEST)
    @ExceptionHandler
    public ErrorResult error2(BadRequestException e) {
        return new ErrorResult("BAD-REQUEST", "잘못된 요청입니다");
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> error3(ArrayIndexOutOfBoundsException e) {
        ErrorResult errorResult = new ErrorResult("BAD-INDEX", "인덱스 초과");
        return new ResponseEntity<>(
                errorResult,
                HttpStatus.BAD_REQUEST
        );
    }
}
@RestController
public class ExceptionResolverController {
    @GetMapping("/{error}")
    public void errorController(@PathVariable String error){
        if(error.equals("parameter")){
            throw new IllegalArgumentException();
        }

        if(error.equals("request")){
            throw new BadRequestException();
        }

        if(error.equals("index")){
            throw new ArrayIndexOutOfBoundsException();
        }
    }
}

예외처리를 따로 분리하니까 로직도 더 깔끔하고 어떤 역할을 하는지 인식도 훨씬 더 잘된다

 

※ @Controller/RestControllerAdvice 내부 속성

단순히 @Controller/RestControllerAdvice만 붙여준다면 모든 컨트롤러에 대해서 글로벌하게 적용된다

value&basePackages는 적용할 패키지(들)를 명시해준다

@ControllerAdvice(value = "org.example.controllers")
→ "org.example.controllers"하위의 모든 클래스들에 대해서 적용된다

@ControllerAdvice(basePackages = {"org.my.pkg", "org.my.other.pkg"})
→ "org.my.pgk"와 "org.my.other.pkg" 하위의 모든 클래스들에 대해서 적용된다

@ControllerAdvice(value = {"org.my.pkg", "org.my.other.pkg"})
→ "org.my.pgk"와 "org.my.other.pkg" 하위의 모든 클래스들에 대해서 적용된다

>> 단순히 {}를 통해서 지정할 경우 default 속성으로 들어가는 것은 "value"이고 basePackages를 사용하려면 따로 명시를 해줘야 한다

  • @ControllerAdvice({"org.hello", "org.spring"}) → @ControllerAdvice(value = {"org.hello", "org.spring"})

 

assignableTypes는 적용할 특정 클래스를 명시해준다

@ControllerAdvice(assignableTypes = A.class)
→ A.class에 대해서 적용

@ControllerAdvice(assignableTypes = {A.class, B.class, C.class})
→ A.class, B.class, C.class에 대해서 적용

 

annotations는 특정 애노테이션이 붙은 클래스에만 적용한다는 의미이다

@ControllerAdvice(annotations = RestController.class)
→ @RestController가 붙은 클래스에만 예외 처리 적용

@ControllerAdvice(annotations = {RestController.class, AllArgsConstructor.class})
→ @RestController와 @AllArgsConstructors가 붙은 클래스에 예외 처리 적용