공통 로직과 서비스 로직 분리 해보기. #2 Feat Proxy Pattern, 동적프록시 기초

BaekGwa·2024년 9월 6일
0

Spring

목록 보기
4/9
post-thumbnail

공통 로직과 서비스 로직

이전에는 Template Method Pattern, Strategy Pattern을 통해서 공통 로직과 서비스 로직을 분리하여 단일 책임을 지키도록 하였다.
이전 글

  • 템플릿 메서드 패턴을 예로 디자인 패턴을 적용하기 전보다 많은 코드 중복을 줄일 수 있었다.
  • 하지만, 그럼에도 로직 내, 템플릿 메서드나 전략패턴을 위한 코드가 상당수 들어가게 되었습니다.
  • 실제로 필요한 로직은 execute에 전달되는 코드만 전부 입니다.
    • 이는, 완벽한 단일 책임 원칙을 지켰다고 보기는 어렵습니다.
  • 다음같은 디자인 패턴을 적용하면 공통로직과 서비스 로직의 완벽한 역할 분리를 진행할 수 있습니다.

Proxy Pattern (프록시 패턴)

  • 먼저 프록시 패턴 부터 알아보도록 하겠습니다.

프록시 패턴이란?

프록시(Proxy) 패턴은 대리자 역할을 하는 객체를 통해, 실제 객체에 대한 접근을 제어하거나 추가적인 작업을 수행하는 구조적인 디자인 패턴입니다. 프록시 객체는 실제 객체와 동일한 인터페이스를 구현하거나 상속하여 클라이언트가 프록시를 통해 마치 실제 객체처럼 접근할 수 있도록 합니다.

  • 이전 포스팅에서 참고한 요구사항은 다음과 같았습니다.

  • 만약, 이 요구사항에 Running Time을 ms단위에서 sec 단위로 변경해주세요! 라고 접수되면, 개발자는 경우에 따라, 모든 class 파일을 뒤지며 수정해야되는 아름답지 못한 일이 일어날 수 있습니다.

  • 프록시 패턴은 이 점을 완벽하게 보안합니다.

기본적인 흐름

  • 위의 흐름은, v0 코드로 소개될 기본 흐름입니다.
    • Controller는 Interface로 잘 구현하지 않기 때문에, 바로 구현체로 선언해서 컴포넌트 스캔을 통해 Bean 등록 진행 하겠습니다.

  • 아래는 프록시 패턴이 적용된 후, 흐름입니다.
  • 실제로 Bean 객체로 등록될 인스턴스는 Proxy 객채가 등록 될 예정이고, 이 객체가 실행되며 공통 로직을 수행하게 될 예정입니다.

기본 예제 코드

예제에 사용한 코드 : Github

  • baekgwa.proxypattern.web.app 참조
  • 사용한 Config = ApplicationConfig
  • 간단하게 Mapping 된 URI로 요청을 받아 컨트롤러-서비스-레포지토리 순서로 작업을 chaining 하며 결과적으로는 body에 "ok"를 반환하는 예제 입니다.
    • 개발 흐름 상, 의존성 주입 방식을 컴포넌트 스캔 방식으로 진행하려다가, 어려움이 있어 수동 등록으로 진행 하였습니다.
  • 해당 결과는 결론적으로 다음과 같은 Logging을 남기기 원합니다.

요구사항은 다음과 같습니다.

  • 로깅을 남겨주세요. Thread 별로 식별될 수 있도록 id를 임의로 부여 해 주세요.
    • ID는 의미 없이 중복만 허용하지 않습니다.
  • 완벽한 책임 분리를 위해서, 기존 코드는 건드리지 않도록 해주세요.
  • 원하지 않는 클래스나, 메서드는 로깅 하지 않도록 해주세요. (개발자가 선택 가능하도록 해주세요.)

  • 이제 이 개발 요구사항에 맞춰 프록시 패턴을 사용하여 작업을 진행 해 보겠습니다.

프록시 패턴 적용

