https://codenme.tistory.com/108
위의 글과 이어지는 내용입니다.
실무에선, 다양한 상황에서 프록시를 적용해야하는 상황이 존재한다.
다양한 상황에서 프록시 사용법을 이해하기 위해 다음과 같은 기준으로 기본 예제 프로젝트를 만들어보자.
예제 프로젝트 만들기
예제는 크게 3가지 상황으로 만든다.
v1 - 인터페이스와 구현 클래스 - 스프링 빈으로 수동 등록
v2 - 인터페이스 없는 구체 클래스 - 스프링 빈으로 수동 등록
v3 - 컴포넌트 스캔으로 스프링 빈 자동 등록
실무에서는 스프링 빈으로 등록할 클래스는 인터페이스가 있는 경우도 있고 없는 경우도 있다. 그리고 스프링 빈을 수동으로 직접 등록하는 경우도 있고, 컴포넌트 스캔으로 자동으로 등록하는 경우도 있다. 이런 다양한 케이스에 프록시를 어떻게 적용하는지 알아보기 위해 다양한 예제를 준비해보자.
V1( 인터페이스- 구현 클래스, 수동 등록) 예제
v1 예제는 Controller-Service-Repository 모두 인터페이스와 구현 클래스로 나누어져있다.
ex)
컨트롤러 인터페이스
여기서는 @Controller 를 사용하지 않고, @RequestMapping 애노테이션을 사용했다. 그 이유는 @Controller 를 사용하면 자동 컴포넌트 스캔의 대상이 되기 때문이다. 여기서는 컴포넌트 스캔을 통한 자동 빈 등록이 아니라 수동 빈 등록을 하는 것이 목표다. 따라서 컴포넌트 스캔과 관계 없는 @RequestMapping 를 타입에 사용했다.
@RequestMapping//스프링은 @Controller 또는 @RequestMapping 이 있어야 스프링 컨트롤러로 인식
@ResponseBody
public interface OrderControllerV1 {
@GetMapping("/v1/request")
String request(@RequestParam("itemId") String itemId);
@GetMapping("/v1/no-log")
String noLog();
}
@RequestMapping : 스프링MVC는 타입에 @Controller 또는 @RequestMapping 애노테이션이 있어야 스프링 컨트롤러로 인식한다. 그리고 스프링 컨트롤러로 인식해야, HTTP URL이 매핑되고 동작한다. 이 애노테이션은 인터페이스에 사용해도 된다.
@ResponseBody : HTTP 메시지 컨버터를 사용해서 응답한다. 이 애노테이션은 인터페이스에 사용해도 된다.
@RequestParam("itemId") String itemId : 인터페이스에는 @RequestParam("itemId") 의 값을 생략하면 itemId 단어를 컴파일 이후 자바 버전에 따라 인식하지 못할 수 있다. 인터페이스에서는 꼭 넣어주자. 클래스에는 생략해도 대부분 잘 지원된다.
코드를 보면 request() , noLog() 두 가지 메서드가 있다. request() 는 LogTrace 를 적용할 대상이고, noLog() 는 단순히 LogTrace 를 적용하지 않을 대상이다
컨트롤러 구현 클래스
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";
}
}
이런식으로 구현된 Controller, Service, Repository는 아래와 같은 AppConfig로 빈으로 수동 등록 할 수 있다.
@Configuration
public class AppV1Config {
@Bean
public OrderControllerV1 orderControllerV1() {
return new OrderControllerV1Impl(orderServiceV1());
}
@Bean
public OrderServiceV1 orderServiceV1() {
return new OrderServiceV1Impl(orderRepositoryV1());
}
@Bean
public OrderRepositoryV1 orderRepositoryV1() {
return new OrderRepositoryV1Impl();
}
}
@Import(AppV1Config.class) //appconfigv1를 추가
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyApplication.class, args);
}
@Bean
public LogTrace logTrace() {
return new ThreadLocalLogTrace();
}
}
@Import(AppV1Config.class) : 클래스를 스프링 빈으로 등록한다. 여기서는 AppV1Config.class 를 스프링 빈으로 등록한다. 일반적으로 @Configuration 같은 설정 파일을 등록할 때 사용하지만, 스프링 빈을 등록할 때도 사용할 수 있다.
@SpringBootApplication(scanBasePackages = "hello.proxy.app") : @ComponentScan 을 포함한다.
hello.proxy.app"패키지 하위의 파일들만 컴포넌트 스캔하도록 설정한 것인데, 이는 AppConfig (v1,v2,...)파일을 계속 바꿀 것이기 때문이다. -> @import로 사용한 appConfig 파일을 지정한다.
이 값을 설정하면 해당 패키지와 그 하위 패키지를 컴포넌트 스캔한다. 이 값을 사용하지 않으면 ProxyApplication 이 있는 패키지와 그 하위 패키지를 스캔한다. 참고로 v3 에서 지금 설정한 컴포넌트 스캔 기능을 사용한다.
V2( 구현 클래스, 수동 등록) 예제
- 인터페이스를 제외하고 바로 구현 클래스로 구현하기만 하면 된다.
기존: @Import(AppV1Config.class)
변경: @Import({AppV1Config.class, AppV2Config.class})
@Import 안에 배열로 등록하고 싶은 설정파일을 다양하게 추가할 수 있다.
V3( 구현 클래스, 컴포넌트 스캔으로 자동 등록) 예제
- V2 처럼 구현 클래스만 구성하고, 컴포넌트 스캔을 사용하기에 @Controller, Serivce, Repository 를 붙이기만하면,
xxxxApplication 에서 @SpringBootApplication(scanBasePackages = "hello.proxy.app") 가 가지고 있는 컴포넌트 스캔 기능을 통해 스프링이 알아서 해당 객체들의 빈을 컴포넌트 스캔을 통해 등록해준다.
이제 모든 예제를 구현했으니, 로그 추적기에 요구사항을 추가하고, 그에 따른 프록시 패턴의 사용법에 대해 알아보자.
요구사항 추가
기존 요구사항
- 모든 PUBLIC 메서드의 호출과 응답 정보를 로그로 출력
- 애플리케이션의 흐름을 변경하면 안됨
- 로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안됨
- 메서드 호출에 걸린 시간
- 정상 흐름과 예외 흐름 구분
- 예외 발생시 예외 정보가 남아야 함
- 메서드 호출의 깊이 표현
- HTTP 요청을 구분
- HTTP 요청 단위로 특정 ID를 남겨서 어떤 HTTP 요청에서 시작된 것인지 명확하게 구분이 가능해야 함
- 트랜잭션 ID (DB 트랜잭션X)
하지만
하지만 이 요구사항을 만족하기 위해서 기존 코드를 많이 수정해야 한다.
코드 수정을 최소화 하기 위해 템플릿 메서드 패턴과 콜백 패턴도 사용했지만, 결과적으로 로그를 남기고 싶은 클래스가 수백개라면 수백개의 클래스를 모두 고쳐야한다. 로그를 남길 때 기존 원본 코드를 변경해야 한다는 사실 그 자체가 개발자에게는 가장 큰 문제로 남는다.
기존 요구사항에 다음 요구사항이 추가되었다.
요구사항 추가
- 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.
- 특정 메서드는 로그를 출력하지 않는 기능
- 보안상 일부는 로그를 출력하면 안된다.
- 다음과 같은 다양한 케이스에 적용할 수 있어야 한다.
v1 - 인터페이스가 있는 구현 클래스에 적용
v2 - 인터페이스가 없는 구체 클래스에 적용
v3 - 컴포넌트 스캔 대상에 기능 적용
가장 어려운 문제는 원본 코드를 전혀 수정하지 않고, 로그 추적기를 도입하는 것이다.
이 문제를 해결하려면 프록시(Proxy)의 개념을 먼저 이해해야 한다.
프록시, 프록시 패턴, 데코레이터 패턴 - 소개
클라이언트( Client )와 서버( Server )라고 하면 개발자들은 보통 서버 컴퓨터를 생각한다.
사실 클라이언트와 서버의 개념은 상당히 넓게 사용된다.
클라이언트는 의뢰인이라는 뜻이고, 서버는 '서비스나 상품을 제공하는 사람이나 물건'을 뜻한다.
따라서 클라이언트와 서버의 기본 개념을 정의하면 클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리하는 것이다.
이 개념을 우리가 익숙한 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저가 되고, 요청을 처리하는 서버는 웹 서버가 된다.
이 개념을 객체에 도입하면, 요청하는 객체는 클라이언트가 되고, 요청을 처리하는 객체는 서버가 된다.
클라이언트와 서버 개념에서 일반적으로 클라이언트가 서버를 직접 호출하고, 처리 결과를 직접 받는다. 이것을 직접 호출이라 한다.
그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 예를 들어서 내가 직접 마트에서 장을 볼 수도 있지만, 누군가에게 대신 장을 봐달라고 부탁할 수도 있다.
여기서 대신 장을 보는 대리자를 영어로 프록시(Proxy)라 한다.
하지만, Proxy는 단순 실제 객체의 호출만 처리하는 것이 아니라, 실제 객체 호출 전, 실제 객체 종료 후 동작을 수행할 수 있다.
ex: 실제와 다르지만 매우 간략하게 프록시를 묘사해보겠다.
Proxy(){
//doSomething
실제객체호출();
//doSomthing
}
직접 호출과 다르게 간접 호출을 하면 대리자가 중간에서 여러가지 일을 할 수 있다.
- 접근 제어, 캐싱: 어떠한 메소드의 접근을 제어하거나, 캐싱 수행도 가능하다.
- 부가 기능 추가: 실제 객체의 호출을 통해 로직 수행하는 것외의 다양한 부가 기능을 수행하게 할 수 있다.
-프록시 체인: proxy가 또 다른 proxy 부를 수도 있다. 중요한 점은 클라이언트는 proxy를 통해서 요청했기 때문에 그 이후 과정은 모른다는 점이다. 클라이언트는 결과만 제대로 리턴받으면 된다 (프록시 체인)
대체 가능
그런데 여기까지 듣고 보면 아무 객체나 프록시가 될 수 있는 것 같다.
객체에서 프록시가 되려면, 클라이언트는 서버에게 요청을 한 것인지, 프록시에게 요청을 한 것인지 조차 몰라야 한다.
따라서 서버와 프록시는 같은 인터페이스를 사용해야 한다.
그리고 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다.
클래스 의존관계를 보면 클라이언트는 서버 인터페이스( ServerInterface ), 즉 추상화에만 의존한다. 그리고 서버와 프록시가 같은 인터페이스를 사용한다. 따라서 DI를 사용해서 대체 가능하다.
이번에는 런타임 객체 의존 관계를 살펴보자.
런타임(애플리케이션 실행 시점)에 클라이언트 객체에 DI를 사용해서 Client -> Server 에서 Client -> Proxy 로 객체 의존관계를 변경해도 클라이언트 코드를 전혀 변경하지 않아도 된다. 클라이언트 입장에서는 변경 사실 조차 모른다.
DI를 사용하면 클라이언트 코드의 변경 없이 유연하게 프록시를 주입할 수 있다.
프록시의 주요 기능
프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있다.
- 접근 제어
- 권한에 따른 접근 차단
- 캐싱
- 지연 로딩
- 부가 기능 추가
- 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
- 예) 요청 값이나, 응답 값을 중간에 변형한다.
- 예) 실행 시간을 측정해서 추가 로그를 남긴다.
GOF 디자인 패턴에서는 이러한 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다.
프록시 패턴: 접근 제어가 목적
데코레이터 패턴: 새로운 기능 추가가 목적
둘다 프록시를 사용하지만, 의도가 다르다는 점이 핵심이다. 용어가 프록시 패턴이라고 해서 이 패턴만 프록시를 사용하는 것은 아니다. 데코레이터 패턴도 프록시를 사용한다.
이왕 프록시를 학습하기로 했으니 GOF 디자인 패턴에서 설명하는 프록시 패턴과 데코레이터 패턴을 나누어 학습해보자.
**참고
프록시는 객체안에서의 개념도 있고, 웹 서버에서의 프록시도 있다.
객체안에서 객체로 구현되어있는가, 웹 서버로 구현되어 있는가 처럼 관점의 차이가 있을 뿐 근본적인 역할은 같다.
컴퓨터 네트워크에서는 클라이언트는 웹 브라우저가 되고, 요청을 처리하는 서버는 웹 서버가 된다.
객체에서는 요청하는 객체는 클라이언트가 되고, 요청을 처리하는 객체는 서버가 된다.
따라서 관점에 따른 클라이언트 - 서버 사이에서 접근제어, 부가기능을 수행하는 것이 프록시의 역할이라고 보면된다.
프록시 패턴
-프록시를 사용하는 수많은 패턴들 중 하나. 접근 제어가 목적이다.
우선, 프록시 없이 간단한 예제를 하나 만들어 보자.
인터페이스
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();
}
}
}
operation()은 dv 조회를 하는 수행하는 메소드로 가정하자.
테스트 코드를 아래와 같이 작성 후, 수행해보자.
public class ProxyPatternTest {
@Test
void noProxyTest() {
RealSubject realSubject = new RealSubject();
ProxyPatternClient client = new ProxyPatternClient(realSubject);
client.execute();
client.execute();
client.execute();
}
}
client.execute()을 3번 호출하면 다음과 같이 처리된다.
1. client -> realSubject 를 호출해서 값을 조회한다. (1초)
2. client -> realSubject 를 호출해서 값을 조회한다. (1초)
3. client -> realSubject 를 호출해서 값을 조회한다. (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; //캐시 존재 시, 캐시를 리턴
}
}
앞서 설명한 것 처럼 프록시도 실제 객체와 그 모양이 같아야 하기 때문에 Subject 인터페이스를 구현해야 한다.
private Subject target : 클라이언트가 프록시를 호출하면 프록시가 최종적으로 실제 객체를 호출해야 한다. 따라서 내부에 실제 객체의 참조를 가지고 있어야 한다. 이렇게 프록시가 호출하는 대상을 target 이라 한다.
operation() : 구현한 코드를 보면 cacheValue 에 값이 없으면 실제 객체( target )를 호출해서 값을 구한다. 그리고 구한 값을 cacheValue 에 저장하고 반환한다. 만약 cacheValue 에 값이 있으면 실제 객체를 전혀 호출하지 않고, 캐시 값을 그대로 반환한다. 따라서 처음 조회 이후에는 캐시( cacheValue ) 에서 매우 빠르게 데이터를 조회할 수 있다.
@Test
void cacheProxyTest() {
RealSubject realSubject = new RealSubject();
CacheProxy cacheProxy = new CacheProxy(realSubject);
ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
client.execute();
client.execute();
client.execute();
}
아까와 마찬가지로 3번 호출 했는데, 실제 객체는 1회만 호출하고, 나머지는 프록시 호출에서 끝난다. ( cache hit)
client.execute()을 3번 호출하면 다음과 같이 처리된다.
1. client의 cacheProxy 호출 -> cacheProxy에 캐시가 없기에 realSubject 호출-> 결과를 캐시에 저장
2. client의 cacheProxy 호출 -> cacheProxy에 캐시가 있기에, 해당 값을 즉시 반환
3. client의 cacheProxy 호출 -> cacheProxy에 캐시가 있기에, 해당 값을 즉시 반환
정리
프록시 패턴의 핵심은 RealSubject 코드와 클라이언트 코드를 전혀 변경하지 않고, 프록시를 도입해서 접근 제어를 했다는 점이다.
그리고 클라이언트 코드의 변경 없이 자유롭게 프록시를 넣고 뺄 수 있다. 실제 클라이언트 입장에서는 프록시 객체가 주입되었는지, 실제 객체가 주입되었는지 알지 못한다.
이번에는 데코레이터 패턴에 대해 알아보자.
데코레이터 패턴
간단한 예제를 작성하여 데코레이터 패턴에 대해 알아보자.
우선, 데코레이터 패턴 도입 전의 예제를 만들어보자.
인터페이스
public interface Component {
String operation();
}
구현 클래스
@Slf4j
public class RealComponent implements Component {
@Override
public String operation() {
log.info("RealComponent 실행");
return "data";
}
}
RealComponent 는 Component 인터페이스를 구현한다. operation() : 단순히 로그를 남기고 "data" 문자를 반환한다.
클라이언트
@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 인터페이스를 의존한다.
execute() 를 실행하면 component.operation() 을 호출하고, 그 결과를 출력한다.
테스트 코드
간단하게, 클라이언트의 execute를 통해 수행해보았다.
@Test
void noDecorator() {
Component realComponent = new RealComponent();
DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
client.execute();
}
테스트 코드는 client -> realComponent 의 의존관계를 설정하고, client.execute() 를 호출한다.
데코레이터 패턴- 에제 코드 2
부가 기능 추가
이번에는 프록시를 활용해서 부가 기능을 추가해보자. 이렇게 프록시로 부가 기능을 추가하는 것을 데코레이터 패턴이라 한다.
데코레이터 패턴: 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
예) 요청 값이나, 응답 값을 중간에 변형한다.
예) 실행 시간을 측정해서 추가 로그를 남긴다.
응답 값을 꾸며주는 데코레이터
응답 값을 꾸며주는 데코레이터 프록시를 만들어보자.
데코레이터 추가.
@Slf4j
public class MessageDecorator implements Component {
private Component component;
public MessageDecorator(Component component) {
this.component = component;
}
@Override
public String operation() {
log.info("MessageDecorator 실행");
//data -> *****data*****
String result = component.operation();
String decoResult = "*****" + result + "*****";
log.info("MessageDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
return decoResult;
}
}
MessageDecorator 는 Component 인터페이스를 구현한다.
프록시가 호출해야 하는 대상을 component 에 저장한다.
operation() 을 호출하면 프록시와 연결된 대상을 호출( component.operation()) 하고, 그 응답 값에 ***** 을 더해서 꾸며준 다음 반환한다.
예를 들어서 응답 값이 data 라면 다음과 같다.
꾸미기 전: data
꾸민 후 : *****data*****
테스트 코드
@Test
void decorator1() {
Component realComponent = new RealComponent();
Component messageDecorator = new MessageDecorator(realComponent);
DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);
client.execute();
}
client -> messageDecorator -> realComponent 의 객체 의존 관계를 만들고 client.execute() 를 호출한다.
Decorator는, 자신이 꾸밀 realSubject와 같은 인터페이스를 implements하고 있고, 파라미터 등으로 realSubject객체를 받아서
추가 기능을 수행한 뒤 리턴한다.
Decorator, realSubject 둘다 같은 인터페이스를 implements하고 있기에, 결과적으로 realSubject 대신 Decorator를 리턴해도
문제 없다.
하지만 클라이언트 입장에서는, 아무런 변경사항도 없이 decorator를 통해 부가 기능을 수행할 수 도 있고,
Decorator로 인해 꾸며진 결과인지 realSubject 인지 알 수 없다. ( Component 인터페이스, 즉추상화에 의존중이기 때문에)
데코레이터 패턴- 예제 코드3
이번엔 기존의 데코레이터에 실행 시간 측정 기능까지 추가해보자.
프록시 체인: 프록시가 프록시를 호출할 수 있다.
데코레이터 패턴에서의 chain의 의미는 어떤 subject에 부가기능을 계속해서 추가하는 것을 말한다.
아래와 같은 데코레이터를 하나 더 추가해보자.
@Slf4j
public class TimeDecorator implements Component {
private Component component;
public TimeDecorator(Component component) { //이 코드는 이전의 데코레이터에서도 나왔다.(중복)
this.component = component; //추상 클래스로 만들어서 상위에서 공통 기능을 1번만 구현하면 더 좋지 않을까?
}
@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;
}
}
테스트 코드
@Test
void decorator2() {
Component realComponent = new RealComponent();
Component messageDecorator = new MessageDecorator(realComponent);
Component timeDecorator = new TimeDecorator(messageDecorator);
DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
client.execute();
}
client -> timeDecorator -> messageDecorator -> realComponent 의 객체 의존관계를 설정하고, 실행한다.
요약
Decorator는 꾸밀 객체(같은 인터페이스를 implements하는) 를 받아와서
자신의 operation()메소드에 가져온 객체의 operation() 코드를 넣는다.
프록시 패턴과 데코레이터 패턴 정리
앞선 코드에선, Decorator 기능에 일부 중복이 있다. 꾸며주는 역할을 하는 Decorator 들은 스스로 존재할 수 없다.
항상 꾸며줄 대상이 있어야 한다. 따라서 내부에 호출 대상인 component 를 가지고 있어야 한다. 그리고 component 를 항상 호출해야 한다. 이 부분이 중복이다.
public class MessageDecorator implements Component {
private Component component;
public MessageDecorator(Component component) { //중복
this.component = component;
}
.....
public class TimeDecorator implements Component {
private Component component;
public TimeDecorator(Component component) { //이 코드는 이전의 데코레이터에서도 나왔다.(중복)
this.component = component; //추상 클래스로 만들어서 상위에서 공통 기능을 1번만 구현하면 더 좋지 않을까?
}
이런 중복을 제거하기 위해 component 를 속성으로 가지고 있는 Decorator 라는 추상 클래스를 만드는 방법도 고민할 수 있다.
이렇게 하면 추가로 클래스 다이어그램에서 어떤 것이 실제 컴포넌트 인지, 데코레이터인지 명확하게 구분할 수 있다.
여기까지 고민한 것이 바로 GOF에서 설명하는 데코레이터 패턴의 기본 예제이다.
프록시 패턴 vs 데코레이터 패턴
여기까지 진행하면 몇가지 의문이 들 것이다.
Decorator 라는 추상 클래스를 만들어야 데코레이터 패턴일까? 프록시 패턴과 데코레이터 패턴은 그 모양이 거의 비슷한 것 같은데?
의도(intent)
사실 프록시 패턴과 데코레이터 패턴은 그 모양이 거의 같고, 상황에 따라 정말 똑같을 때도 있다. 그러면 둘을 어떻게 구분하는 것일까?
디자인 패턴에서 중요한 것은 해당 패턴의 겉모양이 아니라 그 패턴을 만든 의도가 더 중요하다.
따라서 의도에 따라 패턴을 구분한다.
프록시 패턴의 의도: 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공
데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공
정리
프록시를 사용하고 해당 프록시가 접근 제어가 목적이라면 프록시 패턴이고, 새로운 기능을 추가하는 것이 목적이라면 데코레이터 패턴이 된다.
인터페이스 기반 프록시
인터페이스와 구현체가 있는 V1 App에 지금까지 학습한 프록시를 도입해서 LogTrace 를 사용해보자.
(애플리케이션 코드의 변경 없이도, 로그 추적기에 부가기능을 추가할 수 있도록)
프록시를 사용하면 기존 코드를 전혀 수정하지 않고 로그 추적 기능을 도입할 수 있다. V1 App의 기본 클래스 의존 관계와 런타임시 객체 인스턴스 의존 관계는 다음과 같다.
여기에 로그 추적용 프록시를 추가하면 다음과 같다.
V1 프록시 의존 관계 추가
Controller , Service , Repository 각각 인터페이스에 맞는 프록시 구현체를 추가한다. (그림에서 리포지토리는 생략했다.)
V1 프록시 런타임 객체 의존 관계
그리고 애플리케이션 실행 시점에 프록시를 사용하도록 의존 관계를 설정해주어야 한다. 이 부분은 빈을 등록하는 설정 파일을 활용하면 된다. (그림에서 리포지토리는 생략했다.)
프록시 도입 전에는, Controller , Service, Repository 모두 아래와 같이 인터페이스 -> 구현체 의 과정을 거쳤다.
Controller 인터페이스 -> Controller 구현체 -> Service 인터페이스 -> Service 구현체 ->......
하지만, 프록시 도입 후에는, 실제 구현체 호출 전에 프록시(기존 로직에 부가기능 혹은 접근 제어를 추가하는)를 거치게 된다.
Controller 인터페이스 -> Controller 프록시 -> Controller 구현체
-> Service 인터페이스 -> Service 프록시 -> Service 구현체->......
그럼 실제 프록시를 코드에 적용해보자.
Repository 프록시
@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 호출
target.save(itemId);
logTrace.end(status);
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
프록시를 만들기 위해 인터페이스를 구현하고 구현한 메서드에 LogTrace 를 사용하는 로직을 추가한다.
기존에는 OrderRepositoryImpl 에 이런 로직을 모두 추가해야했다. 프록시를 사용한 덕분에 이 부분을 프록시가 대신 처리해준다. 따라서 OrderRepositoryImpl 코드를 변경하지 않아도 된다( 관심사의 분리!)
OrderRepositoryV1 target : 프록시가 실제 호출할 원본 리포지토리의 참조를 가지고 있어야 한다.
@RequiredArgsConstructor로, 자신이 참조(implements) 중인 인터페이스인 OrderRepositoryV1를 받아온다.
**target: 자신이 호출한 객체를 일컸는다.
Service, Controller 프록시는 위와 유사하기에 넘어가겠다.
Service 프록시
@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 호출
target.orderItem(itemId);
logTrace.end(status);
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
Controller 프록시
@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();
}
}
noLog() 메서드는 로그를 남기지 않아야 한다. 따라서 별도의 로직 없이 단순히 target 을 호출하면 된다.
(추가 요구 사항에서, 특정 로그는 수행하지 않는다는 조건이 추가됨)
이제 빈에 등록해보자.
등록하기 전에 간단한 문제를 통해 점검해보자.
아래와 같이 빈을 등록하는 것은, 올바르게 프록시를 적용한 것일까?
@Bean
public OrderControllerV1 orderController(LogTrace logTrace) {
return new OrderControllerV1Impl(orderService(logTrace));
}
정답은 "그렇지 않다." 이다.
정답
@Bean
public OrderControllerV1 orderController(LogTrace logTrace) {
OrderControllerV1Impl controllerImpl = new OrderControllerV1Impl(orderService(logTrace));
return new OrderControllerInterfaceProxy(controllerImpl, logTrace);
}
위와 같이, 프록시를 등록해야한다.
Config
@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);
}
}
LogTrace 가 아직 스프링 빈으로 등록되어 있지 않은데, 이 부분은 바로 다음에 등록할 것이다.
V1 프록시 런타임 객체 의존 관계 설정
이제 프록시의 런타임 객체 의존 관계를 설정하면 된다.
기존에는 스프링 빈이 orderControlerV1Impl , orderServiceV1Impl 같은 실제 객체를 반환했다.
하지만 이제는 프록시를 사용해야한다. 따라서 프록시를 생성하고 프록시를 실제 스프링 빈 대신 등록하고, 실제 객체는 스프링 빈으로 등록하지 않는다.
프록시는 내부에 실제 객체를 참조하고 있다.
예를 들어서 OrderServiceInterfaceProxy 는 내부에 실제 대상 객체인 OrderServiceV1Impl 을 가지고 있다.
정리하면 다음과 같은 의존 관계를 가지고 있다.
proxy -> target
orderServiceInterfaceProxy -> orderServiceV1Impl
스프링 빈으로 실제 객체 대신에 프록시 객체를 등록했기 때문에 앞으로 스프링 빈을 주입 받으면 실제 객체 대신에 프록시 객체가 주입된다.
실제 객체가 스프링 빈으로 등록되지 않는다고 해서 사라지는 것은 아니다. 프록시 객체가 실제 객체를 참조하기 때문에 프록시를 통해서 실제 객체를 호출할 수 있다. 쉽게 이야기해서 프록시 객체 안에 실제 객체가 있는 것이다.
AppV1Config 를 통해 프록시를 적용하기 전
실제 객체가 스프링 빈으로 등록된다. 빈 객체의 마지막에 @x0.. 라고 해둔 것은 인스턴스라는 뜻이다.
InterfaceProxyConfig 를 통해 프록시를 적용한 후
스프링 컨테이너에 프록시 객체가 등록된다. 스프링 컨테이너는 이제 실제 객체가 아니라 프록시 객체를 스프링 빈으로 관리한다.
이제 실제 객체는 스프링 컨테이너와는 상관이 없다. 실제 객체는 프록시 객체를 통해서 참조될 뿐이다.
프록시 객체는 스프링 컨테이너가 관리하고 자바 힙 메모리에도 올라간다.
반면에 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않는다.
이제 LogTrace도 빈으로 등록하고, 설정파일을 가져온 뒤 실행해보자.
@Import(InterfaceProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyApplication.class, args);
}
@Bean
public LogTrace logTrace() {
return new ThreadLocalLogTrace();
}
}
@Bean : 먼저 LogTrace 스프링 빈 추가를 먼저 해주어야 한다. 이것을 여기에 등록한 이유는 앞으로 사용할 모든 예제에서 함께 사용하기 위해서다.
@Import(InterfaceProxyConfig.class) : 프록시를 적용한 설정 파일을 사용하자.
이제 추가된 요구사항들 중, 어떤 것을 완료했는지 확인해보자.
추가된 요구사항
- 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.
- 특정 메서드는 로그를 출력하지 않는 기능
- 보안상 일부는 로그를 출력하면 안된다.
다음과 같은 다양한 케이스에 적용할 수 있어야 한다.
- v1 - 인터페이스가 있는 구현 클래스에 적용
- v2 - 인터페이스가 없는 구체 클래스에 적용
- v3 - 컴포넌트 스캔 대상에 기능 적용
프록시와 DI 덕분에 원본 코드를 전혀 수정하지 않고, 로그 추적기를 도입할 수 있었다.
너무 많은 프록시 클래스를 만들어야 하는 단점이 있기는 하다. 이 부분은 나중에 해결하기로 하고,
우선은 v2 - 인터페이스가 없는 구체 클래스에 프록시를 어떻게 적용할 수 있는지 알아보자.
구체 클래스 기반 프록시 - 예제1
이전에는 인터페이스에 프록시를 적용했다.
이번에는 구체 클래스에 프록시를 적용하는 방법을 학습해보자.
다음에 보이는 ConcreteLogic 은 인터페이스가 없고 구체 클래스만 있다. 이렇게 인터페이스가 없어도 프록시를 적용할 수 있을까?
먼저 프록시를 도입하기 전에 기본 코드를 작성해보자.
@Slf4j
public class ConcreteLogic {
public String operation() {
log.info("ConcreteLogic 실행");
return "data";
}
}
ConcreteLogic 은 인터페이스가 없고, 구체 클래스만 있다. 여기에 프록시를 도입해야 한다
클라이언트
public class ConcreteClient {
private ConcreteLogic concreteLogic;
public ConcreteClient(ConcreteLogic concreteLogic) {
this.concreteLogic = concreteLogic;
}
public void execute() {
concreteLogic.operation();
}
}
이제 테스트를 수행해보자.
@Test
void noProxy() {
ConcreteLogic concreteLogic = new ConcreteLogic();
ConcreteClient client = new ConcreteClient(concreteLogic);
client.execute();
}
위와 같이, ConcreteLogic은 인터페이스가 아닌, 클래스이다.
어떻게 실제 클래스의 코드 변경없이, 프록시를 적용할 수 있을까?
구체 클래스 기반 프록시 - 예제2
클래스 기반 프록시 도입
지금까지 인터페이스를 기반으로 프록시를 도입했다. 그런데 자바의 다형성은 인터페이스를 구현하든, 아니면 클래스를 상속하든 상위 타입만 맞으면 다형성이 적용된다. 쉽게 이야기해서 인터페이스가 없어도 프록시를 만들수 있다는 뜻이다. 그래서 이번에는 인터페이스가 아니라 클래스를 기반으로 상속을 받아서 프록시를 만들어보겠다.
이제 실행 시간을 계산하는 프록시를, 구체 클래스를 상속받아 만들어보자.
@Slf4j
public class TimeProxy extends ConcreteLogic {
private ConcreteLogic concreteLogic;
public TimeProxy(ConcreteLogic concreteLogic) {
this.concreteLogic = concreteLogic;
}
@Override
public String operation() {
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = concreteLogic.operation();
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.operation();
}
}
테스트 코드
@Test
void addProxy() {
ConcreteLogic concreteLogic = new ConcreteLogic();
TimeProxy timeProxy = new TimeProxy(concreteLogic);
ConcreteClient client = new ConcreteClient(timeProxy);
client.execute();
}
여기서 핵심은 ConcreteClient 의 생성자에 concreteLogic 이 아니라 timeProxy 를 주입하는 부분이다.
(인터페이스를 공유하여 사용했을 때는 공유하는 인터페이스를 통해 사용했다.)
ConcreteClient 는 ConcreteLogic 을 의존하는데, 다형성에 의해 ConcreteLogic 에 concreteLogic 도 들어갈 수 있고, timeProxy 도 들어갈 수 있다.
단, timeProxy에서 기존의 ConcreteLogic에는 없는 메소드는 사용할 수 없게 된다.
이 때문에 TimeProxy는 생성자를 제외하면 Override한 public String operation() 만 구현해놓고,
이곳에서 기존의 concreteLogic.operation() 에 더해 몇 가지 로직을 수행하는 것으로 프록시로써 부가기능을 수행하는 것이다.
구체 클래스 기반으 프록시도 올바르게 동작하는 것을 확인할 수 있다.
구체 클래스 기반 프록시 - 적용
이제 실제 로그 추적기를 구체 클래스만 존재하는 V2에 적용해보자.
리포지토리 프록시 적용 (구체 클래스 상속)
public class OrderServiceConcreteProxy extends OrderServiceV2 {
private final OrderServiceV2 target;
private final LogTrace logTrace;
public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace logTrace) {
super(null); //디폴트로 생략 시 super(); 기본 생성자.
//부모인 OrderServiceV2는 기본 생성자가 없고 1 arg Constuctor가 존재하기에, 별도 생성 필요함.
this.target = target;
this.logTrace = logTrace;
}
@Override
public void orderItem(String itemId) {
TraceStatus status = null;
try {
status = logTrace.begin("OrderService.orderItem()");
//target 호출
target.orderItem(itemId);
logTrace.end(status);
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
인터페이스가 아닌 OrderServiceV2 클래스를 상속 받아서 프록시를 만든다.
구체 클래스를 상속받아서 이전과 동일하게 적용해주면 된다.
단, 인터페이스 상속과는 다르게, 클래스를 상속받았기에 추가적인 메소드를 만들 수 도 있지만,
프록시로써 OrderRepositoryConcreteProxy는 OrderRepositoryV2로 upcast될 것이기에, 해당 메소드를 통해 부가기능을
구현하면 프록시로써의 의미가 없다.
클래스 기반 프록시의 단점
super(null) : OrderServiceV2 : 자바 기본 문법에 의해 자식 클래스를 생성할 때는 항상 super() 로 부모 클래스의 생성자를 호출해야 한다. 이 부분을 생략하면 기본 생성자가 호출된다. 그런데 부모 클래스인 OrderServiceV2 는 기본 생성자가 없고, 생성자에서 파라미터 1개를 필수로 받는다. 따라서 파라미터를 넣어서 super(..) 를 호출해야 한다.
프록시는 부모 객체의 기능을 사용하지 않기 때문에 super(null) 을 입력해도 된다. 인터페이스 기반 프록시는 이런 고민을 하지 않아도 된다.
**참고
OrderServiceConcreteProxy의 부모 OrderServiceV2의 생성자
public OrderServiceV2(OrderRepositoryV2 orderRepository) {
this.orderRepository = orderRepository;
}
Controller, Repository도 유사하기에 넘어가겠다.
Config
@Configuration
public class ConcreteProxyConfig {
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
OrderControllerV2 controllerImpl = new OrderControllerV2(orderServiceV2(logTrace));
return new OrderControllerConcreteProxy(controllerImpl, logTrace);
}
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
OrderServiceV2 serviceImpl = new OrderServiceV2(orderRepositoryV2(logTrace));
return new OrderServiceConcreteProxy(serviceImpl, logTrace);
}
@Bean
public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
OrderRepositoryV2 repositoryImpl = new OrderRepositoryV2();
return new OrderRepositoryConcreteProxy(repositoryImpl, logTrace);
}
}
인터페이스 베이스 프록시와 마찬가지로, 프록시를 빈으로 등록 해준다.
그리고 설정 파일을 변경한 뒤 컨트롤러로 매핑하는 url을 통해 테스트 시, 올바르게 프록시가 동작하는 것을 확인 할 수 있다.
@Import(ConcreteProxyConfig.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyApplication.class, args);
}
@Bean
public LogTrace logTrace() {
return new ThreadLocalLogTrace();
}
}
인터페이스 기반 프록시와 클래스 기반 프록시
프록시
프록시를 사용한 덕분에 원본 코드를 전혀 변경하지 않고, V1, V2 애플리케이션에 LogTrace 기능을 적용할 수 있었다.
인터페이스 기반 프록시 vs 클래스 기반 프록시
- 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
- 클래스 기반 프록시는 해당 클래스에만 적용할 수 있다.
- 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.
- 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
- 부모 클래스의 생성자를 호출해야 한다.(앞서 본 예제)
- 클래스에 final 키워드가 붙으면 상속이 불가능하다. ( final은 최초 1회 무조건 수행, 이후 불변을 보장한다.
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다.
이렇게 보면 인터페이스 기반의 프록시가 더 좋아보인다.
맞다. 인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭다. 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나누기 때문에 더 좋다.
인터페이스 기반 프록시의 단점은 인터페이스가 필요하다는 그 자체이다. 인터페이스가 없으면 인터페이스 기반 프록시를 만들 수 없다.
이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋다. 이렇게 하면 역할과 구현을 나누어서 구현체를 매우 편리하게 변경할 수 있다. 하지만 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다.
인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데, 구현을 변경할 가능성이 거의 없는 코드에 무작정 인터페이스를 사용하는 것은 번거롭고 그렇게 실용적이지 않다. 이런곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것이 좋다 생각한다.
결론
실무에서는 프록시를 적용할 때 V1처럼 인터페이스도 있고, V2처럼 구체 클래스도 있다.
따라서 2가지 상황을 모두 대응할 수 있어야 한다.
아직 남은 문제점: 너무 많은 프록시 클래스
지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다.
그런데 문제는 프록시 클래스를 너무 많이 만들어야 한다는 점이다. 잘 보면 프록시 클래스가 하는 일은 LogTrace 를 사용하는 것인데, 그 로직이 모두 똑같다. 대상 클래스만 다를 뿐이다. 만약 적용해야 하는 대상 클래스가 100개라면 프록시 클래스도 100개를 만들어야한다.
프록시 클래스를 하나만 만들어서 모든 곳에 적용하는 방법은 없을까? -> 동적 프록시 기술이 이 문제를 해결해준다.
동적 프록시에 대한 건 다음 글에서 다뤄보겠다.
요약
프록시는, 실제 객체로 대체 가능해야한다. ( 실제 객체 상속 받거나, 같은 인터페이스 사용하거나 )
이러한 특성을 기반으로, 클라이언트는 자신이 프록시를 통하는지, 실제 객체를 사용하는지 알 수 없어야한다.
또한, 이러한 특성을 통해 핵심 로직의 프록시를 생성하여 부가 기능, 접근 제어를 수행하는 것으로,
실제 로직에서는 자신의 로직에만 집중할 수 있고, 부가 기능에 변경사항이 생기더라도 핵심 로직은 변경하지 않아도 된다.
(관심사의 분리)
'Spring boot' 카테고리의 다른 글
스프링 핵심원리 6 [스프링에서의 프록시: proxyFactory] (0) | 2023.06.16 |
---|---|
스프링 핵심원리 5 [동적 프록시 기술] (0) | 2023.06.16 |
스프링 핵심원리 3 [스프링 디자인 패턴] (0) | 2023.06.14 |
자바 제네릭(generic) (0) | 2023.06.11 |
자바 익명함수와 람다식 (0) | 2023.06.11 |