Spring

[Spring] 프록시 패턴과 데코레이터 패턴

이덩우 2024. 3. 4. 19:00

  1. 프록시

- 소개

프록시라는 개념은 상당히 넓게 사용된다. 일반적으로 클라이언트와 서버 또한 좁게 봤을 때는 하나의 애플리케이션 내에서 객체 간의 상호작용으로 볼 수 도 있고, 넓게 보면 웹 브라우저와 API 서버를 의미할 수 도 있다. 

 

프록시는 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 *대리자*를 통해서 간접적으로 요청할 수 있도록 하는 것이다. 여기서 프록시는 대리자를 의미한다.

 

음 그러면 아무나 프록시가 될 수 있나? 생각해보면 아니다.

클라이언트 입장에서는 서버에 요청한 것인지, 대리자인 프록시에게 요청한 것인지 모르고 원하는 결과를 받아야한다.

쉽게 이야기하면 프록시와 서버는 *같은 인터페이스*를 사용하거나 *상속 관계*에 있어야 한다는 것이다.

따라서 *DI*를 사용해 컨트롤이 가능해진다.

클래스 의존 관계 예시(인터페이스)

 

 

대리자를 중간 계층에 두면 좋은 점이 뭘까? 

- 주요 기능

프록시를 통해 하는 일은 크게 두 가지로 구분할 수 있다.

  • 접근 제어
    • 캐싱
    • 권한에 따른 접근 차단
    • 지연 로딩
  • 부가 기능 추가
    • 원래 서버가 하는 일에 더해 부가적인 기능을 수행
    • 예) 요청 및 응답 값을 중간에서 변형
    • 예) 현재 예제 프로젝트처럼 로그 추적기와 같은 기능을 수행

- GOF 디자인 패턴

GOF 디자인 패턴에서는 프록시를 사용하는 방식에서, *의도(intent)*에 따라 프록시 패턴과 데코레이터 패턴을 구분짓는다.

  • 프록시 패턴 : 접근 제어가 목적
  • 데코레이터 패턴 : 새로운 부가 기능 추가가 목적

실제로 두 디자인 패턴을 구현해보면 둘 다 프록시를 구현하는 것이기 때문에 겉모양은 비슷하거나 심지어 똑같을 수 도 있다. 

디자인 패턴을 활용할 때 겉모양보다는, 특정 의도를 구현했다는 것에 초점을 두도록 하자.


2. 프록시 패턴

프록시 패턴 구성도

 

초반에 언급했듯 아무나 프록시 객체가 될 수 있는 건 아니다! 클라이언트 입장에서는 단순히 서버 측에 요청하는 구조로 유지하기 위해서 인터페이스를 도입했다.

 

이제 인터페이스를 구현하는 프록시에서는 내부적으로는 실제 서버 객체의 참조를 가지고 있을 것이다.

예제 코드와 함께 살펴보자. 본 예제에서는 *캐싱*에 중점을 둔 프록시 객체를 생성했다.

 

우선 클라이언트 코드이다.

public class ProxyPatternClient {

    private Subject subject;

    public ProxyPatternClient(Subject subject) {
        this.subject = subject;
    }

    public void execute() {
        subject.operation();
    }
}

 

 

프록시 코드이다.

@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;
    }
}

실제 target과 같은 인터페이스를 상속받고 있어 클라이언트 코드에는 변함이 없다.

또한 필드에 실제 target의 참조를 가지고있다. 

 

이로써 특정 기능을 수행하면서 중간에 target을 호출하는 프록시 패턴이 완성되었다.

public class ProxyPatternTest {
    @Test
    void cacheProxyTest() {
        ProxyPatternClient client = new ProxyPatternClient(new CacheProxy(new RealSubject()));
        client.execute();
        client.execute();
        client.execute();
    }
}

테스트 코드를 간단히 작성해 new 연산자가 덕지덕지 발린 느낌이 있지만,

실제 애플리케이션에서는 DI를 통해 의존 관계가 주입될 것이다.


3. 데코레이터 패턴

데코레이터 패턴 구성도

 

데코레이터 패턴은 말 그대로 *꾸며주는 역할*을 하는 프록시이다. (케이크 데코레이션을 생각하면 이해가 잘 된다.)

따라서 실제 target 외에도 체이닝 방식으로 프록시를 연결해 각 목적에 맞는 프록시를 호출할 수 있다.

 

이번 예시에서는 응답을 꾸며주는 프록시와 시간 정보를 출력해주는 *두 가지의 프록시*, 그리고 실제 target, 클라이언트를 구성해볼 것이다.

 

먼저 클라이언트 코드이다.

@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);
    }
}

