Spring Advanced

Proxy/Decorator Pattern

cwchoiit 2023. 12. 13. 15:11
728x90
반응형
SMALL
728x90
SMALL

 

이제 스프링에서 굉장히 자주 사용되는 프록시와 데코레이터 패턴을 정리해 보자. 이 프록시 패턴을 이해하니 스프링이 어떻게 내가 만들어서 컴포넌트 스캔 대상에 넣은 클래스를 프록시로 주입하는지 알게 됐다. 

 

우선, 프록시(Proxy)가 무엇인가? 

 

프록시(Proxy)란?

프록시는 정말 자주 사용되는 용어이다. 다음 그림을 보자.

클라이언트와 서버가 있다. 클라이언트는 꼭 고객이나 사용자를 의미하는 게 아니고 서버는 꼭 어떤 웹 서버를 의미하는 것이 아니라 넓게 보아 클라이언트는 요청을 하는 쪽, 서버는 요청을 처리하는 쪽이다. 이 개념을 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저가 되는 것이고 서버는 웹 서버가 되는 것이다. 

 

일반적으로 클라이언트와 서버 간 호출에 있어 클라이언트가 서버에 직접 호출을 하고 호출의 결과를 직접 받는다. 이런 흐름을 직접 호출이라고 한다. 그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 이때 대리자를 영어로 프록시(Proxy)라고 한다.

 

더 나아가 프록시는 또 다른 프록시를 호출할 수도 있다. 즉, 중간 대리자가 체인으로 엮일 수 있다는 소리다.

 

 

클라이언트가 프록시를 사용할 때

위 개념을 객체에 도입할 수 있다. 객체 입장에서 프록시가 되려면 클라이언트는 서버에게 요청한 것인지 프록시에게 요청한 것인지 조차 몰라야 한다. 언어적으로 풀면 서버와 프록시는 같은 인터페이스를 사용해야 한다. 그리고 클라이언트는 인터페이스에만 의존하면 된다. 그리고 클라이언트가 누구에게 요청했는지조차 몰라도 된다는 말은 다시 말해 어떤 쪽으로 요청하더라도 클라이언트 코드는 변경되면 안 된다.

다음 그림이 이를 설명한다.

 

클래스 의존 관계를 보면 클라이언트는 인터페이스에만 의존한다. 그리고 서버와 프록시가 같은 인터페이스를 사용한다. 따라서 DI를 통해 대체가 가능하다. 

 

프록시의 주요 기능

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

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

 

접근제어와 부가 기능 추가 모두 프록시를 사용하지만 이 둘을 의도에 따라 프록시 패턴과 데코레이터 패턴으로 구분한다.

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

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

 

 

프록시 패턴 사용 예제

프록시를 도입하기 전

다음은 프록시를 도입하기 전 클라이언트가 서버에게 요청하는 흐름이다.

 

클라이언트는 인터페이스(Subject)를 의존한다. 그 인터페이스의 구현체인 RealSubject 클래스로부터 실제 구현 메서드를 호출한다. 

 

Subject.java

package com.example.advanced.pureproxy.proxy.code;

public interface Subject {
    String operation();
}

 

RealSubject.java

package com.example.advanced.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 ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

ProxyPatternClient.java

package com.example.advanced.pureproxy.proxy.code;

public class ProxyPatternClient {

