본문 바로가기

Spring

Reflection과 Custom Annotation 활용기 2 - @ExceptionHandler 구현하기

안녕하세요! 오늘은 저번 포스팅에 이어서 Reflection을 좀 더 활용해본 활용기를 가져왔습니다. 

 

이번 포스팅에서는 Spring 없이 동작하는(그러나 비슷하게 돌아가는) 제 웹 서버에서 Reflection과 Custom Annotation을 좀 더 활용해서 @ExceptionHandler 어노테이션과 그 역할을 비슷하게 구현해보았습니다. 전체적인 구현방향, 흐름은 아래와 같습니다. UserLogin 기능을 사용할 때 login이 실패하는 경우 Custom 예외를 발생시키도록 구현했다고 할 때, 어떻게 예외를 잘 분리해서 처리할까에 대한 고민을 담았다고 보시면 될 것 같습니다.

 

예외처리의 흐름

 

Spring MVC의 예외 처리 전략과 흐름만 보면 얼핏 비슷합니다. 이 포스팅의 마지막에서는 실제 스프링의 예외처리 전략에 대해서도 간단하게 정리해보겠습니다.

 


예외처리 전략


제 웹서버의 예외 처리 전략은 다음과 같습니다.

 

1. ExceptionHandlerMapping에서 exception 패키지 내의 클래스들을 읽어들여 (예외 이름, 예외 클래스)로 Mapping 정보를 저장합니다.

2. Invoke된 메서드 내에서 예외가 발생합니다. Invoke된 메서드에서 발생한 예외는 InvocationTargetException의 인스턴스로 예외가 던져집니다.

3. 던져진 예외를 받은 controller handler는 @ExceptionHandler의 처리 예외를 읽어들이고, 1의 Map에서 조회하여 예외 클래스 객체를 가져옵니다.

4. InvocationException이 wrapping하고 있는 예외가 Map에서 조회한 예외 클래스의 인스턴스인지 확인합니다. 일치하면 예외 처리 로직을 다시 invoke합니다.

 

제일 먼저, Controller 클래스를 살펴보겠습니다. 이전 포스팅에서 구현했듯이 URL의 어노테이션에 따라 Controller에서  요청을 처리할 Handler Mapping이 이루어집니다. 그리고 Handler의 메소드를 가져와 실행시키는 invoke를 호출하는데요, 이때 invoke 내부에서 비즈니스 로직을 처리하며 예외가 발생할 수 있겠죠. 가장 쉽게 생각할 수 있는 것은 handler 내부에서 예외를 처리해주는 것입니다.

@RequestMapping(url = "/users/login")
public class UserLoginController extends Controller{

    @MethodType(value = "POST")
    public String login(HttpRequest httpRequest, HttpResponse httpResponse) {
        Map<String, String> params = HttpRequestUtils.parseQueryParams(httpRequest.getBody());
        String userInputPassword = params.get("password");
        String userId = params.get("userId");

        if (Database.findUserById(userId).get().validate(userInputPassword)) {
            String session = SessionDb.addSessionedUser(userId);
            httpResponse.addHeader("Set-cookie", String.format("sid=%s; Path=/", session));
            return "redirect:/";
        }
        //로그인 실패 시 예외 발생 + 예외 처리
        try {
        	throw new UserInfoException("로그인 실패")
        } catch (UserInfoException e) {
        	return "/login_failed.html";
    }
}

 

위의 예시는 로그인 실패의 예외처리를 억지로 예시로 가져와서 흐름이 자연스럽지 않습니다만.. 결국 요점은 구현이 쉽지만 비즈니스 로직과 예외 로직이 섞여 가독성이 떨어지고 유지보수가 어렵다는 것입니다. 만약 handler가 직접 처리하지 않고 invoke해준 메소드에게 예외를 넘기고 이를 받아서 처리하는 다른 exceptionHandler를 둔다면 어떨까요. 예외 로직을 분리할 수 있을 것입니다. 아래처럼 말입니다.

 

@RequestMapping(url = "/users/login")
public class UserLoginController extends Controller{

    @MethodType(value = "POST")
    public String login(HttpRequest httpRequest, HttpResponse httpResponse) {
        Map<String, String> params = HttpRequestUtils.parseQueryParams(httpRequest.getBody());
        String userInputPassword = params.get("password");
        String userId = params.get("userId");

        if (Database.findUserById(userId).get().validate(userInputPassword)) {
            String session = SessionDb.addSessionedUser(userId);
            httpResponse.addHeader("Set-cookie", String.format("sid=%s; Path=/", session));
            return "redirect:/";
        }
        throw new UserInfoException("로그인 실패");
    }

