관리 메뉴

나구리의 개발공부기록

프록시 패턴과 데코레이터 패턴, 인터페이스 기반 프록시 - 적용, 구체 클래스 기반 프록시(예제/적용), 인터페이스 기반 프록시와 클래스 기반 프록시 본문

인프런 - 스프링 완전정복 코스 로드맵/스프링 핵심원리 - 고급편

프록시 패턴과 데코레이터 패턴, 인터페이스 기반 프록시 - 적용, 구체 클래스 기반 프록시(예제/적용), 인터페이스 기반 프록시와 클래스 기반 프록시

소소한나구리 2024. 11. 8. 16:25

출처 : 인프런 - 스프링 핵심 원리 - 고급편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


1. 인터페이스 기반 프록시 - 적용

1) V1 App에 LogTrace 적용

(1) 프록시 사용

  • 프록시를 사용하면 기존 코드를 전혀 수정하지 않고 로그 추적 기능을 도입할 수 있음 (데코레이터 패턴 사용)
  • V1예제의 클래스 의존관계를 살펴보면 인터페이스와 구현체를 생성하여 각 인터페이스의 구현체들은 각 계층의 인터페이스를 의존하고 있으며 클라이언트도 컨트롤러 인터페이스를 주입받고 있음
  • 런타임 객체에 의존관계도를 보면 클라이언트는 각 구현체들을 의존관계 주입을 받게 됨
  • 프록시를 도입하게 되면 각 계층의 인터페이스에 실제 구현체외에 프록시 클래스를 생성하여 동시에 구현하고, 런타임 시에는 클라이언트와 각 계층은 프록시를 주입받고 프록시는 실제 계층을 주입받도록 빈 등록 설정을 해주면 됨

좌) 프록시 도입 전 V1의 클래스, 런타임 객체 의존관계 / 우) 프록시 도입 후 V1의 클래스, 런타임 객체 의존 관계

 

(2) OrderRepositoryInterfaceProxy

  • config하위의 경로에 v1_proxy.interface_proxy 패키지를 생성 후 코드를 작성
  • 프록시를 만들기 위해 인터페이스를 구현하고 구현한 메서드에 LogTrace를 사용하는 로직을 추가함
  • 지금까지는 실제 구현체인 OrderRepositoryImpl에 이런 부가기능을 사용하는 로직을 모두 추가했지만 프록시를 사용한 덕분에 부가기능을 프록시가 대신 처리해 주므로 OrderRepositoryImpl 코드들의 변경 없이 실제 비즈니스 로직만 가질 수 있게 됨
  • 프록시는 실제 객체를 가지고 있어야 하므로 target이라는 변수로 실제 객체의 참조를 가질 수 있도록 함(프록시 체인일 경우 의존관계 주입이 되는 프록시가 target이 될 수 있음)
package hello.proxy.config.v1_proxy.interface_proxy;

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

 

(3) OrderServiceInterfaceProxy

  • Repository에서 Service로 대상이 바뀌는 것 빼고는 OrderRepository의 프록시클래스를 생성하는 방식과 동일함
package hello.proxy.config.v1_proxy.interface_proxy;

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

 

(4) OrderControllerInterfaceProxy

  • 프록시를 생성하는 과정은 Service, Repository와 동일하고 요구사항에 따라 noLog()메서드는 로그를 출력하지 않기 위해 단순히 위임만 함
package hello.proxy.config.v1_proxy.interface_proxy;

@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {

    private final OrderControllerV1 target;
    private final LogTrace logTrace;

    @Override
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderController.request()");
            String result = target.request(itemId);
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }

    // 로그를 출력하지 않음
    @Override
    public String noLog() {
        return target.noLog();
    }
}

 

(5) InterfaceProxyConfig

  • 수동으로 스프링 빈으로 등록하는 설정파일 생성
  • 빈으로 등록되는 것은 프록시 객체이고, 프록시 객체에 의존관계 주입을 위해서 실제 객체를 생성함
  • 각 계층의 실제 객체에 주입받는 것은 각 메서드로 반환되는 프록시 객체들임

** 참고

  • 지금 상황에서는 LogTrace가 아직 스프링 빈으로 등록되어있지 않음, 바로 다음에 등록함
package hello.proxy.config.v1_proxy;

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

 

