인터페이스를 직접 구현하면서, 필드에 직접 구현한 인터페이스를 갖고 의존성 주입을 받는다.
처음에는 새로 생성하며 그 후에는 저장된 캐시 값을 그대로 사용하여 접근을 제어한다.
Spring AOP 로드맵
AOP를 학습하기 위해 김영한 강사님의 Spring 고급 편 강의를 듣고 있는데, AOP를 보다 더 완벽하게 이해하기 위해서는 아래의 단계를 따라야 한다.
학습해야 할 내용이 방대하지만 차근차근 나아가보자.
이번 글에서는 Spring이 사용하는 디자인 패턴 중 전략 패턴 그중에서도 프록시 패턴과 데코레티어 패턴을 다룬다.
1. 동시성 문제 - 쓰레드 로컬
2. 전략 패턴 - 템플릿 메서드 패턴과 콜백 패턴
3. 프록시 - 프록시 패턴과 데코레티어 패턴
4. 동적 프록시
5. Spring 지원 동적 프록시
6. 빈 후처리기
7. @Aspect AOP
8. Spring AOP
프록시 패턴(Proxy Pattern)의 이해와 예제 프로젝트 만들기
등장 배경
프록시 패턴은 서비스 제공자(서버)와 이를 사용하려는 클라이언트 사이에 중간자 역할을 하는 대리자(프록시)를 두어, 접근 제어, 지연 로딩, 캐싱, 로깅 등 다양한 목적으로 활용될 수 있는 디자인 패턴이다. 이는 서비스 요청의 복잡성을 추상화하고, 클라이언트가 서버에 대한 직접적인 액세스 없이도 서비스를 이용할 수 있게 하는 구조를 제공한다.
프록시 패턴의 주요 목적
- 접근 제어: 사용자의 권한에 따라 객체에 대한 접근을 제한할 수 있다.
- 캐싱: 한 번 요청된 자원에 대해 재요청 시 빠르게 응답하기 위해 결과를 캐시에 저장한다.
- 지연 로딩: 객체의 로딩을 지연시켜, 필요한 순간까지 객체 생성을 미룸으로써 초기 로딩 시간을 줄일 수 있다.
- 로깅과 모니터링: 서비스 요청과 응답에 대한 로깅을 통해 모니터링이 가능하다.
예제
예제 프로젝트 구조 설명
V1: 인터페이스와 구현 클래스를 사용한 스프링 빈 수동 등록 목적: 가장 기본적인 형태의 프록시 사용법을 이해한다.
구조:
OrderControllerV1: HTTP 요청을 받는 컨트롤러 인터페이스
OrderServiceV1: 주문 로직을 처리하는 서비스 인터페이스
OrderRepositoryV1: 데이터 저장소 접근을 추상화한 리포지토리 인터페이스 각 인터페이스의 구현 클래스는 실제 로직을 수행한다.
/* OrderControllerV1 interface */
@RequestMapping
// Spring은 @Controller 또는 @RequestMapping이 있어야 스프링 컨트롤러로 인식
// 자동 컴포넌트 스캔이 되지 않게하기 위해 @RequestMapping 사용
@ResponseBody
public interface OrderControllerV1 {
@GetMapping("/v1/request")
String request(@RequestParam("itemId") String itemId);
@GetMapping("/v1/no-log")
String noLog();
}
/* OrderControllerV1Impl */
@RequiredArgsConstructor
public class OrderControllerV1Impl implements OrderControllerV1 {
private final OrderServiceV1 orderService;
@Override
public String request(final String itemId) {
orderService.orderItem(itemId);
return "ok";
}
@Override
public String noLog() {
return "noLog";
}
}
/* OrderServiceV1 interface */
public interface OrderServiceV1 {
void orderItem(String itemId);
}
/* OrderServiceImpl */
@RequiredArgsConstructor
public class OrderServiceV1Impl implements OrderServiceV1 {
private final OrderRepositoryV1 orderRepository;
@Override
public void orderItem(final String itemId) {
orderRepository.save(itemId);
}
}
/* OrderRepository interface */
public interface OrderRepositoryV1 {
void save(String itemId);
}
/* OrderRepositoryImpl */
public class OrderRepositoryV1Impl implements OrderRepositoryV1 {
@Override
public void save(final String itemId) {
// 저장 로직
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생");
}
sleep(1000);
}
private void sleep(final int mills) {
try {
Thread.sleep(mills);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
V2: 인터페이스 없는 구체 클래스를 사용한 스프링 빈 수동 등록
목적: 인터페이스 없이 구체 클래스만으로 프록시를 어떻게 적용할 수 있는지 탐색한다.
구조: V1과 유사하나, 모든 컴포넌트가 구체 클래스만으로 구성된다.
/* OrderControllerV2 */
@Slf4j
@RequestMapping
@ResponseBody
@RequiredArgsConstructor
/* @RequestMapping 만 사용해도 컨트롤러 역할을 함.
@Controller를 사용하지 않은 이유는 자동 컴포넌트 스캔의 대상이 되기 때문*/
public class OrderControllerV2 {
private final OrderServiceV2 orderService;
@GetMapping("/v2/request")
public String request(final String itemId) {
orderService.orderItem(itemId);
return "ok";
}
@GetMapping("/v2/no-log")
public String noLog() {
return "noLog";
}
}
/* OrderServiceV2 */
@RequiredArgsConstructor
public class OrderServiceV2 {
private final OrderRepositoryV2 orderRepository;
public void orderItem(final String itemId) {
orderRepository.save(itemId);
}
}
/* OrderRepositoryV2 */
public class OrderRepositoryV2 {
public void save(final String itemId) {
// 저장 로직
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생");
}
sleep(1000);
}
private void sleep(final int mills) {
try {
Thread.sleep(mills);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
V3: 컴포넌트 스캔을 통한 스프링 빈 자동 등록
목적: 스프링의 컴포넌트 스캔 기능을 사용하여 더 간결하고 자동화된 방법으로 프록시를 적용하는 방법을 학습한다.
구조: 평소에 자주 사용하던 @Component, @Service, @Repository 등의 애노테이션을 사용하여 스프링이 자동으로 빈을 등록한다.
코드: 평소에 사용하던 layered Architecture 이므로 생략한다.
AppV1Config
스프링 빈으로 수동 등록
@Configuration
public class AppV1Config {
/* Bean 수동 등록 */
@Bean
public OrderControllerV1 orderControllerV1() {
return new OrderControllerV1Impl(orderServiceV1());
}
@Bean
public OrderServiceV1 orderServiceV1() {
return new OrderServiceV1Impl(orderRepositoryV1());
}
@Bean
public OrderRepositoryV1 orderRepositoryV1() {
return new OrderRepositoryV1Impl();
}
}
로그를 추적하는 기능들을 만족하기 위해서 기존 코드를 많이 수정해야했다.
2024.03.06 - [Spring/Spring AOP] - 디자인 패턴 프록시 패턴
원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용하려면 어떻게 해야할까?
이를 해결하기 위해선 프록시(Proxy)의 개념을 이해해야 한다.
Client, Server
클라이언트와 서버라고하면 개발자들은 보통 서버 컴퓨터라고 생각한다.
사실 이 둘의 개념은 상당히 넓게 사용되는데, 클라이언트는 의뢰인, 서버는 '서비스나 상품을 제공하는 사람이나 물건'을 뜻한다.
따라서 기본 개념을 정의하면 클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리한다.
이 개념을 객체에 도입하면 요청하는 객체는 클라이언트, 요청을 처리하는 객체는 서버가 된다.
직접 호출과 간접 호출
직접 호출
클라이언트와 서버사이에서 직접 호출하고, 직접 처리한다.
간접 호출
직접 요청하고 받는 것이 아닌, 대리자를 통해 간접적으로 요청할 수 있다.
이 대리자를 프록시(Proxy)라고한다.
프록시 체인
하나의 대리자를 사용할 수 있지만 사실 2, 3, .. , n개의 프록시를 사용할 수 있다.
역할에 따라 프록시를 추가할 수 있다. ex) Proxy1 -> 로그 기록, Proxy2 -> 접근제어
대체 가능
하지만 여기까지 보면, 아무 객체나 프록시가 될 수 있을 것 같다.
객체에서 프록시가 되려면, 클라이언트는 서버에 요청한 것인지 프록시에게 요청한 것인지 몰라야한다.
다른 말로, 서버와 프록시가 같은 인터페이스를 사용해야한다.
그리고 클라이언트가 사용하는 서버 객체를 프록시 객체로 바꿔도 클라이언트 코드는 상관 없이 동작해야한다.
클래스 의존관계를 보면 클라이언트는 서버 인터페이스에만 의존한다.
서버와 프록시가 같은 인터페이스를 사용하므로 DI를 사용하여 대체 가능하다.
프록시의 기능
프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있다.
- 접근 제어
- 권한에 따른 접근 차단
- 캐싱
- 지연 로딩
- 부가 기능 추가
- 서버가 제공하는 기본 기능에 더해 부가 기능을 수행
- 요청 값이나, 응답 값을 중간에 변경
- 실행 시간을 측정하여 로그 추가
프록시 객체가 중간에 있으면 접근 제어와 부가 기능 추가를 할 수 있다.
GOF 디자인 패턴
중요한 점은 둘 다 프록시를 사용한다.
하지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라 프록시 패턴과 데코레이터 패턴으로 구분한다.
프록시 패턴: 접근 제어가 목적
데코레이터 패턴: 새로운 기능 추가가 목적
프록시 패턴 적용 전과 후
프록시 패턴 적용 전
프록시 패턴 적용 전 - 런타임 객체 의존 관계
프록시 패턴 적용 전 - 클래스 의존 관계
프록시 패턴 적용 전 code
/* Subject interface */
public interface Subject {
String operation();
}
/* RealSubject */
@Slf4j
public class RealSubject implements Subject {
@Override
public String operation() {
log.info("실제 객체 호출");
sleep(1000);
return "data";
}
private void sleep(final int mills) {
try {
Thread.sleep(mills);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Subject를 RealSubject가 구현했다. opeeration()은 데이터 조회를 시뮬레이션하기 위해 1초 쉰다.
Client는 execute()를 호출하고, 호출하게 되면 subject.operation()를 호출한다.
/* ProxyPatternClient */
public class ProxyPatternClient {
private Subject subject;
public ProxyPatternClient(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.operation();
}
}
/* ProxyPatternTest.noProxyTest() */
@Test
void noProxyTest() {
RealSubject realSubject = new RealSubject();
ProxyPatternClient client = new ProxyPatternClient(realSubject);
client.execute();
client.execute();
client.execute();
}
/* 실제 호출 */
/**
RealSubject - 실제 객체 호출
RealSubject - 실제 객체 호출
RealSubject - 실제 객체 호출
**/
프록시 패턴 적용 후
프록시 패턴 적용 후 - 클래스 의존 관계
프록시 패턴 적용 후 - 런타임 객체 의존 관계
프록시 패턴 적용 후 Code
이제 프록시 패턴을 적용해보자.
/* RealSubject를 CacheProxy로 대체 */
@Slf4j
public class CacheProxy implements Subject {
private Subject target;
private String cacheValue;
public CacheProxy(final Subject target) {
this.target = target;
}
@Override
public String operation() {
log.info("프록시 호출");
if (cacheValue == null) {
// 첫 호출시에 실제 객체를 호출하여 Data 반환
cacheValue = target.operation();
}
return cacheValue;
}
}
앞서 설명한 것처럼 프록시도 실제 객체와 그 모양이 같아야 하기 때문에 Subject 인터페이스를 구현해야한다.
Private Subject target;
클라이언트가 프록시를 호출하면 프록시가 실제 객체를 호출해야한다.
따라서 내부에 실제 객체의 참조를 가지고 있어야하며, 이렇게 프록시가 호출하는 대상을 target이라 한다.
@Override
public String operation(){...};
실제 객체(target)를 호출해서 값을 구한다.
그리고 구한 값을 cacheValue에 저장하고 반환한다.
만약 cacheValue에 값이 있다면 실제 객체를 호출할 필요 없이 갖고 있던 실제 데이터를 반환한다.
따라서 처음 조회 이후에는 캐시를 반환하면 되므로 매우 빠르게 데이터를 조회할 수 있다.
/* ProxyPatternTest.cacheProxyTest() */
@Test
void cacheProxyTest() {
RealSubject realSubject = new RealSubject();
CacheProxy cacheProxy = new CacheProxy(realSubject);
ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
client.execute();
client.execute();
client.execute();
}
/* 실행 결과 */
/**
CacheProxy - 프록시 호출
RealSubject - 실제 객체 호출
CacheProxy - 프록시 호출
CacheProxy - 프록시 호출
**/
realSubject를 생성하고 둘을 연결한다. 결과적으로 cacheProxy가 realSubject를 참조하는 런타임에 객체 의존관계가 완성된다.
따라서 우리가 원했던 client -> cacheProxy -> realSubject 형태가 완성된다.
처음에만 실제 객체를 호추랗고, 그 후 캐시에서 반환하므로 처음에만 1초를 사용하고, 그 후로 사용하지 않는다.
프록시 패턴의 핵심은 RealSubject 코드와 클라이언트 코드를 전혀 변경하지 않고, 프록시를 도입해서 접근을 제어했다는 점이다.
클라이언트 코드의 변경 없이 서버단에서 자유롭게 프록시를 넣고 뺄 수 있다.
실제 클라이언트 입장에선 이것이 프록시인지 실제 객체인지 알 수 없다.
다음 포스트에서 데코레이터 패턴에 대해 알아본다.
'Spring > Spring AOP' 카테고리의 다른 글
Spring AOP 6. 포인트컷, 어드바이스, 어드바이저 (0) | 2024.03.20 |
---|---|
Spring AOP 5. ProxyFactory (0) | 2024.03.18 |
Spring AOP 4. 동적 프록시: JDK 동적프록시와 CGLIB (0) | 2024.03.08 |
Spring AOP 3. 디자인 패턴: 데코레이터 패턴 (0) | 2024.03.06 |
Spring AOP 1. 디자인 패턴: 전략 패턴 - 템플릿 메서드 패턴 (0) | 2024.03.01 |