    private Subject subject;

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

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

 

코드는 매우 간단하다. Client 코드에서 Subject를 주입받는다. Subject를 구현한 클래스가 주입된다. 

테스트 코드

package com.example.advanced.pureproxy.proxy;

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

 

이제 클라이언트가 서버에게 직접 요청해서 응답받는 코드를 수행해 보자. ProxyPatternClient는 Subject를 주입받아야 하므로 RealSubject 객체를 만들어 전달해 준다. 이후 client는 'execute()'를 세 번 실행한다. 'execute()'는 내부 로직에 1초의 대기 시간이 있어서 최소 3초의 시간이 소요된다.

위 결과 화면으로부터 3초 127ms의 소요시간을 확인할 수 있다. 이제 프록시를 도입해 보자.

 

 

프록시를 도입한 후

다음은 프록시를 도입했을 때의 구조이다.

 

프록시를 도입한 후 클라이언트가 주입받을 Subject의 구현체로 Proxy가 추가가 된다.

이제 클라이언트는 중간에 프록시가 끼워져 있는지 끼워져 있지 않은지를 알 필요도 없고 아무런 클라이언트 코드에 대한 변경을 취할 필요가 없다. 프록시를 도입해 보자.

 

위 코드에서는 같은 데이터를 반환하는데 로직 상 1초의 대기시간(조회할 때 걸리는 시간이라고 가정하자)이 있기 때문에 총 3초의 긴 대기시간이 소요된다. 그렇다면 이러한 데이터를 조회하는 과정의 소요시간을 줄이기 위해 캐싱을 사용할 수 있을 것이다. 캐싱 기술 역시 프록시에서 해줄 수 있다. 

CacheProxy.java

package com.example.advanced.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'이라고 칭한다. 그 실제 객체를 프록시 객체가 주입을 받아야 실제 객체의 로직을 수행할 수 있고 거기에 플러스 프록시가 제공하는 기능을 덧붙일 수 있다. 그래서 같은 'operation()'에서 캐시 저장소에 캐시 데이터가 있다면 그 값을 바로 반환하고 없다면 실제 객체로부터 데이터를 받아와 반환한다. 이렇게 되면 3회의 요청 동안 한 번의 실제 객체를 호출할 것이므로 1초 언저리로 응답해 줄 수 있다.

 

테스트 코드

package com.example.advanced.pureproxy.proxy;

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

 

'cacheProxyTest()'는 프록시를 사용한다. 프록시를 사용한다고 해서 클라이언트 코드에 변경을 가하지 않는다. 이것이 핵심이다. 클라이언트 코드는 똑같이 Subject를 구현한 구현체만을 전달받고 그것이 프록시가 될 뿐이다. 'execute()'를 세 번 실행해 보면 1초 언저리로 응답받는 것을 확인할 수 있다.

 

 

이렇게 간단하게 프록시를 통해 클라이언트의 간접 요청을 이해해봤다. 이후에 실제 프로젝트 코드에 프록시 패턴을 적용해서 공통적으로 처리될 부분에 대한 추가 기능을 기존 로직의 아무런 변경 없이(즉, 클래스가 1000개면 그만큼의 변경 공수) 적용해 보자. 그전에 데코레이터 패턴도 알아봐야 한다.

 

 

데코레이터 패턴 예제

데코레이터 패턴 적용 전

 

클라이언트는 Component 인터페이스에 의존하고 그 인터페이스를 구현한 구현체 클래스의 메서드를 호출한다.

 

Component.java

package com.example.advanced.pureproxy.decorator.code;

public interface Component {

    String operation();
}

 

RealComponent.java

package com.example.advanced.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

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

 

DecoratorPatternClient.java

package com.example.advanced.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);
    }
}

 

위 코드까지가 데코레이터 패턴을 적용하기 전이다. 클라이언트는 Component를 주입받는다. 그 Component를 구현한 클래스의 메서드인 'operation()'을 호출하고 끝난다.

 

이제 데코레이터 패턴을 적용해 보자. 이 패턴은 프록시 패턴과 구조가 동일하다. 역시 데코레이터가 같은 인터페이스를 상속받으며 클라이언트 코드는 아무런 변경사항을 주지 않는다. 이 역시 이것이 핵심이다.

 

테스트 코드

package com.example.advanced.pureproxy.decorator;

import com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient;
import com.example.advanced.pureproxy.decorator.code.MessageDecorator;
import com.example.advanced.pureproxy.decorator.code.RealComponent;
import com.example.advanced.pureproxy.decorator.code.TimeDecorator;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void noDecorator() {
        RealComponent realComponent = new RealComponent();
        DecoratorPatternClient client = new DecoratorPatternClient(realComponent);

        client.execute();
    }
}

 

