[Back-end]/[Spring]

[Spring] 프록시와 디자인패턴

DevLoki 2021. 11. 30. 00:50

프록시와 디자인 패턴

스프링의 3대 기반기술 중 AOP를 공부하던 중 관심사 분리를 위한 다이내믹 프록시와 팩토리 빈이라는 개념의 등장에 당황했습니다. 평소 객체지향과 디자인 패턴을 공부할 때 프록시라는 단어를 들어본 적은 있었지만 실제 동작 원리에 대해 이해가 부족해 포스팅을 하게 되었습니다.


프록시란?

프록시(Proxy)대리자 라는 뜻으로, 클라이언트가 사용하려고 하는 실제 대상인 것처럼 위장해서 클라이언트의 요청을 받아주는 역할을 합니다.

프록시는 실제 대상인 것처럼 위장함으로서 이를 사용하는 클라이언트는 구체 클래스를 알 필요가 없어집니다. 

또한 프록시는 클라이언트의 요청을 받아서 원래 요청 대상에게 바로 넘겨주는 게 아닌, 다양한 부가기능을 지원할 수 있습니다.

여기서 원래 요청하려는 대상, 즉 최종적으로 요청을 위임받아 처리하는 실제 오브젝트를 타깃이라고 합니다.

일반적인 프록시 구조

프록시의 조건

클라이언트의 요청을 대리로 수행해주는 모든 객체가 프록시 인것은 아닙니다.

객체가 프록시가 되려면 클라이언트는 요청을 보낸 대상이 타깃인지 프록시인지 구분을 할 수 없어야 합니다. 즉, 타깃프록시는 같은 인터페이스를 확장해야 합니다.(CGLib처럼 구현 클래스를 상속받는 방법도 있습니다..) 이로써 느슨한 연결을 유지하며, OCP원칙을 통해 좋은 코드를 작성할 수 있습니다.

 

프록시 의존관계 다이어그램


디자인 패턴

프록시는 사용목적(intent)에 따라 두 가지로 구분할 수 있습니다.

  1. 부가적인 기능 부여 → 데코레이터(Decorator) 패턴
  2. 접근제어 → 프록시(Proxy) 패턴

데코레이터 패턴

데코레이터 패턴은 타깃에 부가적인 기능을 런타임 시 다이내믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말합니다.

이 패턴의 이름처럼 선물 상자를 포장지로 꾸미는 것처럼 타깃에 부가적인 기능을 부여해줄 수 있습니다. 또한 선물 상자를 꾸미는데 포장지 제한이 없는 것처럼 하나의 타깃에도 다양한 부가기능을 추가하기 위해 한 개 이상의 프록시를 사용할 수 있습니다.

예시

설명

  • TargetInterface.java - 타깃 구현 클래스와 프록시의 부모 클래스로 run() 메서드를 선언했습니다.
  • TargetImpl.java - 타깃 구현 클래스로 run() 메서드는 간단한 로그를 남깁니다.
  • Proxy.java - TargetInterface를 선언하며 타깃의 핵심 로직 전후에 로그를 남깁니다.
  • Client.java - TargetInterface를 의존하며 execute() 메서드로 요청을 보내는 사용자입니다.

 

TargetInterface.java

public interface TargetInterface {

    void run();

}

TargetImpl.java

@Slf4j
public class TargetImpl implements TargetInterface{

    @Override
    public void run() {
        log.info("TargetImpl.run() 실행");  //이 부분이 핵심(비즈니스)로직이라고 가정합니다.
    }
}

Proxy.java

@Slf4j
public class Proxy implements TargetInterface{

    private final TargetInterface target;  //타깃 오브젝트를 멤버 변수로 유지해야 합니다.

    public Proxy(TargetInterface target) {  //생성자 주입
        this.target = target;
    }

    @Override
    public void run() {
        log.info("DecoratorProxy 시작");  //부가적인 기능을 부여하는 것이라 가정합니다.
        target.run();     //실제 타깃의 핵심로직 실행
        log.info("DecoratorProxy 종료");  //부가적인 기능을 부여하는 것이라 가정합니다.
    }
}

데코레이터의 역할을 수행하는 클래스입니다.

run() 메서드의 target.run()을 제외한 나머지는 모두 부가적인 기능을 부여하는 역할을 수행하게 됩니다.

Client.java

public class Client {

    private TargetInterface targetInterface;  //구현 클래스가 아닌 인터페이스에 의존
                                              //런타임 시 구체적 의존관계가 설정됨

    public Client(TargetInterface targetInterface) { //생성자 주입
        this.targetInterface = targetInterface;
    }

    public void execute(){ 
        targetInterface.run();
    }
}

DecoratorPattern.java

public class DecoratorPatternTest {

    @Test
    void noDecoratorPattern(){
        TargetInterface target = new TargetImpl();
        Client client = new Client(target);
        client.execute();
    }