(6) V1 프록시 런타임 객체 의존관계 설정

  • 기존에는 스프링 빈이 실제 객체를 반환했으나 지금은 이전 설정과는 다르게 실제 객체를 스프링빈에 등록하지 않고 프록시를 생성하여 프록시를 스프링빈에 등록함 즉, 실제 객체는 스프링 빈으로 등록되지 않음
  • 프록시는 내부에 실제 객체를 참조하고 있어 proxy -> target(실제 객체)의 의존 관계를 가지고 있음
  • 스프링 빈으로 실제 객체 대신에 프록시 객체를 등록했기 때문에 스프링 빈을 주입받으면 실제 객체 대신 프록시 객체가 주입이 됨
  • 실제 객체가 스프링 빈으로 등록되지 않는다고 사라지는 것이 아니고 프록시 객체가 실제 객체를 참조하고 있기 때문에 프록시를 통해서 실제 객체를 호출할 수 있으므로 프록시 객체 안에 실제 객체가 있다고 이해하면 됨

좌) 스프링 컨테이너 프록시 적용 전 모양 / 우) 적용 후 모양

  • 위 이미지를 보면 프록시가 적용된 이후에는 스프링 컨테이너에 프록시 객체가 등록되어 빈으로 관리하고 실제 객체는 스프링 컨테이너와 상관이 없이 프록시 객체를 통해서 참조하는 모습을 쉽게 그림으로 설명하고 있음
  • 프록시 객체는 스프링 컨테이너가 관리하고 자바 힙 메모리에도 올라가며, 실제 객체는 자바 힙 메모리에만 올라가고 구상하고자 하는 대로 런타임 객체 의존관계가 구성되었음

(7) ProxyApplication 수정

  • @Import로 방금 생성한 InterfaceProxyConfig의 설정 파일을 적용(기존 적용한 Import는 제거)
  • @Bean으로 LogTrace를 사용할 모든 예제에서 함께 사용하도록 스프링 빈으로 등록, 동시성 문제가 없는 ThreadLocal을 적용한 로그 추적기를 사용
@Import(InterfaceProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app.v3") //주의
public class ProxyApplication {
    
    // main 메서드 생략

	// 동시성 문제가 없는 ThreadLocal 로그 추적기를 스프링 빈으로 등록
	@Bean
	public LogTrace logTrace() {
		return new ThreadLocalLogTrace();
	}
}

 

(8) 실행 결과

  • 위 코드들을 적용 후 애플리케이션을 실행하여 매핑된 URL에서 요청파라미터로 접근해 보면 정상적으로 모든 동작이 실행되며 로그 추적기도 동시성 문제없이 정상적으로 동작하고 있음
  • 프록시와 DI덕분에 원본코드를 전혀 수정하지 않고 로그 추적기를 도입할 수 있었음
  • 너무 많은 프록시 클래스를 만들어야하는 단점이 있기는 하나 이부분도 해결할 수 있음


2. 구체 클래스 기반 프록시 - 예제

1) 구체 클래스에 프록시를 적용하기위한 예제 생성

  • 인터페이스가 없고 구체 클래스만 있을 때 프록시를 적용할 수 있는지 테스트
  • 테스트 예제이므로 전부 test 하위에 concreteproxy.code 패키지 구조를 만들어서 작성

(1) ConcreteLogic

  • 인터페이스가 없는 구체 클래스
package hello.proxy.pureproxy.concreteproxy.code;

@Slf4j
public class ConcreteLogic {
    public String operation() {
        log.info("ConcreteLogic 실행");
        return "data";
    }
}

 

(2) ConcreteClient

  • ConcreteLogic을 주입받아 사용하는 클라이언트 클래스
public class ConcreteClient {

    private ConcreteLogic concreteLogic;
    public ConcreteClient(ConcreteLogic concreteLogic) {
        this.concreteLogic = concreteLogic;
    }

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

 

(3) ConcreteProxyTest

  • 일반적으로 프록시를 사용하지 않는 코드를 테스트하는 구조
package hello.proxy.pureproxy.concreteproxy;

public class ConcreteProxyTest {

    @Test
    void noProxy() {
        ConcreteLogic concreteLogic = new ConcreteLogic();
        ConcreteClient client = new ConcreteClient(concreteLogic);
        client.execute();
    }
}

2) 구체 클래스에 프록시 도입

