클라이언트-서버 개념을 객체 세상에 접목시키면 요청(호출시도)하는 객체는 클라이언트가 되고 요청을 처리하는 객체는 서버가 된다.
클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해 간접적으로 서버에 요청할 수 있을 것이다.
클라이언트 -> 서버(클라이언트) -> 서버구조가 되는 것이다. 이러한 대리자를 영어로 프록시(Proxy)라고 한다. 대리자는 중간에서 추가적인 작업이 가능하다. 대리자의 역할은 클라이언트가 의도한 결과를 받도록 클라이언트의 요청을 수행하면 된다.
우리가 직접 은행에 돈을 넣을 수 있지만 자산관리자에게 이를 의탁할수도 있을 것이다. 자산관리자는 이를 은행 ATM기를 이용해서 넣든 인터넷 뱅킹으로 넣든 클라이언트는 자신의 계좌에 돈이 들어오면 그만인 것이다.
프록시는 아무렇게나 설계하지 않는다. 객체가 프록시가 되기 위해서는 클라이언트는 서버에게 요청 한 것인지, 프록시에게 요청한 것인지 조차 몰라야 한다. 그렇기에 요청이 처리되는 과정을 바꾸더라도 클라이언트 코드에는 아무런 변경이 존재해서는 안된다.
이를 구현하기 위해 가장 좋은 방법이 전략 패턴의 의도를 빌리는 것이다. 서버에 해당하는 클래스를 어떠한 인터페이스의 구현체로 설계하고 이 인터페이스 아래에 Proxy클래스를 둔다.

