JDK 동적 프록시 예제를 프록시 체이닝으로 구현...?

들어가기 전에

김영한님의 스프링 핵심 원리 - 고급편 수업을 듣고 있던 중에 LogTraceBasicHandler에 필터링을 추가한다는 말을 듣고 이전 강의에서 말씀해주셨던 프록시 체이닝이 생각이 나서 "FilterHandler 이후에 LogTraceHandler 로 이어지는 프록시 체이닝을 보여주시려나" 보다 하고 있는데 그 둘을 합친 LogTraceFilterHandler을 생성하셔서 구현하시길래 "어라, JDK 동적 프록시는 프록시 체이닝으로 하기 까다로운가?" 라는 생각이 들었습니다.

그렇다면 구현해보면 알 것 같았기 때문에, 한 번 저의 식으로 구현을 해보았습니다. 그리고 아래의 내용은 스프링 AOP와 CGLIB의 진도를 나가기 전에 작성되었습니다.

목표

강의 예제의 구조는 다음과 같습니다.

/**
 * JDK 동적 프록시 사용<br>
 * - {@link InvocationHandler} JDK 동적 프록시에 로직을 적용하기 위한 Handler<br>
 * - {@link PatternMatchUtils#simpleMatch}로 WhiteList 기반 URL 패턴 필터링
 */
@Slf4j
@RequiredArgsConstructor
public class LogTraceFilterHandler implements InvocationHandler {
    private final Object target;
    private final LogTrace logTrace;
    private final String[] patterns;

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        // patterns 에 해당 메서드 이름이 없다면, 바로 목표로 이동
        if (!PatternMatchUtils.simpleMatch(patterns, method.getName())) {
            return method.invoke(target, args);
        }

