👥 프록시란?

일반적으로 클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리한다. 근데 여기서 클라이언트가 서버에 바로 요청하지 않고, 제 3자를 통해서 간접적으로 서버에 요청할 수도 있다. 예를 들어, 내가 직접 장을 보러 마트에 갈 수도 있지만, 다른 누군가에게 장을 대신 봐달라 할 수 있는 것과 같다. 이 다른 누군가가 바로 프록시(Proxy)다.

 

근데 왜 굳이 프록시를 거쳐서 처리하려고 할까? 그 이유는 프록시가 꽤 여러 역할을 수행할 수 있기 때문이다. 프록시가 중간에서 접근 제어나 캐싱, 혹은 다른 부가 기능을 수행할 수도 있고, 프록시가 다른 프록시에게 부탁할 수도 있다. 여기서 중요한 점은 클라이언트는 프록시를 통해 요청했기 때문에 그 이후 과정을 모른다는 것이다.

그렇다면… 클라이언트는 본인이 실제 서버에게 요청하고 있다고 믿도록 속여야 하는데 이를 어떻게 하지? 해결책은 프록시가 서버와 같은 인터페이스를 구현하도록 하는 것이다.

이렇게 하면 실제 런타임에서 Client에 DI를 사용해 Server를 넣든, Proxy를 넣든 Client 코드에는 전혀 영향을 미치지 않는다.

 

프록시를 사용하는 이유를 간단하게 요약해보자.

  • 접근 제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 원래 서버가 제공하는 기능에 더해 부가 기능을 수행
    • ex) 요청 값이나, 응답 값을 중간에 변형
    • ex) 실행 시간을 측정해서 추가 로그를 남김

 

이 프록시를 사용하는 디자인 패턴에는 프록시 패턴데코레이터 패턴이 있다. 둘 다 프록시를 사용한다는 것은 같지만, 의도가 다르다는 것이 핵심이다. 프록시 패턴은 “접근 제어”, 데코레이터 패턴은 “새로운 기능 추가” 가 목적이다.

 

👀 프록시 패턴(Proxy Pattern)

먼저 간단한 예제를 통해 프록시 패턴을 이해해보자.

package com.example.advanced_spring.proxy.pureproxy.proxy.code;

public interface Subject {
    String operation();
}

위는 프록시를 적용하기 전의 서버 역할을 할 인터페이스다. 그리고 이 인터페이스를 구현해서 프록시 없이 직접적으로 클라이언트와 서버가 통신하는 것이다.

 

package com.example.advanced_spring.proxy.pureproxy.proxy.code;

import lombok.extern.slf4j.Slf4j;

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

RealSubject 구현체를 사용해서 로직을 실행하도록 했다.

package com.example.advanced_spring.proxy.pureproxy.proxy.code;

public class ProxyPatternClient {

    private Subject subject;

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

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

// 테스트 코드
package com.example.advanced_spring.proxy.pureproxy.proxy;

import com.example.advanced_spring.proxy.pureproxy.proxy.code.ProxyPatternClient;
import com.example.advanced_spring.proxy.pureproxy.proxy.code.RealSubject;
import org.junit.jupiter.api.Test;

public class ProxyPatternTest {

    @Test
    void noProxyTest() {
        RealSubject realSubject = new RealSubject();
        ProxyPatternClient client = new ProxyPatternClient(realSubject);
        client.execute();
        client.execute();
        client.execute();

        /**
         * 각 요청마다 1초가 걸린 것을 확인할 수 있다.
         * 21:24:00.298 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.RealSubject -- 실제 객체 호출...
         * 21:24:01.301 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.RealSubject -- 실제 객체 호출...
         * 21:24:02.302 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.RealSubject -- 실제 객체 호출...
         */
    }
}

근데 위와 같이 동일한 로직을 수행한 결과를 캐싱한다면 성능이 더 좋아지지 않을까? 이럴 때 프록시를 통해 손쉽게 캐시를 적용할 수 있다. 이제 본격적으로 프록시 패턴을 도입하도록 하자.

 

클래스 의존 관계는 아래와 같다. 실제 타겟 객체와 같은 인터페이스를 프록시가 구현하도록 하는 것이다.

 

package com.example.advanced_spring.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;
    }
}

위의 CacheProxy는 결국 실제 객체를 호출해야 하기 때문에 실제 호출해야 할 객체의 참조를 가지고 있어야 한다. 그리고 캐시된 데이터가 없다면 실제 객체를 호출해서 값을 구하고 cacheValue에 저장하고 반환하고, 캐시된 데이터가 존재한다면 그대로 cacheValue를 반환해서 매우 빠르게 데이터를 조회할 수 있다.

package com.example.advanced_spring.proxy.pureproxy.proxy;

import com.example.advanced_spring.proxy.pureproxy.proxy.code.CacheProxy;
import com.example.advanced_spring.proxy.pureproxy.proxy.code.ProxyPatternClient;
import com.example.advanced_spring.proxy.pureproxy.proxy.code.RealSubject;
import com.example.advanced_spring.proxy.pureproxy.proxy.code.Subject;
import org.junit.jupiter.api.Test;

public class ProxyPatternTest {
		
		...

    @Test
    void cacheProxyTest() {
        Subject subject = new RealSubject();
        Subject clientSubject = new CacheProxy(subject);
        ProxyPatternClient client = new ProxyPatternClient(clientSubject);
        client.execute();
        client.execute();
        client.execute();

        /**
         * 21:39:55.619 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.CacheProxy -- 프록시 호출...
         * 21:39:55.621 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.RealSubject -- 실제 객체 호출...
         *
         * (기존 호출을 통해 캐시된 값이 존재하므로 값을 즉시 반환)
         * 21:39:56.623 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.CacheProxy -- 프록시 호출...
         * 21:39:56.624 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.proxy.code.CacheProxy -- 프록시 호출...
         */
    }
}

