[스프링 핵심 원리 고급편(3)] : 프록시 패턴(Proxy Pattern)

Loopy·2023년 1월 5일
0

스프링

목록 보기
6/16
post-thumbnail

☁️ 들어가기

로그 추적기의 수정을 최소화하기 위해, 템플릿 메서드 패턴과 콜백 패턴까지 도입하였다. 하지만 결과적으로 원본 클래스에서 공통 로직 클래스가 불려지는 것이기 때문에 수백개의 클래스에 로그를 남기고 싶다면 모두 고쳐야 한다는 단점이 존재한다.

추가된 요구사항

  1. 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해야 한다.
  2. 특정 메서드는 로그를 출력하지 않아야 한다.
  3. 다양한 케이스에 적용할 수 있어야 한다.
    v1 : 인터페이스가 있는 구현 클래스에 적용
    v2 : 인터페이스가 없는 구체 클래스에 적용
    v3 : 컴포넌트 스캔 대상에 기능 적용

이처럼 원본 코드를 전혀 수정하지 않고, 로그 추적기를 도입할 수 있을까?
문제의 답은 프록시(proxy)에 존재한다.

프록시(Proxy)

☁️ 프록시(Proxy)란?

클라이언트가 서버로 요청하는 직접 호출이 아닌, 어떤 대리자를 통해서 간접적으로 서버에 요청하는 것을 말한다.

대리자는 한명일 수도 있고, 아래와 같이 여러명일 수도 있다. 참고로 클라이언트 입장에서는 대리자가 몇명이 있던지 간에 그 이후 과정은 전혀 몰라도 되고, 단순히 요청한 결과가 오기만 하면 된다.

🌱 대체 가능성

프록시의 중요한 특성 중 하나는, 바로 대체 가능해야 한다는 것이다.

즉 클라이언트는 프록시에 요청한 것인지 서버에 요청한 것인지 몰라야 하기 때문에 둘은 같은 인터페이스를 사용해야 한다. 또한 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다.

위의 그림을 보면 클라이언트는 ServerInterface 만 의존하고 있기 때문에, DI(Dependency Injection) 를 사용해서 클라이언트 코드의 변경 없이 유연하게 프록시 객체를 주입할 수 있다.

☁️ 프록시의 주요 기능

1. 접근 제어

실제 서버에 접근하는 권한을 체크하여 접근을 차단(예외를 터트리거나 반환)하는 역할을 한다.

캐싱도 접근 제어의 일종인데, 클라이언트가 프록시에 요청했을 때 값을 반환해버리고 실제 서버로는 요청을 보내지 않기 때문이다.

또한 지연 로딩의 기능도 한다. 클라이언트는 처음에는 프록시 객체를 가져다 사용하다가, 실제 접근 요청이 있을때만 데이터를 DB에서 조회한다.

2. 부가 기능

원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
예를 들어 요청 값이나 응답 값을 중간에 변형하거나, 실행 시간을 측정하여 추가적인 로그 정보를 남길 수 있다.

GOF 디자인 패턴에서는 의도를 통해 둘을 구분하고 있다.
1. 프록시 패턴 : 접근 제어가 목적
2. 데코레이터 패턴 : 부가 기능이 목적

주의할 점은 둘다 프록시를 사용하기 때문에 프록시 패턴과 프록시 자체의 개념을 다르다고 봐야한다!

프록시 패턴(Proxy Pattern)

캐싱 예제

클라이언트가 프록시를 호출하면, 프록시도 실제 객체를 호출해야 한다. 따라서 내부에 실제 객체의 참조를 필드로 가지고 있어야 한다.

@Slf4j
public class CacheProxy implements Subject {

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

데코레이터 패턴(Decorator Pattern)

프록시를 통해 기존 서버에 부가적인 기능을 추가하는 것을 데코레이터 패턴이라고 한다. 객체에 추가적인 책임을 동적으로 추가하고, 기능을 확장하기 위한 유연한 대응을 제공하는데 목적이 존재한다.

클라이언트 코드를 변경하지 않고 원하는 만큼 꾸며서 제공하는게 데코레이터 패턴의 장점이다.

응답 값을 꾸며주는 데코레이터 예제

☁️ 인터페이스 기반 프록시 적용

로그를 출력하는 것은 꾸며주는 것과 같으므로, 데코레이터 패턴을 적용하면 된다.

중요한 점은 처음 요청이 들어가는 곳은 실제 컨트롤러 구현 객체가 아닌 프록시 객체라는 것이다. 먼저 로그를 찍고, 내부에서 컨트롤러의 메서드를 호출하게 된다. 마찬가지로 서비스에서도 실제 동작 전에 미리 로그를 찍어야 하므로 실제 컨트롤러는 실제 서비스 객체가 아닌 서비스 프록시 객체를 가지고 있어야 한다.

애플리케이션 실행 시점에 프록시를 사용하도록 의존 관계를 설정하려면, 실제 객체가 아닌 프록시 객체를 빈으로 등록해주어야 한다. 그래야 프록시가 실행되면서 로그가 찍힐 거고, 프록시 내부에서는 실제 객체의 참조를 가지고 있기 때문에 실제 로직이 수행이 될 수 있기 때문이다.

런타임 객체 의존 관계 설정

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