    @ExceptionHandler(exception = "UserInfoException.class")
    public String failLogin() {
    //예외 처리
        return "/user/login_failed.html";
    }
}

 

다음으로는 위 기능을 구현하는 과정을 자세히 기술해보겠습니다.


예외처리 기능 구현


Controller에서는 handlerMapping 후 handler의 메서드를 invoke합니다. 이 때, 위에서도 설명했지만 invoke된 메서드 내에서 예외가 발생하는 경우, Invoke된 메서드에서 발생한 예외는 InvocationTargetException의 인스턴스로 예외가 던져집니다. 

 

InvocationTargetException에 대해서 간단히 살펴보면, InvocationTargetException은 Method나 Constructor와 같은 리플렉션 API를 사용하여 메소드나 생성자를 호출할 때, 대상 메소드 또는 생성자 내에서 발생하는 예외가 래핑되어 발생하는 예외입니다. InvocationTargetException은 즉 필드로 target 예외를 가지는 wrapping 클래스입니다. 아래처럼 getTargetException() 메서드로 target Exception을 가져와 원본 예외에 대한 정보를 얻을 수 있습니다.

아래 컨트롤러에서처럼, 호출한 메서드 내부에서 예외가 던져졌을 때, InvocationTargetException으로 예외를 catch해줄 수 있습니다. 그리고 catch 문 내부의 handleException 메서드에서 exceptionHandler의 메소드를 다시 호출합니다.

 

public abstract class Controller {

    Map<String, Class<?>> exceptionMap;

    public String process(HttpRequest httpRequest, HttpResponse httpResponse) {
    //Controller에서 URL에 따라 HandlerMapping 후 실행할 메서드를 가져옵니다.
        Method method = handlerMethodMap.get(httpRequest.getMethod());

        String viewName = "";
        try {
            viewName = (String) method.invoke(this.getClass().newInstance(), httpRequest,
                httpResponse);
        } catch (InvocationTargetException e) {
            // 컨트롤러가 호출한 메서드에서 예외 발생 시 handler 로직 실행
            viewName = handleException(viewName, e);
        }
        return viewName;
    }
}

 

제일 먼저 Custom Exception을 읽어와 Mapping 정보를 저장할 ExceptionHandlerMapping 클래스를 구현했습니다. 

 

1. ExceptionHandlerMapping 구현

ExceptionHandlerMapping 클래스는 mapper라는 이름의 HashMap 객체를 생성하여 예외 클래스와 예외 클래스의 이름을 매핑해줍니다.

예외 클래스들이 모두 맵에 저장된 후에는 mapper 맵이 최종적으로 반환되어 외부에서 사용될 수 있습니다.

 

/**
 * exception 패키지의 customException 클래스를 <className, Class> 형태로 초기화 시 저장합니다.
 * 이를 통해 예외 발생 시 controller가 어노테이션의 에외 이름으로 map에서 클래스 객체를 가져와서 인스턴스인지 점검할 수 있게 됩니다.
 */
public class ExceptionMapper {
    public static Map<String, Class<?>> doMapException() {
        Map<String, Class<?>> mapper = new HashMap<>();

        try {
            ClassLoader classLoader = ClassLoader.getSystemClassLoader();

            String packageName = "exception"; // 패키지명
            String packagePath = packageName.replace('.', '/'); // 패키지명을 디렉토리 경로로 변환

            //패키지 URL을 불러온다.
            URL packageUrl = classLoader.getResource(packagePath);
            //패키지에서 URLClassLoader 객체를 생성한다.
            URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {packageUrl}, classLoader);

            //package 디렉토리를 읽어온다.
            File packageDir = new File(packageUrl.getFile());

            //package 경로에서 class 파일을 읽어온다.
            File[] classFiles = packageDir.listFiles(file -> file.getName().endsWith(".class"));

            //모든 클래스의 Mapping 정보를 업데이트한다.
            for (File classFile : classFiles) {
                String className = packageName + "." + classFile.getName().replace(".class", "");
                Class<?> exception = urlClassLoader.loadClass(className);

                // 예시 : (UserInfoException.class(String), userinfoexception 클래스)로 map에 저장
                mapper.put(classFile.getName(), exception);
            }
        } catch (ClassNotFoundException e) {
            throw new RuntimeException(e);
        }
        return mapper;
    }
}

 

