Spring - API 예외처리

컴업·2021년 11월 25일
0

스프링 부트 기본 오류 처리

지난 포스트에서 웹 애플리케이션 내 오류가 발생하면 WAS가 /error 경로로 다시 요청을 보내고, 스프링의 BasicErrorController는 /error 경로를 받아 자동으로 HTML 오류 페이지를 제공한다고 이야기 했습니다.

사실 BasicErrorController는 HTML 페이지 뿐만아니라 JSON도 자동으로 반환할 수 있습니다.

BasicErrorController

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

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

BasicErrorController의 구조를 보면 errorHtml(), error()로 메서드가 갈리는 것을 확인 할 수 있습니다.

이는 클라이언트 요청의 Accept 헤더값이 text/html인 경우 errorHtml()을 호출해 view를 제공하고, 그렇지 않으면 error()를 호출해 ResponseEntity로 JSON 데이터를 반환합니다.

JSON에는 BasicErrorController가 기본적으로 제공하는 메세지가 담겨있습니다.

응답 예시.

{
   "timestamp":"2021-04-28T00:00:00.000+00:00",
   "status":500,
   "error":"Internal Server Error",
   "exception":"java.lang.RuntimeException",
   "trace":"java.lang.RuntimeException: 어쩌구 저쩌구.",
   "message":"잘못된 사용자",
   "path":"/api/members/ex"
}

API는 프로젝트마다 정해둔 스펙이 다르기 때문에 이에 맞춘 JSON 데이터를 보내주어야합니다. 이를 위해 BasicErrorController를 확장하여 JSON 데이터를 변경할 수 있지만 컨트롤러나 예외마다 다른 응답 결과를 보내주려면 굉장히 복잡한 작업이 될 수 있습니다.

이는 @ExceptionHandler가 제공하는 기능으로 간편하게 해결할 수 있으므로 BasciErrorController는 HTML 오류 페이지를 전송하는데만 사용하고, 스프링 부트로 API 예외도 처리할 순 있다아~~ 정도만 알고있는게 좋습니다.

HandlerExceptionResolver란?

예외가 터진경우 WAS에 도착하고 다시 /error 경로로 요청하는 것은 비효율적인 방법일 수 있습니다.

HandlerExceptionResolver를 사용하면 컨트롤러 밖으로 던져진 예외를 해결하고, 그 다음 동작을 새로이 정의할 수 있습니다.

기존 예외 처리 흐름

ExceptionResolver() 사용 후

조금 더 자세하게, preHandle 이후 호출 되고, 예외가 존재하면 마찬가지로 postHanlde은 호출되지 않는다.


1. 인터페이스 구조

HandlerExceptionResolver 인터페이스의 구조

public interface HandlerExceptionResolver {

ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response,Object handler, Exception ex);

}

HandlerExceptionResolver는 ModelAndView를 반환하는데, 이 반환값에 따라 DispatcherServlet은 여러가지 동작 방식을 가지고 있습니다.

  • 빈 ModelAndView: 뷰를 렌더링 하지 않고, 정상 흐름으로 서블릿이 리턴된다.

  • ModelAndView 지정: View, Model에 정보를 넣어주면 뷰를 렌더링한다.

  • null: 다음 ExceptionResolver를 찾아서 실행한다. 다음이 없다면 기존에 발생한 예외를 서블릿 밖으로 던진다.


2. 인터페이스 구현

package hello.exception.resolver;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@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("IllegarArgumentException resolver to 400");
              
                // sendError를 호출하고 빈 ModelAndView를 반환한다.
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                return new ModelAndView();
            }
			
            
            if (ex instanceof NullPointerException) {
            	
                // JSON 데이터를 바디에 직접 담고 빈 ModelAndView를 반환하다.
            	response.getWriter().println("JSON 데이터를 직접 입력");
                response.setContentType("application/json");
                response.setCharacterEncoding("utf-8");
                return new ModelAndView();
            }
        } catch (Exception e) {
            log.info("resolver ex", e);
        }

        return null;
    }
}