역시 실제로 호출하는 대상은 인터페이스로 남아있다. *프록시를 호출하는지, 실제 target을 직접 호출하는지 클라이언트는 알 수 없다.*

 

다음은 두 가지의 프록시 클래스이다.

@Slf4j
public class MessageDecorator implements Component{

    private Component component;

    public MessageDecorator(Component component) {
        this.component = component;
    }

    @Override
    public String operation() {
        log.info("MessageDecorator 실행");

        String operation = component.operation();
        String decoResult = "****" + operation + "****";
        log.info("꾸미기 전={}, 꾸미기 후={}", operation, decoResult);
        return decoResult;
    }
}


@Slf4j
public class TimeDecorator implements Component{

    private Component component;

    public TimeDecorator(Component component) {
        this.component = component;
    }

    @Override
    public String operation() {
        log.info("TimeDecorator 실행");
        long startTime = System.currentTimeMillis();
        String result = component.operation();
        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeDecorator 종료, resultTime={}ms", resultTime);
        return result;
    }
}

내부에 호출할 target(= component)를 필드로가지고 있다. 이 외에 부가 기능을 수행하는 모습이다.

 

@Slf4j
public class DecoratorPatternTest {
    @Test
    void decorator2() {
        DecoratorPatternClient client = new DecoratorPatternClient
                (new TimeDecorator
                    (new MessageDecorator
                        (new RealComponent())));
        client.execute();
    }
}

실제 클라이언트가 로직을 호출하는 테스트 환경이다. 프록시 패턴 예시와 마찬가지로 간단한 테스트 코드이기 때문에 new 생성자를 덕지덕지 붙여 조금 지저분한 모습이지만 실제 애플리케이션에 적용할 때는 DI를 통해 의존 관계를 주입할 것이다.


4. 예제 애플리케이션에 적용

보다 다양한 상황에서 프록시를 적용해보기 위해 V1과 V2를 구분하였다.

  • V1 : 각 계층이 인터페이스를 기반으로 구성된 상황
  • V2 : 각 계층이 인터페이스 없이 구체 클래스로만 구성된 상황

하나씩 살펴보자.

- V1 (인터페이스 기반 프록시)

컨트롤러, 서비스, 리포지토리 계층을 모두 인터페이스를 기반으로 구체 클래스를 생성하도록 구성된 상황이다.

각 계층마다 프록시 클래스를 만들었지만 거의 비슷하므로 서비스 계층을 예시로 알아보자.

 

먼저 구체 클래스이다.

public class OrderRepositoryV1Impl implements OrderRepositoryV1{
    @Override
    public void save(String itemId) {
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생");
        }
        sleep(1000);
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

인터페이스를 상속받아 비지니스 로직을 수행하고 있다.

 

프록시 클래스를 살펴보자.

@RequiredArgsConstructor
public class OrderServiceInterfaceProxy implements OrderServiceV1 {

    private final OrderServiceV1 target;
    private final 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);
        }
    }
}

기존 요구사항인 로그 추적기에 대한 부가 기능이 추가된 모습이다. 내부에는 실제 target을 필드로 갖고있어 중간에 호출이 가능하다.

 

각 계층마다 프록시 객체가 존재할텐데, 의존 관계 주입은 어떻게 할까? Config 파일을 살펴보자.

@Configuration
public class InterfaceProxyConfig {

    @Bean
    public OrderControllerV1 orderController(LogTrace logTrace) {
        OrderControllerV1Impl controllerImpl = new OrderControllerV1Impl(orderService(logTrace));
        return new OrderControllerInterfaceProxy(controllerImpl, logTrace);
    }

    @Bean
    public OrderServiceV1 orderService(LogTrace logTrace) {
        OrderServiceV1Impl serviceImpl = new OrderServiceV1Impl(orderRepository(logTrace));
        return new OrderServiceInterfaceProxy(serviceImpl, logTrace);
    }

    @Bean
    public OrderRepositoryV1 orderRepository(LogTrace logTrace) {
        OrderRepositoryV1Impl repositoryImpl = new OrderRepositoryV1Impl();
        return new OrderRepositoryInterfaceProxy(repositoryImpl, logTrace);
    }
}

먼저 각 계층의 인터페이스 필드에 대해서 의존 관계 주입은 *프록시 객체*로 주입되는 모습이다. 

그리고 내부적으로 프록시 객체 내 실제 target에 대해서는 *구체 클래스*를 주입해주고 있다.

아래와 같은 런타임 의존 관계가 완성된 것이다.

Repository는 편의 상 생략

 

위 상황에서는 *실제 스프링 컨테이너에 프록시를 등록한다. 실제 객체는 스프링 빈으로 등록하지 않는다.*