실제 객체를 생성한 후 클라이언트에게 주입한다. 클라이언트는 그대로 'execute()'를 실행한다. 결과는 다음과 같다.

14:52:11.063 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
14:52:11.067 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=data

 

이제 데코레이터 패턴을 적용해보자.

 

MessageDecorator.java

package com.example.advanced.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MessageDecorator implements Component {

    private Component target;

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

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

        String operation = target.operation();
        String decoResult = "****" + operation + "****";

        log.info("MessageDecorator not accepted={}, accepted={}", operation, decoResult);

        return decoResult;
    }
}

 

데코레이터에서는 실제 객체를 주입받는다. 그 실제 객체의 로직을 수행하는데 그 로직에 플러스되는 부가 기능을 데코레이터에서 처리할 뿐이다. 바로 테스트 코드를 보자.

 

테스트 코드

package com.example.advanced.pureproxy.decorator;

import com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient;
import com.example.advanced.pureproxy.decorator.code.MessageDecorator;
import com.example.advanced.pureproxy.decorator.code.RealComponent;
import com.example.advanced.pureproxy.decorator.code.TimeDecorator;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class DecoratorPatternTest {

    @Test
    void decorator1() {
        RealComponent realComponent = new RealComponent();
        MessageDecorator messageDecorator = new MessageDecorator(realComponent);

        DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);

        client.execute();
    }
}

 

이젠 데코레이터 객체를 생성한 후 그 객체에 실제 객체를 주입해 준다. 그리고 클라이언트는 역시 Component를 주입받으면 그만이다. 이 구현체가 데코레이터든 실제 객체든 상관이 없다. 이것이 핵심!

 

결과는 다음과 같다.

14:56:04.918 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator Start
14:56:04.924 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
14:56:04.924 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator not accepted=data, accepted=****data****
14:56:04.927 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=****data****

 

실제 객체가 응답해 준 데이터에 데코레이터가 주는 부가 기능을 추가한 응답 결과를 확인할 수 있다. 이것이 데코레이터 패턴.

 

 

체인 프록시

프록시(데코레이터) 패턴은 체인이 될 수 있다 했다. 그러니까 다음과 같은 그림도 가능하다.

 

기존에 사용했던 messageDecorator보다 앞에 timeDecorator가 추가됐다. 이렇게 여러 개의 데코레이터가 들어가도 클라이언트 코드는 변경되는 것이 없다. 마찬가지로 서버 코드도 변경되는 것이 없다. 이것이 핵심!

 

TimeDecorator.java

package com.example.advanced.pureproxy.decorator.code;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class TimeDecorator implements Component {

    private Component target;

    public TimeDecorator(Component target) {
        this.target = target;
    }

    @Override
    public String operation() {
        log.info("TimeDecorator Start");

        long startTime = System.currentTimeMillis();

        String result = target.operation();

        long endTime = System.currentTimeMillis();

        log.info("TimeDecorator End, resultTime={}ms", endTime - startTime);

        return result;
    }
}

 

새롭게 추가된 TimeDecorator는 마찬가지로 실제 객체를 주입받는데, 이때 실제 객체는 RealComponent가 아닌 messageDecorator가 된다. TimeDecorator 입장에서는 실제 객체가 messageDecorator가 될 뿐이다.

 

이 상태에서 테스트 코드를 수행해 보자.

 

DecoratorPatternTest.java

package com.example.advanced.pureproxy.decorator;

import com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient;
import com.example.advanced.pureproxy.decorator.code.MessageDecorator;
import com.example.advanced.pureproxy.decorator.code.RealComponent;
import com.example.advanced.pureproxy.decorator.code.TimeDecorator;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

@Slf4j
public class 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();
    }
}

 