IllegalArgumentException을 받으면 sendError("400")메서드를 호출하여 WAS에서 Http status 400으로 다시 에러 요청을 하도록 하였습니다.

NullpointerException을 받으면 Response body에 직접 JSON 데이터를 입력하였습니다.

sendError()를 호출하지 않고 빈 ModelAndView를 반환하면 WAS는 정상 적으로 응답메세지를 보내게 됩니다.

그러나 이런식으로 JSON 테이터를 작성하는 것은 너무나 복잡한 과정입니다.

3. ObjectMApper를 이용한 JSON 데이터 생성.

package hello.exception.resolver;

import com.fasterxml.jackson.databind.ObjectMapper;
import hello.exception.exceoption.UserException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {

	// 객체 <-> JSON 매핑해줌.
    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 resoler to 400");
                String acceptHeader = request.getHeader("accept");
		
        	// HttpStatus를 400으로 전환.
		response.setStatus(HttpServletResponse.SC_BAD_REQUEST);

		// JSON 데이터 생성.
                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);
                    
                    // JSON 데이터를 바디에 담고 빈 ModelAndView()를 반환하면 설정한 HTTP status로 응답이 나갑니다.
                    return new ModelAndView();
                } else {
                    // text.html
                    return new ModelAndView("error/500");
                    // template에 500 거기로 간다.
                }
            }

        } catch (IOException e) {
            log.error("resolver ex", e);
        }

        return null;
    }
}

위 처럼 ObjectMapper를 이용해 손쉽게 JSON 데이터를 만들어 반환하였습니다.

4. ExceptionHandler 등록

package hello.exception;

import hello.exception.resolver.MyHandlerExceptionResolver;
import hello.exception.resolver.UserHandlerExceptionResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerExceptionResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.servlet.DispatcherType;
import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

위처럼 WebMvcConfiguer인터페이스의 extendHandlerExceptionResolvers()를 오버라이딩해 등록할 수 있습니다.

한가지 주의할 점은 configureHandlerExceptionResolvers(..)를 통해 등록하면 스프링이 기본적으로 등록하는 ExceptionResolver가 제거됩니다.


스프링이 제공하는 ExceptionResolver

앞서 HandlerExceptionHandler의 개념과 사용법을 알아보았습니다. 결국 핵심은 이처럼 예외를 한 곳에서 모두 처리할 수 있다는 것입니다!

그러나 실제 이를 구현하려고 하니 상당히 복잡합니다.

지금부터는 스프링이 제공하는 ExceptionResolver들을 알아보겠습니다.

스프링 부트가 기본으로 제공하는 ExceptionResolver는 다음과 같습니다.
HandlerExceptionResolverComposite에 다음 순서로 등록됩니다.

  1. ExceptionHandlerExceptionResolver

  2. ResponseStatusExceptionResolver

  3. DefaultHandlerExceptionResolver


1. ResponseStatusExceptionResolver

HTTP 상태 코드를 지정해주는 리졸버로

@ResponseStatus 가 달려있는 예외, ResponseStatusException예외를 처리합니다.

package hello.exception.exception;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}

위처럼 BadRequestException 예외가 컨트롤러 밖으로 나가면 ResponseStatusExceptionResolver가 해당 애노테이션을 확인해 오류코드를 변경하고 메시지도 담습니다.

  • sendError(Http 상태코드, reason)을 호출해준다.

간단하게 애노테이션 하나 붙여 해결할 수 있지만 @ResponseStatus는 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없습니다. (라이브러리 같은경우) 또 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하기도 어렵습니다.

이 때는 ResponseStatusException 예외를 사용하면 됩니다.

@GetMapping("/api/response-status-ex2")
public String responseStatusEx2() {

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

}

2. DefaultHandlerExceptionResolver