프록시는 내부에 실제 객체를 참조하고 있다. 실제 객체가 스프링 빈으로 등록되지 않는다고 해서 사라지는 게 아니라, JVM 힙 메모리에는 존재한다. 참조가 있으니까!

프록시 객체는 스프링 컨테이너와 JVM 힙 메모리 영역 내에 모두 존재하는 것 뿐이다.

 

- V2 (구체 클래스 기반 프록시)

일반적으로 모든 계층에 인터페이스를 도입하지 않을 수 있다. 이런 경우는 구체 클래스에 대해서 어떻게 프록시를 만들까?

정답은 *상속 관계*에 있다. 부모 타입은 자식 타입을 허용할 수 있으므로, 인터페이스와 유사하게 구현할 수 있다. 

실제 target 클래스를 상속받는 프록시를 생성하고, 클라이언트 측에서는 프록시를 호출하게끔 의존 관계를 주입하면 된다.

상속
런타임 의존 관계

 

 

실제 V2 예제 애플리케이션에 적용해보자. 

마찬가지로 각 계층의 프록시 구조가 비슷하기 때문에 서비스 계층에 대해서만 확인해볼 것이다!

 

먼저 실제 target 클래스이다.

public class OrderServiceV2 {

    private final OrderRepositoryV2 orderRepositoryV2;

    public OrderServiceV2(OrderRepositoryV2 orderRepositoryV2) {
        this.orderRepositoryV2 = orderRepositoryV2;
    }

    public void orderItem(String itemId) {
        orderRepositoryV2.save(itemId);
    }
}

 

단순한 비지니스 로직이다. 다음은 프록시 클래스이다.

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);
        }
    }
}

상속 관계를 이용해 구체 클래스를 상속받고 있으며, 기존의 `orderItem` 메서드를 오버라이딩 해 재정의하는 모습이다.

이를 통해 부가 기능을 수행하고 중간에 실제 target을 호출할 수 있다. 

주의할 점은, 자바 언어의 한계로 부모 타입에 있는 생성자를 같이 호출해줘야하는데 현재 상황에서는 사용하지 않는다. *의존 관계 주입으로 실제 target을 주입받을 것이기 때문이다.*

 

의존 관계를 설정하는 Config 파일을 살펴보자.

@Configuration
public class ConcreteProxyConfig {

    @Bean
    public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
        OrderControllerV2 orderControllerV2 = new OrderControllerV2(orderServiceV2(logTrace));
        return new OrderControllerConcreteProxy(orderControllerV2, logTrace);
    }

    @Bean
    public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
        OrderServiceV2 orderServiceV2 = new OrderServiceV2(orderRepositoryV2(logTrace));
        return new OrderServiceConcreteProxy(orderServiceV2, logTrace);
    }

    @Bean
    public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
        OrderRepositoryV2 repositoryImpl = new OrderRepositoryV2();
        return new OrderRepositoryConcreteProxy(repositoryImpl, logTrace);
    }
}

역시 모두 프록시 객체를 스프링 빈으로 등록하는 모습이다. 내부에서는 target으로 구체 클래스를 지정해 주입해주는 모습이다.

 


5. 인터페이스 기반 프록시 vs 클래스 기반 프록시

뭐가 더 좋을까? 100% 정답은 없고 상황에 따라 다르다. 장, 단점을 알아보자.

  • 인터페이스 기반
    • 이론 상 인터페이스를 도입해 역할과 구현을 구분하는게 좋다.
    • 단점은 인터페이스가 필요하다는 점 그 자체이다.
  • 클래스 기반
    • 실제로는 구현을 거의 변경할 일이 없어 인터페이스가 필요없을 수 있다.
      • 실용적인 관점에서 이런 경우 구체 클래스를 바로 사용하는 것도 좋다.
    • 상속에서 오는 제약이 있다.
      • 부모 클래스의 생성자를 호출해야 한다.
      • 클래스에 final 키워드가 붙으면 상속이 불가능하다.
      • 메서드에 final 키워드가 붙으면 오버라이딩이 불가능하다.

위 두 가지 상황이 중복될 수 도 있다. 어떠한 경우에서도 프록시를 적용할 수 있어야한다.

추가로 현재까지는 프록시를 적용하고 싶은 클래스마다 프록시 클래스를 만들어줘야 했다. 만약 100개의 클래스에 대해 프록시를 적용해야 한다면,,, 힘든 일이 될 것이다. 

프록시 클래스를 하나만 만들어서 모든 곳에 적용할 수 없을까? 다음 포스팅에서 *동적 프록시 기술*에 대해 알아보도록 하자.

 

 

출처 : 인프런, 김영한의 스프링 핵심 원리 - 고급편