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

develemon·2024년 2월 22일

Spring

목록 보기
7/9
post-thumbnail

❗이 게시물은 김영한님의 인프런 강좌 스프링 핵심 원리 - 고급편의 내용을 개인 정리를 위한 목적으로 작성하였습니다.

지난 게시물 [Spring] 동시성 문제 해결을 위한 ThreadLocal을 통해 개발한 로그 추적기에 존재했던 동시성 문제를 해결하였다.

Intro


그런데 로그 추적기는 서비스에 대한 부가 기능으로 개발되어야 마땅하지만, 서비스에 대한 핵심 기능보다 로그를 출력하는 부가 기능 코드가 더 많아졌다는 문제가 나타났다. 이 문제는 개발자가 앞으로 유지보수하기 힘들어질 것이라고 암시하고 있다.

TraceStatus status = null;
try {
	status = trace.begin("message");
	//핵심 기능 호출
	trace.end(status);
} catch (Exception e) {
	trace.exception(status, e);
	throw e;
}

앞서 작성한 코드를 살펴보면 위와 같은 패턴이 Controller, Service, Repository에서 모두 반복됨을 확인할 수 있다. 이러한 반복되는 패턴을 효율적으로 처리할 수 있는 방법으로 여러 디자인 패턴들이 있다. 각 패턴들에 대해 간략히나마 짚어보자.

  • 템플릿 메서드 패턴(Template Method Pattern)
    • 부모 클래스에 알고리즘의 골격인 템플릿을 정의하고, 일부 변경되는 로직은 자식 클래스에 정의한다. 알고리즘의 전체 구조를 변경하지 않고, 자식 클래스에서 특정 부분만 재정의함으로써 상속과 오버라이딩을 통한 다형성으로 문제를 해결한다.
    • 상속을 사용함으로써 그에 따른 단점을 가진다. 자식 클래스에서 부모 클래스의 기능을 전혀 사용하지 않음에도 불구하고 자식 클래스는 부모 클래스에 강하게 의존함으로써 자식 클래스의 코드에 부모 클래스 코드가 따라오게 된다. 그리고 부모 클래스를 수정하게 되면 자식 클래스에도 영향을 줄 수 있다.
    • 추가로 상속 구조로 인해 별도의 클래스나 익명 내부 클래스를 만들어야 하기 때문에 복잡성이 증가한다.
  • 전략 패턴(Strategy Pattern)
    • 변하지 않는 부분과 변하는 부분을 분리하여, 변하는 부분은 인터페이스를 만들어 해당 인터페이스를 구현하도록 해서 문제를 해결하며, 변하지 않는 부분은 구현체가 아닌 해당 인터페이스에만 의존한다. 상속이 아닌 위임을 통해 문제를 해결하는 것이다.
    • 전략 패턴은 두 가지 구사 방법이 있는데, 그 중 하나는 Context의 필드에 Strategy를 저장하는 방법으로, 선 조립, 후 실행 방법에 적합하다. 다만 이 둘을 조립한 이후에는 전략을 변경하기가 번거로워진다. 만약 Context를 싱글톤으로 사용할 때에는 동시성 이슈 등 고려할 점이 많아진다.
    • 다른 한 방법은 Strategy를 필드가 아닌 파라미터로 전달받는 방법이다. 클라이언트는 Context를 실행하는 시점에 원하는 Strategy를 전달할 수 있어 선 조립, 후 실행 방식에 비해 더 유연하게 변경할 수 있다. 다만 실행할 때마다 전략을 계속 지정해주어야 한다.
  • 템플릿 콜백 패턴(Template Callback Pattern)
    • callback은 코드가 호출(call)은 되는데 코드를 넘겨준 곳의 뒤(back)에서 실행된다는 뜻이다. 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴으로, 전략 패턴에서 Context가 템플릿 역할을 하고, Strategy부분이 콜백으로 넘어온다고 생각하면 된다. GOF 패턴은 아니지만 스프링 내부에서 이 방식을 자주 사용하기 때문에 붙여진 이름이다. 스프링에서는 JdbcTemplate, RestTemplate, TransactionTemplate, RedisTemplate처럼 다양한 템플릿 콜백 패턴이 사용된다. 콜백을 사용할 경우 익명 내부 클래스나 람다를 사용하는 것이 편리하다.