스프링 내부에서 발생하는 스프링 예외를 처리합니다.

대표적으로 파라미터 바인딩 시점에 타입이 맞지 않아 발생하는 TypeMismatchException이 있습니다.

이 예외는 클리이언트가 요청 HTTP를 잘못보내 발생하는 예외이므로 400오류 이지만, WAS까지 가면 무조건 500으로 처리하기 때문에 DefaultHandlerExceptionResolver가 400으로 변경합니다.


3.ExceptionHandlerExceptionResolver

스프링이 제공하는 리졸버로 조금 더 쉽게 http status를 변경하고, 스프링 내부 예외를 처리하였습니다. 그럼에도 불구하고 API 예외처리를 위해 HandlerExceptionResolver를 직접 사용하는 것은 여전히 복잡한 일입니다.

어려운 점

  1. ModelAndView를 반환해야한다: API에서는 필요없는 인스턴스를 생성해야한다.

  2. API응답을 위해 HttpServletResponse에 직접 JSON을 넣어주었다 : 거의 지옥이다. JSP없이 서블릿으로 HTML 페이지를 만드는 느낌...

  3. 특정 컨트롤러에서만 발생하는 예외를 처리하기 어렵다. 같은 예외라도

이 문제를 해결하기 위해 스프링은 @ExceptionHandler라는 예외 처리 기능을 제공하는데 이것이 바로 ExceptionHandlerExceptionResolver입니다.

@Exceptional

ErrorResult

@Data
@AllArgsConstructor
public class ErrorResult {

	private String code;
	private String message;

}

Controller


@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);
    }
    	//ResponseEntity를 사용해서 HTTP 바디에 직접 넣을 수 도 있다. HTTP 컨버터가 사용된다.
    
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    @ExceptionHandler
    public ErrorResult exHandle(Exception e) {
        log.error("[exceptionHandle] ex", e);
        return new ErrorResult("EX", "내부 오류");
    }
    
    
    @GetMapping("/")
	... // url 매핑 메서드들.
    
}

컨트롤러 내부 메서드에 @ExceptionHandler 애노테이션을 선언하고, 처리하고 싶은 예외를 지정하면 해당 컨트롤러 내부에서 이 예외가 발생하면 메서드가 실행됩니다.

참고로 지정한 예외 외에도 예외의 자식 클래스는 모두 잡을 수 있습니다.


다양한 예외

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

}

위 코드처럼 다양한 예외를 한번에 처리할 수 도 있습니다.


예외 생략

@ExceptionHandler
public ResponseEntity<ErrorResult> userExHandle(UserException e) {}

애노테이션에 예외를 생략하면 메서드의 파라미터로 들어온 예외를 자동으로 등록합니다.


@ControllerAdvice

@ExceptionHandler를 사용해 예외를 깔끔하게 처리했지만, 컨트롤러 코드와, 예외 처리 코드가 하나의 클래스에 섞여있는 형태는 좋지 못합니다.

@ControllerAdvice 혹은 @RestControllerAdvice를 사용하면 이 둘을 분리할 수 있습니다.

package hello.exception.exhandler.advice;

import hello.exception.exceoption.UserException;
import hello.exception.exhandler.ErrorResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice(basePackages = "hello.exception.api")
public class ExControllerAdvice {

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResult illegalExHandler(IllegalArgumentException e) {
        log.info("[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("UserException", 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는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler, @InitBinder기능을 부여해 주는 역할을 합니다.

대상을 지정하지 않으면 모든 컨트롤러에 적용됩니다.

@RestControllerAdvice는 @ControllerAdvice에 @RequestBody가 추가되어있습니다. 그외에는 같습니다.

@ControllerAdvice 대상지정

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


<출처>
Inflearn 김영한 선생님, Spring MVC 2

profile
좋은 사람, 좋은 개발자 (되는중.. :D)

0개의 댓글