Question
- 프록시란?
- 서버와 프록시는 왜 같은 인터페이스를 구현해야 하는가?
- 프록시 패턴과 데코레이터 패턴의 차이는
- 프록시 패턴 구현 방법은?
- 데코레이터 패턴 구현 방법은?
- 인터페이스가 없는 구현체에 프록시를 적용하는 방법은?
- 단순 프록시 기술만 사용했을 때 여러 클래스마다 프록시를 중복해서 생성해줘야 하는 문제는 어떻게 개선 가능한가
프록시
- Clinet -> Server로 요청을 하려고 할 때 중간에서 Proxy(대리자)를 통해서 요청을 보낼 수 있음
- 이는 직접 호출이 아니라 간접 호출을 하게 되는데, 이러면 중간에 대리자가 기존 기능 외에 여러 기능을 추가할 수 있음
- 캐싱을 통해 이미 데이터가 존재하면 Server로 요청을 하지 않고 Proxy가 바로 데이터를 반환하고 데이터가 없을 경우에만 Server로 요청
- 기존에 처리 해야 하는 요청 외에 Proxy가 추가적인 처리를 더 해줄 수도 있음
- Client는 자신이 호출한게 Proxy든 Server든 상관이 없고 자신이 원하는 요청만 처리되면 됨
프록시와 인터페이스
- 프록시를 사용하면 Client는 서버에게 요청한 것인지 프록시에게 요청한 것인지 모르지만 그 요청한 결과는 받아야 함
- 서버인지 프록시인지 몰라야 하는 조건을 만족하려면 이는 서버와 프록시가 같은 인터페이스를 사용해야 한다는 의미
- 이렇게 같은 인터페이스를 사용하면 DI와 같은 기능을 통해 기존 Server에서 Proxy로 바꿔치기 할 수 있음
프록시의 주요 기능
- 접근 제어
- 부가 기능 추가
- 기존의 기능에 추가적인 기능 처리
- 응답값 변형 혹은 로깅 추가 등
GOF 디자인 패턴
- GOF 디자인 패턴에서는 프록시를 사용하는 패턴이 2가지인데, 둘 다 프록시의 기능을 사용하지만 사용 의도에 따라 2가지로 분류
- 프록시 패턴: 접근 제어가 목적
- 데코레이터 패턴: 새로운 기능 추가가 목적
프록시 패턴 - 캐싱기능
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) {
log.error("에러 발생", e);
Thread.currentThread().interrupt();
}
}
}
@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;
}
}
public class ProxyPatternClient {
private Subject subject;
public ProxyPatternClient(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.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(); // 세 번째 호출 (캐시된 값 반환)
}
}
- 기존에 Client -> Subject로 요청해서 데이터를 받아요는 케이스가 있었음
- 이 때는 RealSubject라는 구현체가 실제로 데이터를 반환
- 하지만, Subject를 구현하는 새로운 CacheProxy클래스를 생성
- Client는 기존처럼 Subject로 요청하지만 설정을 변경하여 구현클래스를 RealSubject가 아닌 CacheProxy로 변경
- CacheProxy는 Client의 요청을 받아 저장하고 있는 데이터가 없으면 RealSubject로 요청을 넘기고, 만약 본인이 저장하고 있는 데이터가 있으면 RealSubject로 요청하지 않고 바로 Client로 반환
- Client 입장에서는 변했는지 전혀 알 수 없고, 코드 변화도 없음
- RealSubject에도 코드의 변화가 없으며 넣어주는 객체만 바꿔주면 됨
데코레이터 패턴 - 기능 추가
@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;
}
}
- 데코레이터 패턴도 프록시 패턴과 방법은 유사함
- Client -> Component의 operation() 함수를 호출
- 기존에는 RealComponent 라는 구현체가 operation() 함수를 사용했음
- 하지만, Component 클래스를 상속하는 TimeDecorator, MessageDecorator를 추가
- Client는 기존처럼 Component의 operation() 함수를 호출
- 하지만, 실제 구현체를 TimeDecorator로 바꾸고, TimeDecorator -> MessageDecorator -> RealComponent 이런식으로 요청을 넘겨줌
- 프록시는 2개 이상 적용해서 활용할 수 있으며 이를 프록시 체인이라고 함
- TimeDecorator에서는 시간을 출력해주고, MessageDecorator에서는 메시지를 추가해 줌
- Client는 기존과 동일하게 호출하고 원하는 요청을 응답받지만 시간 출력, 메시지 출력 기능을 기존 변경 없이 추가 가능
데코레이터 패턴 개선
- 데코레이터 패턴에서 여러 프록시를 사용하다 보면 중복 문제가 발생함
- 예를 들어서, 데코레이터 패턴은 혼자만은 존재할 수 없고 무조건 실제 RealComponent의 operation() 함수를 호출이 존재 해야함
- 이는 여러 Decorator에 동일하게 적용되는 부분이고 따라서, 여러 데코레이터는 이러한 Component에 대한 지정과 operation()함수 호출이 반복됨
- 따라서, GOF에서는 Decorator는 상위 추상 클래스를 만들고 그 아래 여러 데코리어터가 상속하는 방식을 사용
프록시 패턴 vs 데코레이터 패턴
- 둘 다 구현되는 모양은 거의 동일
- 디자인 패턴에서 중요한 것은 패턴의 모양이 아니라 패턴을 만든 의도
- 따라서 의도를 잘 판단해야 함
- 프록시 패턴의 의도: 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공
- 데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공
프록시 적용 - 로그 트레이스 예제
@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()");
// target 호출
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();
}
}
@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);
}
@Bean
public LogTrace logTrace() {
return new ConsoleLogTrace();
}
}
- 이전에 사용했던 로그 트레이스를 Controller -> Service -> Repository 예시에 적용하려고 함
- 요건은 Controller, Service, Repository 코드를 변경하지 않고 각각에 로깅만 추가 해야 함
- 예시는 Controller에만 적용하는 예시
- 방법은 Controller 구현체를 구현하는 ProxyController 생성
- ProxyController에서 로깅 수행하는 작업 추가해주고 진짜 Controller를 부르게 하면 됨
- 중요한 점은 Config를 수정해 줘야 하는데, Config에서 Bean을 등록할 때 Controller의 Parameter로는 진짜 Controller를 넣어주고, Bean은 Proxy를 리턴해 줘야 한다는 것
프록시 적용 - 인터페이스가 없는 경우
@Slf4j
public class TimeProxy extends ConcreteLogic {
private final ConcreteLogic realLogic;
public TimeProxy(ConcreteLogic realLogic) {
this.realLogic = realLogic;
}
@Override
public String operation() {
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = realLogic.operation();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeDecorator 종료 resultTime={}ms", resultTime);
return result;
}
}
@Test
void addProxy() {
ConcreteLogic concreteLogic = new ConcreteLogic();
TimeProxy timeProxy = new TimeProxy(concreteLogic);
ConcreteClient client = new ConcreteClient(timeProxy);
client.execute();
}
- 인터페이스가 없으면 어떻게 기존 코드를 변경하지 않고 진짜 클래스가 아닌 프록시 클래스로 바꿔치기 할 수 있을까?
- 사실 중요한 것은 진짜 클래스와 프록시 클래스가 동일한 상속관계 안에만 있으면 됨
- 예를 들어, 인터페이스를 쓴다는 것도 진짜 클래스와 프록시 클래스가 같은 인터페이스를 구현하므로 동일한 상속 관계에 있는 것
- 이와 마찬가지로 만약에 프록시 클래스가 진짜 클래스를 상속해버린다면, 이 또한 동일한 상속관계에 있다고 볼 수 있음
- 따라서, 동일한 상속 관계라면 빈을 주입해줄 때 기존에 진짜 클래스를 빈으로 주입해주던걸 프록시 클래스 주입으로 변경해줄 수 있음
위 프록시 기술을 사용했을 때의 한계
- 원하는 클래스에 적용하고자 했을 때 적용할 때 마다 프록시를 새로 생성해줘야 함
- 예를 들어서 OrderControllerInterfaceProxy, OrderSerivceInterfaceProxy, OrderRepositoryInterfaceProxy 이런식으로 같은 코드를 사용하지만 여러 프록시를 생성해줘야 하는 중복의 문제가 있음
- 이는 뒤에서 설명할 동적 프록시로 해결 가능