테스트 결과를 살펴보면 클라이언트는 프록시를 먼저 호출하고, 프록시가 실제 객체를 호출한다. 이후 동일한 로직 결과는 캐시된 데이터를 통해 빠르게 가져오는 것을 확인할 수 있다.

 

🎆 데코레이터 패턴(Decorator Pattern)

데코레이터 패턴도 일단 간단하게 시작해보자. 이번엔 실제 객체가 Component라는 인터페이스를 구현한다. 클라이언트는 단순히 Component 인터페이스를 의존한다.

package com.example.advanced_spring.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@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);
    }
}
package com.example.advanced_spring.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class RealComponent implements Component {

    @Override
    public String operation() {
        log.info("RealComponent 실행...");
        return "data";
    }
}
package com.example.advanced_spring.proxy.pureproxy.decorator;

import com.example.advanced_spring.proxy.pureproxy.decorator.code.Component;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void noDecorator() {
        Component realComponent = new RealComponent();
        DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
        client.execute();

        /**
         * 22:06:32.422 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent -- RealComponent 실행...
         * 22:06:32.424 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient -- result=data
         */
    }
}

데코레이터 패턴의 의도는 “부가 기능을 추가” 하는 것이다. 이번엔 출력될 메시지를 꾸며주는 MessageDecorator라는 프록시를 만들어보자.

package com.example.advanced_spring.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MessageDecorator implements Component {

    private Component component;

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

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

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

        return decoResult;
    }
}
package com.example.advanced_spring.proxy.pureproxy.decorator;

import com.example.advanced_spring.proxy.pureproxy.decorator.code.Component;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.MessageDecorator;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.message.Message;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void noDecorator() {
        Component realComponent = new RealComponent();
        DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
        client.execute();

        /**
         * 22:06:32.422 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent -- RealComponent 실행...
         * 22:06:32.424 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient -- result=data
         */
    }

    @Test
    void decoratorCase1() {
        Component realComponent = new RealComponent();
        MessageDecorator decorator = new MessageDecorator(realComponent);
        DecoratorPatternClient client = new DecoratorPatternClient(decorator);

        client.execute();

        /**
         * 22:19:41.223 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.MessageDecorator -- MessageDecorator 실행...
         * 22:19:41.225 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent -- RealComponent 실행...
         * 22:19:41.225 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.MessageDecorator -- MessageDecorator 꾸미기 적용 전=data, 적용 후=*****data*****
         * 22:19:41.226 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient -- result=*****data*****
         */
    }
}

이번에는 출력될 메시지를 꾸며주는 프록시 이외에 실행 시간을 측정하는 프록시를 추가해보도록 하자. 이런 식으로 여러 개의 프록시를 만들어서 다양한 부가 기능을 수행할 수 있다.

package com.example.advanced_spring.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@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;
    }
}
package com.example.advanced_spring.proxy.pureproxy.decorator;

import com.example.advanced_spring.proxy.pureproxy.decorator.code.Component;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.MessageDecorator;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent;
import com.example.advanced_spring.proxy.pureproxy.decorator.code.TimeDecorator;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.message.Message;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    ...

    @Test
    void decoratorCase2() {
        Component realComponent = new RealComponent();
        MessageDecorator messageDecorator = new MessageDecorator(realComponent);
        TimeDecorator timeDecorator = new TimeDecorator(messageDecorator);
        DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);

        client.execute();

        /**
         * 22:30:33.623 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.TimeDecorator -- TimeDecorator 실행...
         * 22:30:33.625 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.MessageDecorator -- MessageDecorator 실행...
         * 22:30:33.625 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.RealComponent -- RealComponent 실행...
         * 22:30:33.625 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.MessageDecorator -- MessageDecorator 꾸미기 적용 전=data, 적용 후=*****data*****
         * 22:30:33.626 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.TimeDecorator -- TimeDecorator 종료 resultTime=1ms
         * 22:30:33.626 [Test worker] INFO com.example.advanced_spring.proxy.pureproxy.decorator.code.DecoratorPatternClient -- result=*****data*****
         */
    }
}

보다시피 성공적으로 실행 시간까지 로그로 출력되는 것을 확인할 수 있다.

 

🤔 중복되는 부분이 발생하는 것 같은데…

잘 살펴보면 Decorator에는 일부 중복되는 부분이 존재한다. 프록시는 항상 호출할 객체가 있어야 한다. 위의 메시지를 꾸며주는 MessageDecorator, 실행 시간을 뽑아내는 TimeDecorator 클래스를 보면 내부에 component를 가지고 있는 것을 볼 수 있다. 이런 중복을 제거하기 위해 아예 Decorator를 인터페이스가 아닌 추상 클래스로 설계할 수도 있다.

 

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

꼭 인터페이스가 아니어도 클래스 기반의 프록시를 생성할 수도 있다. 하지만 클래스 기반의 프록시는 상속을 이용하기 때문에 부모 클래스의 생성자를 호출해야 하고, 클래스에 final 키워드가 붙으면 상속이 불가능할 뿐더러 메서드에 final이 붙으면 해당 메서드를 오버라이딩 할 수 없다.

이렇게 상속이라는 제약으로부터 자유롭기 때문에 인터페이스 기반의 프록시를 사용하는 것이다. 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나눌 수 있기 때문에 훨씬 더 좋다. 구현을 변경할 가능성이 거의 없는 코드에서만 클래스 도입을 고려하도록 하자.

0개의 댓글