Client는 ServerInterface에만 의존한다. 그러므로 이 인터페이스의 구현체에 대해서는 몰라야 한다.
아래의 코드와 글을 같이 참고하자.
이제부터 RealSubject는 비즈니스 로직을 가진 실제 객체이다.
프록시를 도입하여 프록시가 실제 객체의 비즈니스 로직을 실행하도록 할 것이고,
단순하게 실제 객체를 호출하지 않고 프록시 객체가 하는 역할을 수행할 것이다.
우선 프록시 객체 없이 실제 객체와 프록시 도입을 위해 실제 객체가 인터페이스를 구현하도록 구성했다.
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) {
e.printStackTrace();
}
}
}
다음과 같이 Client는 곧장 RealSubject의 operation()을 호출하여도 상관없다.
하지만 우리는 프록시를 통해 RealSubject의 operation()을 호출할 것이다.
public class Client {
private RealSubject subject;
public ProxyPatternClient(RealSubject subject) {
this.subject = subject;
}
public void execute() {
subject.operation();
}
}
이 기능은 현재 쓰레드를 1초 대기시킨 후 "data"라는 String을 반환해준다.
고객(Client 객체)에게 불만사항이 들어왔다. 요청(subject.operation())마다 1초간 기다리는 것이 매우 싫증이 났다는 것이다.
이를 어떻게 해결할 수 있을까?
Client -> RealSubject 사이에 프록시 객체를 두어 캐싱 을 구현해서 해결해보려고 한다.
이를 위해 RealSubject의 인터페이스가 필요하다.
public interface Subject {
String operation();
}
Proxy 코드를 아래와 같이 구성할 수 있다.
Proxy클래스 역시 Subject의 구현체이며 operation()을 오버라이딩으로 구현해야한다.
cacheValue필드가 null일 경우 target의 operation()을 호출한다.
이때 target은 RealSubject가 된다.
주목할 점은 RealSubject를 Subject타입 즉 인터페이스 타입으로 가진다는 것이다.(다형성 활용)
@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();
}
}
// TEST CODE
@Test
void cacheProxyTest() {
RealSubject realSubject = new RealSubject();
CacheProxy cacheProxy = new CacheProxy(realSubject);
ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
client.execute();
client.execute();
client.execute();
}
클라이언트는 프록시 객체를 주입받아 execute()시점에 프록시 객체의 operation()을 호출한다.
클라이언트 코드에 프록시에 대한 부분이 드러나는가?
CacheProxy는 Subject를 구현한 객체이다.
그리고 클라이언트는 Subject 타입을 주입받도록 하고있다.
주입하는 쪽에서 CacheProxy를 주입하던, RealSubject를 주입하던ProxyPatternClient(클라이언트)는 영향을 받지 않는다.
즉 클라이언트는 프록시의 존재 자체를 알지 못한다.
지금은 테스트 코드에서 프록시 객체를 생성하여 클라이언트에 주입하였다.
이전의 전략 패턴(약결합)과 템플릿 메서드 패턴(강결합)은 중복은 줄여주었지만 클라이언트 코드에 관련된 코드가 드러난다는 것이 문제였고
프록시 패턴은 이 문제를DI(의존관계 주입)과프록시 객체도입으로 해결할 수 있었다.
전략 패턴으로 횡단 관심사 처리시 발생하는 문제점
클라이언트 코드에 컨텍스트(LogTemplate)의 존재가 노출: 클라이언트는mainService.execute()와 같이 비즈니스 로직이 담긴 객체를 실행시키는 것이 아니라,LogTemplate과 같은 컨텍스트 객체를 만들고 여기에mainService객체를 전달해서 실행시켜야 함.public void clientMethod() { MainLogic mainLogic = new MainLogic(); LogTemplate logTemplate = new LogTemplate(); logTemplate.execute(mainLogic); // logTemplate은 전략을 받는다. }
핵심 비즈니스 로직과 횡단 관심사의 혼재:clientMethod()에서logTemplate.execute(mainLogic)부분은 절대 사라질 수 없다.
다시 돌아와서, 프록시 패턴을 활용항 캐싱 구조를 통해 어떻게 고객의 불편한 상황(항상 1초가 걸리는 것)을 개선할 수 있는지 알아보자.
@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;
}
}
프록시 객체는 cacheValue 필드를 가진다.
이 필드가 채워지기 위해서는 CacheProxy의 operation() 메서드가 호출되어야하며, RealSubject의 operation()호출이 한번 이상 필요하다.
즉 클라이언트는 Subject타입의 객체의 operation()을 호출하고 실제로 실행되는 것은 CacheProxy의 operation()여야 한다.
RealSubject의 operation()은 1초의 Thread.sleep()후 "data"라는 문자열을 뱉는 아주 간단한 메서드이다.
첫 호출때 1초가 걸리는 기능인 RealSubject의 operation()이 프록시에 의해 호출되어 이에 대한 return이 프록시의 cacheValue 필드에 저장된다.
두번째 호출부터는 cacheValue 필드에 RealSubject의 operation()에 대한 리턴값("data")이 들어있으므로 1초 가량의 시간비용없이(Thread.sleep 없이) 이를 곧장 가져올 수 있게 되었다.
누군가는 그냥 Client 클래스에 Collection을 두어서 캐시처럼 사용할 수 있지않나 반문할 수 있지만,
결국 캐싱을 위해 Client 클래스에 캐싱을 위한 추가 코드가 필요해진다.
하지만 프록시 패턴을 통해 캐시기능을 클라이언트 코드도 모르게 추가할 수 있다는 점에서 의의가 있다.
프록시 패턴을 통해 할 수 있는 일은 크게 두 가지로 분류된다. 접근 제어와 부가 기능 추가이다.
우리가 위에서 실습한 프록시 패턴으로 구현한 캐싱 구조는 프록시가 데이터(원본 객체가 리턴해야할)를 확보하고 있을 때 원본 객체에 대한 접근을 제한하여 빠르게 클라이언트에게 데이터를 제공한 것이므로 접근 제어에 해당한다.
이전에 학습했던 로직에 대한 로그를 띄운다던지 시간을 재는 등의 행위는 부가 기능의 추가이다. 프록시 개념을 활용한 부가기능 추가는 데코레이터 패턴에 해당한다.
아래의 코드는 프록시 패턴을 활용하여 최종결과로 반환되는 String 리턴값 앞뒤에 ***을 추가하는 기능이다.
데코레이터 패턴과 프록시 패턴은 같은 형식을 사용한다. 다만 개념적 목적이 다를 뿐이다.(접근 제어 vs 부가기능 추가)
@Slf4j
public class MessageDecorator implements Component {
private final Component component;
public MessageDecorator(Component component) {
this.component = component;
}
@Override
public String operation() {
log.info("MessageDecorator 실행");
String result = component.operation();
String decoResult = "*****" + result + "*****";
log.info("MessageDecorator 꾸미기 적용 전 = {}, 적용 후 = {}", result, decoResult);
return decoResult;
}
}
@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.reqeust()");
//target 호출
target.save(itemId);
logTrace.end(status);
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
이전에 사용한 Repository의 인터페이스를 만들게 되면(OrderRepositoryV1) 해당 인터페이스 아래에 Proxy를 구현할 수 있다.
위 코드에서는 OrderRepositoryInterfaceProxy라는 프록시를 구현했다.
이 프록시는 실제 실행되어야 할 객체(같은 인터페이스를 공유하는)를 target으로 주입받으며, 로그 기능이 포함된 target의 메서드(save()) 실행을 수행한다.
우리는 Config에서 빈 구성에 대한 설정을 수행할 때, 실제 Repository 구현체를 넣는 것이 아니라 프록시 구현체를 꼽아주면 이전 계층(클라이언트)에 대한 코드 변경은 필요가 없다.
하지만 이 로그 기능은 당연히 서비스계층, 컨트롤러계층에도 적용해야하기 때문에 Repository에 작업해준 것을 모두 진행해주어야 한다.
무조건 인터페이스 구현일 필요는 없다.
우리는 프록시 개념을 활용하고 있다. 이 개념은 자바의 다형성이라는 아주 강력한 이점을 활용하여 변경용이성을 끌어올린 것이다. 인터페이스는 구현을 강제한다. 하지만 부모-자식관계의 규칙만 지킨다면 굳이 인터페이스를 필수로 사용하지는 않아도 된다. 추상 클래스, 일반 클래스 모두 가능한 설계이다. 하지만 분명한 것은 인터페이스 기반 설계가 가장 좋다. 클래스 기반 상속은 부모 클래스의 생성자를 호출해야하는 문제가 존재한다.
그럼 항상 인터페이스 아래에서 클래스를 작성해야하는가?
인터페이스 도입은 분명 변경에 대한 용이성을 높여준다. 하지만 절대 변경하지 않는다면? 다형성을 활용할 일이 지극히 적다면 처음부터 인터페이스를 도입하는 것은 프로젝트의 복잡도만 높일 수 있다.
지금까지 프록시 패턴을 사용하기 위해 RealSubect에 Subjcet 인터페이스를 도입한 경우를 살펴보았다.
이번의 경우는 인터페이스 없이 프록시 패턴을 구현하는 경우를 살펴볼 것이다.
@Slf4j
public class RealSubject {
public String operation() {
log.info("비즈니스 로직 실행");
return "data";
}
}
RealSubject는 이전과 동일하게 사용한다.
이번 프록시는 RealSubject의 operation() 로직의 시간을 잴 수 있는 프록시 클래스를 설계해보도록 하겠다.
@Slf4j
@RequiredArgsConstructor
public class TimeProxy extends RealSubject {
private final RealSubject subject;
@Override
public String operation() {
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = subject.operation();
long endTime = System.currentTimeMillis();
log.info("TimeDecorator 종료 resultTime = {}ms", endTime-startTime);
return result;
}
}
인터페이스기반 프록시의 경우 프록시와 RealSubject가 동일한 인터페이스를 구현하는 서로 같은 부모를 둔 상태였다면,
인터페이스 없이 프록시를 구현할 경우 RealSubject의 상속을 받은 상태로 구현된다.
이 경우 Interface가 존재하지 않아도 된다는 장점이 존재하지만, 프록시는 항상 부모의 생성자를 호출해줘야한다는 점,
만약 RealSubject가 final 클래스라면 사용할 수 없다는 점.(상속이 제한되기 때문)
만약 RealSubject의 final 메서드가 존재한다면 오버라이딩이 불가능해지는 점 등 제약사항이 추가된다.
횡단 관심사는 많은 로직에서 일관적으로 필요할 수 있는 기능이며, 이것을 단순하게 모든 로직에 붙여넣는다면 수많은 중복된 코드가 존재하게 되고 중복된 코드는 유지보수를 어렵게 한다.
또한 최대한 횡단 관심사에 대한 기능을 부여할 때 기존 코드에 수정이 덜 가고싶은 것이 목적이다.
템플릿 메서드 패턴, 전략 패턴등의 활용으로 중복을 제거할 수 있었지만, 여전히 클라이언트 코드에 횡단관심사 관련 코드가 필요하거나 횡단 관심사를 끼워넣어주는 클래스와 강하게 결합되거나 하는 추가적인 문제가 발생했다.
프록시 패턴 + DI를 통해 클라이언트 코드에 횡단 관심사 코드를 없애고 강하게 결합되는 문제도 사라졌지만
템플릿 메서드 패턴, 전략 패턴, 프록시 패턴등에서 발생하는 일관된 문제가 여전히 존재하는데 바로 클래스 폭발이다.
프록시 패턴으로만 설명하자면, 횡단 관심사가 필요한 클래스마다 프록시 객체를 직접 구현해야 했다.
또한 대부분의 실제 실행될 클래스는 하나의 메서드만 가지지 않는다.
import java.util.Arrays;
import java.util.List;
// 순수 프록시 패턴으로 구현된 TimeMeasuringProxy
public class TimeMeasuringItemServiceProxy implements ItemService {
private final ItemService target; // 실제 ItemService 구현체를 주입받음
public TimeMeasuringItemServiceProxy(ItemService target) {
this.target = target;
}
@Override
public Item save(Item item) {
long startTime = System.nanoTime(); // 횡단 관심사: 시간 측정 시작
System.out.println("Proxy: Calling save() with item - " + item.getName()); // 횡단 관심사: 로깅
Item result = target.save(item); // 실제 비즈니스 로직 호출
long endTime = System.nanoTime(); // 횡단 관심사: 시간 측정 끝
System.out.println("Proxy: save() executed in " + (endTime - startTime) / 1_000_000 + " ms"); // 횡단 관심사: 결과 로깅
return result;
}
@Override
public Item findById(Long id) {
long startTime = System.nanoTime(); // 횡단 관심사: 시간 측정 시작
System.out.println("Proxy: Calling findById() with id - " + id); // 횡단 관심사: 로깅
Item result = target.findById(id); // 실제 비즈니스 로직 호출
long endTime = System.nanoTime(); // 횡단 관심사: 시간 측정 끝
System.out.println("Proxy: findById() executed in " + (endTime - startTime) / 1_000_000 + " ms"); // 횡단 관심사: 결과 로깅
return result;
}
@Override
public void delete(Long id) {
long startTime = System.nanoTime(); // 횡단 관심사: 시간 측정 시작
System.out.println("Proxy: Calling delete() with id - " + id); // 횡단 관심사: 로깅
target.delete(id); // 실제 비즈니스 로직 호출
long endTime = System.nanoTime(); // 횡단 관심사: 시간 측정 끝
System.out.println("Proxy: delete() executed in " + (endTime - startTime) / 1_000_000 + " ms"); // 횡단 관심사: 결과 로깅
}
@Override
public List<Item> findAll() {
long startTime = System.nanoTime(); // 횡단 관심사: 시간 측정 시작
System.out.println("Proxy: Calling findAll()"); // 횡단 관심사: 로깅
List<Item> result = target.findAll(); // 실제 비즈니스 로직 호출
long endTime = System.nanoTime(); // 횡단 관심사: 시간 측정 끝
System.out.println("Proxy: findAll() executed in " + (endTime - startTime) / 1_000_000 + " ms"); // 횡단 관심사: 결과 로깅
return result;
}
}
save(), findById(), delete(), findAll() 메서드 모두에 시간측정 코드 부분이 반복해서 등장.시간 측정 로직의 구현 방식이 약간만 변경되어도, TimeMeasuringItemServiceProxy 내의 모든 메서드를 수정해주어야 함.
현재는 ItemService 하나에 대한 TimeMeasuringItemServiceProxy만 있지만, 실제 애플리케이션에서는 UserService, OrderService, PaymentService 등 수많은 서비스들이 존재하고, 각각에 대해 시간 측정, 트랜잭션, 보안 등 다양한 횡단 관심사를 적용해야 할 수 있다.
예를 들어 UserService에도 시간 측정이 필요하다면 TimeMeasuringUserServiceProxy를 또 만들어야 함.
순수하게 프록시 패턴을 사용하면 기존의 문제를 해결하지만 추가되는 문제가 존재한다. JDK가 지원하는 동적 프록시 패턴을 통해 이러한 추가되는 문제를 억누르며 프록시 패턴의 이점만 취할 수 있다.