스프링 AOP : 프록시 패턴, 데코레이터 패턴 도입

    이 강의 인프런의 김영한님의 강의를 듣고 복습하며 작성한 글입니다. 

     


    템플릿 콜백 패턴은 오리지날 코드의 변경 필요

    앞서 작성한 글(https://ojt90902.tistory.com/698)에서 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴 등을 이용해서 로그 추적기를 좀 더 편리하게 작성을 했었따. 마지막에는 템플릿 콜백 패턴에 익명 클래스의 오버라이딩을 이용해서 반복되는 코드 부분을 최소화했었다.

     

    그렇지만 한계점은 명확하다. 왜냐하면 이런 저런 템플릿을 만들고, 원본 코드에서 익명 클래스의 오버라이딩이 필요하기 때문이다. 즉, 원본 코드에서의 변경이 필요하다. 좀 더 편하게 수정할 수 있게 된 것이지, 하나하나 손이 가는 것은 마찬가지다. 원본 코드에 손을 대지 않으면서 원하는 형태가 실행될 수 있도록 할 수는 없을까?

     


    해결 방법 → 프록시 개념의 도입

    클라이언트의 직접 호출

    원본 코드의 변경 없이 원하는 것을 도입하기 위해서는 프록시 개념이 필요하다. 프록시는 '대체자'라는 의미를 가진다. 지금까지는 클라이언트가 서버에 직접 요청을 했다고 하자. 서버에서는 직접 모든 것을 다 처리해줘야 했기 때문에 변경이 필요하다. 

    프록시(대체자) 도입

    그렇다면 클라이언트와 서버 사이에 대체자가 하나 있고, 이 대체자가 로그 추적기 기능을 구현해준다면 어떨까? 그렇다면 서버는 원래 서버의 역할만 하면 되고, 대체자는 로그 추적기의 기능을 그대로 하면 된다. 그런데 여기서 한 가지 문제점이 발생한다. 클라이언트가 서버에 요청하는 것과 클라이언트가 대체자에게 요청을 할 때 똑같은 코드로 접근할 수 있냐는 것이다.

     

    결론은 "그렇게 할 수 있다"이다. 객체지향의 '다형성'기능을 이용하면 충분히 가능하다. 두 가지 방법으로 이렇게 구현을 할 수 있다. 그리고 이런 목적을 위해 구현한 객체를 프록시 객체라고 하자. 프록시 객체를 구현하는 방법은 아래에 정리했다. 

    1. 서버가 인터페이스를 가질 때 → 인터페이스 구현
      • 인터페이스를 똑같이 구현했지만, 부가 기능만 구현한 구현체를 만든다. 
      • 구현체는 내부적으로 기존 서버(타겟)의 참조값을 가진다.
    2. 서버가 인터페이스가 없을 때 → 상속
      • 서버를 상속받은 클래스를 만든다.
      • 자식 클래스는 내부적으로 부모 클래스 타겟을 가진다. 
      • 부가 기능을 하다가 필요한 시점이 왔을 때, 타겟의 메인 메서드를 실행해준다. 

     

    위의 방법을 참고해서 프록시 객체를 만들고, 그 프록시 객체에서 필요한 기능을 구현해준 다음 원래 객체는 객체의 일을 수행하도록 해준다. 

     

    이 방법이 유효할 수 있는 이유는 다음과 같다.

    1. 프록시 객체는 타겟이 되는 객체를 상속, 혹은 구현한 클래스다. 단지, 내부적으로는 타겟 필드만 하나 더 가진다.
    2. 프록시는 오버라이딩 된 메서드를 실행하고, 오버라이딩 된 메서드 내부에 실제 타겟의 메서드도 실행하도록 한다. 

    즉, 객체 지향의 특성을 최대한 사용한 것이다. 

     


    프록시 패턴과 데코레이터 패턴

    프록시 패턴과 데코레이터 패턴은 GOF 디자인 패턴에 나오는 것이다. 둘다 프록시 객체를 사용하고 있고, 디자인 패턴의 모양 역시 거의 대동소이하다. 모양이 어떻게 만들어지느냐로 디자인 패턴이 달라지지 않는다. 

     

    프록시 패턴과 데코레이터 패턴의 차이는 그 패턴의 '의도'에서 온다. 각 패턴의 의도는 다음과 같다.

    • 프록시 패턴 : 프록시의 접근 제한을 살리기 위한 패턴(캐싱, 지연 로딩, 권한 관리 등)
    • 데코레이터 패턴 : 프록시의 부가 기능을 살리기 위한 패턴(로그 추적기, 시간 추적기 등)

     

    이런 저런 내용을 이야기 하긴 했지만, 결론은 다형성을 이용한 프록시 객체를 만들고, 내부 참조를 통해서 핵심 기능과 부가 기능을 나누는데 그 의미가 있다고 할 수 있겠다. 

     


    프록시 패턴의 테스트 코드 작성

    프록시 패턴의 의도는 '접근 제한'이다. 권한 관리가 될 수도 있고, 캐싱이 될 수도 있고, 지연로딩이 될 수도 있다. 이 테스트 코드에서는 '캐싱'에 의미를 둔다.

    프록시 패턴의 의존 관계는 다음과 같다. 

     

     

    SubJect 인터페이스

    public interface Subject {
        String operation();
    }

     

    Subject 구현체 (실제 타겟값)

    @Slf4j
    public class RealSubject implements Subject{
    
        @Override
        public String operation() {
            log.info("실제 객체 호출");
            sleep(1000);
            return "data";
        }
    
        private void sleep(int millis) {
            try {
                Thread.sleep(millis);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

     

    프록시 객체 (캐시)

    package hello.proxy.pureproxy.proxy.code;
    
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j
    public class CacheProxy implements Subject{
    
        // 프록시 입장에서 호출해야 할 객체
        private Subject target;
        private String cacheValue;
    
        // 의존관계 주입을 해준다.
        // 클라이언트가 프록시를 참조하도록 한다.
        public CacheProxy(Subject target) {
            this.target = target;
        }
    
        @Override
        public String operation() {
            log.info("프록시 호출");
    
            // 처음에 값이 없으면, 값을 불러온다.
            if (cacheValue == null) {
                cacheValue = target.operation();
            }
    
            return cacheValue;
        }
    }
    

     

    클라이언트 객체

    public class ProxyPatternClient {
    
        private Subject subject;
    
        public ProxyPatternClient(Subject subject) {
            this.subject = subject;
        }
    
        public void execute() {
            subject.operation();
        }
    
    }
    

     

    프록시 도입 전, 테스트 코드 

    @Test
    void noProxyTest() {
        RealSubject realSubject = new RealSubject();
    
        ProxyPatternClient client = new ProxyPatternClient(realSubject);
    
        client.execute();
        client.execute();
        client.execute();
    }
    • 클라이언트 코드가 실제 실행 객체를 알고 있는 상황이다.
    • 실제 실행 객체는 execute() 한번 당 1초를 sleep한다.
    • 실행 객체는 execute()를 세 번 했기 때문에 3초를 sleep하게 된다. 

     

    프록시 도입 후, 테스트 코드 

    @Test
    void cacheProxyTest() {
        RealSubject realSubject = new RealSubject();
        CacheProxy cacheProxy = new CacheProxy(realSubject);
        ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
        client.execute();
        client.execute();
        client.execute();
    
    
    }
    • 프록시 객체는 내부적으로 realSubject를 가진다.
    • 클라이언트는 CacheProxy 객체에 의존한다. 
    • 클라이언트는 execute() 첫번째 실행만 실제 객체에 접근한다.
      • 실제 객체에 접근 후 데이터를 가져오면, Cache Proxy는 내부 저장소에 값을 가지고, 다음부터는 내부적으로 가진 값을 Return 해주기 때문이다. 

     

    프록시 패턴 도입 결과

    내부적으로 접근 제어(캐싱)을 하기 위해서 프록시 패턴을 도입했다. 골자는 타겟 클래스가 인터페이스를 가지고 있고, 이걸 똑같이 구현한 프록시 클래스를 하나 만든다. 그리고 그 프록시 클래스는 내부적으로 타겟 클래스를 참조로 가진다. 클라이언트는 동일한 인터페이스 타입이 전달되기 때문에 이 객체가 프록시인지 진짜 클래스인지 구분을 할 수 없다. 

    client.execute();

    프록시 객체를 클라이언트가 참조하게 하면서 원본 코드를 수정하지 않고, 캐싱 기능을 추가할 수 있었다.

     


    데코레이터 패턴 테스트 코드 작성 : 인터페이스가 있는 경우

    데코레이터 패턴은 프록시의 기능 중 부가기능을 추가하고자 하는 '의도'를 가질 때 사용하는 패턴이다. 위의 프록시 패턴과 구성 자체는 다를 것이 없다. 의도만 다르다. 이 테스트 코드에서는 아래 두 가지 기능을 추가한다.

    1. 받아온 데이터에 ******를 붙여준다
    2. 실행 시간을 출력한다. 

    클래스 의존 관계는 다음과 같다. 

     

     

    실제 타켓 클래스의 인터페이스

    public interface Component {
        String operation();
    }
    

     

    실제 타켓 클래스의 구현체

    @Slf4j
    public class RealComponent implements Component{
        @Override
        public String operation() {
            log.info("ReadlComponent 실행! ");
            return "data";
        }
    }

     

    프록시 클래스 : 실행 시간 측정기 

    @Slf4j
    public class TimeDecorator implements Component{
    
        private Component component;
    
        public TimeDecorator(Component component) {
            this.component = component;
        }
    
        @Override
        public String operation() {
            log.info("Time Decorator 실행! ");
            long startTimeMs = System.currentTimeMillis();
            
            // 핵심 기능 수행 
            String result = component.operation();
            
            
            long endTimeMs = System.currentTimeMillis();
            long resultTimes = endTimeMs - startTimeMs;
            
            // 부가 기능 수행 
            log.info("Time Decorator 종료, Result Time = {}",resultTimes);
            return result;
        }
    }

     

    프록시 클래스 : 문자에 ****** 추가하기 

    @Slf4j
    public class MessageDecorator implements Component{
    
        private Component component;
    
        public MessageDecorator(Component component) {
            this.component = component;
        }
    
        @Override
        public String operation() {
            log.info("MessageDecorator 실행");
    
            // 핵심 기능 real 객체 호출. "data"를 돌려준다.
            String result = component.operation();
            
            // 부가 기능
            String decoResult = "*****" + result + "*****";
            log.info("MessageDecorator 꾸미기 적용 전 = {}, 적용 후 = {}", result, decoResult);
    
            return decoResult;
        }
    }

     

    데코레이터 패턴 클라이언트 

    @Slf4j
    public class DecoratorPatternClient {
    
        private Component component;
    
        public DecoratorPatternClient(Component component) {
            this.component = component;
        }
    
        public void execute() {
            String result = component.operation();
            log.info("result = {}", result);
        }
    
    }
    

     

    데코레이터 패턴 도입 전, 테스트 코드 

    @Test
    void noDecorator() {
        RealComponent realComponent = new RealComponent();
        DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
        client.execute();
    }
    • 클라이언트는 realComponent에 의존한다.
    • client.execute()를 하면, realComponent.call()을 하게 되고 "data"라는 문자열을 돌려 받는다. 

     

    데코레이터 패턴 도입 후, 테스트 코드 (시간 출력 + 문자열 변경) 

    @Test
    void decorator2() {
        RealComponent realComponent = new RealComponent();
        MessageDecorator messageDecorator = new MessageDecorator(realComponent);
        TimeDecorator timeDecorator = new TimeDecorator(messageDecorator);
        DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
        client.execute();
    }
    • 메세지 데코레이터(문자열 수정)은 내부적으로 실제 객체를 참조한다.
    • 시간 데코레이터(수행 시간 측정)는 내부적으로 메세지 데코레이터를 참조한다.
    • 데코레이터 클라이언트는 시간 데코레이터 객체를 참조한다.
      • execute()를 수행하면, 시간 데코레이터 → 메세지 데코레이터 → 실제 객체 순으로 실행이 된다. 
      • 부가 기능이 둘다 추가된 것이다. 

     

    데코레이터 패턴 도입 결과

    데코레이터 패턴은 원본 코드에 변경 없이 실행 시간을 측정하고, 문자열을 변경하는 부가 기능을 추가하기 위해서 사용되었다. 이 때, 내부적으로 동일한 인터페이스를 참조하는 프록시 객체를 각 기능을 구현하도록 만들었다. 그리고 서로 참조하도록 해서, 원본 코드에 변경 없이 원하는 목적을 실행할 수 있었다.

     


    데코레이터 패턴 테스트 코드 작성 : 인터페이스가 없는 경우

    프록시 객체는 결국 같은 타입의 다형성을 이용해서 구성하는 것이다. 이가 없으면 잇몸이다. 인터페이스가 없으니 실제 객체를 똑같이 상속받은 클래스를 만들고, 내부적으로 부모 클래스를 타겟으로 가진다. 그리고 메서드를 오버라이딩해서 필요한 부가 기능을 구현하도록 할 수 있다. 

    클래스 의존 관계는 다음과 같다. 

     

    실제 타겟 클래스 

    @Slf4j
    public class ConcreteLogic {
        public void call() {
            log.info("콘크리트 로직 실행");
        }
    
    }

     

    프록시 클래스 : 타겟 클래스 상속 받음

    @Slf4j
    public class TimeProxy extends ConcreteLogic{
    
        private final ConcreteLogic target;
    
        public TimeProxy(ConcreteLogic target) {
            this.target = target;
        }
    
        @Override
        public void call() {
            long startTime = System.currentTimeMillis();
            target.call();
    
            long endTime = System.currentTimeMillis()
            long resultTime = endTime - startTime;
            log.info("실행 완료, resultTime = {}",resultTime);
        }
    }

     

    클라이언트 클래스 : 단순 실행 

    public class ConcreteClient {
    
        private final ConcreteLogic concreteLogic;
    
        public ConcreteClient(ConcreteLogic concreteLogic) {
            this.concreteLogic = concreteLogic;
        }
    
    
        public void execute() {
            concreteLogic.call();
        }
    
    
    }
    

     

    테스트 코드

    
    // 실제 객체만 소환한다.
    @Test
    void noProxyTest() {
        ConcreteLogic concreteLogic = new ConcreteLogic();
        ConcreteClient client = new ConcreteClient(concreteLogic);
        client.execute();
    }
    
    
    // 실행 시간까지 같이 측정한다. 
    @Test
    void ProxyTest() {
        ConcreteLogic concreteLogic = new ConcreteLogic();
        TimeProxy timeProxy = new TimeProxy(concreteLogic);
        ConcreteClient client = new ConcreteClient(timeProxy);
        client.execute();
    }
    • noProxyTest는 클라이언트가 실제 객체에 의존한다.
      • 따라서 실행하면 실제 객체의 기능만 수행한다.
    • ProxyTest는 실제 객체를 가지고 있는 프록시 객체에 클라이언트가 의존한다.
      • 따라서 실행 시간이 측정되면서 실제 객체의 기능도 함께 수행한다. 

     

    상속받은 데코레이터 패턴 도입 정리

    실제 객체는 인터페이스를 가지고 있지 않았다. 인터페이스가 없었기 때문에 실제 객체를 상속받은 자식 클래스를 만들었다. 자식 클래스는 내부적으로 부모 클래스를 타겟으로 가졌다. 그리고 부모 클래스의 메서드를 오버라이딩해서 필요한 기능을 구현했다. 

    이 실행 결과에서도 역시 알 수 있는 것은 의존관계 설정만 해주면, 원본 코드의 수정 없이 필요한 것을 할 수 있다는 것이다. 

     


    프록시 개념 로그 추적기에 도입하기

    앞선 테스트 코드에서 알 수 있었던 것을 살짝 정리해본다.

    1. 실제 객체를 동일하게 상속/구현한 프록시객체를 만들고, 내부적으로 실제 객체를 참조하게 한다.
    2. 프록시 객체와 실제 객체 간의 의존관계를 잘 설정한다.

    위 두 가지 과정을 거치게 되면 원본 코드의 수정 없이 우리가 필요로 하는 부가 기능을 추가할 수 있게 된다. 그럼 실제로 1,2번 과정을 로그 추적기에 적용하기 위해서 나는 무엇을 해야할까?

    1. 인터페이스를 구현한 프록시 클래스를 만든다. 인터페이스가 없다면 구체 클래스를 상속 받은 프록시 클래스를 만든다.
    2. 프록시 객체가 내부적으로 실제 클래스를 가지게 의존관계를 설정한 후, 스프링 빈으로 등록한다. 

     

    인터페이스가 있는 V1 컨트롤러, 리포지토리, 서비스의 의존관계는 다음과 같았다.

    프록시 객체를 도입해서 다음과 같이 의존관계를 만들 것이다.

     

    프록시 클래스 만들기 : 인터페이스 있는 클래스 (Service 클래스 하나만)  → 구현 

    public class OrderServiceInterfaceProxy implements OrderServiceV1{
    
        private final OrderServiceV1 target;
        private final LogTrace logTrace;
    
    
        public OrderServiceInterfaceProxy(OrderServiceV1 target, LogTrace logTrace) {
            this.target = target;
            this.logTrace = logTrace;
        }
    
        @Override
        public void orderItem(String itemId) {
    
            TraceStatus status = null;
            try {
                status = logTrace.begin("orderService.orderItem()");
    
                //로직 호출
                target.orderItem(itemId);
                logTrace.end(status);
    
            } catch (Exception e) {
                logTrace.exception(status, e);
                throw e;
    
            }
        }
    
    }
    • 내부적으로 Target을 가지게 만든다. 로그를 찍어야 하기 때문에 로그 추적기도 가진다.
    • 기존 템플릿 콜백 패턴의 로직을 가져온다. 필요 시점에 target.orderItem()으로 실제 객체의 핵심 기능을 수행할 수 있도록 작성한다. 

     

    프록시 클래스 만들기 : 인터페이스 없는 클래스 (서비스 클래스만)  → 상속

    public class OrderServiceConcreteProxy extends OrderServiceV2 {
    
        private final OrderServiceV2 target;
        private final LogTrace logTrace;
    
        public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace logTrace) {
            super(null);
            this.target = target;
            this.logTrace = logTrace;
        }
    
        @Override
        public void orderItem(String itemId) {
            TraceStatus status = null;
            try {
                status = logTrace.begin("orderService.orderItem()");
    
                //로직 호출
                target.orderItem(itemId);
                logTrace.end(status);
    
            } catch (Exception e) {
                logTrace.exception(status, e);
                throw e;
    
            }
        }
    }
    
    • 내부적으로 Target을 가지게 만든다. 로그를 찍어야 하기 때문에 로그 추적기도 가진다.
    • 기존 템플릿 콜백 패턴의 로직을 가져온다. 필요 시점에 target.orderItem()으로 실제 객체의 핵심 기능을 수행할 수 있도록 작성한다. 
    • 생성자에서 부모 클래스 생성도 함께 들어가는데, 부모 클래스는 사용할 일이 없다. 따라서 null을 넘겨준다. 

     

    인터페이스 있는 프록시 클래스의 의존관계 설정

    @Configuration
    public class InterfaceProxyConfig {
        @Bean
        public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
            OrderControllerV1 orderControllerV1 = new OrderControllerV1Impl(orderServiceV1(logTrace));
            OrderControllerV1 proxy = new OrderControllerInterfaceProxy(orderControllerV1, logTrace);
            return proxy;
        }
    
    
        @Bean
        public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
            OrderServiceV1 orderServiceV1 = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
            OrderServiceV1 proxy = new OrderServiceInterfaceProxy(orderServiceV1, logTrace);
            return proxy;
        }
    
    
        @Bean
        public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
            OrderRepositoryV1 orderRepositoryV1 = new OrderRepositoryV1Impl();
            OrderRepositoryV1 proxy = new OrderRepositoryInterfaceProxy(orderRepositoryV1, logTrace);
            return proxy;
        }
    }
    • 의존관계를 설정한다.
    • 프록시 객체가 실제 객체를 참조해야하기 때문에 실제 객체를 먼저 생성한다.
    • 프록시 객체를 생성하면서 내부적으로 실제 객체를 참조할 수 있도록 변수를 넘겨준다.
    • 프록시 객체를 Return 해준다

    위처럼 실행하게 되면 내부적으로 실제 객체를 참조하는 프록시 객체가 빈 이름으로 등록이 된다. 예를 들어 orderRepositoryV1 이라는 빈 이름으로 OrderRepositoryV1 인터페이스를 구현한 프록시 객체가 스프링 빈으로 등록된다.

    실제 런타임 시점의 의존관계는 위와 같다.

     

    인터페이스 없는 프록시 클래스의 의존관계 설정

    @Configuration
    public class ConcreteProxyConfig {
    
        @Bean
        public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
            OrderControllerV2 orderControllerV2 = new OrderControllerV2(orderServiceV2(logTrace));
            OrderControllerV2 proxy = new OrderControllerConcreteProxy(orderControllerV2, logTrace);
            return proxy;
        }
    
        @Bean
        public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
            OrderServiceV2 orderServiceV2 = new OrderServiceV2(orderRepositoryV2(logTrace));
            OrderServiceV2 proxy = new OrderServiceConcreteProxy(orderServiceV2, logTrace);
            return proxy;
        }
    
        @Bean
        public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
            OrderRepositoryV2 orderRepositoryV2 = new OrderRepositoryV2();
            OrderRepositoryV2 proxy = new OrderRepositoryConcreteProxy(orderRepositoryV2, logTrace);
            return proxy;
        }
    }
    • 의존관계를 설정한다.
    • 프록시 객체가 실제 객체를 참조해야하기 때문에 실제 객체를 먼저 생성한다.
    • 프록시 객체를 생성하면서 내부적으로 실제 객체를 참조할 수 있도록 변수를 넘겨준다.
    • 프록시 객체를 Return 해준다

    위처럼 실행하게 되면 내부적으로 실제 객체를 참조하는 프록시 객체가 빈 이름으로 등록이 된다. 예를 들어 orderRepositoryV2 이라는 빈 이름으로 OrderRepositoryV2 클래스를 상속받은 프록시 객체가 스프링 빈으로 등록된다.


    프록시 개념 도입, 정리

    먼저 템플릿 콜백 패턴등을 도입해서 로그 추적기를 도입했었으나, 이 때 원본 코드의 수정이 불가피하다는 단점이 있었다. 이것을 극복하기 위해 객체의 다형성을 활용해 프록시 개념을 도입하고, 의존관계 주입을 통해서 원본 코드의 수정없이 필요한 부가 기능을 구현할 수 있게 되었다. 

    그렇지만 한계는 명확하다. 부가 기능을 도입하고자 하는 클래스의 갯수만큼 필요한 클래스를 만들어야 한다. 즉, 또 다시 코드 노가다의 굴레에 갖히게 된 것이다. 이런 것들을 좀 개선할 수 있는 방법은 없을까? 

     


    참고하면 좋은 점

    1. 인터페이스 기반 프록시가 상속 기반 프록시보다 좋다. 상속 기반의 프록시는 몇가지 제약이 있기 때문이다. 
      1. 부모 클래스의 생성자를 호출해야한다.
      2. 클래스에 final 키워드가 붙으면 상속이 불가능하다.
      3. 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. 
    2. 인터페이스 기반은 역할과 구현이 나누어 졌다는 점에서 확실히 좋다.
    3. 실제로 인터페이스와 구현으로 나누는 것을 모든 것에 도입하는 것은 비효율적이다. 변화가 많은 곳에는 인터페이스를 도입하는 것이 좋을 수 있으나, 변화가 없을 것 같은 곳에 인터페이스를 사용하는 곳은 실용적이지 않다. 

    댓글

    Designed by JB FACTORY