둘셋 개발!

[spring mvc 2편-웹 개발 활용 기술 - 7 ] API 예외처리 본문

SPRING/MVC

[spring mvc 2편-웹 개발 활용 기술 - 7 ] API 예외처리

23 2022. 2. 25. 01:50

html페이지로 예외처리를 하는 경우 4xx, 5xx 같은 오류 페이지만 전송하면 되지만

api 경우에는 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 보내야 한다

 

 

스프링 부트 기본 오류 처리

다음은 스프링 부트가 제공하는 BasicErrorController의 일부 이다. 

@RequestMapping
public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) {
	HttpStatus status = getStatus(request);
	if (status == HttpStatus.NO_CONTENT) {
		return new ResponseEntity<>(status);
	}
	Map<String, Object> body = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
	return new ResponseEntity<>(body, status);
}

ResponseEntity로 Http Body에 JSON 데이터 형태로 반환한다.

따라서 api 예외처리는 이렇게 BasicErrorController에서 JSON 데이터 형태로 반환한다.

@RequestMapping(produces = MediaType.TEXT_HTML_VALUE) 가 아닌 모든 경우는 이런 로직을 따른다.

다음은 JSON데이터 결과이다

 

여기서 문제점은 api마다, 각각의 컨트롤러나 예외마다 서로 다른 응답 결과(JSON)을 출력해야 하는 것이다.

이것을 지금부터 차근차근 해결해보도록 하자.


HandlerExceptionResolver 사용

예외가 발생해서 WAS까지 예외가 전달되면 상태코드는 500으로 처리가 된다.

하지만 발생하는 예외에 따라서 400,404 등 다른 상태코드도 처리해야한다.

또한 상태코드마다 예외를 각각 처리해주어야 한다.

HandlerExceptionResolver로 해결하면된다!! 줄여서 ExceptionResolver라 한다.

 

다음은 ExceptionResolver를 적용했을 때를 작동과정을 그림으로 표현한 것이다.

컨트롤러에서 예외가 발생하면 ExceptionResolver가 실행되어 response.sendError()를 사용하여 에러를 보내주고 

return new ModelAndView()를 해주어서 정상흐름처럼 갈 수 있겠금 한다.

 

다음코드는 IllegalArgumentException이 발생했을 때 ExceptionResolver를 사용한 예시이다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        //try catch로 exception이 터진 것을 정상처리 해줌 서블릿이 오류를 알 수 있게 response.sendError()에 예외를 넣어줌
        try{
            if(ex instanceof IllegalArgumentException){
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST,ex.getMessage());
                return new ModelAndView();  //빈 ModelAndView는 뷰를 렌더링하지않고 정상흐름으로 서블릿이 리턴
            }
        }catch (IOException e){
            log.error("resolver ex", e);
        }
        return null; // 다음 ExceptionResolver를 호출하고 없으면 기존의 발생한 예외를 그대로 서블릿밖으로 던짐
    }

}

-> IllegalArgumentException이 발생한 경우 MyHandlerExceptionResolver에서 response.sendError()로 상태코드와 예외 메세지를 보내준다. 그리고 빈 ModelAndVIew를 리턴해서 정상흐름으로 WAS에 도착하도록 한다. 그러면 WAS에서 sendError를 확인해서 다시 /error를 내부호출하여 Json데이터 형태로 예외정보를 전송한다.

* 반환 값에 따른 동작 방식 (참고)
1. 빈 ModelAndView : 뷰를 렌더링 하지 않고 정상 흐름으로 서블릿에 리턴 (위에 예시에 해당)
2. ModelAndView 지정 : View와 Model 등의 정보를 지정해서 반환하면 뷰를 렌더링
3. null : 다음 ExceptionResolver를 호출하고 만약 처리할 수 있는 ExceptionResolver가 없으면 기본에 발생한 예외를 서블릿 밖으로 던짐

 


HandlerExceptionResolver 활용 (예외를 여기서 멈추기)

위의 같은 상황은 WAS가 오류를 확인하고 다시 내부호출을 하는 복잡한 과정이였다.

ExceptionResolver를 활용하면 그러한 복잡한 과정없이 ExceptionResolver안에서 해결 할 수 있다.

 

다음 코드는 직접만든 오류(UserException)이 발생했을 때 ExceptionResolver로 예외처리를 해결한 예시이다.

@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); //response에 직접 넣음1
				
                //헤더 accept값이 "aaplication/json"일 경우 json형태로 오류정보 전송
                if("application/json".equals(acceptHeader)){
                    Map<String, Object> errorResult = new HashMap<>(); //json데이터로 보낼 오류정보
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());
                    String result = objectMapper.writeValueAsString(errorResult);

                    response.setContentType("application/json"); //response에 직접 넣음2
                    response.setCharacterEncoding("utf-8");//response에 직접 넣음3
                    response.getWriter().write(result);//response에 직접 넣음4
                    return new ModelAndView();
                    
                //헤더 accept값이 "aaplication/json"이 아닐경우 html로 오류페이지 전송
                }else{
                    return new ModelAndView("error/500");
                }
            }
        }catch (IOException e){
            log.error("resolver ex", e);
        }

        return null;
    }
}