    @Bean
    public OrderRepositoryV1 orderRepository(LogTrace logTrace) {
        OrderRepositoryV1Impl repositoryImpl = new OrderRepositoryV1Impl();
        return new OrderRepositoryInterfaceProxy(repositoryImpl, logTrace);
    }

}

실제 객체가 아닌 프록시 객체가 스프링 빈으로 등록된다. 프록시 객체는 스프링 컨테이너가 직접 관리하고, 힙 메모리에 올라간다. 하지만 실제 객체는 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지 않는다는 차이가 존재한다.

프록시를 코드에 적용

컨트롤러, 서비스, 레파지토리까지 3가지만 있지만 컨트롤러 프록시만 코드를 봐보자.

@RequestMapping
@ResponseBody
public interface OrderControllerV1 { // 인터페이스

    @GetMapping("/v1/request")
    String request(@RequestParam("itemId") String itemId);

    @GetMapping("/v1/no-log")
    String noLog();
}

참고로 위와 같이 인터페이스에 적용된 어노테이션은 구현 클래스들에도 자동으로 적용된다.

@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {
    private final OrderControllerV1 target;  // 실제 OrderControllerV1Impl 객체 주입
    private final LogTrace logTrace;

    @Override
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderRepository.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();
    }
}

☁️ 구체 클래스 기반 프록시 적용

이번에는 인터페이스가 없는 상태에서 프록시를 적용해야 한다. 즉, 인터페이스를 구현할 수 없어지는 것이다.

하지만, 우리에게는 자바의 다형성이 있다.
자바는 인터페이스를 구현하던지, 클래스를 상속하던지 상위 타입만 맞는다면 다형성이 적용이 되기 때문에 클래스를 상속받기만 하면 프록시를 생성할 수 있는 것이다. (해당 타입과 해당 타입의 하위 타입들은 모두 다형성의 적용 대상이 된다.)

적용

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
    public String request(String itemId) {
       ...  // 로그 로직들 
       String result = target.request(itemId);  // 실제 객체 호출
       ...
    }
}

생성자가 하나도 없으면 자바가 기본 생성자를 만든다.
생성자가 있기 때문에 기본 생성자가 만들어지지 않고, super() 로 부모 클래스의 기본 생성자를 호출하는 것 또한 오류가 나기 때문에 어쩔 수 없이 super(null) 을 전달해줘야 하는 상황이 생긴다.

프록시는 부모 객체의 기능을 아예 사용하지 않기 때문에 null 을 넣어줘도 되지만, 인터페이스 기반 프록시에서는 이러한 고민을 하지 않아도 된다.

클래스 기반 프록시 단점

상속을 사용하기 때문에 다음과 같은 단점이 존재한다.

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

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

클래스 기반 프록시는 인터페이스를 따로 만들지 않아도 되지만, 해당 클래스에만 적용할 수 있으며 상속으로 인한 제약들이 단점이 된다. 반면 인터페이스 기반 프록시는 따로 생성해줘야 하지만 인터페이스만 같으면 모든 곳에 적용할 수 있다는 장점이 있다.

물론 인터페이스를 도입하는 것이 클래스 기반 프록시보다 효과적이지만, 그렇다고 항상 인터페이스가 필요한 것도 아니다.

Service, ServiceImpl 처럼 인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데 변경 가능성이 없다면 도입하지 않는 것이 실용적이기 때문이다.

☁️ 다음으로..

현재 방식의 문제점은 로그를 적용해야 하는 클래스에 비례하여 프록시 클래스가 증가한다는 것이다. 프록시 객체들을 보면 대상 클래스만 다르고 로직이 모두 같기 때문에, 하나만 생성해도 괜찮을 것 같다.

프록시 클래스를 하나만 만들어서 모든 곳에 적용하는 방법은 없을까? 바로 다음에 설명할 동적 프록시 기술이 이 문제를 해결해준다.

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글