        // LogTrace 로직 실행
        TraceStatus status = null;
        try {
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
            status = logTrace.begin(message);

            Object result = method.invoke(target, args);

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

보면, 필터를 수행하는 로직과 로깅을 수행하는 로직이 하나의 메서드로 합쳐져 있습니다. 물론, 저 두개의 로직을 메서드로 따로 빼면 될 일이긴 합니다만… 저의 SRP 영혼이 슬프게 울고 있더군요.

그리고 이전 수업 내용에 프록시의 장점 중 프록시 체이닝이란 것도 있기도 했고, Spring의 Filter도 이런식으로 구현되어 있을거니 나눠봤습니다.

제가 구현하고자하는 내용은 아래와 같습니다.

image

흐름만 봐서는 그냥 위의 코드와 동일하지만, 중요한 점은 필터를 담당하는 객체와 로깅을 담당하는 객체가 분리되었다는 것입니다. 그럼 구현을 한 번 해보죠.

구현

FilterHandler

/**
 * 타겟의 메서드 이름을 필터링하는 Handler<br>
 * {@link PatternMatchUtils#simpleMatch}를 이용하여 패턴 검증<br>
 * - 해당 메서드의 이름이 패턴과 일치한다면: {@link #nextHandler}<br>
 * - 해당 메서드의 이름이 패턴과 일치하지 않는다면: {@link #target}
 *
 * @author MinyShrimp
 * @see Proxy
 * @see InvocationHandler
 * @see PatternMatchUtils#simpleMatch(String[], String)
 * @since 2023-03-02
 */
public class FilterHandler implements InvocationHandler {
    private final Object target;
    private final Object nextHandler;
    private final String[] methodPatterns;

    /**
     * @param target         최종 목표 구현체
     * @param nextHandler    다음 ProxyHandler
     * @param methodPatterns 필터링을 원하는 패턴 목록 - {@link PatternMatchUtils#simpleMatch}
     */
    public FilterHandler(
            Object target,
            Object nextHandler,
            String[] methodPatterns
    ) {
        this.target = target;
        this.nextHandler = nextHandler;
        this.methodPatterns = methodPatterns;
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        Object next = PatternMatchUtils.simpleMatch(methodPatterns, method.getName())
                ? nextHandler
                : target;

        return method.invoke(next, args);
    }
}

필터를 담당하는 프록시 핸들러입니다. 이 핸들러는 단순히 입력받은 패턴을 조사해 맞으면 다음 핸들러로, 맞지 않으면 바로 목표 구현체로 이동되도록 구현되었습니다.

생성자를 보시면 알 수 있겠지만, 최종 목표 구현체(여기서는 OrderControllerV1Impl), 다음 프록시 핸들러(여기서는 LogTraceHandler), 그리고 패턴 패칭을 원하는 문자열 배열을 받습니다.

이 예제와는 상관없지만, 개인적으로는 편의를 위해 생성자를 하나 더 만들어서 methodPattern이 배열이 아닌 하나의 문자열만 받을 수 있도록 구현해도 괜찮다고 생각이 듭니다. 하지만, 당장은 사용하지 않기 때문에 제거를 했습니다.

그리고 final 맴버변수를 받기 위해 @RequiredArgsConstructor를 사용해도 괜찮지만, 그렇게 하게되면 위와 같이 주석을 남길 수 없기 때문에 사용하지 않았습니다.

LogTraceHandler

/**
 * Logging Handler<br>
 * {@link LogTrace}를 이용하여 로그 출력
 *
 * @author MinyShrimp
 * @see Proxy
 * @see InvocationHandler
 * @see LogTrace
 * @see ThreadLocalLogTrace
 * @since 2023-03-02
 */
public class LogTraceHandler implements InvocationHandler {
    private final Object target;
    private final LogTrace logTrace;

    /**
     * @param target   목표 구현체, 다음 ProxyHandler
     * @param logTrace {@link LogTrace} 구현체
     */
    public LogTraceHandler(
            Object target,
            LogTrace logTrace
    ) {
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {
        TraceStatus status = null;

        try {
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
            status = logTrace.begin(message);

            Object result = method.invoke(target, args);

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

로깅을 담당하는 프록시 핸들러입니다. 이 핸들러는 기존 강의에서 사용된 LogTrace를 받아 로그 메시지를 출력하는 역할을 수행합니다. 강의에서 제작한 LogTraceBasicHandler와 동일하기 때문에 설명을 생략합니다.

다만, 정말 개인적인 아쉬움이긴 합니다만, 위의 TraceStatus를 받아옴에 있어서 단순히 저 Exception 하나 때문에 status 변수를 try 외부에 null로 선언하고 재할당 해주는 부분이 너무 아쉬웠습니다. 이를 해결하기 위해선 몇가지 방법이 있긴 합니다만, 이전 시간에 배운 ThreadLocal로 한 번 바꿔보겠습니다. ( 단순히 멤버 변수로 할당하면 동시성 문제가 발생합니다. )

public class LogTraceHandler implements InvocationHandler {
    // 동시성 문제를 해결하기 위해 ThreadLocal 사용
    private final ThreadLocal<TraceStatus> thStatus = new ThreadLocal<>();

    // 중간 부분 생략

    @Override
    public Object invoke(
            Object proxy,
            Method method,
            Object[] args
    ) throws Throwable {

        try {
            String message = method.getDeclaringClass().getSimpleName() + "." + method.getName() + "()";
            TraceStatus status = logTrace.begin(message); // 여기서 생성
            thStatus.set(status); // ThreadLocal에 저장

            Object result = method.invoke(target, args);

            logTrace.end(status);
            return result;
        } catch (Exception e) {
            TraceStatus status = thStatus.get(); // ThreadLocal에서 값을 가져옴
            logTrace.exception(status, e);
            throw e;
        } finally {
            if (logTrace.isFirstLevel()) {
                thStatus.remove(); // 사용을 마치면 제거하자.
            }
        }
    }
}

재할당하는 부분이 사라졌습니다! 이제 status를 받아오기 위해 ThreadLocal.get()을 사용하면 됩니다.

그런데 여기서 주의사항이 있습니다. ThreadLocal의 사용이 끝나면 반드시 remove를 통해 지워줘야 합니다. 그래서 위와 같이 finally를 이용해 TraceId의 Level이 0인지 확인하고 0이면 remove를 하도록 작성해보았습니다. TraceId는 LogTrace가 가지고 있으니 넘겨주면 되겠군요.

public class ThreadLocalLogTrace implements LogTrace {
    // 중간 생략
    // LogTrace 인터페이스에도 추가해줍니다.
    @Override
    public boolean isFirstLevel() {
        return traceIdHolder.get().isFirstLevel(); // 그대로 넘겨줍니다.
    }
}

좋습니다. 해결이 된 것 같군요. …과연 그럴까요? 이것 또한 버그가 있습니다.

TraceIdThreadLocalLogTrace에서 ThreadLocal로 잡고 있는 값입니다. 이것 또한 우리는 이전 시간에서 remove를 해주었습니다.

public class ThreadLocalLogTrace implements LogTrace {
    // 중간 생략
    /**
     * 이전 TraceID 로 전환<br>
     * - {@link #complete}에서 호출
     */
    private void releaseTraceId() {
        TraceId traceId = traceIdHolder.get();
        if (traceId.isFirstLevel()) {
            traceIdHolder.remove(); // TraceId의 ThreadLocal을 제거한다.
        } else {
            traceIdHolder.set(traceId.createPreviousId());
        }
    }
}

겉보기에는 문제가 없어보입니다. 맞습니다. 평소에는 문제가 없습니다. 그런데 FirstLevel이 0이 되었을 때 문제가 발생합니다. 찬찬히 살펴보죠.

위의 코드에서는 TraceStatus를 제거할 때는 finally에서 진행되고, TraceId를 제거할 때는 releaseTraceId에서 제거됩니다. 그리고 이 releaseTraceId는 end()exception()메서드에서 실행됩니다!

즉, TraceStatus를 제거하기 위해 isFirstLevel() 메서드에서 traceIdHolder.get()을 사용하면, null을 리턴합니다. 그리고, null.isFirstLevel()NPE를 발생시킵니다. 그래서 아래와 같이 수정되어야 합니다.

@Override
public boolean isFirstLevel() {
    // releaseTraceId에서 제거되었다면 TraceId의 Level도 0이라는 소리.
    return traceIdHolder.get() == null; 
}

DynamicProxyConfig

/**
 * JDK 동적 {@link Proxy}를 스프링 빈으로 등록하기 위한 설정 파일
 *
 * @author MinyShrimp
 * @see Proxy
 * @see FilterHandler
 * @see LogTraceHandler
 * @since 2023-03-02
 */
@Configuration
public class DynamicProxyConfig {

    /**
     * {@link FilterHandler}에서 사용하는 필터링 조건들, Whitelist 방식.
     */
    private static final String[] METHOD_PATTERNS = {
            "request*", "order*", "save*"
    };

    /**
     * @param target   최종 목표 구현체, 예) {@link OrderControllerV1Impl}
     * @param logTrace {@link LogTrace}
     * @return {@link FilterHandler} -> {@link LogTraceHandler} -> {@link OrderControllerV1Impl}
     */
    private static Object filterLogProxyFactory(
            Object target,
            LogTrace logTrace
    ) {
        // 타겟이 상속받은 인터페이스들 중 첫 번째를 가져온다.
        // 해당 예제의 목표 타겟인 app.v1 들의 구현체들은 모두 인터페이스를 하나만 가지고 있기 때문에 가능하다.
        Class<?> superIntf = target.getClass().getInterfaces()[0];

        // LogTraceProxy 생성
        Object logTraceProxy = Proxy.newProxyInstance(
                superIntf.getClassLoader(),
                new Class[]{superIntf},
                new LogTraceHandler(target, logTrace)
        );

        // LogTraceProxy, 목표 타겟을 담은 FilterProxy 생성
        return Proxy.newProxyInstance(
                superIntf.getClassLoader(),
                new Class[]{superIntf},
                new FilterHandler(target, logTraceProxy, METHOD_PATTERNS)
        );
    }

    /**
     * @return {@link OrderControllerV1Impl}의 Proxy
     */
    @Bean
    OrderControllerV1 orderControllerV1(LogTrace logTrace) {
        OrderControllerV1Impl target = new OrderControllerV1Impl(orderServiceV1(logTrace));
        return (OrderControllerV1) filterLogProxyFactory(target, logTrace);
    }

    /**
     * @return {@link OrderServiceV1Impl}의 Proxy
     */
    @Bean
    OrderServiceV1 orderServiceV1(LogTrace logTrace) {
        OrderServiceV1 target = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
        return (OrderServiceV1) filterLogProxyFactory(target, logTrace);
    }

    /**
     * @return {@link OrderRepositoryV1Impl}의 Proxy
     */
    @Bean
    OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
        OrderRepositoryV1Impl target = new OrderRepositoryV1Impl();
        return (OrderRepositoryV1) filterLogProxyFactory(target, logTrace);
    }
}

위에서 제작한 FilterHandler와 LogTraceHandler를 스프링 빈으로 등록하기 위한 설정 클래스입니다. 기본 베이스는 기존 강의에서 제작한 DynamicProxyFilterConfig와 동일합니다. 차이점이 있다면 각 빈에서 프록시 핸들러를 반환할때 발생하던 중복 코드를 filterLogProxyFactory() 메서드로 합쳐준 것 뿐입니다.

그렇기 때문에 가장 중요한 filterLogProxyFactory()에 대해 설명하겠습니다.

/**
 * 이 메서드를 사용하기 위해선 FilterHandler와 LogTraceHandler는
 * target의 첫 번째 인터페이스를 기반으로 프록시를 생성할 수 있어야 한다.
 *
 * @param target   1. 최종 목표 구현체, 예) {@link OrderControllerV1Impl}
 * @param logTrace {@link LogTrace}
 * @return {@link FilterHandler} -> {@link LogTraceHandler} -> {@link OrderControllerV1Impl}
 */
private static Object filterLogProxyFactory(
        Object target,
        LogTrace logTrace
) {
    // 2. 타겟이 상속받은 인터페이스들을 가져온다.
    Class<?>[] supIntfs = target.getClass().getInterfaces();

    // 3. LogTraceProxy 생성
    Object logTraceProxy = Proxy.newProxyInstance(
            supIntfs[0].getClassLoader(),
            supIntfs,
            new LogTraceHandler(target, logTrace)
    );

    // 4. LogTraceProxy, 목표 타겟을 담은 FilterProxy 생성
    return Proxy.newProxyInstance(
            supIntfs[0].getClassLoader(),
            supIntfs,
            new FilterHandler(target, logTraceProxy, METHOD_PATTERNS)
    );
}

이 메서드의 과정은 다음과 같습니다.

  1. 타겟 객체(OrderControllerV1Impl)를 파라미터로 받아온다.

  2. 타겟이 상속받은 인터페이스들의 정보(supIntfs)를 가져온다.

  3. 그 정보를 이용해 LogTraceHandler의 프록시(logTraceProxy)를 생성한다.

  4. FilterHandler의 프록시를 생성하고 반환한다.

기존 코드와 다른 점은 newProxyInstance()를 사용할 때, ClassLoader를 타겟의 첫 번째 인터페이스로 가져오는 것과, 두 번째 인자에 타겟의 모든 인터페이스들을 넣어 주는 부분입니다.

이렇게 한 이유는 다름이 아니라, 이 메서드를 사용하는 모든 타겟 객체가 하나의 인터페이스만 상속받으며, 그 인터페이스를 이용해 핸들러들을 프록시로 만들어도 문제가 없기 때문입니다. 만약, 여러 개의 인터페이스를 상속받으며, 첫 번째 인터페이스를 기반으로 프록시를 생성하면 안되는 경우에는 위의 메서드를 사용할 수 없습니다. 그 때는 파라미터를 하나 추가해서 ClassLoader를 받아오면 됩니다.

추가로 주의할 점은 FilterHandler 생성자에 LogTraceHandler를 주입한 것이 아닌, LogTraceProxy를 주입했다는 점입니다. LogTraceHandlerinvoke 함수가 제공되지만, 이는 Methodinvoke 함수와 무관하기 때문에 작동되지 않습니다. (예외가 발생합니다.)

참고로, MainApplication에 해당 설정 파일을 등록하는 부분은 생략했습니다.

완성…?

image

이 구조를 완성했습니다. 구조 자체는 간단합니다만, 이쁘게 만들려다보니 신경써야할 부분이 좀 많았습니다.

물론, 처음에 코드를 작성할 때 TraceStatus를 멤버 변수로 등록했다가 동시성 문제도 터지고, 위에서 설명한 finally에서도 NPE가 발생하기도 하고, FilterHandler에 LogTraceProxy를 넣어야하는데 LogTraceHandler를 넣어서 체이닝이 안되기도 했습니다만 구현해 놓고 보니 뿌듯합니다.

하지만 아직 미심쩍은 부분과 아쉬운 부분이 보입니다.

“ThreadLocal가 좋은건 알겠는데 이렇게 많이 사용해도 괜찮나…?”

ThreadLocal의 가장 큰 주의점은 쓰레드 풀 환경에서 remove를 해주지 않는다면 다른 유저가 그 정보를 볼 수 있다는 점입니다. 그리고 ThreadLocalMap은 크기가 커지면 커질 수록 2배의 크기로 할당한다는 점입니다. 지금까지는 별 문제가 없어보이지만, 다른 프로그래머가 코드를 수정하여 remove가 실행이 안되게 되거나, 비즈니스 로직 변경으로 인해 급하게 수정하다가 remove를 놓치게 되면 위의 문제는 꽤 심각하게 발생합니다. 이때는 어떻게 대처를 해야하나요? 아니면 위 문제는 별로 신경쓰지 않아도 되나요?

“지금처럼 핏한 상황은 잘 작동하지만, 이 코드를 확장하려면 고칠 부분이 많네…”

꽤 만족할만한 코드 퀄리티라고 생각은 합니다만, 위의 코드를 재사용하여 확장해야 하는 상황이 온다면 고쳐야할 부분이 눈에 들어옵니다. 일례로, 목표 타겟의 인터페이스 상속이 늘어나고 순서가 바뀌면 위의 코드의 filterLogProxyFactory는 더이상 사용할 수 없습니다.

그리고 Handler가 늘어나면 설정 파일에서 그에 맞게 주입을 먼저 해주어야합니다. 이는 전략 패턴의 단점과도 연결됩니다. 또한, FilterHandler 와 같이 분기점에 따른 Proxy 변경도 많아지게 되면 일반화를 진행해야합니다.(BranchHandlerFactory 와 비슷한 이름으로..)

정리

사실, 위의 로직들은 JDK 동적 프록시가 아닌 다른 방법으로 구현하는게 맞습니다. 스프링 MVC에서 배운 필터와 인터셉터도 있고, (저는 아직 진도를 안나갔습니다 만은)앞으로 배울 스프링 AOP가 해결 방법이 될 수도 있습니다. 그럼에도 이렇게 시간을 들여 글을 쓰는 이유는 다음과 같습니다.

코드를 구현할 때 어떤 방식으로 구현하는지 생각을 정리하고 기록을 남기기 위함이었습니다.

혼자서 코드를 작성하는 것은 언제나 자신과의 싸움을 하고 있다는 말과 같습니다. 보통 이런 상황에서 누군가에게 피드백을 받기란 요원한게 사실입니다. 그리고 그 기간이 길어지면 길어질수록 현재 자신의 위치가 어느 정도인지 짐작조차 할 수 없게 되고 다른 사람에게 물어볼때 어떤 방법으로 물어봐야하는지 모를 수 밖에 없습니다.

“지금 짜고 있는 코드가 좋은 코드인가?” 과연 어떤 프로그래머가 이 생각을 하지 않겠냐만은, 혼자서 코드를 작성하면 정말 나쁜 코드를 작성하더라도 위에 대한 판단을 내릴 수가 없습니다. 또한, 나쁜 코드를 벗어나서 좋은 코드로 향하는 방법을 알고 싶어도 키워드를 모르니 방법이 없는 거지요.

그래서 저의 코드 구현 방식을 공유하고 다른 분들에게 피드백을 받기 위해 이 글을 작성했습니다.

현재 구현한 이 방법보다 더 좋은 방법이 무엇인지, 어떤 사이트 이펙트가 발생하는지, 내가 생각하고 있는 개념이 맞는지, JavaDoc 쓰는 방법은 올바른지, 등등 알고 싶은게 많습니다. (그러니까 비-법 소스 주세요!!!)

꼭 긴글이 아니더라도 지나가는 말처럼 짧은 키워드만 툭툭 던져주셔도 저같이 공부하시는 분들에게는 많은 도움이 됩니다.

 

긴 글 읽어주셔서 감사드리며, 저와 같은 다른 취준생 여러분들도 다 같이 화이팅입니다. ^^7

댓글을 작성해보세요.

채널톡 아이콘