예제에 사용한 코드 : Github

  • baekgwa/proxypattern/gloabl/config/v1_proxy 참조
  • 사용한 Config = InterfaceProxyConfig
  • 다음과 같은 프록시 객체를 생성합니다.
  • 실제 사용할 객체를 의존성 주입받아 실행하는데, 앞 뒤로 로깅을 위한 작업이 추가되도록 합니다.
  • 이후, Service와 Repository의 Bean 등록을 다음과 같이 진행합니다.
  • 해당 코드를 간단히 해석해 보면, TestService를 Bean 등록을 진행하는데, 구현체는 TestServiceInterfaceProxy를 등록합니다. 즉 프록시 객체를 등록하죠
  • 해당 프록시 객체에서는 실제 구현체를 호출해야 하기 때문에 생성자를 통해 주입받도록 파라미터로 전달 해줍니다.
  • 이렇게 진행하면, Controller에서 의존성을 주입받을 때, Proxy 객채를 주입 받을 수 있습니다.

  • 훌륭하게 적용이 되는 모습입니다.

문제점

  • 개발 요구사항은 맞추었습니다.
  • 개발자가 원하는 원본 코드는 손대지 않아도 됩니다.
  • 단, 새로운 Proxy 객체를 개발자가 직접 만들어야 되는 엄청나게 큰 문제가 발생했습니다. 아주 큰문제입니다. 코딩을 2배로 해야됩니다.
  • 해당 문제점을 CGLIBJDK 동적 프록시 를 통해 회피 해 보겠습니다.
    • 해당 기능을 사용하면 알아서 프록시 객체를 만들어줍니다. (물론, 어떤 프록시 객체가 만들어지면 되는지 Template는 필요 합니다.)

동적 프록시 생성

  • 말 그대로 동적으로 프록시 객체를 생성하는 기술을 뜻합니다.
  • 내가 원할때마다 실행할 코드에 알맞은 프록시 객체를 생성하는 것을 의미합니다.
  • 이 동적 프록시 기능을 이해하기 위해서는, Java의 리플렉션 기술을 이해하는 것이 필요합니다.

리플렉션

리플렉션이란, 클래스나 메서드의 메타 정보를 동적으로 획득하고, 코드도 동적으로 호출하는 방법입니다.

  • 만약, 다음과 같은 코드가 있습니다. 실제로 동작하는 흐름은 똑같은데, 중간에 호출하는 메서드가 달라 메서드화가 어려운 코드입니다.
@Slf4j
public class JustReflectionTest {

    @Test
    void 문제코드() {
        Printer printer = new Printer();

        log.info("시작");
        printer.printA();
        log.info("끝");

        log.info("시작");
        printer.printB();
        log.info("끝");
    }

    @Slf4j
    static class Printer {
        public void printA(){
            log.info("A");
        }

        public void printB(){
            log.info("B");
        }
    }
}
  • 만약, 저 printA, printB 부분을 무언가를 공통 파라미터로 받을 수 있으면 공통 처리가 가능할 것 같습니다.
  • 이때 적용 가능한 기술이 리플렉션 입니다.
    @Test
    void 공통_메서드로_처리()
            throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Printer printer = new Printer();
        Class printerClass = Class.forName(
                "baekgwa.proxypattern.reflection.JustReflectionTest$Printer");

        dynamicCall(printerClass.getMethod("printA"), printer);
        dynamicCall(printerClass.getMethod("printB"), printer);
    }

    private void dynamicCall(Method method, Object obj)
            throws InvocationTargetException, IllegalAccessException {
        log.info("시작");
        method.invoke(new Object());
        log.info("끝");
    }
  • 다음과 같이, Class를 ClassName을 통해 로드 합니다.
  • 로드된 Class를 사용해서 메서드 이름을 통해 method를 메타 정보를 추출하고, 이를 invoke()를 통해 실행 하도록 합니다.
  • 코드는 더 길어 졌지만, 공통 처리가 가능하다는 점에서 훌륭한 수단 입니다.
  • 단, 주의할 점으로는, 해당 작업 중, Method를 잘못된 것을 불러 온다면 RunTime에서 Error/Exception이 발생한다는 점 입니다.
  • 이 기술을 사용하기 전에는, 잘못된 메서드를 실행시키면 컴파일 단계에서 없는 메서드라고 에러가 발생합니다.
  • 리플렉션은 런타임 과정에서 method를 name을 통해 찾기 때문에, 컴파일 단계에서는 문제를 확인 할 수 없습니다.

