Spring 예외 처리 동작 원리

임현규·2023년 9월 28일
1

개인 공부

목록 보기
11/11

작성하게 된 계기

@ControllerAdvice로 Controller의 예외처리를 다른 클래스로 분리하고, @ExceptionHandler를 클래스 내부 메서드에 달아서 예외처리를 구현했다. 이를 테스트할 때 어떻게 테스트를 진행할까? 고민하던 중에 Spring에서 내부적으로 예외처리를 어떻게 수행할지 궁금해졌다. 그래서 동작원리를 분석해보기로 했다.

예외처리 동작 과정

이해해야 하는 것들

Spring에서 내부적으로 예외처리를 하는 과정을 이해하려면 3개의 클래스를 집중적으로 살펴보면 된다.

  1. DispatcherServlet
  2. ExceptionHandlerExceptionResolver(HandlerExceptionResolver)
  3. ExceptionHandlerResolver

이 3개의 클래스 특징과 메서드 동작을 숙지하면 예외처리에 대해서 정확하게 이해가 가능하다.

해당 포스트에서는 공부하면서 분석했던 내용을 3개의 클래스에 대해서 간단한 소개와 실제 동작과정에 필요한 메서드들에 대해서 분석하고, 전체적인 동작과정을 클래스 입장에서 설명할 예정이다.

DispatcherServlet

DispatcherServlet이란
DispatcherServlet은 Spring의 핵심 기술로 여러 요청을 DispatcherServlet 한 곳에서 처리하도록 한다. 이를 Front-Controller 패턴이라고도 한다. dispatcherServlet 한곳에서 모든 요청을 받아서 처리하고 Handler를 활용해 Controller에서 로직을 처리하도록 하기 때문에 구현이 쉽고 간편해졌다.


예외처리 동작 과정

코드와 함께 살펴보자

	protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
		HttpServletRequest processedRequest = request;
		HandlerExecutionChain mappedHandler = null;
		boolean multipartRequestParsed = false;

		WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);

		try {
			ModelAndView mv = null;
			Exception dispatchException = null;

			try {
				processedRequest = checkMultipart(request);
				multipartRequestParsed = (processedRequest != request);

				// Determine handler for the current request.
				mappedHandler = getHandler(processedRequest);
				if (mappedHandler == null) {
                    noHandlerFound(processedRequest, response); // 처리할 handler를 찾지 못함
					return;
				}

				// Determine handler adapter for the current request.
				HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());

				// Process last-modified header, if supported by the handler.
				// ..........
				// Actually invoke the handler.
				mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
				// ..........
			}
			catch (Exception ex) {
				dispatchException = ex;
			}
			catch (Throwable err) {
				// As of 4.3, we're processing Errors thrown from handler methods as well,
				// making them available for @ExceptionHandler methods and other scenarios.
				dispatchException = new NestedServletException("Handler dispatch failed", err);
			}
			processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);

예외처리의 시작은 DispatcherServlet의 doDispatch 메서드 호출에서 시작한다. doDispatch는 HttpServletRequest와 HttpServletResponse 인자 2개를 받아서 요청 응답을 처리한다.

코드에서보다 싶이 HttpServletRequest를 인자로 handler 탐색시 처리할 handler가 존재하지 않으면 noHandlerFound 메서드를 통해 페이지를 찾지 못했다는 응답을 처리한다.

존재하는 경우 handler를 handle해서 결과를 가져오고 response를 업데이트한다.
위의 코드를 보면 try catch로 되어있음을 알 수 있는데 중간에 예외가 발생하는 경우 processDispatchResult 메서드를 통해 예외 인자를 넘겨서 예외를 처리함을 알 수 있다.

코드를 계속 파고 들어가면 메서드에 해당 코드를 볼 수 있다.

저장된 HandlerExceptionResolver를 모두 탐색해서 4개 인자에 맞는 resolveException을 처리해서 결과를 얻어오고, 결과를 처리할 수 있다면 더 이상 순회하지 않는다.

ExceptionHandlerExceptionResolver

클래스 소개
해당 클래스는 HandlerExceptionResolver를 상속받아 만들어진다. 정확히는 HandlerExceptionResolver는 인터페이스며 이를 통해 구현된 템플릿 추상 클래스로부터 상속되어 만들어진다. 그러니 ExceptionHandlerExceptionResolver 클래스는 HandlerExceptionResolver 소속의 타입이라 볼 수 있다.

해당 Resolver는 handler로 부터 controller 클래스 정보를 추출하고 그것을 추출한 것을 key로 활용해 ExceptionHandlerResolver라는 클래스 인스턴스들을 모두 캐싱해서 보관한다. 뿐만 아니라 ControllerAdvice 어노테이션이 붙은 클래스를 key로 하여 따로 캐싱해둔다. 그리고 이들을 호출해서 ModelView를 리턴해서 결과를 반영한다.

예외처리 동작

doResolveHandlerMethodException에 세부 동작이 정의되어 있다. 이 메서드는 doResolveException의 세부사항을 구현하기 위해 템플릿 패턴을 활용해서 protected를 오버라이드해서 구현했다고 보면 된다.

여기서 핵심은 Handler 응답을 위해 ServletInvocableHandlerMethod를 getExceptionHandlerMethod로 부터 가져오고 이를 실행해 응답 결과를 가져온다.