2. 예외 handling 메서드 구현

위에서 Invocation 예외를 catch했으니, controller에 정의되어있는 ExceptionHandler 메소드를 리플렉션으로 호출해야할 것입니다.

 

로직을 간단하게 적어보겠습니다.

 

1. handlerException 메서드는 현재 클래스에서 선언된 모든 메소드를 가져옵니다. 이 메소드가 속한 클래스는 예외 처리를 위한 핸들러 메소드들이 선언되어 있어야 합니다.

2. 가져온 메소드들 중에 @ExceptionHandler 어노테이션이 존재하는 메소드들을 찾습니다. @ExceptionHandler 어노테이션은 예외 처리를 위한 핸들러 메소드를 표시하기 위해 사용되는 커스텀 어노테이션으로, 해당 어노테이션이 적용된 메소드들만이 예외 처리 로직에 참여합니다.

3. ExceptionMapper 클래스에서 미리 등록된 예외 클래스와 핸들러 메소드의 @ExceptionHandler 어노테이션에 명시된 예외 클래스를 비교하여, 예외 클래스가 일치하는 경우에만 예외 처리 로직을 실행합니다. 예외 클래스는 exceptionMap 변수에서 미리 등록되어 있는 예외 클래스와 매핑되어 있어야 합니다.

4. 예외 처리 로직이 실행되면, 해당 핸들러 메소드를 호출하여 예외를 처리하고, 처리된 view를 리턴합니다.

5. 만약 예외 처리 로직이 실행되지 않은 경우(errorHandledFlag가 false인 경우), 런타임 예외를 다시 던져줍니다.

private String handleException(String viewName, InvocationTargetException e)
        throws IllegalAccessException, InvocationTargetException, InstantiationException {
        boolean errorHandledFlag = false;
        // 모든 메소드를 불러온다.
        Method[] exceptionHandlers = this.getClass().getDeclaredMethods();
        for (Method exceptionMethod : exceptionHandlers) {
            //메소드 중 ExceptionHandler 어노테이션이 있으면 어노테이션을 불러온다.
            if (exceptionMethod.isAnnotationPresent(ExceptionHandler.class)) {
                Annotation annotation = exceptionMethod.getDeclaredAnnotation(
                    ExceptionHandler.class);
                // ExceptionMapper 클래스에서 등록해둔 클래스이름 - 클래스 map에서 일치하는 클래스를 가져온다.
                Class<?> exception = exceptionMap.get(((ExceptionHandler) annotation).exception());
                // 발생한 Invocation으로 Wrapping된 예외가 annotation가 일치하면 예외 로직을 실행한다.
                if (exception.isInstance(e.getTargetException())) {
                    viewName = (String)exceptionMethod.invoke(this.getClass().newInstance());
                    errorHandledFlag = true;
                }
            }
        }
        //Handler가 예외를 처리하지 않았으면 다시 던져준다.
        if (!errorHandledFlag) {
            throw new RuntimeException("Unhandled Exception Occured");
        }
        return viewName;
    }

 

이렇게 웹 서버에서 리플렉션과 커스텀 어노테이션으로 비슷하게 ExceptionHandler의 동작을 구현해보았습니다.

 

마지막으로, 실제 Spring MVC에서는 어떻게 구현되어있는지 살펴보겠습니다.


Spring MVC의 ExceptionHandler 구현


Spring Framework의 소스코드를 찾아서 예외 처리 과정을 분석해보았습니다.

 

1. HandlerExceptionResolvers를 초기화합니다.

(Handler 이름, HandlerResolver)의 Map 형태로 저장합니다. 다른 점은, Spring은 Bean이 초기화 시 등록되기 때문에, ExceptionResolver.class와 일치하는 beans를 간단하게 조회할 수 있습니다.

 

Initialize the HandlerExceptionResolver used by this class.
If no bean is defined with the given name in the BeanFactory for this namespace,
we default to no exception resolver.

 