동적 프록시 생성

  • 동적 프록시를 생성하는 과정에는 크게 두가지 경우가 있습니다.
    • Interface 객체를 프록싱
    • 일반 구현체 (Concrete) 객체를 프록싱
  • 앞에서는 Interface 객체만을 프록시 객체를 만들어 @Bean 등록 해주었지만, Concrete 객체를 프록싱 해야하는 경우도 생깁니다.

  • 다양한 프록시 라이브러리가 존재 하는데, 대표적으로는 다음과 같습니다.
    • Interface 객체 프록싱 -> JDK 동적 프록시
    • 인터페이스가 없어도 프록싱 -> CGLIB
      • CGLIB은 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술입니다.

동적 프록시 생성 방법

  • JDK 동적 프록시와 CGLIB 프록시의 생성 방법은 다르나, Spring 에서는 이러한 기술을 집약해 프록시 객체를 만들 수 있는 Factory를 지원합니다.
    • 두가지 모두 Spring을 사용하지 않고 생성할 수도 있지만, 효율성이 떨어져 다루지는 않습니다.

동적 프록시를 생성하기 위해서는 Advisor가 필요합니다.

Try try!

예제에 사용한 코드 : Github

  • baekgwa/proxypattern/gloabl/config/v2_proxyfactory 참조
  • baekgwa/proxypattern/gloabl/config/advice 참조
  • 사용한 Config = ProxyFactoryConfig
  • 먼저, Advice 를 만들어 보겠습니다. (Advice란? 아래에서 설명하겠습니다)
  • Advice를 구현하기 위해서 MethodInterceptor 를 implement 하여 사용합니다.
    • 이때, org.aopalliance.intercept를 import 해야 합니다!
    • 같이 나오는 CGLIB의 MethodInterceptor는, CGLIB를 위한 객체 입니다.
@RequiredArgsConstructor
public class LoggerAdvice implements MethodInterceptor {

    private final Logger logger;

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {

        Class<?> targetClass = invocation.getThis().getClass();
        String message = targetClass.getSimpleName() + "." + invocation.getMethod().getName() + "()";
        LogInfo logInfo = logger.start(message);

        Object result = invocation.proceed();

        logger.end(logInfo);

        return result;
    }
}
  • 다음으로, Pointcut 객체를 생성하면 되는데, 이는 @Configuration 파일에서 같이 진행하도록 하겠습니다.
@Configuration
@RequiredArgsConstructor
@Slf4j
public class ProxyFactoryConfig {

    private final Logger logger;

    @Bean
    public TestService testService(){
        TestServiceImpl testService = new TestServiceImpl(testRepository());
        ProxyFactory factory = new ProxyFactory(testService);
        factory.addAdvisor(getAdvisor());

        TestService proxy = (TestService) factory.getProxy();
        log.info("TestService 프록시 객체 생성 완료");
        return proxy;
    }

    @Bean
    public TestRepository testRepository() {
        TestRepositoryImpl testRepository = new TestRepositoryImpl(); //실제 동작할 구현체
        ProxyFactory factory = new ProxyFactory(testRepository);
        factory.addAdvisor(getAdvisor());

        TestRepository proxy = (TestRepository) factory.getProxy();
        log.info("TestRepository 프록시 객체 생성 완료");
        return proxy;
    }

