[Project]/[Momo]

[이슈 #5] Spring Life Cycle Event를 이용한 런타임에러를 컴파일 시점으로 변경하기

DevLoki 2022. 8. 5. 15:00

현 프로젝트에서 분산락을 적용하는 방법은 MySQL의 USER_LEVEL_LOCK, 즉 NAMED_LOCK 방식입니다.

NAMED_LOCK은 get_lock() 함수를 이용해 임의의 문자열에 대해 잠금을 거는 방식으로 동작합니다. 따라서 임의의 문자열인 LockName이 반드시 지정되어야 합니다.

 

분산락 AOP를 사용하는 요청이 발생할 때마다, 분산락 Advice에서 파라미터에 @LockName 애노테이션이 존재하는지 검사하며, 해당 파라미터가 존재하지 않으면 다음 그림과 같이 런타임예외를 던지는 구조로 동작합니다.

LockName 검사 프로세스

위처럼 LockName애노테이션의 부재로 인해 예외를 던지는 것은 사용자의 잘못된 요청이 아닌 개발자의 실수가 원인입니다. 또한, 예외 처리 과정에서 별도의 핸들링 로직 없이 사용자에게 5XX의 상태를 반환하며 반복되는 요청에도 같은 에러를 반환하게 됩니다.

따라서 LockName 부재로 인해 발생하는 문제는 컴파일 시점에서 확인할 수 있어야 합니다. 

컴파일 시점에서 해당 LockName 부재를 발견하기 위해 Spring Life Cycle Event를 사용해 애플리케이션이 시작되는 시점에 분산락 AOP가 적용된 메서드를 모두 검사하고 LockName이 존재하지 않는 메서드가 있는 경우 에러로그를 남기며 비정상 종료를 하도록 설계했습니다.

 

우선, 애플리케이션 시작 시점에 분산락이 적용되어 있는 메서드들을 검사하는 방법에 대해 고민했습니다.

 

프로젝트 구조 상 분산락은 서비스 계층에만 적용되어 있습니다. 해당 메서드들은 @Service가 적용된 클래스에 존재한다는 공통점을 발견하였고 ApplicationContext로부터 Bean LookUp을 통해 서비스 클래스들을 조회했습니다.

ApplicationContext에 등록된 빈을 조회해야 하기 때문에 빈생성 및 초기화, 등록이 적용된 이후인 ApplicationReadyEvent를 사용했습니다.

Bean LookUp Process

동작 방식은 다음과 같습니다.

  • ApplicationContext에서 @Service가 적용된 빈들을 조회합니다.
  • 각 Bean에서 getMethods() 를 통해 해당 클래스 내에 선언된 메서드들을 Method[] 형태로 가져옵니다.
  • 각 메서드에 애노테이션 적용 여부를 판단하는 isAnnotationPresent() 메서드를 통해 @DistributedLock이 적용되어 있는지 판단합니다.

하지만 적용 결과는 예상과 달랐습니다. 여러 메서드에 @DistributedLock을 적용했음에도 불구하고 단 한개의 메서드도 조건에 부합하지 않았습니다.

빈 메서드에서 @DistributedLock이 보이지 않는 이유

그 이유는 빈이 AOP가 적용된 프록시이기 때문입니다. AOP가 적용된 클래스들은 빈으로 등록되는 과정에서 프록시를 생성합니다. 프록시는 실제 객체에 대한 참조를 가지며, 프록시 자체는 Advice를 적용하고 실제 타깃을 호출하여 요청을 처리하게 됩니다. 

 

따라서 ApplicationContext가 아닌 ClassLoader를 사용하여 클래스들을 조회하는 방법을 사용하였습니다.

ClassLoader를 사용하는 방법은 ApplicationContext가 필요하지 않기 때문에 기존의 ApplicationReadyEvent를 ApplicationStartingEvent로 변경하여 ApplicationContext가 생성되기 전에  동작하도록 하였습니다.

클래스 로더 계층 구조

ClassLoader는 BootStrapClassLoader, PlatformClassLoader, SystemClassLoader 중 ClassPath에 있는 클래스 파일과 jar에 속한 클래스들을 로드하는 SystemClassLoader를 사용하였습니다. 상위 ClassLoader에서는 가시 범위 원칙에 의해 하위 클래스로더의 클래스를 알 수 없게 되기 때문입니다.

 

 마지막으로 이벤트를 등록하는 과정에서 @Component를 사용하여 빈등록 해주는 경우 Event가 정상적으로 동작하지 않았습니다.

@Component
public class LockNameVerifyEvent{
    @EventListener(ApplicationStartingEvent.class)
    public void verifyLockName() {
        //로직
    }
}

그 이유는 ApplicationStartingEvent는 ApplicationContext가 생성되기 이전에 동작하는 Event이기 때문입니다. 따라서 해당 이벤트를 직접 ApplicationRunner에 등록하여 사용했습니다. 코드는 다음과 같습니다.

public class LockNameVerifyEvent implements ApplicationListener<ApplicationStartingEvent> {
    private static final int UNSUCCESSFUL_TERMINATION = -1;
    private static final String SERVICE_PACKAGE_PATH = "com/project/momo/service";
    private static final String CLASS_EXTENSION_NAME = ".class";
    private static final String CLASS_NOT_FOUND_ERROR_MSG = "error thrown during application ready event\n";
    private static final String LOCK_NAME_NOT_FOUND_MSG = "@LockName Not Found";
    private static final String ERROR_MSG_FORMAT = "Error : %s\n" +
            "path : %s\n" +
            "method : %s\n" +
            "Parameters : %s\n\n";

    @Override
    public void onApplicationEvent(ApplicationStartingEvent event) {
        List<? extends Class<?>> classList = getClassesByPackageName(SERVICE_PACKAGE_PATH);
        List<Method> noLockNameMethodList = getMethodsWithDistributedLock(classList)
                .filter(Predicate.not(this::hasLockName))
                .collect(Collectors.toList());
        if (!noLockNameMethodList.isEmpty()) {
            noLockNameMethodList.forEach(method -> System.err.printf(ERROR_MSG_FORMAT,
                    LOCK_NAME_NOT_FOUND_MSG,
                    method.getDeclaringClass().getName(),
                    method.getName(),
                    Arrays.stream(method.getParameters())
                            .map(Parameter::getType)
                            .collect(Collectors.toList())));
            System.exit(UNSUCCESSFUL_TERMINATION);
        }
    }

    private Stream<Method> getMethodsWithDistributedLock(List<? extends Class<?>> classList) {
        return classList
                .stream()
                .map(Class::getDeclaredMethods)
                .flatMap(Arrays::stream)
                .filter(method -> method.isAnnotationPresent(DistributedLock.class));
    }

    private boolean hasLockName(Method method) {
        return Arrays.stream(method.getParameterAnnotations())
                .flatMap(Arrays::stream)
                .anyMatch(annotation -> annotation instanceof LockName);
    }

    private List<? extends Class<?>> getClassesByPackageName(String packagePath) {
        return new BufferedReader(new InputStreamReader(Objects.requireNonNull(ClassLoader
                .getSystemClassLoader()
                .getResourceAsStream(packagePath))))
                .lines().filter(line -> line.endsWith(CLASS_EXTENSION_NAME))
                .map(className -> loadClassByName(className, packagePath))
                .collect(Collectors.toList());
    }

    private Class<?> loadClassByName(String line, String packagePath) {
        try {
            return Class.forName(packagePath
                            .replaceAll("/", ".")
                            + "."
                            + line.substring(0, line.lastIndexOf('.')
                    )
            );
        } catch (ClassNotFoundException e) {
            System.err.println(CLASS_NOT_FOUND_ERROR_MSG + e);
            System.exit(-1);
            return null;
        }
    }
}

 

@SpringBootApplication
public class MomoApplication {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(MomoApplication.class);
        app.addListeners(new LockNameVerifyEvent());
        app.run(args);
    }
}

결과