본문은 인프런의 [스프링 핵심 원리 - 고급편]를 수강하고 정리한 글입니다. 필요에 따라 생략/수정된 부분이 있을 수 있으며, 내용이 추후 변경될 수 있습니다.
스프링 핵심 원리 - 고급편
1. 쓰레드 로컬
2. 템플릿 메서드 패턴과 콜백 패턴
3. 프록시 패턴과 데코레이터 패턴
4. 동적 프록시 기술
5. 스프링이 지원하는 프록시
6. 빈 후처리기
7. @Aspect AOP
8. 스프링 AOP 개념
9. 스프링 AOP 구현
10. 포인트컷
11. 실무 주의사항
전 챕터에서는 템플릿 메서드 패턴과 콜백 패턴을 사용해서 코드 수정을 최소화하는 예제를 살펴봤다. 하지만 결과적으론 로그를 남기고 싶은 클래스를 일일히 모두 수정해야 한다는 사실은 변하지 않았다. 즉, 로그를 남기기 위해 원본 코드를 변경해야 한다는 부담은 여전히 남아 있는 것이다. 이를 해결하기 위해선 프록시(Proxy) 개념을 먼저 이해해야 한다.
콜백 패턴을 적용한 기존 예제
@Service
public class OrderServiceV5 {
private final OrderRepositoryV5 orderRepository;
private final TraceTemplate template;
public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace trace) {
this.orderRepository = orderRepository;
this.template = new TraceTemplate(trace);
}
// 로그를 남기기 위한 코드가 포함되어 있다!!
public void orderItem(String itemId) {
template.execute("OrderService.orderItem()", () -> {
orderRepository.save(itemId);
return null;
});
}
}
1. 프록시
1) 프록시의 의미
먼저 프록시에 대해 알아보자. 클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리한다. 클라이언트와 서버 개념에서 일반적으로 클라이언트가 서버를 직접 호출하고 처리 결과를 받는데, 이를 직접 호출이라고 한다.
그런데 클라이언트가 서버에 직접 요청하는 것이 아닌, 중간에 존재하는 대리자를 통해 서버에게 간접적으로 요청할 수 있다. 이를 간접 호출이라고 하며, 여기서 말하는 대리자를 프록시(Proxy)라고 한다.
직접 호출과 다르게 간접 호출을 하면 대리자가 중간에서 여러가지 일을 할 수 있다.
1) 접근 제어, 캐싱
- 엄마에게 라면을 사달라고 부탁했는데, 그 라면이 집에 있으면 엄마는 바로 라면을 갖다 준다.
- 클라이언트가 기대한 것보다 빠르게 자원을 구할 수 있다.
2) 부가 기능 추가
- 아버지께 주유를 부탁했는데, 세차까지 하고 오셨다.
- 클라이언트가 기대한 것 외에 세차라는 부가 기능까지 얻게 되었다.
3) 프록시 체인
- 대리자가 또 다른 대리자를 부를 수도 있다. 예를 들어 내가 동생에게 라면을 사달라고 했는데, 동생이 또 다른 사람에게 라면을 사달라고 요청할 수 있다.
- 중요한 점은 클라이언트는 대리자를 통해서 요청했기 때문에 그 이후 과정은 모른다는 점이다. 클라이언트 입장에선 요청의 결과만 받으면 된다.
2) 프록시의 역할
객체에서 프록시가 되려면 클라이언트는 서버에게 요청을 한 것인지, 또는 프록시에게 요청을 한 것인지 몰라야 한다. 쉽게 이야기해서 서버와 프록시는 같은 인터페이스를 사용해야 한다. 또한, 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다. 즉 프록시는 서버를 완전히 대체할 수 있어야 한다.
클라이언트는 서버 인터페이스에만 의존하고, 서버와 프록시가 같은 인터페이스를 사용한다. 따라서 DI(의존성 주입)을 이용해서 서버를 프록시로 대체할 수 있다. 클래스 의존 관계는 다음과 같다.
런타임 시에는 클라이언트 객체에 DI를 이용해서 client -> server에서 client -> proxy로 의존관계를 변경해도 클라이언트는 전혀 변경하지 않아도 된다. 심지어 클라이언트 입장에선 변경 사실 조차 모른다. 이와 같이 DI를 사용해서 클라이언트의 변경 없이 유연하게 프록시를 주입할 수 있다는 점에 주목하자.
3) 프록시의 주요 기능
프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있다. 프록시 객체가 중간에 있으면 크게 접근 제어와 부가 기능 추가를 수행할 수 있게 된다.
1) 접근 제어
- 권한에 따른 접근 차단 (접근 제어)
- 캐싱 (리소스에 대한 접근을 제어한다는 측면에서 접근 제어의 일종으로 볼 수 있다)
- 지연 로딩
2) 부가 기능
- 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다
- ex) 요청 값이나 응답 값을 중간에 변형한다
- ex) 실행시간을 측정해서 추가 로그를 남긴다
4) 프록시 패턴과 데코레이터 패턴의 차이
프록시 패턴과 데코레이터 패턴 모두 프록시를 사용하는 방법이지만, GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다. 둘 다 프록시를 사용하지만, 두 패턴의 의도가 다르다는 점이 핵심이다.
- 프록시 패턴: 접근 제어가 목적
- 데코레이터 패턴: 새로운 기능 추가가 목적
2. 프록시 패턴
Subject (프록시와 실제 객체의 인터페이스)
public interface Subject {
String operation();
}
RealSubject - Subject를 구현
@Slf4j
public class RealSubject implements Subject {
@Override
public String operation() {
log.info("실제 객체 호출");
// 로직 실행
return "data";
}
}
- operation()을 통해 실제 동작을 수행한다
CacheProxy - 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;
}
}
- 클라이언트가 프록시를 호출하면 프록시가 최종적으로 실제 객체를 호출해야 하므로 프록시는 내부에 실제 객체의 참조를 가지고 있어야 한다 (여기서 프록시가 호출하는 대상을 target이라고 한다)
- cachedValue에 값이 없으면 실제 객체를 호출해서 값을 구하고, 구한 값을 cachedValue에 저장하고 반환한다
- 따라서 처음 조회 이후에는 캐시에서 매우 빠르게 데이터를 조회할 수 있다
Client - Subject 타입의 프록시 객체를 주입받음
public class ProxyPatternClient {
private Subject subject;
public ProxyPatternClient(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.operation();
}
}
- Subject 인터페이스에 의존하고, Subject를 호출하는 클라이언트 코드이다
실행 코드
@Test
void cacheProxyTest() {
RealSubject realSubject = new RealSubject();
CacheProxy cacheProxy = new CacheProxy(realSubject); // 프록시에는 실제 객체가 주입
ProxyPatternClient client = new ProxyPatternClient(cacheProxy); // 클라이언트에 캐시 객체가 주입됨
client.execute();
client.execute();
client.execute();
}
프록시 패턴의 핵심은 실제 객체 코드와 클라이언트 코드를 전혀 변경하지 않고, 프록시를 도입해서 접근 제어를 한다는 점이다. 그리고 프록시의 특성상 실제 클라이언트 입장에서는 프록시 객체가 주입되었는지, 또는 실제 객체가 주입되었는지 여부를 알지 못하므로 클라이언트 코드의 변경 없이 자유롭게 프록시를 변경할 수 있다.
3. 데코레이터 패턴
Component (프록시와 실제 객체의 인터페이스)
public interface Component {
String operation();
}
RealComponent - Component를 구현
@Slf4j
public class RealComponent implements Component {
@Override
public String operation() {
log.info("RealComponent 실행");
return "data";
}
}
MessageDecorator - Component를 구현함과 동시에 실제 객체를 주입받고, 부가 기능을 수행한다
@Slf4j
public class MessageDecorator implements Component {
private 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;
}
}
- 프록시가 호출해야 하는 대상을 component에 저장한다
- operation()을 호출하면 프록시와 연결된 대상를 호출하고, 그 응답값을 꾸며준 다음 반환한다 (부가기능 수행)
TimeDecorator - Component를 구현함과 동시에 실제 객체를 주입받고, 부가 기능을 수행한다
@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;
}
}
- TimeDecorator는 실행 시간을 측정하는 부가 기능을 제공한다
Client - Component 타입의 프록시 객체를 주입받음
@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);
}
}
실행 코드
@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 -> realComponet의 객체 의존관계를 설정하고 실행한다
데코레이터 패턴의 기본적인 동작은 프록시 패턴과 동일하다. 본 예제를 통해 프록시로 부가 기능을 추가하는 데코레이터 패턴을 살펴보았다. 특히 주목할 점은 프록시 체인을 통해서 프록시가 프록시를 호출하면서 부가기능이 중첩되어 수행된다는 점이다.
위의 코드를 다시 보면 Decorator들은 내부에 호출 대상인 component를 가지고 있고, 이를 호출하는 중복된 형태를 취한다. 이는 꾸며주는 역할을 하는 Decorator가 스스로 존재할 수 없기 때문인데, 이런 중복을 제거하기 위해 component를 속성으로 가지고 있는 Decorator라는 추상 클래스를 만드는 방법도 고민할 수 있다. 이렇게 하면 클래스 다이어그램에서 어떤 클래스가 실제 컴포넌트인지, 또는 데코레이터인지 명확하게 구분할 수 있다.
프록시 패턴과 데코레이터 패턴은 그 모양이 거의 같고 상황에 따라 정말 똑같을 때도 있다. 앞서 말했다시피 이 둘을 구분하는 것은 중요한 것은 의도이다. 디자인 패턴에서 중요한 것은 해당 패턴의 겉모양이 아니라, 그 패턴을 만든 의도이다. 따라서 의도에 따라 패턴을 구분한다.
- 프록시 패턴의 의도: 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공
- 데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공
프록시를 사용하면서 그 프록시를 접근 제어의 목적으로 쓴다면 프록시 패턴에 해당하고, 새로운 기능을 추가하는 목적으로 쓴다면 데코레이터 패턴이라고 할 수 있다.
4. 인터페이스 기반 프록시
앞선 예제에 인터페이스 기반의 프록시를 적용해보자. 프록시를 만들기 위해서 인터페이스를 구현하고, 실제 객체(Controller, Service, Repository)가 이를 구현하게끔 한다. 지금까지는 실제 객체에 부가 기능을 위한 로직을 모두 추가해야 했지만, 프록시를 이용하면 이를 분리할 수 있다.
OrderService - 실제 객체에 부가 기능을 위한 코드가 들어가 있다
@Service
public class OrderServiceV5 {
private final OrderRepositoryV5 orderRepository;
private final TraceTemplate template;
public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace trace) {
this.orderRepository = orderRepository;
this.template = new TraceTemplate(trace);
}
// 로그를 남기기 위한 코드가 포함되어 있다!!
public void orderItem(String itemId) {
template.execute("OrderService.orderItem()", () -> {
orderRepository.save(itemId);
return null;
});
}
}
OrderService1
public interface OrderServiceV1 {
void orderItem(String itemId);
}
OrderServiceV1Imp1 - 실제 객체
public class OrderServiceV1Impl implements OrderServiceV1 {
private final OrderRepositoryV1 orderRepository;
public OrderServiceV1Impl(OrderRepositoryV1 orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public void orderItem(String itemId) {
orderRepository.save(itemId);
}
}
OrderServiceInterfaceProxy
@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;
}
}
}
InterfaceProxyConfig
@Configuration
public class InterfaceProxyConfig {
@Bean
public OrderServiceV1 orderService(LogTrace logTrace) {
OrderServiceV1Impl serviceImpl = new OrderServiceV1Impl(orderRepository(logTrace));
return new OrderServiceInterfaceProxy(serviceImpl, logTrace); // 프록시 객체 안에 실제 객체가 있다
}
// ...
}
- 기존에는 스프링 빈이 실제 객체를 반환했었지만, 이제는 프록시를 사용해야 하므로 프록시를 실제 스프링 빈 대신 등록한다
- 따라서 실제 객체는 스프링 빈으로 등록하지 않는다
- 스프링 빈으로 실제 객체 대신에 프록시 객체를 등록했으므로 스프링 빈을 주입받으면 실제 객체 대신에 프록시 객체가 주입된다
- 프록시 객체가 실제 객체를 참조하므로 프록시를 통해서 실제 객체를 호출할 수 있다
아래는 스프링 컨테이너의 상태를 나타낸 그림이다. 스프링 컨테이너에는 프록시 객체가 등록되고, 이를 빈으로 관리한다. 실제 객체는 스프링 컨테이너가 아닌 프록시 객체를 통해서 참조될 뿐이다. 따라서 프록시 객체는 스프링 컨테이너가 관리하고 자바 힙 메모리에도 올라가는 반면, 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않는다.
5. 구체 클래스 기반 프록시
자바의 다형성은 인터페이스를 구현하든, 아니면 클래스를 상속하든 상위 타입만 맞으면 다형성이 적용된다. 따라서 인터페이스가 없어도 프록스를 만들 수 있다. 여기서는 구체 클래스를 기반으로한 프록시 예제를 살펴보자.
OrderServiceV2
public class OrderServiceV2 {
private final OrderRepositoryV2 orderRepository;
public OrderServiceV2(OrderRepositoryV2 orderRepository) {
this.orderRepository = orderRepository;
}
public void orderItem(String itemId) {
orderRepository.save(itemId);
}
}
OrderServiceConcreteProxy
public class OrderServiceConcreteProxy extends OrderServiceV2 {
private final OrderServiceV2 target;
private final LogTrace logTrace;
public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace logTrace) {
super(null); // OrderServiceV2.orderRepositoryV2가 필수로 초기화되어야 하기 때문에 null을 넣는다.
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;
}
}
}
ConcreteProxyConfig
@Configuration
public class ConcreteProxyConfig {
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
OrderServiceV2 serviceImpl = new OrderServiceV2(orderRepositoryV2(logTrace));
return new OrderServiceConcreteProxy(serviceImpl, logTrace);
}
// ...
}
클래스 기반 프록시는 단점이 명확하다. 자바 기본 문법상 자식 클래스를 생성할 때는 항상 super()로 부모 클래스의 생성자를 호출해야 한다. 이 부분을 생략하면 부모 클래스의 기본 생성자가 호출된다. 그런데 부모 클래스인 OrderService2는 기본 생성자가 없고 생성자에서 파라미터 1개를 필수로 받기 때문에 파라미터를 넣어서 super()를 호출해야 한다.
이때 프록시는 부모 객체의 기능을 사용하지 않으므로 파라미터에 null을 넣어 호출해도 된다. 그래서 OrderService2의 생성자에서 super(null)를 호출한 것이다. 반면 인터페이스 기반 프록시는 이러한 문제가 발생하지 않는다.
6. 인터페이스 기반 프록시와 클래스 기반 프록시
인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다. 그러나 클래스 기반 프록시는 해당 클래스에만 적용 가능한 반면, 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.
또한, 클래스 기반 프록시는 상속에 의한 몇가지 제약이 있다.
- 자식 클래스(프록시)는 부모 클래스(실제객체)의 생성자를 호출해야 한다
- 클래스에 final 키워드가 붙으면 상속이 불가능하다
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다
인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭고, 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나눈다는 측면에서 더 좋다. 하지만, 인터페이스가 존재하지 않거나 해당 인터페이스를 구현할 수 없는 클래스의 경우엔 인터페이스 기반 프록시를 사용할 수 없다.
이론적으로는 모든 객체에 인터페이스를 적용해서 역할과 구현을 나누는 것이 좋지만, 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다. 구현을 변경할 가능성이 없는 코드에 무작정 인터페이스를 사용하는 것보다는 구체 클래스를 사용하는 게 구현적으로 편리하다. 핵심은 인터페이스가 항상 필요한 것이 아니라는 것이다. 실무에서는 프록시를 적용할 때 인터페이스도 있고, 구체 클래스도 있다. 따라서 두 가지 모두에 대응할 수 있어야 한다.
'Spring > 스프링 핵심 원리 - 고급편' 카테고리의 다른 글
[스프링 핵심 원리 - 고급편] 빈 후처리기 (0) | 2023.03.06 |
---|---|
[스프링 핵심 원리 - 고급편] 스프링이 지원하는 프록시 (0) | 2023.03.06 |
[스프링 핵심 원리 - 고급편] 동적 프록시 기술 (0) | 2023.02.21 |
[스프링 핵심 원리 - 고급편] 템플릿 메서드 패턴과 콜백 패턴 (0) | 2023.02.09 |
[스프링 핵심 원리 - 고급편] 쓰레드 로컬 (ThreadLocal) (1) | 2023.02.03 |