프록시

업데이트:

프록시란?

  • 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 간접적으로 서버에 요청할 수 있다. 이 때 대리자를 프록시(Proxy)라고 한다.

    Untitled

    클라이언트와 서버에서 직접 호출하는 상황에서 프록시를 사용하면 아래와 같은 흐름이 만들어진다.

    Untitled

    이렇게 프록시를 사용해서 간접 호출을 하게 되면 대리자가 중간에서 여러 일을 할 수 있다.

프록시의 주요 기능

  • 접근 제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행

      예) 요청 값이나, 응답 값을 중간에서 변형

      예) 실행 시간을 측정해서 추가 로그를 남긴다.

  • 프록시를 사용하는 패턴에는 프록시 패턴과 데코레이터 패턴이 있다. 사용법은 동일하지만 GOF에서는 의도(intent)에 따라 구분한다.
    • 프록시 패턴 : 접근 제어가 목적
    • 데코레이터 패턴 : 새로운 기능 추가가 목적

참고 : 프록시라는 개념은 클라이언트 서버라는 큰 개념안에서 자연스럽게 발생할 수 있다. 프록시는 객체안에서의 개념도 있고, 웹 서버에서의 프록시도 있다. 객체안에서 객체로 구현되어있는가, 웹 서버로 구현되어 있는가 처럼 규모의 차이가 있을 뿐 근본적인 역할은 같다.

테스트를 통한 패턴 학습

프록시 패턴

public interface Subject {
    String operation();
}
@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();
        }
    }
}
@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;
    }
}

프록시 객체가 실제 객체를 대신하기 위해선 그 모양(인터페이스)가 같아야 하기 때문에 Subject 인터페이스를 구현해야 한다.

클라이언트가 프록시를 호출하면 프록시가 실제 객체를 호출해야 한다. 그를 위해 내부에 실제 객체를 가지고 있어야 한다. 이를 target이라고 한다.

operation() 메서드를 보면 캐시가 없으면 실제 객체를 호출해서 값을 가져오고 저장된 값이 있으면 프록시에서 캐시 값을 그대로 반환한다. 이를 이용해 처음 조회 이후 빠르게 데이터를 조회할 수 있다.

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

스크린샷 2022-01-04 오후 10.02.59.png

실행 결과를 보면 첫 실행에만 실제 객체까지 들어가고 그 후에는 프록시에서 캐시 데이터를 가져오는 것을 확인할 수 있다. 이렇게 프록시 패턴을 적용해서 RealSubject코드를 전혀 변경하지 않고 접근 제어를 했다.

데코레이터 패턴

  • 앞서 기능에서 말했던 것처럼 데코레이터 패턴은 부가 기능을 추가하는 프록시 사용법을 뜻한다.
    • 예) 요청 값이나 응답 값을 중간에 변형
    • 예) 실행 시간을 측정해서 추가 로그를 남긴다.

스크린샷 2022-01-04 오후 10.26.31.png

@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);
    }
}
public interface Component {
    String operation();
}
@Slf4j
public class RealComponent implements Component {
    @Override
    public String operation() {
        log.info("RealComponent 실행");
        return "data";
    }
}

기존 코드는 DecoratorPatternClientComponentRealComponent의 흐름을 타고 있다. 여기에 MessageDecorator를 추가해보자.

@Slf4j
public class MessageDecorator implements Component {

    private Component component;

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

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

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

스크린샷 2022-01-04 오후 10.17.30.png

스크린샷 2022-01-04 오후 10.19.21.png

public class DecoratorPatternTest {
    @Test
    void decorator1() {
        Component realComponent = new RealComponent();
        Component messageDecorator = new MessageDecorator((realComponent));
        DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);
        client.execute();
    }
}

스크린샷 2022-01-04 오후 10.30.06.png

실행 결과를 보면 MessageDecoratorRealComponent를 호출하고 반환한 응답 메시지를 꾸며서 반환한다.

이번엔 실행 시간을 측정하는 기능까지 추가해보자.

스크린샷 2022-01-04 오후 11.39.12.png

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

TimeDecorator는 실행 시간을 측정하는 부가 기능을 제공한다. 대상의 호출이 끝나면 호출 시간을 로그로 남긴다.

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

스크린샷 2022-01-04 오후 11.43.23.png

실행 결과를 보면 TimeDecoratorMessageDecorator를 실행하고 MessageDecoratorRealComponent를 실행하는 것을 확인할 수 있다. 이렇게 추가 기능이 생길 때마다 기존 코드를 수정하지 않고 새로운 기능을 추가할 수 있다.

스크린샷 2022-01-04 오후 11.45.27.png

여기서 Decorator들은 내부에 꾸며줘야할 호출 대상인 component를 가지고 있어야 한다. 그렇다면 component를 필드로 가지고 있는 Decorator라는 추상 클래스를 만드는 방법도 있다. 이렇게 만드는 것이 GOF에서 설명하는 데코레이터 패턴의 기본 예제이다.

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

  • 이 둘의 차이는 무엇일까? 그것을 가르는 것이 의도(intent)이다.
  • 의도
    • 프록시 패턴 : 다른 개체에 대한 접근을 제어하기 위한 대리자
    • 데코레이터 패턴 : 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공

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

  • 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
  • 클래스 기반 프록시는 해당 클래스에만 적용할 수 있다. 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.
  • 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
    • 부모 클래스의 생성자를 호출해야 한다.(앞서 본 예제)
    • 클래스에 final 키워드가 붙으면 상속이 불가능하다.
    • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.

인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭다. 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나누기 때문에 더 좋다. 인터페이스 기반 프록시의 단점은 인터페이스가 필요하다는 그 자체이다. 인터페이스가 없으면 인터페이스 기반 프록시를 만들 수 없다.

이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋다. 이렇게 하면 역할과 구현을 나누어서 구현체를 매우 편리하게 변경할 수 있다. 하지만 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다. 인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데, 구현을 변경할 가능성이 거의 없는 코드에 무작정 인터페이스를 사용하는 것은 번거롭고 실용적이지 않다. 이런곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것도 괜찮다고 생각한다.

정리

프록시 패턴과 데코레이터 패턴을 이용해서 기존 코드를 변경하지 않고 부가 기능을 성공적으로 적용했다. 하지만 매번 새로운 부가 기능이 나올 때마다 클래스를 만들어야 해서 너무 많은 프록시 클래스가 생성된다. 이런 프록시 클래스들을 하나로 통합해서 모든 곳에 적용하는 방법은 다음 글인 동적 프록시 기술에서 다루도록 하겠다.

이 내용은 인프런의 김영한님 강의 “스프링 핵심 원리 - 고급편”을 시청하고 정리한 글입니다.

댓글남기기