Post

스프링의 예외처리

동작 원리

컨트롤러에서 예외가 발생했는데 어떤 곳에서도 예외를 잡지 못하면 예외는 was 까지 전달된다.

예외를 전달받은 was 는 오류 정보를 request 객체에 attribute 로 추가하고 BasicErrorController 로 내부 요청을 한다. (혹은 특정 객체가 HttpServletResponse 객체의 sendError() 메서드를 호출해 에러 발생을 알렸을 때)

요청을 받은 BasicErrorController 는 요청의 Accept 헤더가 text/html 이면 응답으로 html 을 보내고 그 외에는 json 으로 응답을 보낸다.

1
2
3
4
5
요청 → was → 필터 → 디스패처 서블릿 → 인터셉터 → 컨트롤러

was ← 필터 ← 디스패처 서블릿 ← 인터셉터 ← 컨트롤러 (throw Exception / sendError)

was → 필터 → 디스패처 서블릿 → 인터셉터 → BasicErrorController → 응답

컨트롤러에서 예외가 발생하면 postHandle() 메서드가 호출되지 않지만 HttpServletResponse 객체의 sendError() 메서드를 사용하면 postHandle() 메서드까지 호출된다.

was 가 BasicErrorController 로 요청을 하는 URI 는 기본적으로 /error 이며 application.properties 에서 변경할 수 있다.

1
server.error.path=[원하는 URI]

스프링부트는 WhiteLabel Error Page 라는 기본 에러 페이지를 제공한다.

이를 원하는 에러 페이지로 변경하려면 resources 하위에 error 디렉터리를 생성하고 404.html, 5xx.html 처럼 HTTP 상태 코드에 맞는 페이지를 생성해두면 스프링 부트가 자동으로 해당 html 을 반환해준다.