여기에 HandlerMethod를 가져오는 핵심 로직이 담겨있는데 전에 클래스 소개를 할때 ExceptionHandlerExceptionResolver는 실제로 @ExceptionHandler를 캐치해서 method를 가져오는 것이 아니라, 캐싱해서 관리하는 Resolver라고 설명했다. 이 메서드에서는 실제 Method를 찾아서 가져와주는 ExceptionHandlerMethodResolver를 해시 맵으로부터 가져온다. 처음엔 Controller class에서 구현된 예외처리 메서드부터 탐색하며, 그 이후에 ControllerAdviceBean을 key로 저장한 해시에서 @ExceptionHandler 메서드를 찾아서 예외를 처리하는 메서드를 반환한다.

ExceptionHandlerMethodResolver

클래스 소개
클래스에 적힌 주석을 인용하면

Discovers @ExceptionHandler methods in a given class, including all of its superclasses, and helps to resolve a given Exception to the exception types supported by a given Method.
Since:
3.1
Author:
Rossen Stoyanchev, Juergen Hoeller, Sam Brannen

주어진 클래스의 @ExceptionHandler 어노테이션이 적용된 method를 찾는다고 나와있다. 이 클래스에서 실제로 리플렉션을 통해 어노테이션이 적용된 method를 찾고 Exception을 key로 해당 method를 해시맵에 저장한다. 리플렉션은 비용이 크기 때문에 Resolver 생성시 Hashmap에 해당 클래스에 어노테이션이 적용된 모든 메서드를 찾아서 저장해두는 것이다.

클래스 분석하기

이 코드를 보면 내부 변수로 mappedMethods는 method를 저장하는 변수임을 알 수 있다. 당연한 이야기이긴 하지만 Class<? extends Throwable\> 을 key로 사용한 이유는 Throwable 하위 타입의 클래스도 저장하기 위해서이다. 이렇게 하면 Throwable의 하위 타입인 Exception도 key로 저장할 수 있다.

위의 유틸 메서드를 살펴보면 리플렉션을 통해 @ExceptionHandler 어노테이션을 가진 메서드를 찾고 이를 mappedMethods에 저장함을 알 수 있다. 이 작업은 모두 인스턴스 생성시 생성자에서 호출한다.

이렇게 만들어진 Resolver는 exception을 입력받으면 캐싱해둔 method를 리턴해준다.

헷갈리는 Resolver 정리

  • ExceptionHandlerExceptionResolver
    • ExceptionHandlerMethodResolver를 Controller 타입 기준으로 캐싱해둠
    • ExceptionHandlerMethodResolver를 @ControllerAdvice가 저장된 class 타입을 기준으로 캐싱해둠
    • 예외처리 요청시 controller 타입기준으로 캐싱해둔 맵에서 resolver를 가져와 예외처리 메서드 요청함.
    • 없다면 @ControllerAdvice 적용된 클래스 타입을 기중으로 캐싱해둔 맵에서 resolver를 가져와 예외처리 메서드 요청함.
  • ExceptionHandlerMethodResolver
    • 생성시 리플렉션을 통해 @ExceptionHandler가 적용된 method를 탐색하고 캐싱해둠
    • resolveException 요청시 exception 인자에 따라 캐싱해둔 method 호출

ControllerAdvice는 AOP인가

나는 AOP가 맞다고 생각한다. 그 이유는 ControllerAdvice에서 Advice가 AOP 관점에서 지어진 이름이기도 하고, AOP는 프로그래밍을 할 때, 특정 로직을 핵심적인 로직과 부가적인 로직으로 분리하는 것이기 때문이다.

Spring에서 제공하는 ControllerAdvice를 이용한 예외처리를 살펴보자. 예외 발생시 try catch을 controller에서 하면 굉장히 코드가 지저분해지고, 중복되는 코드가 발생할 가능성이 높다. 그러나 이것을 throw로 예외처리를 미루고 dispatcher servlet에서 잡아서 예외 처리 로직만 모아둔 클래스의 메서드를 리플렉션으로 가져와 처리함으로써, Controller의 메인 로직, 예외처리 로직(부가 기능) 이렇게 분리가 가능해진다. 그래서 나는 AOP라 생각한다.

그러나 ControllerAdvice를 이용한 방식이 Spring Proxy AOP라고 묻는다면, 이건 틀렸다고 생각한다. proxy 기반으로 로직이 분리된 것이 아니기 때문이다.

테스트를 어떻게 할 것인가

통합 테스트를 실행하면 테스트 커버리지는 일반적으로 채워진다. 그러나 모든 예외 케이스에 대해 통합 테스트를 실행하는 것은 어려울 수 있다. 따라서 내 제안은 @ControllerAdvice 클래스 내에서 예외를 처리하는 방법을 단위 테스트하는 것이다. 이 접근 방식은 Spring에서 ControllerAdvice를 사용하는 주된 의도와 일치하며, 내부적으로 리플렉션을 사용하여 메서드를 가져와 캐싱하므로 내부 호출 단계를 테스트할 필요가 없기 때문이다.

이를 통해 예외 처리 로직이 예상대로 작동하는지 확인할 수 있고, 코드의 안정성을 더욱 확보할 수 있다고 생각한다.

profile
엘 프사이 콩그루

0개의 댓글