    @Test
    void decoratorPattern(){
        TargetInterface target = new TargetImpl();
        TargetInterface proxy = new Proxy(target);
        Client client = new Client(proxy);
        client.execute();
    }

}

결과 화면

프록시 패턴

프록시 패턴은 타깃에 대한 접근 방법을 제어하려는 목적을 가지고 프록시를 사용하는 패턴을 말합니다.

프로젝트에서 객체를 생성하는 일은 언제나 비용이 소모됩니다. 따라서 객체를 최소한으로 생성할수록, 필요 시점까지 생성을 미룰수록 좋습니다. 프록시를 이용하면 타깃 오브젝트에 대한 레퍼런스가 미리 필요할 때, 객체를 생성해서 넘겨주지 않고 프록시를 먼저 넘겨준 후 프록시의 메서드를 통해 실제로 사용될 때 타깃 오브젝트를 생성할 수 있습니다.

또한 특정 상황에서 타깃에 대한 접근권한을 제어할 수 있습니다. 특정 조건이 만족되면 타깃의 핵심 로직을 호출하기 전에 예외를 던져서 접근을 불가능하게 만들 수 있습니다.

마지막으로 캐싱(cache)이 가능합니다. 타깃으로부터 응답으로 받은 데이터가 메모리에 존재할 때, 프록시는 타깃으로 요청을 보내지 않고, 기존 응답의 데이터를 클라이언트에게 전달할 수 있습니다.

 

예시

대부분의 코드가 데코레이터 패턴 예시와 동일하므로 변경된 클래스들만 설명하겠습니다.

  • Proxy1.java - private String data를 사용하여 null이라면 타깃 메서드를 호출하고, null이 아니라면 해당 값을 반환한다.(캐싱 기능)
  • Proxy2.java - private int count를 사용하여 3보다 작다면 타깃에 접근 가능하지만, 크다면 예외가 던져져 접근을 제한합니다. (접근 제한 기능)

 

 

Proxy1.java (캐싱 기능)

public class Proxy1 implements TargetInterface{

    private TargetInterface target;  //타깃 오브젝트를 멤버 변수로 유지해야 합니다.
    private String data;  //메모리에 저장된 캐싱용 데이터라고 가정합니다.(다른 방법으로 구현가능)

    public Proxy1(TargetInterface target) {
        this.target = target;
    }

    @Override
    public String run() {
        if(data==null){  //메모리에 저장된 데이터가 없다면
            data = target.run();  //타겟의 비즈니스 로직을 호출합니다.
        }
        return data;  //메모리에 데이터가 있다면 타깃에 접근하지 않으며 비용을 절약합니다.
    }
}

Proxy2.java (접근 제한 기능)

public class Proxy2 implements TargetInterface{

    private TargetInterface target;  //타깃 오브젝트를 멤버 변수로 유지해야 합니다.
    private int count;  //접근 횟수를 멤벼 변수로 저장합니다.

    public Proxy2(TargetInterface target) {
        this.target = target;
    }

    @Override
    public String run() {
        if(++count>3){  //접근 횟수가 3회 이상이라면 예외를 던져 접근을 제한합니다.
            throw new RuntimeException("접근 제한!!");
        }
        return target.run();
    }
}

ProxyPatternTest.java

import blog.proxy.proxy.*;
import org.junit.jupiter.api.Test;

public class ProxyPatternTest {
    @Test
    void noProxyPattern(){
        TargetInterface target = new TargetImpl();
        Client client = new Client(target);
        client.execute();
    }

    @Test
    void proxyPattern1(){
        TargetInterface target = new TargetImpl();
        TargetInterface proxy = new Proxy1(target);
        Client client = new Client(proxy);
        for(int i=0;i<4;i++){
            client.execute();
        }
    }

    @Test
    void proxyPattern2(){
        TargetInterface target = new TargetImpl();
        TargetInterface proxy = new Proxy2(target);
        Client client = new Client(proxy);
        for(int i=0;i<4;i++){
            client.execute();
        }
    }

}

결과 화면

proxyPattern1.test는 client.execute()가 4번 호출되었지만, 프록시에서 첫번째 호출 외에는 캐싱된 데이터를 반환했기 때문에 타깃에는 한번만 접근했습니다.

proxyPattern2.test도 마찬가지로 client.execute()가 4번 호출되었지만, 프록시에서 3번째 접근후에는 예외를 던져 접근을 제한했기 때문에 3번째 로그를 남긴 후 예외가 발생했습니다.


토비의 스프링 6장을 읽으며 프록시의 개념을 확실히 이해하고 넘어가야겠다는 생각을 하며 포스팅을 하게 되었습니다.

부족한 글이지만 읽어주셔서 감사합니다!

다음 포스팅은 다이내믹 프록시입니다.