실제 객체 RealComponent를 생성하면 그 객체를 MessageDecorator가 전달받는다. 그리고 MessageDecorator는 TimeDecorator에게 자신을 전달한다. 이 TimeDecorator가 클라이언트에게 전달된다. 클라이언트는 변경 사항 없이 그저 본인의 'execute()'를 실행할 뿐이다.

 

결과는 다음과 같다.

15:02:48.305 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.TimeDecorator -- TimeDecorator Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator not accepted=data, accepted=****data****
15:02:48.314 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.TimeDecorator -- TimeDecorator End, resultTime=4ms
15:02:48.315 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=****data****

 

기존 messageDecorator가 추가해 준 부가기능에 더해 timeDecorator가 추가해 준 부가기능까지 있다. 이렇게 체인으로도 가능하다.

 

 

프록시 패턴 결론

결론은 프록시 패턴에서 프록시는 기존 서버가 해주는 기능에 부가적인 기능을 추가해 주는 그 이상 이하도 아닌 것이다. 그 기존 기능을 똑같이 수행하기 위해 프록시 객체는 실제 객체를 주입받아야 한다. 그리고 가장 중요한 핵심은 클라이언트와 서버 모두 프록시의 추가로 인해 변경되는 부분은 없다는 점이다. 스프링에서 이 프록시 패턴이 굉장히 중요하다. 왜냐하면 스프링에선 등록한 빈이 프록시로 등록되는게 거의대부분이기 때문이다. 그럼 실제로 프록시가 어떻게 생성되는걸까? 그것을 알아보자.

 

 

스프링에서 프록시 패턴

스프링에서는 3가지 경우의 프록시를 만들어내는 방법이 있다. 

  • 인터페이스가 있는 구현 클래스에 적용
  • 인터페이스가 없는 구체 클래스에 적용
  • 컴포넌트 스캔 대상에 기능 적용 (스프링이 알아서 해준다)

하나씩 차근차근해보자.

 

 

인터페이스가 있는 구현 클래스에 프록시 패턴 적용하기

우선, 간단한 컨트롤러, 서비스, 레포지토리를 만들자. 그리고 이 세 개에 대해서 프록시를 만들거고 각각이 인터페이스가 존재한다.

 

OrderControllerV1.java

package com.example.advanced.app.proxy_v1_controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
@ResponseBody
public interface OrderControllerV1 {

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

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

 

OrderControllerV1Impl.java

package com.example.advanced.app.proxy_v1_controller;

import com.example.advanced.app.proxy.v1.OrderServiceV1;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class OrderControllerV1Impl implements OrderControllerV1 {

    private final OrderServiceV1 orderService;

    public OrderControllerV1Impl(OrderServiceV1 orderService) {
        this.orderService = orderService;
    }

    @Override
    public String request(String itemId) {
        orderService.orderItem(itemId);
        return "ok";
    }

    @Override
    public String noLog() {
        return "ok";
    }
}

 

OrderServiceV1.java

package com.example.advanced.app.proxy.v1;

public interface OrderServiceV1 {

    void orderItem(String itemId);
}

 

OrderServiceV1Impl.java

package com.example.advanced.app.proxy.v1;

public class OrderServiceV1Impl implements OrderServiceV1 {

    private final OrderRepositoryV1 orderRepository;

    public OrderServiceV1Impl(OrderRepositoryV1 orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public void orderItem(String itemId) {
        orderRepository.save(itemId);
    }
}

 

 

OrderRepositoryV1.java

package com.example.advanced.app.proxy.v1;

public interface OrderRepositoryV1 {

    void save(String itemId);
}

 

OrderRepositoryV1Impl.java

package com.example.advanced.app.proxy.v1;

public class OrderRepositoryV1Impl implements OrderRepositoryV1 {
    @Override
    public void save(String itemId) {

        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }

