프록시와 데코레이터 패턴
이번에 배울 디자인 패턴은 프록시 패턴이다. GOF 의 디자인 패턴에서 프록시 패턴은 크게 프록시 패턴
과 데코레이터 패턴
으로 나뉜다. 자세히 배워보자.
프록시 패턴
프록시(Proxy)는 대리자
라는 의미를 갖고 있다. 의미만 봤을때 어떤 일을 대신 해준다라는 느낌을 받을 수 있다.
요청하는 쪽이 Client
, 응답하는 쪽이 Server
라고 하자. (보통 이렇게 부른다.) Proxy 가 없는 경우에는 Client 와 Server 의 관계가 다음과 같다.
위 경우를 직접 호출
이라고 부른다. Proxy 가 도입되면 다음과 같다.
위 경우를 간접 호출
이라고 부른다.
예시
프록시의 예시를 보자.
- 카페에 들어가서 손님이 캐셔에게 커피 주문을 한다. 캐셔는 바리스타에게 커피를 만들어 달라고 한다.
- 요청 :
손님
, 응답 :캐셔
- 손님은 커피를 누가 만드는지 알 수없고 신경도 안써도 된다.
- 만약에 매장 블랙리스트의 손님이 온다면
캐셔(Proxy)
가 미리 막아서 바리스타가 커피를 만들지 못하게 할 수 있다. - 이것을
권한에 따른 접근 차단
라고 한다.
- 요청 :
- 카페에 들어가서 커피를 주문하는데, 나는 항상 같은 시간에 먹던 것만 먹어서 이미 나를 위한 커피가 만들어져 있다면 기대한 것 보다 빨리 커피를 받을 수 있다.
- 이것을
캐싱
이라고 한다.
- 이것을
- 카페에 들어가서 커피를 주문하는데, 캐셔가 바리스타 A 에게 커피를 만들라고 요청 했는데, 바리스타 A 가 화장실이 급하다고 바리스타 B 에게 커피를 만들라고 요청할 수 도 있다. 손님 입장에서는 누가 만들든 상관 없이 캐셔를 통해서 커피를 잘 받기만 하면 된다.
- 이것을
프록시 체인
이라고 한다.
- 이것을
- 친구에게 음료를 사오라고 요청 했는데, 친구가 토핑을 추가해서 온다.
- 이것을
부가 기능 추가
라고 한다.
- 이것을
종류
프록시 패턴은 크게 프록시와, 데코레이터 패턴으로 나뉜다고 설명했다. 따라서, 데코레이터 패턴도 프록시를 사용하는 패턴이다. 다만 프록시 객체를 만드는 의도 가 무엇인지에 따라서 두 가지로 나뉜다.
접근 제어
- 권한에 따른 접근 차단
- 캐싱
- 지연 로딩
접근 제어가 목적인 경우 프록시 패턴
을 사용한다.
부가 기능 추가
- 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행
부가 기능 추가가 목적인 경우 데코레이터 패턴
을 사용한다.
프록시 체인
프록시 체인은 다음과 같다.
대체 가능
프록시는 실제 객체랑, 프록시 객체랑 서로 막 바꾸어도 클라이언트 코드를 변경하지 않고 동작 할 수 있어야 하는 대체 가능
한 경우에만 객체를 프록시로 만들 수 있다.
프록시 패턴은 다음과 같은 구조로 생겼다.
Subject Interface 를 Proxy 와 RealSubject 모두 구현하고, Proxy 는 위임을 사용하여 RealSubject 에 접근한다.
프록시 패턴을 코드로 살펴보자.
구현: 캐싱
public interface Subject {
String call();
}
@Slf4j
public class RealSubject implements Subject {
@Override
public String call() {
log.info("RealSubject Call");
// Database 에서 data 를 조회하는데 1초 걸린다고 가정
sleep(1000);
return "data";
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class Client {
private Subject subject;
public Client(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.call();
}
}
@Slf4j
public class CacheProxy implements Subject {
private Subject target;
private String cache;
public CacheProxy(Subject target) {
this.target = target;
}
@Override
public String call() {
log.info("CacheProxy Calls");
if (cache == null) {
cache = target.call();
}
return cache;
}
}
@DisplayName("프록시 적용 X")
@Test
void noProxyTest() throws Exception {
RealSubject realSubject = new RealSubject();
Client client = new Client(realSubject);
client.execute();
client.execute();
client.execute();
}
@DisplayName("캐시 프록시 적용 : 캐싱 기능 테스트")
@Test
void cacheProxyTest() {
// 마치 JPA 에서 1차 캐시에 데이터가 있으면 DB 조회를 하지 않고, 캐시에서 꺼내는 것처럼 사용된다.
RealSubject realSubject = new RealSubject();
CacheProxy cacheProxy = new CacheProxy(realSubject);
Client client = new Client(cacheProxy);
client.execute();
client.execute();
client.execute();
}
캐시 프록시를 적용하지 않은 경우에는 총 3초의 시간이 걸린 반면, 캐시 프록시를 적용한 경우에는 3초 미만의 시간으로 데이터를 조회해 온다.
구현: 지연 로딩
Team 과 Member 엔티티가 존재하고, Team 엔티티안에 Member 가 선언되어있다고 가정
@Getter @Setter
public class Team {
private Long id;
private Member member;
private String teamName;
}
@Slf4j
public class EarlyLoadingRealSubject implements Subject {
@Override
public String call() {
log.info("EarlyLoadingRealSubject Call");
log.info("EarlyLoadingRealSubject : Team 조회 & Member 조회");
// Database 에서 data 를 조회하는데 1초 걸린다고 가정
sleep(1000);
return "data";
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
EarlyLoadingRealSubject 에서는 Team 엔티티를 조회할 때, Member 엔티티까지 같이 조회한다.
@Slf4j
public class LazyLoadingProxy implements Subject {
private Subject target;
private boolean cached = false;
public LazyLoadingProxy(Subject target) {
this.target = target;
}
@Override
public String call() {
log.info("LazyLoadingProxy Call");
log.info("LazyLoadingProxy : Team 조회");
if(notCached() && callGetMember()) {
log.info("LazyLoadingProxy : Member 조회");
cached = true;
}
return "data";
}
private boolean callGetMember() {
log.info("Member Entity 의 Get 메서드가 호출되면");
return true;
}
private boolean notCached() {
log.info("Cache 된 내역이 존재하지 않으면");
return !cached;
}
}
LazyLoadingProxy 에서는 Team 을 먼저 조회한 후, Member 엔티티에 대한 Get 메서드가 호출되면서, Cache 된 내역이 존재하지 않을 경우에만 데이터베이스에서 Member 데이터를 조회한다.
@DisplayName("지연로딩 테스트")
@Test
void lazyLoadingTest() throws Exception {
EarlyLoadingRealSubject realSubject = new EarlyLoadingRealSubject();
LazyLoadingProxy lazyLoadingProxy = new LazyLoadingProxy(realSubject);
Client client = new Client(lazyLoadingProxy);
client.execute();
client.execute();
client.execute();
}
실제 Member 객체가 사용되는 시점까지 조회 기능을 미루는 것을 지연 로딩(Lazy Loading)
이라고 한다.
구현 : 권한에 따른 접근 제어
public interface SubjectHandler {
String call(String method);
}
@Slf4j
public class SecureRealSubject implements SubjectHandler {
@Override
public String call(String method) {
log.info("SecureRealSubject Call");
return "data";
}
}
@Slf4j
public class SecureProxy implements SubjectHandler {
private SubjectHandler target;
private String[] patterns;
public SecureProxy(SubjectHandler target, String[] patterns) {
this.target = target;
this.patterns = patterns;
}
@Override
public String call(String method) {
log.info("SecureProxy Call");
if (!PatternMatchUtils.simpleMatch(patterns, method)) {
log.info("filtered");
return "filtered";
}
return target.call(method);
}
}
public class SecureClient {
private SubjectHandler subjectHandler;
public SecureClient(SubjectHandler subjectHandler) {
this.subjectHandler = subjectHandler;
}
public void execute(String method) {
subjectHandler.call(method);
}
}
@DisplayName("보호 프록시 테스트")
@Test
void secureProxyTest() throws Exception {
final String[] PATTERNS = {"call*"};
SecureRealSubject realSubject = new SecureRealSubject();
SecureProxy secureProxy = new SecureProxy(realSubject, PATTERNS);
SecureClient client = new SecureClient(secureProxy);
client.execute("execute");
client.execute("call");
}
실행 되는 메서드 이름이 call 이 아니면 filtered
가 출력되고, call 일 경우에 RealSubject 의 call 메서드가 호출된다.
데코레이터 패턴
데코레이터 패턴은 부가 기능 추가
가 목적이다. 데코레이터 패턴도 프록시를 사용하기 때문에, 기본적인 UML 구조는 위와 동일하다.단, 구현 방법이 인터페이스를 활용한 구현 방법만 있는 것은 아니기 때문에, 어떤 식으로 구현했는지에 따라서 UML 구조가 달라질 수 있다.
public interface Component {
String call();
}
@Slf4j
public class RealComponent implements Component {
@Override
public String call() {
log.info("음료 주문");
return "data";
}
}
@Slf4j
public class JellyToppingDecorator implements Component {
private Component component;
public JellyToppingDecorator(Component component) {
this.component = component;
}
@Override
public String call() {
log.info("JellyToppingDecorator 실행");
String result = component.call();
String decoResult = "*젤리추가*" + result + "*젤리추가*";
log.info("JellyToppingDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
return decoResult;
}
}
@Slf4j
public class ChocolateToppingDecorator implements Component {
private Component component;
public ChocolateToppingDecorator(Component component) {
this.component = component;
}
@Override
public String call() {
log.info("ChocolateToppingDecorator 실행");
String result = component.call();
String decoResult = "*초콜릿추가*" + result + "*초콜릿추가*";
log.info("ChocolateToppingDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
return decoResult;
}
}
@Slf4j
public class DecoratorClient {
private Component component;
public DecoratorClient(Component component) {
this.component = component;
}
public void execute() {
String result = component.call();
log.info("result={}", result);
}
}
@DisplayName("데코레이터 테스트")
@Test
void decoratorTest() throws Exception {
Component realComponent = new RealComponent();
// Proxy Chain
Component jellyToppingDecorator = new JellyToppingDecorator(realComponent);
Component chocolateToppingDecorator = new ChocolateToppingDecorator(jellyToppingDecorator);
DecoratorClient client = new DecoratorClient(chocolateToppingDecorator);
client.execute();
}
위 테스트 코드 처럼 프록시 체인(Proxy Chain)
을 활용하여 여러 부가 기능들을 추가할 수 있다.
GOF 데코레이터 패턴
Decorator 구현체에 중복되는 기능들이 있을 수 있다. 이러한 기능들을 Decorator
라는 추상 클래스로 만들어 중복을 제거할 수 있는데, Decorator 추상 클래스 내부에서 Component 를 속성으로 가지고 있어야 한다. 이렇게 하면 추가로 클래스 다이어그램에서 어떤 것이 실제 컴포넌트 인지, 데코레이터인지 명확하게 구분할 수 있다. 이것이 바로 GOF 에서 설명하는 데코레이터 패턴이다.
클래스 기반 프록시
지금까지 보여준 코드는 인터페이스 기반으로 구현한 프록시 패턴들이다. 하지만 굳이 인터페이스를 사용하지 않고 클래스
를 사용하여 프록시 패턴을 구현할 수 도 있다.
@Slf4j
public class ConcreteLogic {
public String call() {
log.info("ConcreteLogic 실행");
return "data";
}
}
@Slf4j
public class TimeProxy extends ConcreteLogic {
private ConcreteLogic concreteLogic;
public TimeProxy(ConcreteLogic concreteLogic) {
this.concreteLogic = concreteLogic;
}
@Override
public String call() {
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = concreteLogic.call();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeDecorator 종료 resultTime={}ms", resultTime);
return result;
}
}
public class ConcreteClient {
private ConcreteLogic concreteLogic;
public ConcreteClient(ConcreteLogic concreteLogic) {
this.concreteLogic = concreteLogic;
}
public void execute() {
concreteLogic.call();
}
}
@DisplayName("구체 클래스 기반 프록시 테스트")
@Test
void concreteProxyTest() throws Exception {
ConcreteLogic concreteLogic = new ConcreteLogic();
TimeProxy timeProxy = new TimeProxy(concreteLogic);
ConcreteClient client = new ConcreteClient(timeProxy);
client.execute();
}
인터페이스 기반 vs 클래스 기반
인터페이스 기반과 클래스 기반 중 어떤것이 더 좋을까 ? 클래스 기반 프록시는 상속
을 사용하기 때문에 상속에서 오는 단점들을 다 안고 간다. 하지만, 인터페이스를 사용해야할 이유가 없는 경우(Ex. 구현이 변경될 가능성 등)에는 클래스 기반으로 프록시를 구현하는 것이 좋다고 생각한다.