    private Advisor getAdvisor(){

        //Pointcut 생성
        //포인트 컷은, 필터와 같은 역할을 한다.
        //다양한 포인트 컷이 있다,
        // NameMatchMethodPointcut 같은 경우, 메서드와 매칭된 이름을 기준으로 필터링 한다.
        Pointcut pointcut = Pointcut.TRUE; //무조건 통과하는 Pointcut

        //Advice
        //필터링 되고 실제로 프록시 객체가 생성될 때 실행될 로직
        //MethodInterceptor 를 구현한 객체가 들어가면 된다.
        LoggerAdvice advice = new LoggerAdvice(logger);

        return new DefaultPointcutAdvisor(pointcut, advice);
    }
}
  • ProxyFactory를 사용하여 새로운 프록시 객체를 만듭니다.
    • 인스턴스를 생성할 때, 생성자로 넣어줄 object는 target으로, 프록시 객체가 생성되어야 할 구현체가 들어가면 됩니다.
  • 다음으로, 해당 factory에, Advisor를 넣어줍니다. (Advisor != Advice)
    • getAdvisor() 에서는, advisor를 만들어서 반환하는데, advisor는 pointcutadvice를 주입하여 만들어 집니다.
    • 현재는 Pointcut을 그냥 Pointcut.TRUE로 넣어주어, 무조건 통과하도록 만들어 주겠습니다. (뒤에서 다시 한번 자세히 다룰 예정입니다.)
  • factory.getProxy()를 호출해서, 프록시 객체를 return 받고, 다운캐스팅을 통해 각각의 인터페이스에 맞는 객체로 생성하고 반환하여 Bean 등록을 완료 해줍니다.
  • 이렇게 되면, Bean에는 TestRepository 형태로, Proxy 객체가 등록되어있을 것 입니다.
    • 이 Proxy 객체는 advisor로 동작을 할 예정이고, 이 동작은 아까 구현한, LoggerAdvice를 따라 작동하게 될 예정입니다.

결과 확인

  • 정상 동작 확인!!

Advisor 란?

  • Advisor란, spring에서 제공하는 Interface 객체로, 프록시 객체를 생성하기 위해 어디에, 어떻게라는 구현을 담고있는 객체 입니다.
  • 어디에에 해당하는 내용은 Pointcut
  • 어떻게에 해당하는 내용은 Advice 라고 이해하시면 편합니다.
  • 즉, 이 두개를 모두 가지고 있는 객체를 Advisor 라고 하고, 이를 통해서 어디에 어떤 프록시를 생성할 지 결정할 수 있습니다.

Advice

  • Advice는 Advisor의 구성품 중 하나로, 어떻게에 해당하는 내용입니다.
  • MethodInterceptor를 통해, Adviser를 구현하게 되면, invoke() 메서드를 Overriding 해야하는데, 이 메서드가 바로, 프록시 객체가 동작할 동작을 구현하는 부분입니다.
  • 또, MethodInvocation 파라미터를 제공하는데, 이 안에는 실행될 객체의 정보를 확인할 수 있습니다.
    • 예를들어. invocation.getThis().getClass() 를통해 targetClass의 정보를 확인할 수도 있고, invocation.getMethod()를 통해 메소드 정보를 가져 올 수도 있습니다.

Pointcut

  • Pointcut은, Advisor 에서 필터링의 역할을 합니다.
  • 현재, 코드에서는 Pointcut.TRUE를 통해 기능을 잠시 막아두었지만, 다음과 같이 설정한다면 메서드 명칭 단위로 필터링을 진행 할 수도 있습니다.

  • *Name 이라는 매칭 조건을 넣어, 해당하는 method만, 프록시 동작을 하도록 만들 수 있습니다.
  • 그 밖에도 다양한 Pointcut을 제공합니다.
  • 또한, Pointcut를 implement 해서, 직적 포인트 컷을 만들 수도 있습니다.

전체 결과 확인

  • 결과적으로, ProxyFactory를 통해 원본코드는 전혀 건들이지 않은채로, pointcut을 통해 원하는 메서드, 클래스에만 logger를 적용할 수 있었습니다.
  • 하지만, 아직가지 막상 사용하기에는 불편한 점이 있습니다.
    • 수동 Bean 등록을 해야하고, 그에 맞게 Configuration 을 잘 설정 해줘야 한다.
  • 따라서 다음에는, 빈 후처리기를 도입하여 이 문제점을 조금더 편하게 사용할 수 있도록 개선해보도록 하겠습니다.
profile
현재 블로그 이전 중입니다. https://blog.baekgwa.site/

0개의 댓글