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

지현·2022년 1월 6일
0

스프링

목록 보기
30/32

API 예외 처리 - 시작

오류 페이지는 단순히 고객에게 오류 화면을 보여주고 끝이지만, API 예외 처리는 각 오류 상황에 맞는 오류 응답 스펙을 자율적으로 정하고, JSON으로 데이터를 내려주어야 함

ErrorPageController.java

    @RequestMapping("/error-page/500")
	
    ...
    
    }

    @RequestMapping(value = "/error-page/500",
    				produces = MediaType.APPLICATION_JSON_VALUE)
    // 위의 컨트롤러와 같은 URL을 처리하는 컨트롤러여도 produces에 따라
    // 클라이언트의 accept 타입이 application/json인 경우에는 이 컨트롤러가 호출됨
    
    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));

    }
  • 오류 페이지 컨트롤러가 JSON 응답을 할 수 있도록 컨트롤러 생성
  • @RequestMapping도 자세한게 더 높은 우선순위를 가짐

스프링 부트 기본 오류 처리

  • API 예외 처리도 스프링 부트가 제공하는 기본 오류 방식을 사용할 수 있음 -> BasicErrorController
  • BasicErrorController에서 클라이언트의 accpet 타입이 text/html이면 html을 제공해주고, application/json이면 json으로 오류 페이지를 제공해주도록 개발이 되어있음

주의

  • 스프링 부트가 제공하는 BasicErrorController는 HTML 페이지를 제공하는 경우에는 매우 편리, 하지만 API 오류 처리는 매우 세밀하고 복잡
  • BasicErrorController은 HTML 화면을 처리할 때 사용하고, API는 오류 처리는 @ExceptionHandler를 사용하는것이 좋음

API 예외 처리 - HandlerExceptionResolver 시작

IllegalArgumentException을 처리하지 못해서 컨트롤러 밖으로 넘어가는 일이 발생하면 상태코드 500으로 처리되는데, HTTP 상태코드를 400으로 처리하고싶다면 ?

HandlerExceptionResolver

컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver를 사용

  • ExceptionResolver 적용 전에는 컨트롤러에서 예외가 발생하면 postHandler를 호출하지 않고, afterCompletion호출 뒤 WAS에 예외 전달
  • ExceptionResolver 적용 후에는 ExceptionResolver에서 예외 해결 후 render 호출하고 afterCompletion 호출하고 정상응답으로 나갈 수 있음
  • ExceptionResolver로 예외를 해결해도 postHandle() 은 호출되지 않음

MyHandlerExceptionResolver.java

@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");
            //IllegalArgumentException가 발생하면 400오류로 바꾸겠다!
            response.sendError(HttpServletResponse.SC_BAD_REQUEST,ex.getMessage());
            return new ModelAndView();
            //빈값으로 넘기면 정상적인 흐름으로 WAS까지 return이 됨
            //500 error는 여기서 먹음
            }
        } catch (IOException e) {
                log.error("resolver ex",e);
            }
        return null;
        //null로 리턴 되면 원래 에러를 그냥 내보냄
    }
}
  • ExceptionResolver에서 ModelAndView를 반환하면, 흐름이 정상흐름처럼 변경

반환 값에 따른 동작 방식

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

WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {
    
    ...
    
    //HandlerExceptionResolver 등록
    @Override
    public void extendHandlerExceptionResolvers(
    			List<HandlerExceptionResolver> resolvers) {
        resolvers.add(new MyHandlerExceptionResolver());
    }
}
  • HandlerExceptionResolver를 등록 후 사용

ExceptionResolver 활용

  • 예외 상태 코드 변환
  • 뷰 템플릿 처리
  • API 응답 처리

HandlerExceptionResolver 활용

  • ExceptionResolver를 활용하면 예외가 발생하면 WAS까지 예외가 던져지고, WAS에서 오류 페이지 정보를 찾아서 다시 /error를 호출하는 과정을 생략할 수 있음
  • ExceptionResolver를 사용하면 컨트롤러에서 예외가 발생해도 ExceptionResolver에서 예외를 처리
  • 예외가 발생해도 서블릿 컨테이너까지 예외가 전달되지 않고, 스프링 MVC에서 예외 처리가 끝이나서 결과적으로 WAS 입장에서는 정상 처리가 됨
  • 서블릿 컨테이너까지 예외가 올라가면 복잡하고 지저분하게 추가 프로세스가 실행되는 반면 ExceptionResolver 를 사용하면 예외처리가 깔끔

API 예외 처리 - 스프링이 제공하는 ExceptionResolver

  • 스프링 부트는 다음과 같은 우선순위로 ExceptionResolver 기본으로 제공
  1. ExceptionHandlerExceptionResolver : @ExceptionHandler을 처리
  2. ResponseStatusExceptionResolver : HTTP 상태 코드를 지정
  3. DefaultHandlerExceptionResolver : 스프링 내부 기본 예외를 처리
  • 첫번째에서 해결이 되면 두번째, 세번째로 넘어가지 않으나 첫번째에서 해결이 되지 않고 null이 반환되면 두번째로 넘어감

ResponseStatusExceptionResolver