그런데 위 패턴들에 따라 아무리 최적화를 해도 결국 로그 추적기를 적용하기 위해서는 원본 코드를 수정해야 한다. 클래스가 수백개이면 수백개를 더 힘들게 수정하는가 조금 덜 힘들게 수정하는가의 차이가 있을 뿐, 본질적으로 코드를 다 수정해야 한다.

지금부터 원본 코드를 손대지 않고 로그 추적기를 적용할 수 있는 방법을 알아보자. 추가로 보안상 일부는 로그를 출력하지 않을 수 있도록 하는 방법도 알아보자. 그리고 인터페이스의 유무에 따른 상황과 컴포넌트 스캔 상황 등 다양한 케이스에 적용할 수 있도록 해보자. 그러기 위해서는 프록시 개념을 먼저 이해해야 한다.

프록시


클라이언트와 서버 개념에서 일반적으로 클라이언트가 서버를 직접 호출하고, 처리 결과를 직접 받는다. 이것을 직접 호출이라고 한다. 그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 여기서 대리자를 프록시(Proxy)라고 한다. 이때 직접 호출과 다르게 간접 호출을 하면 프록시가 중간에서 여러가지 일을 할 수 있다.

객체에서 프록시가 되려면 클라이언트는 서버에게 요청을 한 것인지, 프록시에게 요청을 한 것인지조차 몰라야 한다. 즉, 서버와 프록시는 같은 인터페이스를 사용해야 한다. 그리고 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다.

클래스 의존관계를 보면 클라이언트는 서버 인터페이스(ServerInterface)에만 의존한다. 그리고 서버와 프록시가 같은 인터페이스를 사용한다. 따라서 DI를 사용해서 대체 가능하다. 런타임 객체 의존관계라고 해도 마찬가지로 런타임에 클라이언트 객체에 DI를 사용해서 Client -> Server에서 Client -> Proxy로 객체 의존관계를 변경해도 클라이언트 코드를 전혀 변경하지 않아도 된다. 클라이언트 입장에서는 변경 사실 조차 모른다. DI를 사용하면 클라이언트 코드의 변경 없이 유연하게 프록시를 주입할 수 있다.

프록시의 주요 기능

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

  • 접근 제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 예) 요청 값이나 응답 값을 중간에 변형한다.
    • 예) 실행 시간을 측정해서 추가 로그를 남긴다.

프록시 객체가 중간에 있으면 크게 접근 제어부가 기능 추가를 수행할 수 있다.

GOF 디자인 패턴

둘다 프록시를 사용하는 방법이지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서 프록시 패턴데코레이터 패턴으로 구분한다.

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

둘다 프록시를 사용하지만, 의도가 다르다는 점이 핵심이다. 용어가 프록시 패턴이라고 해서 이 패턴만 프록시를 사용하
는 것은 아니다. 데코레이터 패턴도 프록시를 사용한다.

그럼 프록시에 보다 잘 알기 위해 프록시 패턴과 데코레이터 패턴에 대해서 알아보도록 하자.

프록시 패턴을 알아보기에 앞서서 테스트 코드에서 Lombok을 사용하려면 build.gradle에 테스트에서 lombok을 사용할 수 있도록 의존관계를 추가해야 한다.

dependencies {
	...
	//테스트에서 lombok 사용
	testCompileOnly 'org.projectlombok:lombok'
	testAnnotationProcessor 'org.projectlombok:lombok'
}

프록시 패턴


프록시 패턴에 대해 이해하기 위해 다음 예제를 살펴보도록 하자.

RealSubject.java

package hello.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();
        }
    }
}

Subject.java

package hello.proxy.pureproxy.proxy.code;

public interface Subject {
    String operation();
}

ProxyPatternClient.java

package hello.proxy.pureproxy.proxy.code;

public class ProxyPatternClient {

    private Subject subject;

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

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

CacheProxy.java

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

클라이언트가 프록시를 호출하면 프록시가 최종적으로 실제 객체를 호출해야 한다. 따라서 내부에 실제 객체의 참조를 갖고 있어야 한다. 이렇게 프록시가 호출하는 대상을 target이라고 하였다. 그리고 cacheValue에 값이 없으면 실제 객체(target)를 통해 값을 구하고 이를 cacheVaule에 저장하고 반환한다. 만약 cacheValue에 값이 있으면 실제 객체는 호출되지 않고, 캐시 값을 그대로 반환한다. 따라서 처음 조회 이후에는 실제 객체가 아닌 캐시(cacheValue)에서 보다 빠르게 데이터를 조회할 수 있다.

ProxyPatternTest.java

package hello.proxy.pureproxy.proxy;

import hello.proxy.pureproxy.proxy.code.CacheProxy;
import hello.proxy.pureproxy.proxy.code.ProxyPatternClient;
import hello.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();
    }

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