        sleep(1000);
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

이런 식으로 컨트롤러, 서비스, 레포지토리가 있다고 생각하자. 컨트롤러는 보통은 인터페이스까지 사용하지 않는 게 일반적이지만 인터페이스를 이용해서 프록시를 만드는 것을 연습하기 위해 만들었다. 

 

이제 컨트롤러 프록시부터 만들어보자.

OrderControllerInterfaceProxy.java

package com.example.advanced.app.proxy.config.v1_proxy.interface_proxy;

import com.example.advanced.app.proxy_v1_controller.OrderControllerV1;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

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

 

컨트롤러 프록시 클래스이다. 이 프록시는 OrderControllerV1을 구현한다. 해당 인터페이스를 구현하므로써 이 프록시 클래스는 실제 구현 클래스를 대체할 수 있게 됐다. 클라이언트는 이제 컨트롤러로의 요청을 이 프록시로 하게 된다. 그것이 가능한 이유는 클라이언트는 인터페이스를 의존하기 때문이다.

 

그리고 프록시 객체는 반드시 실제 객체를 주입받는다. 그래야 실제 객체의 로직을 수행할 수 있기 때문에. 그래서 실제 구현 클래스를 받을 필드 'target'을 선언한다. 이 프록시 클래스는 실제 로직 수행의 시작과 끝 사이의 소요시간을 구해주는 추가 기능이 있다.

 

OrderRepositoryInterfaceProxy.java

package com.example.advanced.app.proxy.config.v1_proxy.interface_proxy;

import com.example.advanced.app.proxy.v1.OrderRepositoryV1;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.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.save(itemId);

            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

이번엔 레포지토리의 프록시 클래스다. 위 컨트롤러 프록시 객체와 동일하다. 달라지는 건 구현하는 인터페이스가 OrderRepositoryV1인 것과 실제 객체를 주입받을 때 주입되는 타입이 OrderRepositoryV1인 것뿐. 

 

OrderServiceInterfaceProxy.java

package com.example.advanced.app.proxy.config.v1_proxy.interface_proxy;

import com.example.advanced.app.proxy.v1.OrderServiceV1;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;

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

 

이번엔 서비스의 프록시 객체다. 위 설명과 이하동문이다.

 

이렇게 프록시 객체를 만들면 스프링 컨테이너에 빈으로 등록되는 것은 인터페이스를 구현한 실제 객체(구현 클래스)가 아닌 이 프록시 객체가 등록되어야 한다. 이 과정을 스프링이 대신해 주는 것뿐이었고 이를 직접 해보자.

 

InterfaceProxyConfig.java

package com.example.advanced.app.proxy.config.v1_proxy;

import com.example.advanced.LogTraceConfig;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1;
import com.example.advanced.app.proxy_v1_controller.OrderControllerV1Impl;
import com.example.advanced.app.proxy.config.v1_proxy.interface_proxy.OrderControllerInterfaceProxy;
import com.example.advanced.app.proxy.config.v1_proxy.interface_proxy.OrderRepositoryInterfaceProxy;
import com.example.advanced.app.proxy.config.v1_proxy.interface_proxy.OrderServiceInterfaceProxy;
import com.example.advanced.app.proxy.v1.*;
import com.example.advanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Import(LogTraceConfig.class)
@Configuration
public class InterfaceProxyConfig {

    @Bean
    public OrderServiceV1 orderService(LogTrace logTrace) {
        OrderServiceV1Impl serviceImpl = new OrderServiceV1Impl(orderRepository(logTrace));

        return new OrderServiceInterfaceProxy(serviceImpl, logTrace);
    }

    @Bean
    public OrderControllerV1 orderController(LogTrace logTrace) {
        OrderControllerV1Impl controllerImpl = new OrderControllerV1Impl(orderService(logTrace));

        return new OrderControllerInterfaceProxy(controllerImpl, logTrace);
    }