BasicErrorController 가 반환하는 뷰를 선택하는 기준은
  1. 뷰 템플릿: resources/templates/error/*.html
  2. 정적 리소스: resources/static/*.html
  3. 뷰 파일 이름: resources/templates/404.html

필터와 인터셉터는 한번 더 호출된다.

필터와 인터셉터는 was 가 BasicErrorController 로 내부 요청을 할 때에도 호출된다.

처음 요청을 받아서 컨트롤러 메서드를 호출할 때 한번 호출됐는데 다시 호출되는건 맞지 않다.

그래서 서블릿은 이런 문제를 해결하기 위해 DispatcherType 이라는 정보를 제공한다.

1
2
3
4
5
6
7
public enum DispatcherType {
	FORWARD,
	INCLUDE,
	REQUEST,
	ASYNC,
	ERROR
}

처음 요청을 받을때는 DispatcherType 이 REQUEST, 내부 요청을 할 때는 DispatcherType 이 ERROR 가 된다.

필터는 기본적으로 DispatcherType 이 ERROR 면 호출되지 않는다.

인터셉터의 경우는 특정 DispatcherType 을 설정할 수 없어서 /error URI 를 exclude 해야한다.

에러가 발생했을 때 원하는 컨트롤러로 요청하려면?

WebServerFactoryCustomizer 를 상속받아서 구현하고 빈으로 등록하면 특정 에러가 발생했을 때 내가 원하는 URI를 가진 컨트롤러로 내부 요청을 보낼 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
@Component
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {

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

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

ErrorPage 생성자의 첫번째 매개변수에 해당하는 예외/오류 가 발생했을 때 두번째 매개변수에 해당하는 URI 로 내부 요청을 보낸다.

WebServerFactoryCustomizer 를 상속받고 구현한다해도 프레임워크가 BasicErrorController 를 등록해놨기 때문에 직접 등록한 ErrorPage 에 해당하는 예외/오류 가 없으면 BasicErrorController 가 동작한다.

HandlerExceptionResolver

컨트롤러에서 예외가 발생하면 was 까지 전달되고 BasicErrorController 로 내부 요청을 보내지만, HandlerExceptionResolver 를 적용하면 내부 요청을 생략하고 클라이언트에 응답을 전달할 수 있다.

1
2
3
요청 → was → 필터 → 디스패처 서블릿 → 인터셉터(preHandle) → 컨트롤러

컨트롤러 (예외 발생) → ExceptionResolver → 인터셉터(afterCompletion) → 디스패처 서블릿 → 필터 → was → 응답

ExceptionResolver 가 존재해도 인터셉터의 postHandle() 메서드는 호출되지 않는다.

예외가 발생하면 BasicErrorController 는 항상 HTTP 상태코드를 500으로 반환한다. 또한, API로 요청한 경우 응답 json 을 원하는 형식으로 받을 수 없다. 스프링은 이 문제를 해결하기 위해 HandlerExceptionResolver 클래스를 제공한다.

스프링 부트가 기본적으로 등록한 ExceptionResolver 는 다음과 같다.

1. ExceptionHandlerExceptionResolver

@ExceptionHandler 어노테이션을 예외처리 메서드로 사용하는 ExceptionResolver 클래스다.

컨트롤러에서 @ExceptionHandler 어노테이션에 지정한 예외가 발생하면 DispatcherServlet 은 해당 어노테이션이 적용된 메서드를 호출해서 예외를 처리한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Slf4j
@RestController
public class ApiExceptionV2Controller {
	
	@ResponseStatus(HttpStatus.BAD_REQUEST)  // @ResponseStatus 어노테이션을 적용하여 응답 HTTP 상태코드를 지정할 수 있다.
	@ExceptionHandler(IllegalArgumentException.class)
	public ErrorResult illegalExHandle(IllegalArgumentException e) {  // 여기서 예외를 처리! DispatcherServlet이 메서드를 호출한다.
		log.error("illegalExHandle error", e);
		return new ErrorResult("BAD", e.getMessage());  // @RestController 어노테이션이 적용된 클래스 내부에 있으므로 응답을 json 형식으로 반환한다.
	}

	@ExceptionHandler  // 예외를 생략하면 자동으로 파라미터의 예외를 지정한다.
	public ResponseEntity<ErrorResult> userExHandle(UserException e) {
		log.error("userExHandle error", e);
		ErrorResult errorResult = new ErrorResult("USER-EX", e.getMessage());
		return new ResponseEntity<>(errorResult, HttpStatus.BAD_REQUEST);  // ResponseEntity 클래스를 이용하면 응답 HTTP 상태코드와 body 를 모두 설정할 수 있다.
	}

	@GetMapping("/api2/members/{id}")
	public MemberDTO getMember(@PathVariable 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, "member-name");
	}
	
	@Data
	@AllArgsConstructor
	static class MemberDTO {
		private String memberId;
		private String name;
	}
}

@ExceptionHandler({AException.class, BException.class}) 처럼 예외를 여러개 지정할 수도 있다.

2. ResponseStatusExceptionResolver

커스텀 Exception 객체를 만들어서 클래스 레벨에 @ResponseStatus 어노테이션을 사용하거나 ResponseStatusException 객체를 throw 하면 응답으로 자신이 설정한 HTTP 상태코드와 예외 메시지를 반환한다.

하지만 코드를 확인해보면 HttpServletResponse 객체의 sendError() 메서드를 사용하고 있기 때문에 BasicErrorController 가 호출된다.

예외 메시지 파라미터에 값을 전달할 때 messages.properties 에 설정한 메시지도 전달할 수 있다.

3. DefaultHandlerExceptionResolver

TypeMismatchException , MissingPathVariableException , HttpRequestMethodNotSupportedException 등등 스프링 내부에서 발생하는 예외를 처리하며, 예외에 맞는 HTTP 상태코드와 메시지가 설정되어 있다.

하지만 역시 sendError() 메서드를 사용하고 있기 때문에 BasicErrorController 가 호출된다.

@ControllerAdvice

@ExceptionHandler 어노테이션을 사용하면 깔끔하게 예외를 처리할 수 있지만 컨트롤러에 정상 코드와 예외처리 코드가 함께 존재하게 된다.

스프링은 이러한 문제를 해결하기 위해 @ControllerAdvice 어노테이션을 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Slf4j
@RestControllerAdvice  // @ControllerAdvice + @ResponseBody
public class ExControllerAdvice {

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

  @ExceptionHandler
  public ResponseEntity<ErrorResult> userExHandle(UserException e) {
    log.error("userExHandle error", 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("exHandle error", e);
    return new ErrorResult("EX", "내부 오류");
  }
}

@ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을 부여해주는 역할을 한다. 대상을 지정하지 않으면 모든 컨트롤러에 적용된다.

1
2
3
4
5
6
7
8
9
10
11
12
// @RestController 가 설정된 모든 컨트롤러를 대상으로 함
@ControllerAdvice(annotations = RestController.class)
public class ExampleAdvice1 {}

// org.example.controllers 패키지 하위의 모든 컨트롤러를 대상으로 함
@ControllerAdvice("org.example.controllers")
public class ExampleAdvice2 {}

// 해당 클래스 혹은 해당 클래스를 상속받은 모든 컨트롤러를 대상으로 함
@ControllerAdvice(assignableTypes = {ControllerInterface.class,
AbstractController.class})
public class ExampleAdvice3 {}
This post is licensed under CC BY 4.0 by the author.