문제점은 너무 복잡하다는 것이다..... 스프링이 제공하는 ExceptionResolver를 알아보도록 하자.


스프링이 지원하는 ExceptionResolver1

1. ExceptionHandlerExceptionResolver -> ******가장중요***** 편리한 예외처리 제공
2. ResponseStatusExceptionResolver -> 상태코드 변경
3. DefaultHandlerExceptionResolver -> 스프링 내부 예외 처리

(HandlerExceptionResolverComposite에 등록되어 있는 순서대로 123이다)


1. ResponseStatusExcep tionResolver

예외 따라 상태코드를 쉽게 변경할 수 있다.

방법은 2가지 이다.

1.  @ResponseStatus 
2.  ResponseStatusException

 

(1) @ResponseStatus

 

다음코드는 @ResponseStatus를 활용해 BadRequestException예외가 발생했을 경우 상태코드를 설정하는 것이다.

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

-> BadRequestException예외가 발생했을 때 컨트롤러 밖으로 넘어가면 ResponseStatusResolver가 @ResponseStatus가 있는 곳을 확인해서 상태코드를 400(HttpStatus.BAD_REQUEST)으로 바꾸고 메세지도 담는다.

 

ResponseStatusResolver를 확인해보면 response.sendError()를 호출하는 것을 알 수 있을 것이다.

따라서 WAS는 /error를 내부요청을 진행하게 된다.

 

(2) ResponseStatusException

@ResponseStatus에서는 개발자가 직접 변경할 수 없는 예외에는 적용하기 힘들고 애노테이션을 사용하기 때문에 동적으로 변경하기 힘들다. 그럴때는 ResponseStatusException를 사용하면 된다.

 

다음코드는 ResponseStatusException를 활용하여 IllegalArgumentException이 발생했을 경우 상태코드를 설정하는 것이다.

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new IllegalArgumentException());

-> IllegalArgumentException이 발생했을 경우 상태코드를 404(HttpStatus.NOT_FOUND)로 변경하고 메시지도 담는다


2. DefaultHandleExceptionResolver

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

대표적으로 파리미터 바인딩 시점에서 타입이 맞지 않아 TypeMismatchException 발생했을 경우 500오류가 발생하게 된다.

하지만 이 예외의 경우 클라이언트에서 잘못 호출한 것이기 때문에 400대 오류가 나야한다.

이때 DefaultHandleExceptionResolver는 상태코드를 400로 변경해준다.

 

DefaultHandleExceptionResolver를 확인해보면 ResponseStatusExceptionResolver와 같이 response.sendError()를 호출한다.


3. ExceptionHandlerExceptionResolver

HandlerExceptionResolver에서는 해당 ExceptionResolver에서 해당 예외를 끝내기 위해 복잡한 코드가 필요했다.

(response에 직접데이터 넣기, ModelAndView를 반환한다는 것도 api와 맞지 않음)

스프링은 @ExceptionHandler를 통해서 간단하게 해결한다. 그리고 이것은 특정 컨트롤러에서 발생하는 예외를 별도로 처리할 수 있다.

 

다음코드는 ExceptionHandlerExceptionResolver를 활용한 예시이다.

@Slf4j
@RestController
public class ApiExceptionV2Controller {
//현재 컨트롤러에서 IllegalArgumentException이 발생할 경우 상태코드:400, ErrorResult를 제이슨으로 변환하여 정상흐름으로 전송
    @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
    public static class MemberDto {
        private String memberId;
        private String name;
    }
}

(맨위 3개의 메소드를 주목하면 된다)

-> @ExceptionHandler를 사용하고 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면된다.

해당 컨트롤러에서 예외가 발생할 경우 해당예외가 지정된 @ExceptionHandler가 붙여진 메소드가 호출된다.

그리고 지정한 예외 또는 그 예외의 자식클래스를 모두 잡을 수 있다.

맨위에서 2번째는 ReponseEntity를 사용해서 HTTP메시지 바디에 직접 응답한다. 이 경우 응답코드를 프로그래밍해서 동적으로 변경가능 하다

 


여기서 끝난 것이 아니다.

@ExceptionHandler만 사용하게 되면 정상코드와 예외처리 코드가 하나의 컨트롤러에 섞여있다.

이때 @ControllerAdvice 또는 @RestControllerAdvice를 사용하면 이 둘을 분리시킬 수 있다. 

 

@ControllerAdvice 또는 @RestControllerAdvice

사용방법은 하나의 클래스를 생성하고 그 위에 @ControllerAdvice 또는 @RestControllerAdvice를 붙이면 된다.

그리고 대상컨트롤러를 따로 지정할 수 있다.

 

다음 코드는 스프링 공식문서를 참고한 지정방법 3가지 이다.

// 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 {}

-> 첫번째는 대상 컨트롤러를 지정할 수 있고, 두번째는 패키지를 지정하여 그 하위에 있는 컨트롤러가 대상이 되고 세번째는 특정 클래스를 지정할 수 있다. 만약 대상 컨트롤러를 생략하면 모든 컨트롤러에 적용된다.


결론!!

@ExceptionHandler와 @ControllerAdvice를 잘 조합해서 깔끔하게 예외를 해결하자!

 

 

(참고 : 인프런 김영한 강사님 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 )