    @Bean
    public OrderRepositoryV1 orderRepository(LogTrace logTrace) {
        OrderRepositoryV1Impl repositoryImpl = new OrderRepositoryV1Impl();

        return new OrderRepositoryInterfaceProxy(repositoryImpl, logTrace);
    }
}

 

이게 직접 컴포넌트를 프록시 형태로 등록하는 부분이다. OrderServiceV1 인터페이스의 구현체를 등록하고자 할 때, 실제 구현 클래스가 아닌 프록시를 반환한다. 프록시는 실제 구현체와 LogTrace 객체가 필요한데, 실제 구현체는 새로운 인스턴스로 전달하고 LogTrace는 다른곳에서 스프링 빈으로 등록되어 있기 때문에 파라미터로 그대로 가져다가 쓸 수 있다. 나머지 컨트롤러와 레포지토리도 마찬가지다. 이게 바로 프록시를 스프링 컨테이너에 등록하는 방법이다. 

 

이렇게 등록된 프록시 객체로 실제 동작하는지 확인해 보자.

내가 localhost:8080/v1/request?itemId=xx로 요청하면 LogTrace의 로그가 찍혀야 한다.

2023-12-13T20:40:21.194+09:00  INFO 5514 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2aebf44d] OrderController.request()
2023-12-13T20:40:21.196+09:00  INFO 5514 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2aebf44d] |-->OrderService.orderItem()
2023-12-13T20:40:21.196+09:00  INFO 5514 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2aebf44d] |   |-->OrderRepository.request()
2023-12-13T20:40:22.197+09:00  INFO 5514 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2aebf44d] |   |<--OrderRepository.request() spentTime=1001ms
2023-12-13T20:40:22.197+09:00  INFO 5514 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2aebf44d] |<--OrderService.orderItem() spentTime=1001ms
2023-12-13T20:40:22.197+09:00  INFO 5514 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [2aebf44d] OrderController.request() spentTime=1003ms

 

정상적으로 찍히는 모습을 확인할 수 있다. 이게 어떤 식으로 찍히냐가 중요한 게 아니고 프록시 패턴을 이용해서 추가적인 기능을 원래 구현체에 어떠한 변화도 주지 않고 넣어줄 수 있다는 것이다. 그리고 지금 알아본 것은 인터페이스가 있는 구현 클래스에 적용한 버전이다.

 

인터페이스가 있는 구현 클래스를 프록시로 만들려면 같은 인터페이스를 구현한 프록시가 있으면 된다. 그러면 클라이언트는 인터페이스에만 의존하기 때문에 어떤 구현체가 오더라도 아무런 상관이 없다. 즉, 클라이언트와 서버 쪽 소스에 전혀 손을 대지 않고도 추가적인 기능을 제공할 수 있다는 것. 역시 여러 번 말해도 이게 핵심이다!

 

다음은 인터페이스가 없는 구체 클래스에 적용해 보자. 이는 어떻게 가능할까? '다형성'이다.

 

 

구체 클래스에 프록시 적용하기

이제 인터페이스가 존재하지 않는 구체클래스에 프록시를 적용해보자. 프록시를 어떻게 적용할 수 있을까? 프록시가 구체 클래스를 상속받으면 된다.

 

OrderControllerV2.java

package com.example.advanced.app.proxy.v2;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Slf4j
@Controller
@ResponseBody
public class OrderControllerV2 {

    private final OrderServiceV2 orderService;

    public OrderControllerV2(OrderServiceV2 orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/v2/request")
    public String request(String itemId) {
        log.info("OrderControllerV2.request()");

        orderService.orderItem(itemId);
        return "ok";
    }

    @GetMapping("/v2/no-log")
    public String noLog() {
        return "ok";
    }
}

 

OrderRepositoryV2.java

package com.example.advanced.app.proxy.v2;

public class OrderRepositoryV2 {

    public void save(String itemId) {

        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }

        sleep(1000);
    }

    private void sleep(int ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}

 

OrderServiceV2.java

package com.example.advanced.app.proxy.v2;


public class OrderServiceV2 {