(1) 관계도 및 설명

  • 지금까지는 인터페이스를 기반으로 프록시를 도입하였는데 자바의 다형성을 인터페이스를 구현하든 클래스를 상속하든 상위 타입만 맞으면 다형성이 적용됨
  • 즉, 인터페이스가 없어도 프록시를 만들수가 있어 예제에 만들어둔 ConcreteLogic 구체 클래스를 상속받아서 프록시 객체를 생성하고 런타임 객체 의존관계는 client가 프록시를, 프록시가 실제 객체를 의존하도록 설계

 

(2) TimeProxy

  • 인터페이스가 아닌 실제 로직을 가지고 있는 ConcreteLogic클래스를 상속받아서 조상의 메서드를 오버라이딩하여 시간을 측정하는 부가 기능을 추가
package hello.proxy.pureproxy.concreteproxy.code;

@Slf4j
public class TimeProxy extends ConcreteLogic {

    private ConcreteLogic target;
    public TimeProxy(ConcreteLogic concreteLogic) {
        this.target = concreteLogic;
    }

    // 조상 객체의 메서드를 오버라이딩
    @Override
    public String operation() {
        log.info("TimeDecorator 실행");
        long startTime = System.currentTimeMillis();

        String result = target.operation();

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeDecorator 종료 resultTime={}ms", resultTime);
        return result;
    }
}

 

(3) ConcreteProxyTest - addProxy() 추가 및 실행

  • addProxy() 테스트 케이스를 생성하여 client는 프록시를, 프록시는 실제 비즈니스 로직을 가지고 있는 객체를 의존관계 주입하고 실행하면 테스트가 정상적으로 실행되고 TimeProxy가 적용되어 메서드의 실행시간이 출력됨
  • 클라이언트의 코드를보면 ConcreteLogic을 의존하지만 자바의 다형성에 의해 ConcreteLogic을 상속받은 timeProxy도 주입받을 수 있음 (조상 타입에는 본인과 자식 타입을 할당할 수 있음)

** 참고

  • 자바 언어에서 다형성은 인터페이스나 클래스를 구분하지 않고 모두 적용되어 해당 타입과 그 타입의 하위 타입은 모두 다형성의 대상이 됨
  • 이부분은 자바 언어의 너무 기본적인 내용이지만 인터페이스가 없어도 프록시가 가능하다는 것을 확실하게 전달하기 위하여 설명함
@Test
void addProxy() {
    ConcreteLogic concreteLogic = new ConcreteLogic();
    TimeProxy timeProxy = new TimeProxy(concreteLogic);
    ConcreteClient client = new ConcreteClient(timeProxy);
    client.execute();
}

3. 구체 클래스 기반 프록시 - 적용

1) 구체 클래스인 V2 애플리케이션에 프록시 적용

(1) OrderRepositoryConcreteProxy

  • v1_proxy패키지에 concrete_proxy패키지 생성 후 작성
  • @RequiredArgsConstructor를 사용해도 되고 지금처럼 생성자로 주입해도 상관은 없으나 V2 예제에서 중요한 점은 인터페이스가 아닌 구체 클래스를 상속하여 프록시 객체를 생성하였다는 점이 포인트임
package hello.proxy.config.v1_proxy.concrete_proxy;

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) {
         // save로직은 동일
    }
}

 

(2) OrderServiceConcreteProxy

  • 구체 클래스인 OrderServiceV2를 상속받아서 구현할 때 클래스 기반의 프록시의 단점이 드러남
  • OrderServiceV2에서 생성자를 가지고 있는데 자바 문법에서는 자식 클래스를 생성할 때는 항상 super()로 부모 클래스의 생성자를 호출해야함
  • 그러나 OrderServiceV2에서는 기본 생성자가 없고 파라미터가 있는 생성자밖에 없기 때문에 super(...)로 자식 클래스에서 호출을 해주어야 하지만, 프록시는 부모 객체의 기능을 사용하지 않기 때문에 super(null)로 추가적인 코드의 입력을 강제하게 됨
  • 인터페이스 기반의 프록시는 이런 고민을 하지 않아도 상관없음
package hello.proxy.config.v1_proxy.concrete_proxy;

@Slf4j
public class OrderServiceConcreteProxy extends OrderServiceV2 {

    private final OrderServiceV2 target;
    private final LogTrace logTrace;