예외에 따라서 HTTP 상태 코드를 지정

  • @ResponseStatus가 달려있는 예외
  • ResponseStatusException 예외

@ResponseStatus가 달려있는 예외

@ResponseStatus(code= HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException{
}
  • @ResponseStatus 애노테이션을 적용하면 ResponseStatusExceptionResolver가 HTTP 상태 코드를 변경해줌
  • ResponseStatusExceptionResolver 코드를 확인해보면 결국 response.sendError(statusCode, resolvedReason)를 호출
  • sendError(400)를 호출했기 때문에 WAS에서 다시 오류 페이지/error를 내부 요청
  • 메시지 기능 : reason 을 MessageSource (messages.properties)에서 찾는 기능도 제공

ResponseStatusException 예외
@ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없기 때문에 이때는 ResponseStatusException 예외를 사용

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

DefaultHandlerExceptionResolver

  • 스프링 내부에서 발생하는 스프링 예외를 해결
  • 스프링 내부에서 터진 예외에 따라서 적절한 상태코드를 넣어서 해결
  • 예를 들어 파라미터 바인딩 시점에 타입이 맞지 않으면 내부에서 TypeMismatchException이 발생하는데, DefaultHandlerExceptionResolver는 이것을 500 오류가 아니라 HTTP 상태 코드 400 오류로 변경

API 예외 처리 - @ExceptionHandler

웹 브라우저에 HTML 화면을 제공할 때는 오류가 발생하면 BasicErrorController를 사용하는게 편리하지만 API는 로 매우 세밀한 제어가 필요

API 예외처리의 어려운 점

  • HandlerExceptionResolver를 구현하면 ModelAndView 를 반환해야 함
  • API 응답을 위해서 HttpServletResponse에 직접 응답 데이터를 넣어줘야함
  • 특정 컨트롤러에서만 발생하는 예외를 별도로 처리하기 어려움

@ExceptionHandler

  • 스프링은 API 예외 처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용하는 ExceptionHandlerExceptionResolver을 기본으로 제공
  • 기본으로 제공하는 ExceptionResolver 중에 우선순위도 가장 높음

실행 흐름
1. Exception 예외가 터짐
2. ExceptionResolver를 통해 예외 해결 시도
3. 우선순위에 따라 ExceptionHandlerExceptionResolver가 먼저 실행
4. ExceptionHandlerExceptionResolver는 컨트롤러에 해당 예외를 처리할 수 있는 @ExceptionHandler가 있으면 이 메서드를 대신 호출해줌
5. @RestController@ResponseBody 등 코드에 적혀있는 특성이 다 적용이 됨
6. 바로 정상흐름으로 바꿔서 정상적으로 return
7. 여기서 정상흐름으로 끝났기 때문에 다시 서블릿 컨테이너로 올라가지 않음

@Slf4j
@RestController
public class ApiExceptionV2Controller {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    //정상흐름으로 반환되기때문에 상태코드가 200이 되어서 이를 바꿔주기 위해 사용
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illigalExHandler(IllegalArgumentException e){
        log.error("[exceptionHandler] ex",e);
        return new ErrorResult("BAD",e.getMessage());
     }

     //예외를 생략하면 메서드 파라미터의 예외가 지정됨
     @ExceptionHandler
     public ResponseEntity<ErrorResult> userExHandler(UserException e){
        log.error("[exceptionHAndler] 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 exHandler(Exception e){
        log.error("[exceptionHandler] 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;
    }

}
  • @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정
  • 지정한 예외와 그 예외의 자식 클래스 까지 적용됨
  • @ExceptionHandler는 해당 컨트롤러 안에서 발생한 예외만 처리해줌, 다른 컨트롤러까지 영향을 주지 않음
  • 자식예외가 발생했을 때 부모예외처리(), 자식예외처리()가 있으면 자식예외처리()가 호출됨 -> 스프링은 항상 자세한 것이 우선권을 가짐
  • 다양한 예외를 한번에 처리할 수 있음
    @ExceptionHandler({AException.class, BException.class})

@ControllerAdvice

@ControllerAdvice 또는 @RestControllerAdvice를 사용하면 정상 코드와 예외 처리 코드를 분리할 수 있음

@Slf4j
@RestControllerAdvice //@ResponseBody+@ControllerAdvice
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illigalExHandler(IllegalArgumentException e){
        log.error("[exceptionHandler] ex",e);
        return new ErrorResult("BAD",e.getMessage());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorResult> userExHandler(UserException e){
        log.error("[exceptionHAndler] 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 exHandler(Exception e){
        log.error("[exceptionHandler] ex",e);
        return new ErrorResult("EX","내부 오류");
    }

}
  • 컨트롤러에 있던 것들을 여기에 모아서 처리하여 코드 분리 가능 > 예외를 깔끔하게 해결
  • @ControllerAdvice에 대상을 지정하지 않으면 모든 컨트롤러에 적용(글로벌 적용)
  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody

대상 컨트롤러 지정 방법

// 특정 애너테이션이 있는 컨트롤러에 적용
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// 해당 패키지 포함 하위 패키지에 있는 컨트롤러에 적용
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 직접 컨트롤러 지정
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class})
public class ExampleAdvice3 {}


출처
[인프런] 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

0개의 댓글