    private final OrderRepositoryV2 orderRepository;

    public OrderServiceV2(OrderRepositoryV2 orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void orderItem(String itemId) {
        orderRepository.save(itemId);
    }
}

 

위와 같은 컨트롤러, 서비스, 레포지토리가 있다고 해보자. 이 녀석들의 프록시를 만들고자 한다. 프록시를 만들고 이 구체 클래스를 상속받자.

 

OrderRepositoryConcreteProxy.java

package com.example.advanced.app.proxy.config.v1_proxy.concrete_proxy;

import com.example.advanced.app.proxy.v2.OrderRepositoryV2;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.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.save(itemId);

            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

이 레포지토리의 프록시 클래스는 레포지토리를 상속받는다. 이렇게 되면 다형성을 이용해서 클라이언트가 역시나 어떠한 코드 변경 없이 기존 코드 그대로 레포지토리를 주입받을 수 있게 된다. (부모타입에 자식은 들어갈 수 있기 때문에)

 

그리고 프록시의 기본인 실제 객체를 주입받는다. 프록시가 제공할 추가 기능인 LogTrace 클래스도 주입받는다.

그리고 메서드를 오버라이딩해서 프록시 기능 + 실제 객체의 원래 기능이 합쳐진 새로운 메서드가 만들어진다.

 

OrderServiceConcreteProxy.java

package com.example.advanced.app.proxy.config.v1_proxy.concrete_proxy;

import com.example.advanced.app.proxy.v2.OrderServiceV2;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.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.orderItem(itemId);

            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}

 

마찬가지로 프록시 클래스는 구체 클래스를 상속을 받는다. 여기서 한 가지 주의할 점이 있는데 구체 클래스인 OrderServiceV2는 기본 생성자가 존재하지 않고 OrderRepository를 주입받는 생성자밖에 없다. 그래서 상속받을 때 'super()'를 사용할 수가 없다.('super()'는 슈퍼 클래스의 기본 생성자를 호출하는 코드이고 상속받을 땐 반드시 슈퍼 클래스의 생성자를 호출하기 때문이다. 이 'super()'는 생략이 가능하기 때문에 없으면 기본 생성자를 무조건 호출) 그럼 상속받을 때 슈퍼 클래스의 생성자를 호출해야 하는데 이 프록시 클래스는 OrderRepository가 없다. 대신 실제 객체를 주입하면서 그 실제 구체 클래스가 가진 OrderRepository를 사용하면 된다. 그래서 'super(null)'을 'super()' 대신 작성해 준다. 나머지는 모두 동일하다.  

 

OrderControllerConcreteProxy.java

package com.example.advanced.app.proxy.config.v1_proxy.concrete_proxy;

import com.example.advanced.app.proxy.v2.OrderControllerV2;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;

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) {
        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();
    }
}

 

OrderControllerConcreteProxy는 위 OrderServiceConcreteProxy를 설명할 때와 동일하다. 

 

이렇게 구체 클래스를 상속받은 프록시를 만들면 끝난다. 마찬가지로 클라이언트와 서버 코드(실제 구체 클래스)에는 어떠한 변경사항도 없이 프록시가 제공하는 추가 기능을 사용할 수 있게 된다. 이제 이 프록시를 빈으로 등록하자.

 

ConcreteProxyConfig.java

package com.example.advanced.app.proxy.config.v1_proxy;