private void initHandlerExceptionResolvers(ApplicationContext context) {
		this.handlerExceptionResolvers = null;

		if (this.detectAllHandlerExceptionResolvers) {
			// Find all HandlerExceptionResolvers in the ApplicationContext, including ancestor contexts.
			Map<String, HandlerExceptionResolver> matchingBeans = BeanFactoryUtils
					.beansOfTypeIncludingAncestors(context, HandlerExceptionResolver.class, true, false);
			if (!matchingBeans.isEmpty()) {
				this.handlerExceptionResolvers = new ArrayList<>(matchingBeans.values());
				// We keep HandlerExceptionResolvers in sorted order.
				AnnotationAwareOrderComparator.sort(this.handlerExceptionResolvers);
			}
		}
		else {
			try {
				HandlerExceptionResolver her =
						context.getBean(HANDLER_EXCEPTION_RESOLVER_BEAN_NAME, HandlerExceptionResolver.class);
				this.handlerExceptionResolvers = Collections.singletonList(her);
			}
			catch (NoSuchBeanDefinitionException ex) {
				// Ignore, no HandlerExceptionResolver is fine too.
			}
		}

		// Ensure we have at least some HandlerExceptionResolvers, by registering
		// default HandlerExceptionResolvers if no other resolvers are found.
		if (this.handlerExceptionResolvers == null) {
			this.handlerExceptionResolvers = getDefaultStrategies(context, HandlerExceptionResolver.class);
			if (logger.isTraceEnabled()) {
				logger.trace("No HandlerExceptionResolvers declared in servlet '" + getServletName() +
						"': using default strategies from DispatcherServlet.properties");
			}
		}
	}

2. doDispatch는 핸들러로 실제 디스패치를 처리하는 메서드입니다. 적절한 Handler에게 HTTP 요청을 위임하고, Handler 내부에서 발생한 예외는 익숙한 try ~ catch로 잡은 후에 dispatchException에 저장해서 processDispatchResult로 넘겨줍니다.

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
	try {
        ModelAndView mv = null;
        Exception dispatchException = null;

        try {
                //..코드 생략
                //handler Mapping 및 dispatch 로직
            }
            catch (Exception ex) {
                 // handler 내부의 예외를 dispatchException에 저장합니다.
            dispatchException = ex;
        }
            //.. 코드 생략

           //handler에서 발생한 예외를 processDispatchResult에 전달합니다.
            processDispatchResult(processedRequest, response, mappedHandler, mv, dispatchException);
    }

 

3. processDispatchResult에서는 HandlerExecutionChain에서 handler mapping으로 가져와서, processHandlerException에 인자로 넘겨줍니다. 즉, 핸들러 선택 및 핸들러 호출의 결과를 처리합니다. 1에서 초기화했던 Mapping 정보가 handlerExecutionChain에 반영된다고 생각되는데, 코드를 좀 더 읽어보고 정정하겠습니다.

 

private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
			@Nullable HandlerExecutionChain mappedHandler, @Nullable ModelAndView mv,
			@Nullable Exception exception) throws Exception {

		boolean errorView = false;

		if (exception != null) {
			if (exception instanceof ModelAndViewDefiningException) {
				logger.debug("ModelAndViewDefiningException encountered", exception);
				mv = ((ModelAndViewDefiningException) exception).getModelAndView();
			}
			else {
            //Handler Map에서 mappedHandler를 조회한다.
				Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
				mv = processHandlerException(request, response, handler, exception);
				errorView = (mv != null);
			}
		}
        //view의 리턴과 관련된 로직
    }

 

 

4. processHandlerException에서는 catch된 예외를 HandlerExceptionResolvers가 각자 구현하는 resolveException 메서드를 실행해서 예외를 처리합니다. 

 

@Nullable
	protected ModelAndView processHandlerException(HttpServletRequest request, HttpServletResponse response,
			@Nullable Object handler, Exception ex) throws Exception {

		// Success and error responses may use different content types
		request.removeAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);

		// Check registered HandlerExceptionResolvers...
		ModelAndView exMv = null;
		if (this.handlerExceptionResolvers != null) {
			for (HandlerExceptionResolver resolver : this.handlerExceptionResolvers) {
            //예외 처리 로직을 실행, modelAndView 업데이트
				exMv = resolver.resolveException(request, response, handler, ex);
				if (exMv != null) {
					break;
				}
			}
		}
    //이하 modelAndView를 처리하고 return하는 로직
}

이상으로 Spring의 예외처리 흐름을 살펴보았습니다. 저도 사실 Dispatcher Servlet의 코드를 완벽히 이해하지 못해서.. 시간나면 좀 더 코드를 분석해보고 보완하겠습니다. 틀린 사항이 있으면 편하게 지적해주세요.