    // 자바 문법상 조상의 생성자를 강제로 호출해야하는데, 프록시 객체에서 조상 생성자를 사용하지 않으므로 super(null)을 입력
    public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace logTrace) {
        super(null);
        this.target = target;
        this.logTrace = logTrace;
    }

    @Override
    public void orderItem(String itemId) {
        // orderItem 로직은 동일
    }
}

 

(3) OrderControllerConcreteProxy

  • Controller 프록시를 생성할 때도 Service와 동일한 문제가 발생함
package hello.proxy.config.v1_proxy.concrete_proxy;

public class OrderControllerConcreteProxy extends OrderControllerV2 {

    private final OrderControllerV2 target;
    private final LogTrace logTrace;

    public OrderControllerConcreteProxy(OrderControllerV2 target, LogTrace logTrace) {
        super(null);
        this.target = target;
        this.logTrace = logTrace;
    }

    // Override 메서드 로직 동일
}

 

(4) ConcreteProxyConfig

  • 구체 클래스를 기반으로 생성한 프록시를 반환하는 설정 파일을 생성
  • 인터페이스 기반의 설정파일이랑 구조는 동일하며 인터페이스 대신 구체 클래스를 기반으로 의존관계를 주입하는 것만 다름
package hello.proxy.config.v1_proxy;

@Configuration
public class ConcreteProxyConfig {

    @Bean
    public OrderControllerV2 orderController(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);
    }
}

 

(5) ProxyApplication 수정 및 실행

  • @Import를 방금 생성한 ConcreteProxyConfig로 설정하고 애플리케이션을 동작 후 매핑된 URL로 요청 파라미터들을 입력해보면 모든 기능이 정상적으로 동작하게 됨
@Import(ConcreteProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app.v3") //주의
public class ProxyApplication {
    // 기존 코드 동일
}

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

1) 프록시

  • 프록시를 사용한 덕분에 원본 코드를 전혀 변경하지 않고 V1, V2 애플리케이션에 LogTrace 기능을 적용할 수 있었음

(1) 인터페이스 기반 프록시 vs 클래스 기반 프록시

  • 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있음
  • 클래스 기반 프록시는 해당 클래스에만 적용할 수 있고 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있음
  • 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있음
  • 부모 클래스의 생성자를 호출해야만 하거나 final 키워드가 붙은 클래스는 상속을 할 수 없으므로 적용하지 못하거나 final 키워드가 붙은 메서드는 오버라이딩 할 수가 없기 때문에 재정의 자체를 못하거나 하는 문제가 있을 수 있음
  • 인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭기 때문에 프로그래밍 관점에서도 역할과 구현을 명확하게 나누는 인터페이스를 사용하는 것이 더 좋으며, 단점은 인터페이스 자체가 필요하다는 점임
  • 그러나 이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋으나 실제로는 구현을 거의 변경할 일이 없는 클래스도 많기에 이런 클래스에 인터페이스를 모두 도입하는 것은 번거롭고 실용적이지는 않음
  • 이런곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것이 더 좋은 설계라고 생각함
  • 인터페이스를 도입하는 다양한 이유가 있는데 여기서의 본질적인 핵심은 인터페이스가 모든 설계에 항상 필요하지 않다는 것임

** 참고

  • 인터페이스 기반 프록시는 캐스팅 관련해서 단점이 있는데 해당 내용은 강의 뒷부분에서 설명함

(2) 결론

  • 실무에서는 프록시를 적용할 때 V1처럼 인터페이스도 있고 V2 처럼 구체 클래스도 있고 한 애플리케이션에 이두가지 상황이 혼재되어 있음
  • 그렇기에 두가지 상황에 모두 대응할 수 있어야 함

(3) 프록시 클래스 생성의 단점

  • 프록시를 활용하여 원본 코드를 변경하지 않고 로그 추적기라는 부가 기능을 적용할 수 있었지만 프록시 클래스를 너무 많이 만들어야하는 단점도 존재함
  • 프록시 클래스가 하는일은 단순히 LogTrace를 사용하는 것인데 그 로직이 모두 같고 대상 클래스만 다름
  • 이런 상황도 원본 클래스를 수정하지 않을 뿐 대상 클래스가 100개라면 프록시도 100개 만들어야 하는 번거로움은 똑같은데 이를 동적 프록시 기술로 하나의 프록시 클래스로 모든 곳에 적용할 수 있음