import com.example.advanced.app.proxy.config.v1_proxy.concrete_proxy.OrderControllerConcreteProxy;
import com.example.advanced.app.proxy.config.v1_proxy.concrete_proxy.OrderRepositoryConcreteProxy;
import com.example.advanced.app.proxy.config.v1_proxy.concrete_proxy.OrderServiceConcreteProxy;
import com.example.advanced.app.proxy.v2.OrderControllerV2;
import com.example.advanced.app.proxy.v2.OrderRepositoryV2;
import com.example.advanced.app.proxy.v2.OrderServiceV2;
import com.example.advanced.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 orderControllerV2 = new OrderControllerV2(orderServiceV2(logTrace));

        return new OrderControllerConcreteProxy(orderControllerV2, logTrace);
    }

    @Bean
    public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
        OrderServiceV2 orderServiceV2 = new OrderServiceV2(orderRepositoryV2(logTrace));

        return new OrderServiceConcreteProxy(orderServiceV2, logTrace);
    }

    @Bean
    public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
        OrderRepositoryV2 orderRepositoryV2 = new OrderRepositoryV2();

        return new OrderRepositoryConcreteProxy(orderRepositoryV2, logTrace);
    }
}

 

프록시 객체를 빈으로 등록하는 건 위 인터페이스를 이용한 방법으로 이미 본 내용이다. 다른 것은 없다. 

 

테스트해보자. /v2/request를 요청하면 마찬가지로 소요시간에 대한 로그가 찍혀야 한다.

 

결과

2023-12-14T09:58:03.642+09:00  INFO 25388 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [bffbaa33] OrderController.request()
2023-12-14T09:58:03.643+09:00  INFO 25388 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [bffbaa33] |-->OrderService.orderItem()
2023-12-14T09:58:03.644+09:00  INFO 25388 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [bffbaa33] |   |-->OrderRepository.request()
2023-12-14T09:58:04.645+09:00  INFO 25388 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [bffbaa33] |   |<--OrderRepository.request() spentTime=1001ms
2023-12-14T09:58:04.645+09:00  INFO 25388 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [bffbaa33] |<--OrderService.orderItem() spentTime=1002ms
2023-12-14T09:58:04.645+09:00  INFO 25388 --- [nio-8080-exec-1] c.e.a.t.logtrace.ThreadLocalLogTrace     : [bffbaa33] OrderController.request() spentTime=1003ms

 

 

구체 클래스에 프록시를 적용해 본 결론

V2 버전에도 프록시를 적용해봤다. 구체 클래스를 이용해 프록시를 만드는 것은 인터페이스를 이용해 프록시를 만드는 것에 비해 단점이 있다. 상속의 제약이 생긴다는 것. 이 말은 부모 클래스에 의존한다는 것이고 다시 말해 프록시를 만들 때 부모 클래스의 생성자를 호출해야 하고 클래스에 final 키워드가 붙으면 상속이 불가능하며 메서드에 final 키워드가 붙어도 해당 메서드를 오버라이딩 할 수 없다. 

 

그럼 인터페이스 기반 프록시를 만드는 게 더 좋은 것일까? 반드시 그건 아닐 것이다. 왜냐하면 인터페이스 기반은 인터페이스를 무조건 만들어야 한다는 것 자체가 단점이 될 수 있다. 그러니까 이 역시도 정답이 아닌 선택인 것이다. 두 가지 모두 사용할 수 있고 두 가지 모두 사용 안 할 수 있는 것이다. 

 

 

이제 인터페이스로 프록시를, 구체 클래스로 프록시를 만들고 스프링 컨테이너에 해당 프록시를 등록하는 방법을 모두 알아보았다. 그리고 스프링이 이런 프록시 패턴을 정말 자주 사용한다. 대표적인 예시로 @Transactional 애노테이션이 붙은 클래스도 사실 스프링이 다 프록시로 만들어서 스프링 컨테이너에 이 프록시를 등록한다. 그러니까 이 과정을 이해하는 게 스프링의 기본을 이해하는 것과 같다. 근데 한 가지 문제가 여전히 있다. 이는 프록시를 만드려면 너무 번거롭다는 것. 프록시로 만들고자 하는 모든 클래스를 다 프록시 클래스로 만들어내야 하는 것 자체에 큰 문제가 있다. 클래스가 100개면 100개의 프록시를 만들어야 한다. 이 점을 개선할 수는 없을까? 답은 '동적 프록시'이다.

 

728x90
반응형
LIST