noProxyTest()cacheProxyTest()를 비교해서 보면, noProxyTest()에서는 clientrealSubject를 바로 연결하지만, cacheProxyTest()에서는 realSubjectcacheProxy에 연결한 후 그 cacheProxyclient에 연결한다. 이 과정을 통해 client -> cacheProxy -> realSubject 런타임 객체 의존관계가 완성된다.

실행 결과

위 테스트 코드의 실행 결과는 아래와 같다.

//noProxyTest()
17:11:35.928 [main] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출
17:11:36.941 [main] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출
17:11:37.944 [main] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출

//cacheProxyTest()
17:11:34.912 [main] INFO hello.proxy.pureproxy.proxy.code.CacheProxy - 프록시 호출
17:11:34.914 [main] INFO hello.proxy.pureproxy.proxy.code.RealSubject - 실제 객체 호출
17:11:35.922 [main] INFO hello.proxy.pureproxy.proxy.code.CacheProxy - 프록시 호출
17:11:35.922 [main] INFO hello.proxy.pureproxy.proxy.code.CacheProxy - 프록시 호출

noProxyTest()에서는 realSubject를 호출하는 데에 1초씩 소요되면서 총 3초가 걸렸고, cacheProxyTest()에서는 처음 realSubject에 접근하면서 1초 소요되고 이후에는 캐시 프록시를 통해 거의 즉시 반환하여 단 1초만 걸린다.

정리

프록시 패턴의 핵심은 RealSubject 코드와 클라이언트 코드를 전혀 변경하지 않고, 프록시를 도입해서 접근 제어를 했다는 점이다. 그리고 클라이언트 코드의 변경 없이 자유롭게 프록시를 넣고 뺄 수 있다. 실제 클라이언트 입장에서는 프록시 객체가 주입되었는지, 실제 객체가 주입되었는지 알지 못한다.

데코레이터 패턴


앞서 설명한 것처럼 프록시를 통해서 할 수 있는 기능은 크게 접근 제어와 부가 기능 추가라는 2가지로 구분한다. 앞서 프록시 패턴에서 캐시를 통한 접근 제어를 알아보았다. 이번에는 프록시를 활용해서 부가 기능을 추가해보자. 이렇게 프록시로 부가 기능을 추가하는 것을 데코레이터 패턴이라고 한다.

RealComponent.java

package hello.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

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

Component.java

package hello.proxy.pureproxy.decorator.code;

public interface Component {
    String operation();
}

DecoratorPatternClient.java

package hello.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);
    }
}

MessageDecorator.java

package hello.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 실행");
        //data -> *****data*****
        String result = component.operation();
        String decoResult = "*****" + result + "*****";
        log.info("MessageDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
        return decoResult;
    }
}

MessageDecoratorComponent 인터페이스를 구현한다. 프록시가 호출해야하는 대상을 component에 저장한다. operation()을 호출하면 프록시와 연결된 대상을 호출(component.operation())하고, 그 응답 값에 *****을 더해서 꾸며준 다음 반환한다.

DecoratorPatternTest.java

package hello.proxy.pureproxy.decorator;

import hello.proxy.pureproxy.decorator.code.*;
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();
    }

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

client -> messageDecorator -> realComponent의 객체 의존관계를 만들고 client.execute()를 호출한다. 위 테스트 코드의 실행 결과는 아래와 같다. MessageDecoratorRealComponent를 호출하고 반환한 응답 메시지를 꾸며서 반환한 것을 확인할 수 있다.

실행 결과

//noDecorator()
19:15:14.687 [main] INFO hello.proxy.pureproxy.decorator.code.RealComponent - RealComponent 실행
19:15:14.687 [main] INFO hello.proxy.pureproxy.decorator.code.DecoratorPatternClient - result=data

//decorator1()
19:15:14.673 [main] INFO hello.proxy.pureproxy.decorator.code.MessageDecorator - MessageDecorator 실행
19:15:14.675 [main] INFO hello.proxy.pureproxy.decorator.code.RealComponent - RealComponent 실행
19:15:14.678 [main] INFO hello.proxy.pureproxy.decorator.code.MessageDecorator - MessageDecorator 꾸미기 적용 전=data, 적용 후=*****data*****
19:15:14.680 [main] INFO hello.proxy.pureproxy.decorator.code.DecoratorPatternClient - result=*****data*****

그리고 이 데코레이터에 더해서 다른 데코레이터를 더 추가할 수 있다. 이번에는 기존 데코레이터에 실행 시간을 측정하는 기능까지 추가해보자.

TimeDecorator.java

package hello.proxy.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TimeDecorator implements Component {

    private final 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는 실행 시간을 측정하는 부가 기능을 제공한다. 이를 DecoratorPatternTest 테스트 코드에 추가해보자.

	// 생략
    @Test
    void decorator2() {
        RealComponent realComponent = new RealComponent();
        MessageDecorator messageDecorator = new MessageDecorator(realComponent);
        TimeDecorator timeDecorator = new TimeDecorator(messageDecorator);
        DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
        client.execute();
    }

이번에는 client -> timeDecorator -> messageDecorator -> realComponent의 객체 의존관계를 설정하고, 실행한다.

실행 결과

//decorator2()
19:15:14.686 [main] INFO hello.proxy.pureproxy.decorator.code.TimeDecorator - TimeDecorator 실행
19:15:14.686 [main] INFO hello.proxy.pureproxy.decorator.code.MessageDecorator - MessageDecorator 실행
19:15:14.686 [main] INFO hello.proxy.pureproxy.decorator.code.RealComponent - RealComponent 실행
19:15:14.686 [main] INFO hello.proxy.pureproxy.decorator.code.MessageDecorator - MessageDecorator 꾸미기 적용 전=data, 적용 후=*****data*****
19:15:14.686 [main] INFO hello.proxy.pureproxy.decorator.code.TimeDecorator - TimeDecorator 종료 resultTime=0ms
19:15:14.687 [main] INFO hello.proxy.pureproxy.decorator.code.DecoratorPatternClient - result=*****data*****

실행 결과를 보면 TimeDecoratorMessageDecorator를 실행하고 실행 시간을 측정해서 출력한 것을 확인할 수 있다.

중간 정리


여기서 생각해보면 Decorator 기능에 일부 중복이 있다. 꾸며주는 역할을 하는 Decorator들은 스스로 존재할 수 없다. 항상 꾸며줄 대상이 있어야 한다. 따라서 내부 호출 대상인 component를 가지고 있어야 한다. 그리고 component를 항상 호출해야 한다. 이 부분이 중복이다. 이런 중복을 제거하기 위해 component를 속성으로 가지고 있는 Decorator라는 추상 클래스를 만드는 방법도 고민할 수 있다. 이렇게 하면 추가로 클래스 다이어그램에서 어떤 것이 실제 컴포넌트인지, 데코레이터인지 명확하게 구분할 수 있다.

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

여기까지 진행하면 몇가지 의문이 들 것이다.

  • Decorator라는 추상 클래스를 만들어야 데코레이터 패턴일까?
  • 프록시 패턴과 데코레이터 패턴은 그 모양이 거의 비슷한 것 같은데?

사실 프록시 패턴과 데코레이터 패턴은 그 모양이 거의 같고, 상황에 따라 정말 똑같을 때도 있다. 다만 디자인 패턴에서 중요한 것은 해당 패턴의 겉모양이 아니라 그 패턴을 만든 의도가 더 중요하다. 따라서 의도에 따라 패턴을 구분한다.

  • 프록시 패턴의 의도: 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공
  • 데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공

인터페이스 기반 프록시


지금까지 학습한 프록시를 도입해서 LogTrace를 사용해보자. 프록시를 사용하면 기존 코드를 전혀 수정하지 않고 로그 추적 기능을 도입할 수 있다.

우선 기존 클래스 의존관계를 살펴보면 아래 그림과 같다.

여기에 로그 추적용 프록시를 추가하면 다음과 같다.

Controller, Service, Repository 각각 인터페이스에 맞는 프록시 구현체를 추가한다. (그림에서 리포지토리 부분은 생략했다.)

그럼 실제 프록시를 코드에 적용해보자.

OrderRepositoryInterfaceProxy.java

package hello.proxy.config.v1_proxy.interface_proxy;

import hello.proxy.app.v1.OrderRepositoryV1;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class OrderRepositoryInterfaceProxy implements OrderRepositoryV1 {

    private final OrderRepositoryV1 target;
    private final LogTrace logTrace;

    @Override
    public void save(String itemId) {

        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderRepository.request()");
            //target 호출
            target.save(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

프록시를 만들기 위해 인터페이스를 구현하고 구현한 메서드에 LogTrace를 사용하는 로직을 추가한다. 지금까지는 OrderRepositoryImpl에 이런 로직을 모두 추가해야했다. 프록시를 사용한 덕분에 이 부분을 프록시가 대신 처리해준다. 따라서 OrderRepositoryImpl 코드를 변경하지 않아도 된다. 그리고 프록시가 실제 호출할 원본 리포지토리를 참조할 수 있도록 내부에 OrderRepositoryV1 target을 갖고 있어야 한다.

(OrderServiceInterfaceProxyOrderControllerInterfaceProxy는 동일한 코드가 반복되므로 생략한다.)

InterfaceProxyConfig.java

package hello.proxy.config.v1_proxy;

import hello.proxy.app.v1.*;
import hello.proxy.config.v1_proxy.interface_proxy.OrderControllerInterfaceProxy;
import hello.proxy.config.v1_proxy.interface_proxy.OrderRepositoryInterfaceProxy;
import hello.proxy.config.v1_proxy.interface_proxy.OrderServiceInterfaceProxy;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

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

LogTrace가 아직 스프링 빈으로 등록되어 있지 않은데, 이 부분은 바로 다음에 등록할 것이다.

기존에는 스프링 빈이 oderControllerV1Impl, OrderServiceV1Impl 같은 실제 객체를 반환했지만, 이제는 프록시를 생성하고 실제 스프링 빈 대신 프록시를 등록한다. 실제 객체는 스프링 빈으로 등록하지 않는다. 이제 의존관계는 proxy -> target 형태로 orderRepositoryInterfaceProxy -> orderRepositoryV1Impl를 갖고 있다.

참고로 실제 객체가 스프링 빈으로 등록되지 않는다고 해서 사라지는 것이 아니라, 프록시 객체 안에 실제 객체가 있어 프록시 객체가 실제 객체를 참조해서 호출할 수 있게 된다.

InterfaceProxyConfig를 통해 프록시를 적용하면 스프링 컨테이너에 프록시 객체가 등록된다. 스프링 컨테이너는 이제 실제 객체가 아니라 프록시 객체를 스프링 빈으로 관리한다. 이제 실제 객체는 스프링 컨테이너와는 상관이 없고, 프록시 객체는 스프링 컨테이너가 관리하고 자바 힙 메모리에도 올라간다. 물론 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않는다.

ProxyApplication.java

package hello.proxy;

import hello.proxy.config.v1_proxy.InterfaceProxyConfig;
import hello.proxy.trace.logtrace.LogTrace;
import hello.proxy.trace.logtrace.ThreadLocalLogTrace;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;

@Import(InterfaceProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {

	public static void main(String[] args) {
		SpringApplication.run(ProxyApplication.class, args);
	}

	@Bean
	public LogTrace logTrace() {
		return new ThreadLocalLogTrace();
	}
}

LogTrace를 여기에서 스프링 빈(@Bean)으로 추가한 이유는 앞으로 수정되는 다른 예제에서도 적용할 것이기 때문이다. 그리고 프록시를 적용한 설정 파일을 사용하도록 @Import(InterfaceProxyConfig.class)를 추가하였다. 참고로 @SpringBootApplication(scanBasePackages = "hello.proxy.app")에서 scanBasePackages는 전체 패키지가 아닌 특정 패키지와 그 하위 부분으로 스캔을 제한한다는 뜻이다.

이로써 프록시와 DI 덕분에 원본 코드를 전혀 수정하지 않고, 인터페이스가 있는 구현 클래스에 로그 추적기를 적용할 수 있게 되었다. 그럼 다음으로는 인터페이스가 없는 구체 클래스에 프록시를 어떻게 적용할 수 있는지 알아보자.

구체 클래스 기반 프록시


자바의 다형성은 인터페이스를 구현하든, 아니면 클래스를 상속하든 상위 타입만 맞으면 다형성이 적용된다. 즉, 인터페이스가 없어도 프록시를 만들 수 있다. 그래서 이번에는 인터페이스가 아니라 클래스를 기반으로 상속을 받은 프록시 기능을 적용해보자.

OrderRepositoryConcreteProxy.java

package hello.proxy.config.v1_proxy.concrete_proxy;

import hello.proxy.app.v2.OrderRepositoryV2;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;

public class OrderRepositoryConcreteProxy extends OrderRepositoryV2 {

    private final OrderRepositoryV2 target;
    private final LogTrace logTrace;

    public OrderRepositoryConcreteProxy(OrderRepositoryV2 target, LogTrace logTrace) {
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderRepository.request()");
            //target 호출
            target.save(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

인터페이스가 아닌 OrderRepositoryV2 클래스를 상속 받아서 프록시를 만든다.

OrderServiceConcreteProxy.java

package hello.proxy.config.v1_proxy.concrete_proxy;

import hello.proxy.app.v2.OrderServiceV2;
import hello.proxy.trace.TraceStatus;
import hello.proxy.trace.logtrace.LogTrace;

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 호출
            target.orderItem(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

마찬가지로 인터페이스가 아닌 OrderServiceV2 클래스를 상속 받아서 프록시를 만든다. OrderControllerConcreteProxy는 동일한 코드가 반복되므로 생략한다.

클래스 기반으로 프록시를 적용할 때에는 몇가지 단점이 존재한다.

  • super(null): 자바 기본 문법에 의해 자식 클래스를 생성할 때는 항상 super()로 부모 클래스의 생성자를 호출해야 한다. 이 부분을 생략하면 기본 생성자가 호출된다. 그런데 부모 클래스인 OrderServiceV2는 기본 생성자가 없고, 생성자에서 파라미터 1개를 필수로 받는다. 따라서 파라미터를 넣어서 super(..)를 호출해야 한다.
  • 프록시는 부모 객체의 기능을 사용하지 않기 때문에 super(null)을 입력해도 된다.
  • 인터페이스 기반 프록시는 이런 고민을 하지 않아도 된다.

ConcreteProxyConfig.java

package hello.proxy.config.v1_proxy;

import hello.proxy.app.v2.OrderControllerV2;
import hello.proxy.app.v2.OrderRepositoryV2;
import hello.proxy.app.v2.OrderServiceV2;
import hello.proxy.config.v1_proxy.concrete_proxy.OrderControllerConcreteProxy;
import hello.proxy.config.v1_proxy.concrete_proxy.OrderRepositoryConcreteProxy;
import hello.proxy.config.v1_proxy.concrete_proxy.OrderServiceConcreteProxy;
import hello.proxy.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ConcreteProxyConfig {

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

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

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

ProxyApplication.java

package hello.proxy;

import hello.proxy.config.v1_proxy.ConcreteProxyConfig;
import hello.proxy.trace.logtrace.LogTrace;
import hello.proxy.trace.logtrace.ThreadLocalLogTrace;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;

@Import(ConcreteProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app")
public class ProxyApplication {

	public static void main(String[] args) {
		SpringApplication.run(ProxyApplication.class, args);
	}

	@Bean
	public LogTrace logTrace() {
		return new ThreadLocalLogTrace();
	}
}

설정을 @Import(ConcreteProxyConfig.class)로 변경하자.

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


인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있지만, 클래스 기반 프록시는 해당 클래스에만 적용할 수 있다. 그리고 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.

  • 부모 클래스의 생성자를 호출해야 한다.
  • 클래스에 final 키워드가 붙으면 상속이 불가능하다.
  • 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.

반면 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.

이렇게 보면 인터페이스 기반 프록시가 더 좋아보인다. 다만 인터페이스 기반 프록시의 단점은 결국 인터페이스가 필요하다는 그 자체이다. 인터페이스가 없으면 안되는 것이다. (그리고 인터페이스 기반 프록시는 캐스팅 관련해서도 단점이 존재하는데, 나중에 기회가 되면 알아보기로 하자.)

이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋다. 하지만 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다. 인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데, 구현을 변경할 가능성이 거의 없는 코드에 무작정 인터페이스를 사용하는 것은 번거롭고 실용적이지 못하다. 이런 곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것이 좋을 수 있다. 물론 이 두 가지 상황에 모두 대응할 수 있어야 한다.

너무 많은 프록시 클래스

지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다. 그런데 문제는 프록시 클래스를 너무 많이 만들어야 한다는 점이다. 잘 보면 프록시 클래스가 하는 일은 LogTrace를 사용하는 것인데, 그 로직이 모두 똑같다. 대상 클래스만 다를 뿐이다. 만약 적용해야 하는 대상 클래스가 100개라면 프록시 클래스도 100개를 만들어야 한다.

프록시 클래스를 하나만 만들어서 모든 곳에 적용할 방법은 없을까? 다음으로는 동적 프록시 기술을 알아보자.

profile
유랑하는 백엔드 개발자 새싹 블로그

0개의 댓글