참고자료
프록시, 프록시 패턴, 데코레이터 패턴 소개
이전 포스팅까지 로그 추적기를 만들어서 기존 요구사항을 모두 만족했지만, 결국엔 기존 코드를 많이 수정해야 한다는 한계가 여전히 남아있다. 아무리 코드 수정을 최소화하기 위해 템플릿 메서드 패턴, 전략 패턴, 템플릿 콜백 패턴을 사용해서 줄이고 줄였다만, 결과적으로 로그를 남기고 싶은 클래스가 수백개라면 수백개의 클래스를 모두 고쳐야 한다. 로그를 남길 때 기존 원본 코드를 변경해야 한다는 사실 그 자체가 개발자에게는 가장 큰 문제로 남는다.
그래서 새로운 요구사항이 들어왔다.
- 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.
- 특정 메서드는 로그를 출력하지 않는 기능 (보안상 일부는 로그를 출력하면 안되기 때문)
- 다음과 같은 다양한 케이스에 적용할 수 있어야 한다.
- 인터페이스가 있는 구현 클래스에 적용
- 인터페이스가 없는 구체 클래스에 적용
- 컴포넌트 스캔 대상에 기능 적용
가장 어려운 문제는 원본 코드를 전혀 수정하지 않고 로그 추적기를 도입하는 것이다. 이 문제를 해결하려면 프록시가 필요하다!
프록시(Proxy)란?
프록시는 정말 자주 사용되는 용어이다. 다음 그림을 보자.
클라이언트와 서버가 있다. 클라이언트는 꼭 고객이나 사용자를 의미하는 게 아니고 서버는 꼭 어떤 웹 서버를 의미하는 것이 아니라 넓게 보아 클라이언트는 요청을 하는 쪽, 서버는 요청을 처리하는 쪽이다. 이 개념을 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저가 되는 것이고 서버는 웹 서버가 되는 것이다.
일반적으로 클라이언트와 서버 간 호출에 있어 클라이언트가 서버에 직접 호출을 하고 호출의 결과를 직접 받는다. 이런 흐름을 직접 호출이라고 한다. 그런데 클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 이때 대리자를 영어로 프록시(Proxy)라고 한다.
더 나아가 프록시는 또 다른 프록시를 호출할 수도 있다. 즉, 중간 대리자가 체인으로 엮일 수 있다는 소리다.
클라이언트가 프록시를 사용할 때
위 개념을 객체에 도입할 수 있다. 객체 입장에서 프록시가 되려면 클라이언트는 서버에게 요청한 것인지 프록시에게 요청한 것인지 조차 몰라야 한다. 언어적으로 풀면 서버와 프록시는 같은 인터페이스를 사용해야 한다. 그리고 클라이언트는 인터페이스에만 의존하면 된다. 그리고 클라이언트가 누구에게 요청했는지조차 몰라도 된다는 말은 다시 말해 어떤 쪽으로 요청하더라도 클라이언트 코드는 변경되면 안 된다.
다음 그림이 이를 설명한다.
클래스 의존 관계를 보면 클라이언트는 인터페이스에만 의존한다. 그리고 서버와 프록시가 같은 인터페이스를 사용한다. 따라서 DI를 통해 대체가 가능하다.
프록시의 주요 기능
프록시를 통해서 할 수 있는 일은 크게 2가지로 구분할 수 있다.
- 접근제어
- 권한에 따른 접근 차단
- 캐싱
- 지연 로딩
- 부가 기능 추가
- 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
- 예) 요청 값이나 응답 값을 중간에 변형한다.
- 예) 실행 시간을 측정해서 추가 로그를 남긴다.
접근제어와 부가 기능 추가 모두 프록시를 사용하지만 이 둘을 의도에 따라 프록시 패턴과 데코레이터 패턴으로 구분한다.
- 프록시 패턴: 접근 제어가 목적
- 데코레이터 패턴: 새로운 기능 추가 목적
둘 다 프록시를 사용하지만, 의도가 다르다는 점이 핵심이다. 프록시 패턴이라해서 이 패턴만 프록시를 사용하는 것이 아니라 데코레이터 패턴도 프록시를 사용한다.
프록시 패턴 사용 - 예제 코드1
프록시를 도입하기 전 다음은 프록시를 도입하기 전 클라이언트가 서버에게 요청하는 흐름이다.
클라이언트는 인터페이스(Subject)를 의존한다. 그 인터페이스의 구현체인 RealSubject 클래스로부터 실제 구현 메서드를 호출한다.
Subject
package cwchoiit.springadvanced.proxy.pure.code;
@FunctionalInterface
public interface Subject {
String operation();
}
RealSubject
package cwchoiit.springadvanced.proxy.pure.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class RealSubject implements Subject {
@Override
public String operation() {
log.info("실제 객채 호출");
sleep(1000);
return "DATA";
}
private void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
ProxyPatternClient
package com.example.advanced.pureproxy.proxy.code;
public class ProxyPatternClient {
private Subject subject;
public ProxyPatternClient(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.operation();
}
}
- 코드는 매우 간단하다. Client 코드에서 Subject를 주입받는다. Subject를 구현한 클래스가 주입된다.
ProxyPatternTest
package cwchoiit.springadvanced.proxy.pure;
import cwchoiit.springadvanced.proxy.pure.code.ProxyPatternClient;
import cwchoiit.springadvanced.proxy.pure.code.RealSubject;
import org.junit.jupiter.api.Test;
public class ProxyPatternTest {
@Test
void noProxyTest() {
RealSubject realSubject = new RealSubject();
ProxyPatternClient client = new ProxyPatternClient(realSubject);
client.execute();
client.execute();
client.execute();
}
}
- 이제 클라이언트가 서버에게 직접 요청해서 응답받는 코드를 수행해 보자. ProxyPatternClient는 Subject를 주입받아야 하므로 RealSubject 객체를 만들어 전달해 준다. 이후 client는 execute()를 세 번 실행한다. execute()는 내부 로직에 1초의 대기 시간이 있어서 최소 3초의 시간이 소요된다.
위 결과 화면으로부터 3초 127ms의 소요시간을 확인할 수 있다. 이제 프록시를 도입해 보자.
프록시 패턴 - 예제 코드2
다음은 프록시를 도입했을 때의 구조이다.
프록시를 도입한 후 클라이언트가 주입받을 Subject의 구현체로 Proxy가 추가가 된다.
이제 클라이언트는 중간에 프록시가 끼워져 있는지 끼워져 있지 않은지를 알 필요도 없고 아무런 클라이언트 코드에 대한 변경을 취할 필요가 없다. 프록시를 도입해 보자. 위 코드에서는 같은 데이터를 반환하는데 로직 상 1초의 대기시간(조회할 때 걸리는 시간이라고 가정하자)이 있기 때문에 총 3초의 긴 대기시간이 소요된다. 그렇다면 이러한 데이터를 조회하는 과정의 소요시간을 줄이기 위해 캐싱을 사용할 수 있을 것이다. 캐싱 기술 역시 프록시에서 해줄 수 있다.
CacheProxy
package cwchoiit.springadvanced.proxy.pure.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CacheProxy implements Subject {
private final 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이라고 칭한다. 그 실제 객체를 프록시 객체가 주입을 받아야 실제 객체의 로직을 수행할 수 있고 거기에 플러스 프록시가 제공하는 기능을 덧붙일 수 있다. 그래서 같은 operation()에서 캐시 저장소에 캐시 데이터가 있다면 그 값을 바로 반환하고 없다면 실제 객체로부터 데이터를 받아와 반환한다. 이렇게 되면 3회의 요청 동안 한 번의 실제 객체를 호출할 것이므로 1초 언저리로 응답해 줄 수 있다.
ProxyPatternTest
package cwchoiit.springadvanced.proxy.pure;
import cwchoiit.springadvanced.proxy.pure.code.CacheProxy;
import cwchoiit.springadvanced.proxy.pure.code.ProxyPatternClient;
import cwchoiit.springadvanced.proxy.pure.code.RealSubject;
import org.junit.jupiter.api.Test;
public class ProxyPatternTest {
@Test
void cacheProxyTest() {
RealSubject realSubject = new RealSubject(); // 실제 객체
CacheProxy cacheProxy = new CacheProxy(realSubject); // 프록시
ProxyPatternClient client = new ProxyPatternClient(cacheProxy); // 클라이언트에게 프록시가 주입됨
client.execute();
client.execute();
client.execute();
}
}
- cacheProxyTest()는 프록시를 사용한다. 프록시를 사용한다고 해서 클라이언트 코드에 변경을 가하지 않는다. 이것이 핵심이다. 클라이언트 코드는 똑같이 Subject를 구현한 구현체만을 전달받고 그것이 프록시가 될 뿐이다. execute()를 세 번 실행해 보면 1초 언저리로 응답받는 것을 확인할 수 있다.
이렇게 간단하게 프록시를 통해 클라이언트의 간접 요청을 이해해봤다. 이후에 실제 프로젝트 코드에 프록시 패턴을 적용해서 공통적으로 처리될 부분에 대한 추가 기능을 기존 로직의 아무런 변경 없이 적용해 보자. 그전에 데코레이터 패턴도 알아봐야 한다.
데코레이터 패턴 - 예제 코드 1
데코레이터 패턴 적용 전 코드의 관계는 다음과 같다.
클라이언트는 Component 인터페이스에 의존하고 그 인터페이스를 구현한 구현체 클래스의 메서드를 호출한다.
Component
package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;
@FunctionalInterface
public interface Component {
String operation();
}
RealComponent
package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class RealComponent implements Component {
@Override
public String operation() {
log.info("RealComponent 실행");
return "DATA";
}
}
DecoratorPatternClient
package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class DecoratorPatternClient {
private final Component component;
public void execute() {
String result = component.operation();
log.info("result = {}", result);
}
}
- 위 코드까지가 데코레이터 패턴을 적용하기 전이다. 클라이언트는 Component를 주입받는다. 그 Component를 구현한 클래스의 메서드인 operation()을 호출하고 끝난다.
- 이제 데코레이터 패턴을 적용해 보자. 이 패턴은 프록시 패턴과 구조가 동일하다. 클라이언트는 인터페이스인 Component를 상속받으며 Component를 구현한 구현체가 프록시든 실제 객체든 클라이언트 코드는 아무런 변경사항을 주지 않는다. 이 역시 이것이 핵심이다.
DecoratorPatternTest
package cwchoiit.springadvanced.proxy.pureproxy.decorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.RealComponent;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class DecoratorPatternTest {
@Test
void noDecorator() {
RealComponent realComponent = new RealComponent();
DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
client.execute();
}
}
- 실제 객체를 생성한 후 클라이언트에게 주입한다. 클라이언트는 그대로 execute()를 실행한다. 결과는 다음과 같다.
14:52:11.063 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
14:52:11.067 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=data
데코레이터 패턴 - 예제 코드 2
부가 기능 추가
앞서 설명한 것처럼 프록시를 통해서 할 수 있는 기능은 크게 접근 제어와 부가 기능 추가라는 2가지로 구분한다. 앞서 프록시 패턴에서 캐시를 통한 접근 제어를 알아보았다. 이번에는 프록시를 활용해서 부가 기능을 추가해보자. 이렇게 프록시로 부가 기능을 추가하는 것을 데코레이터 패턴이라 한다.
응답 값을 꾸며주는 데코레이터
응답 값을 꾸며주는 데코레이터 프록시를 만들어보자.
MessageDecorator
package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class MessageDecorator implements Component{
private final Component component;
@Override
public String operation() {
log.info("MessageDecorator 실행");
String operation = component.operation();
String decoString = "*****" + operation + "*****";
log.info("MessageDecorator 꾸미기 적용 전 = {}, 적용 후 ={}", operation, decoString);
return decoString;
}
}
- 데코레이터에서는 실제 객체를 주입받는다. 그 실제 객체의 로직을 수행하는데 그 로직에 플러스되는 부가 기능을 데코레이터에서 처리할 뿐이다. 바로 테스트 코드를 보자.
DecoratorPatternTest
package cwchoiit.springadvanced.proxy.pureproxy.decorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.MessageDecorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.RealComponent;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class DecoratorPatternTest {
@Test
void decorator1() {
RealComponent realComponent = new RealComponent();
MessageDecorator decorator = new MessageDecorator(realComponent);
DecoratorPatternClient client = new DecoratorPatternClient(decorator);
client.execute();
}
}
- 이젠 데코레이터 객체를 생성한 후 그 객체에 실제 객체를 주입해 준다. 그리고 클라이언트는 역시 Component를 주입받으면 그만이다. 이 구현체가 데코레이터든 실제 객체든 상관이 없다. 이것이 핵심!
실행 결과
14:56:04.918 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator Start
14:56:04.924 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
14:56:04.924 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator not accepted=data, accepted=****data****
14:56:04.927 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=****data****
실제 객체가 응답해 준 데이터에 데코레이터가 주는 부가 기능을 추가한 응답 결과를 확인할 수 있다. 이것이 데코레이터 패턴.
데코레이터 패턴 - 예제 코드 3 (체인 프록시)
프록시(데코레이터) 패턴은 체인이 될 수 있다 했다. 그러니까 다음과 같은 그림도 가능하다.
기존에 사용했던 messageDecorator보다 앞에 timeDecorator가 추가됐다. 이렇게 여러 개의 데코레이터가 들어가도 클라이언트 코드는 변경되는 것이 없다. 마찬가지로 서버 코드도 변경되는 것이 없다. 이것이 핵심!
TimeDecorator
package cwchoiit.springadvanced.proxy.pureproxy.decorator.code;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@RequiredArgsConstructor
public class TimeDecorator implements Component {
private final Component component;
@Override
public String operation() {
log.info("TimeDecorator 실행");
long startTime = System.currentTimeMillis();
String result = component.operation();
long endTime = System.currentTimeMillis();
log.info("TimeDecorator 종료 resultTime = {} ms", endTime - startTime);
return result;
}
}
- 새롭게 추가된 TimeDecorator는 마찬가지로 실제 객체를 주입받는데, 이때 실제 객체는 RealComponent가 아닌 messageDecorator가 된다. TimeDecorator 입장에서는 실제 객체가 messageDecorator가 될 뿐이다.
- 이 상태에서 테스트 코드를 수행해 보자.
DecoratorPatternTest
package cwchoiit.springadvanced.proxy.pureproxy.decorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.DecoratorPatternClient;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.MessageDecorator;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.RealComponent;
import cwchoiit.springadvanced.proxy.pureproxy.decorator.code.TimeDecorator;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class DecoratorPatternTest {
@Test
void decorator2() {
RealComponent realComponent = new RealComponent();
MessageDecorator messageDecorator = new MessageDecorator(realComponent);
TimeDecorator timeDecorator = new TimeDecorator(messageDecorator);
DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
client.execute();
}
}
- 실제 객체 RealComponent를 생성하면 그 객체를 MessageDecorator가 전달받는다. 그리고 TimeDecorator는 MessageDecorator를 전달받는다. 이 TimeDecorator가 클라이언트에게 전달된다. 클라이언트는 변경 사항 없이 그저 본인의 execute()를 실행할 뿐이다.
실행 결과
15:02:48.305 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.TimeDecorator -- TimeDecorator Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.RealComponent -- RealComponent Start
15:02:48.310 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.MessageDecorator -- MessageDecorator not accepted=data, accepted=****data****
15:02:48.314 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.TimeDecorator -- TimeDecorator End, resultTime=4ms
15:02:48.315 [Test worker] INFO com.example.advanced.pureproxy.decorator.code.DecoratorPatternClient -- result=****data****
기존 messageDecorator가 추가해 준 부가기능에 더해 timeDecorator가 추가해 준 부가기능까지 있다. 이렇게 체인으로도 가능하다.
프록시 패턴 결론
결론은 프록시 패턴에서 프록시는 기존 서버가 해주는 기능에 부가적인 기능을 추가해 주는 그 이상 이하도 아닌 것이다. 그 기존 기능을 똑같이 수행하기 위해 프록시 객체는 실제 객체를 주입받아야 한다. 이 말은 생각해보면 꾸며주는 역할을 하는 Decorator들은 스스로 존재할 수 없고 항상 꾸며줄 대상이 있어야 한다. 따라서 내부에 호출 대상인 실제 객체를 항상 가지고 있어야 한다. 그리고 이 실제 객체(또는 또다른 프록시가 될 수도 있음)를 항상 호출해야 한다. 그리고 가장 중요한 핵심은 클라이언트와 서버 모두 프록시의 추가로 인해 변경되는 부분은 없다는 점이다. 그런데 프록시 패턴을 사용해야 하나? 데코레이터 패턴을 사용해야 하나?
그런데 이 둘은 모양이 거의 같고, 상황에 따라 정말 똑같을 수도 있다. 그러면 이 둘을 구분하는 방법은 의도다 의도!
- 프록시 패턴의 의도: 다른 객체에 대한 접근을 제어하기 위해 대리자를 제공
- 데코레이터 패턴의 의도: 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공
스프링에서 이 프록시 패턴이 굉장히 중요하다. 왜냐하면 스프링에선 등록한 빈이 프록시로 등록되는게 거의대부분이기 때문이다. 그럼 실제로 프록시가 어떻게 생성되는걸까? 그것을 알아보자.
스프링에서 프록시 패턴
스프링에서는 3가지 경우의 프록시를 만들어내는 방법이 있다.
- 인터페이스가 있는 구현 클래스에 적용
- 인터페이스가 없는 구체 클래스에 적용
- 컴포넌트 스캔 대상에 기능 적용
실무에서는 스프링 빈으로 등록할 클래스는 인터페이스가 있는 경우도 있고 없는 경우도 있다. 그리고 스프링 빈을 수동으로 직접 등록하는 경우도 있고, 컴포넌트 스캔으로 자동으로 등록하는 경우도 있다. 이런 다양한 케이스에 프록시를 어떻게 적용하는지 알아보기 위해 다양한 예제를 준비해보자.
인터페이스가 있는 구현 클래스
먼저, 인터페이스가 있는 구현 클래스에 프록시를 적용해보기 위해, Controller, Service, Repository 인터페이스와 구현체를 각각 만들어보자.
OrderControllerV1
package cwchoiit.springadvanced.proxy.app.v1;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public interface OrderControllerV1 {
@GetMapping("/v1/request")
String request(@RequestParam("itemId") String itemId);
@GetMapping("/v1/no-log")
String noLog();
}
- 실제로는 Controller는 인터페이스가 있는 경우가 거의 없다. 그런데 지금은 이 경우에 공부를 할 필요가 있기 때문에 약간 억지스럽긴 하지만, 일단 만들어보자.
OrderControllerV1Impl
package cwchoiit.springadvanced.proxy.app.v1;
public class OrderControllerV1Impl implements OrderControllerV1 {
private final OrderServiceV1 orderServiceV1;
public OrderControllerV1Impl(OrderServiceV1 orderServiceV1) {
this.orderServiceV1 = orderServiceV1;
}
@Override
public String request(String itemId) {
orderServiceV1.orderItem(itemId);
return "ok";
}
@Override
public String noLog() {
return "noLog";
}
}
OrderServiceV1
package cwchoiit.springadvanced.proxy.app.v1;
public interface OrderServiceV1 {
void orderItem(String itemId);
}
OrderServiceV1Impl
package cwchoiit.springadvanced.proxy.app.v1;
public class OrderServiceV1Impl implements OrderServiceV1 {
private final OrderRepositoryV1 orderRepositoryV1;
public OrderServiceV1Impl(OrderRepositoryV1 orderRepositoryV1) {
this.orderRepositoryV1 = orderRepositoryV1;
}
@Override
public void orderItem(String itemId) {
orderRepositoryV1.save(itemId);
}
}
OrderRepositoryV1
package cwchoiit.springadvanced.proxy.app.v1;
public interface OrderRepositoryV1 {
void save(String itemId);
}
OrderRepositoryV1Impl
package cwchoiit.springadvanced.proxy.app.v1;
public class OrderRepositoryV1Impl implements OrderRepositoryV1 {
@Override
public void save(String itemId) {
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
}
private void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
- 이런 식으로 컨트롤러, 서비스, 레포지토리가 있다고 생각하자. 컨트롤러는 보통은 인터페이스까지 사용하지 않는 게 일반적이지만 인터페이스를 이용해서 프록시를 만드는 것을 연습하기 위해 만들었다.
이 세 개의 컴포넌트들을 수동으로 빈으로 등록해야한다.
AppV1Config
package cwchoiit.springadvanced.proxy.config;
import cwchoiit.springadvanced.proxy.app.v1.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@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();
}
}
- 왜 이렇게 수동으로 등록해야 하느냐? V1만 있는게 아니라 V2, V3모두 다 존재하고 설정값을 그때 그때 달리해서 빈으로 등록하다가 등록하지 않다가 하기 위함이다.
SpringAdvancedApplication
package cwchoiit.springadvanced;
import cwchoiit.springadvanced.proxy.config.AppV1Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
@Import(AppV1Config.class)
@SpringBootApplication(scanBasePackages = "cwchoiit.springadvanced.proxy.app")
public class SpringAdvancedApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAdvancedApplication.class, args);
}
}
- 그리고, 엔트리 클래스에 이제 @Import 애노테이션으로 수동으로 설정 클래스를 추가한다.
- 왜 이렇게 해야하냐면 지금 보면 scanBasePackages가 해당 패키지를 검사하지 않게 설정했기 때문이다.
인터페이스가 있는 구현 클래스의 프록시 만들기
이제, 원래 애플리케이션으로 돌아와서, 요구사항을 만족하는 프록시를 만들어보자! 요구사항에서 가장 중요한 건 원본 코드를 손대지 않고 로그 추적기를 도입하는 것이었다. 프록시를 도입하면 이 요구사항을 만족시킬 수 있다.
OrderControllerInterfaceProxy
package cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy;
import cwchoiit.springadvanced.proxy.app.v1.OrderControllerV1;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {
private final OrderControllerV1 target;
private final LogTrace trace;
@Override
public String request(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderController.request(String itemId)");
String result = target.request(itemId);
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
@Override
public String noLog() {
return target.noLog();
}
}
- 컨트롤러 프록시 클래스이다. 이 프록시는 OrderControllerV1을 구현한다. 해당 인터페이스를 구현하므로써 이 프록시 클래스는 실제 구현 클래스를 대체할 수 있게 됐다. 클라이언트는 이제 컨트롤러로의 요청을 이 프록시로 하게 된다. 그것이 가능한 이유는 클라이언트는 인터페이스를 의존하기 때문이다.
- 그리고 프록시 객체는 반드시 실제 객체를 주입받는다. 그래야 실제 객체의 로직을 수행할 수 있기 때문에. 그래서 실제 구현 클래스를 받을 필드 target을 선언한다. 이 프록시 클래스는 실제 로직 수행의 시작과 끝 사이의 소요시간을 구해주는 추가 기능이 있다.
- noLog()는 로그를 남기지 않는 메서드이다. 따라서, 그냥 실제 객체를 바로 호출하면 된다.
OrderRepositoryInterfaceProxy
package cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy;
import cwchoiit.springadvanced.proxy.app.v1.OrderRepositoryV1;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class OrderRepositoryInterfaceProxy implements OrderRepositoryV1 {
private final OrderRepositoryV1 target;
private final LogTrace trace;
@Override
public void save(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderRepository.save(String itemId)");
target.save(itemId);
trace.end(status);
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
OrderServiceInterfaceProxy
package com.example.advanced.app.proxy.config.v1_proxy.interface_proxy;
import com.example.advanced.app.proxy.v1.OrderServiceV1;
import com.example.advanced.trace.TraceStatus;
import com.example.advanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
@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.orderItem(itemId);
logTrace.end(status);
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
이제 실제 구현체 대신에 프록시를 스프링 빈으로 등록해야 한다. 그래서 클라이언트는 인터페이스를 의존하고 사용하지만 주입받는 것은 그 인터페이스의 구현체 중 프록시가 주입되도록 해야 한다. 그 설정을 해보자.
InterfaceProxyConfig
package cwchoiit.springadvanced.proxy.config.v1_proxy;
import cwchoiit.springadvanced.proxy.app.v1.*;
import cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy.OrderControllerInterfaceProxy;
import cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy.OrderRepositoryInterfaceProxy;
import cwchoiit.springadvanced.proxy.config.v1_proxy.interface_proxy.OrderServiceInterfaceProxy;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class InterfaceProxyConfig {
@Bean
public OrderControllerV1 orderController(LogTrace trace) {
OrderControllerV1Impl target = new OrderControllerV1Impl(orderService(trace));
return new OrderControllerInterfaceProxy(target, trace);
}
@Bean
public OrderServiceV1 orderService(LogTrace trace) {
OrderServiceV1Impl target = new OrderServiceV1Impl(orderRepository(trace));
return new OrderServiceInterfaceProxy(target, trace);
}
@Bean
public OrderRepositoryV1 orderRepository(LogTrace trace) {
OrderRepositoryV1Impl target = new OrderRepositoryV1Impl();
return new OrderRepositoryInterfaceProxy(target, trace);
}
}
- OrderServiceV1 인터페이스의 구현체를 등록하고자 할 때, 실제 구현 클래스가 아닌 프록시를 반환한다. 프록시는 실제 구현체와 LogTrace 객체가 필요한데, 실제 구현체는 새로운 인스턴스로 전달하고 LogTrace는 다른곳에서 스프링 빈으로 등록되어 있기 때문에 파라미터로 그대로 가져다가 쓸 수 있다. 나머지 컨트롤러와 레포지토리도 마찬가지다. 이게 바로 프록시를 스프링 컨테이너에 등록하는 방법이다.
귀찮지만, V1, V2, V3가 다 있기 때문에 지금 등록하고 사용할 것들만 따로 빈을 등록해줘야 한다. 그래서 아래 코드와 같이 @Import 애노테이션을 사용해서 원하는 것만 설정을 읽게 해주자.
SpringAdvancedApplication
package cwchoiit.springadvanced;
import cwchoiit.springadvanced.proxy.config.AppV2Config;
import cwchoiit.springadvanced.proxy.config.v1_proxy.InterfaceProxyConfig;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
@Import({InterfaceProxyConfig.class, AppV2Config.class, LogTraceConfig.class})
@SpringBootApplication(scanBasePackages = "cwchoiit.springadvanced.proxy.app")
public class SpringAdvancedApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAdvancedApplication.class, args);
}
}
- 이렇게 등록된 프록시 객체로 실제 동작하는지 확인해보자.
### OrderControllerV1.request
GET http://localhost:8080/v1/request?itemId=Good
[a9e1d383] OrderController.request(String itemId)
[a9e1d383] |--->OrderService.orderItem(String itemId)
[a9e1d383] | |--->OrderRepository.save(String itemId)
[a9e1d383] | |<---OrderRepository.save(String itemId) time=1002ms
[a9e1d383] |<---OrderService.orderItem(String itemId) time=1003ms
[a9e1d383] OrderController.request(String itemId) time=1005ms
- 정상적으로 찍히는 모습을 확인할 수 있다. 이게 어떤 식으로 찍히냐가 중요한 게 아니고 프록시 패턴을 이용해서 추가적인 기능을 원래 구현체에 어떠한 변화도 주지 않고 넣어줄 수 있다는 것이다. 그리고 지금 알아본 것은 인터페이스가 있는 구현 클래스에 적용한 버전이다.
- 인터페이스가 있는 구현 클래스를 프록시로 만들려면 같은 인터페이스를 구현한 프록시가 있으면 된다. 그러면 클라이언트는 인터페이스에만 의존하기 때문에 어떤 구현체가 오더라도 아무런 상관이 없다. 즉, 클라이언트 코드에 전혀 손을 대지 않고도 추가적인 기능을 제공할 수 있다는 것. 이게 핵심이다!
V1 프록시 (인터페이스가 있는 구현 클래스에 프록시 적용) 정리
- 기존에는 스프링 빈이 orderControllerV1Impl, orderServiceV1Impl같은 실제 객체를 반환했다. 하지만 이제는 프록시를 반환해야 한다. 그렇기에 프록시를 만들었고 프록시를 실제 객체 대신 빈으로 등록한다. 실제 객체는 스프링 빈으로 등록하지 않는다.
- 프록시는 내부에 실제 객체를 참조하고 있다. 예를 들어, OrderServiceInterfaceProxy는 내부에 실제 대상 객체인 OrderServiceV1Impl을 가지고 있다.
- 스프링 빈으로 실제 객체 대신 프록시 객체를 등록했기 때문에 앞으로 스프링 빈을 주입 받으면 실제 객체 대신에 프록시 객체가 주입된다.
- 실제 객체가 스프링 빈으로 등록되지 않는다고 해서 사라지는 것이 아니다. 프록시 객체가 실제 객체를 참조하고 있기 때문에 프록시를 통해서 실제 객체를 호출할 수 있다. 쉽게 이야기해서 프록시 객체 안에 실제 객체가 있는 것이다. 실제 객체는 스프링 빈으로는 등록되지 않지만 자바 힙 메모리에는 버젓이 살아있다.
- 프록시를 적용하기 전에는 당연히 스프링 빈으로 등록되는 것은 실제 객체이다.
- InterfaceProxyConfig를 통해 프록시를 적용하면, 스프링 컨테이너에 위 그림과 같이 프록시 객체가 등록된다.
- 이제 실제 객체는 스프링 컨테이너와는 상관이 없다. 실제 객체는 프록시 객체를 통해서 참조될 뿐이다.
- 프록시 객체는 스프링 컨테이너가 관리하고 자바 힙 메모리에도 올라간다. 반면에 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않는다.
다음은 인터페이스가 없는 구체 클래스에 적용해 보자. 이는 어떻게 가능할까? 다형성이다.
인터페이스가 없는 구체 클래스
이제 인터페이스가 존재하지 않는 구체클래스에 프록시를 적용해보자. 프록시를 어떻게 적용할 수 있을까? 프록시가 구체 클래스를 상속받으면 된다.
OrderControllerV2
package cwchoiit.springadvanced.proxy.app.v2;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class OrderControllerV2 {
private final OrderServiceV2 orderServiceV2;
@GetMapping("/v2/request")
public String request(String itemId) {
orderServiceV2.orderItem(itemId);
return "ok";
}
@GetMapping("/v2/no-log")
public String noLog() {
return "noLog";
}
}
OrderRepositoryV2
package cwchoiit.springadvanced.proxy.app.v2;
public class OrderRepositoryV2 {
public void save(String itemId) {
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
}
private void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
OrderServiceV2
package cwchoiit.springadvanced.proxy.app.v2;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class OrderServiceV2 {
private final OrderRepositoryV2 orderRepositoryV2;
public void orderItem(String itemId) {
orderRepositoryV2.save(itemId);
}
}
AppV2Config
package cwchoiit.springadvanced.proxy.config;
import cwchoiit.springadvanced.proxy.app.v2.OrderControllerV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderRepositoryV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderServiceV2;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AppV2Config {
@Bean
public OrderControllerV2 orderControllerV2() {
return new OrderControllerV2(orderServiceV2());
}
@Bean
public OrderServiceV2 orderServiceV2() {
return new OrderServiceV2(orderRepositoryV2());
}
@Bean
public OrderRepositoryV2 orderRepositoryV2() {
return new OrderRepositoryV2();
}
}
SpringAdvancedApplication
package cwchoiit.springadvanced;
import cwchoiit.springadvanced.proxy.config.AppV1Config;
import cwchoiit.springadvanced.proxy.config.AppV2Config;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Import;
@Import({AppV1Config.class, AppV2Config.class})
@SpringBootApplication(scanBasePackages = "cwchoiit.springadvanced.proxy.app")
public class SpringAdvancedApplication {
public static void main(String[] args) {
SpringApplication.run(SpringAdvancedApplication.class, args);
}
}
인터페이스가 없는 구체 클래스로 프록시 만들기
OrderRepositoryConcreteProxy
package cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy;
import cwchoiit.springadvanced.proxy.app.v2.OrderRepositoryV2;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class OrderRepositoryConcreteProxy extends OrderRepositoryV2 {
private final OrderRepositoryV2 target;
private final LogTrace trace;
@Override
public void save(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderRepository.save(String itemId)");
target.save(itemId);
trace.end(status);
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
- 이 프록시 클래스는 구체 클래스를 상속받는다. 이렇게 되면 다형성을 이용해서 클라이언트가 역시나 어떠한 코드 변경 없이 기존 코드 그대로 레포지토리를 주입받을 수 있게 된다.
- 그리고 프록시의 기본인 실제 객체를 주입받는다. 프록시가 제공할 추가 기능인 LogTrace 클래스도 주입받는다.
- 그리고 메서드를 오버라이딩해서 프록시 기능 + 실제 객체의 원래 기능이 합쳐진 새로운 메서드가 만들어진다.
OrderServiceConcreteProxy
package cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy;
import cwchoiit.springadvanced.proxy.app.v2.OrderServiceV2;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
public class OrderServiceConcreteProxy extends OrderServiceV2 {
private final OrderServiceV2 target;
private final LogTrace trace;
public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace trace) {
super(null);
this.target = target;
this.trace = trace;
}
@Override
public void orderItem(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderService.orderItem(String itemId)");
target.orderItem(itemId);
trace.end(status);
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
- 마찬가지로 프록시 클래스는 구체 클래스를 상속을 받는다. 여기서 한 가지 주의할 점이 있는데 구체 클래스인 OrderServiceV2는 기본 생성자가 존재하지 않고 OrderRepository를 주입받는 생성자밖에 없다. 그래서 상속받을 때 super()를 사용할 수가 없다
- super()는 슈퍼 클래스의 기본 생성자를 호출하는 코드이고 상속받을 땐 반드시 슈퍼 클래스의 생성자를 호출하기 때문이다. 이 super()는 생략이 가능하기 때문에 없으면 기본 생성자를 무조건 호출한다. 그럼 상속받을 때 슈퍼 클래스의 생성자를 호출해야 하는데 이 프록시 클래스는 OrderRepository가 없다. 대신 실제 객체를 주입하면서 그 실제 구체 클래스가 가진 OrderRepository를 사용하면 된다. 그래서 super(null)을 작성해 준다. 나머지는 모두 동일하다.
OrderControllerConcreteProxy
package cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy;
import cwchoiit.springadvanced.proxy.app.v2.OrderControllerV2;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
public class OrderControllerConcreteProxy extends OrderControllerV2 {
private final OrderControllerV2 target;
private final LogTrace trace;
public OrderControllerConcreteProxy(OrderControllerV2 target, LogTrace trace) {
super(null);
this.target = target;
this.trace = trace;
}
@Override
public String request(String itemId) {
TraceStatus status = null;
try {
status = trace.begin("OrderController.request(String itemId)");
String result = target.request(itemId);
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
@Override
public String noLog() {
return target.noLog();
}
}
- OrderControllerConcreteProxy는 위 OrderServiceConcreteProxy를 설명할 때와 동일하다.
이렇게 구체 클래스를 상속받은 프록시를 만들면 끝난다. 마찬가지로 클라이언트 코드에는 어떠한 변경사항도 없이 프록시가 제공하는 추가 기능을 사용할 수 있게 된다. 이제 이 프록시를 빈으로 등록하자.
ConcreteProxyConfig
package cwchoiit.springadvanced.proxy.config.v2_proxy;
import cwchoiit.springadvanced.proxy.app.v2.OrderControllerV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderRepositoryV2;
import cwchoiit.springadvanced.proxy.app.v2.OrderServiceV2;
import cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy.OrderControllerConcreteProxy;
import cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy.OrderRepositoryConcreteProxy;
import cwchoiit.springadvanced.proxy.config.v2_proxy.concrete_proxy.OrderServiceConcreteProxy;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ConcreteProxyConfig {
@Bean
public OrderRepositoryV2 orderRepository(LogTrace trace) {
OrderRepositoryV2 target = new OrderRepositoryV2();
return new OrderRepositoryConcreteProxy(target, trace);
}
@Bean
public OrderServiceV2 orderService(LogTrace trace) {
OrderServiceV2 target = new OrderServiceV2(orderRepository(trace));
return new OrderServiceConcreteProxy(target, trace);
}
@Bean
public OrderControllerV2 orderControllerV2(LogTrace trace) {
OrderControllerV2 target = new OrderControllerV2(orderService(trace));
return new OrderControllerConcreteProxy(target, trace);
}
}
결과
[f6be055c] OrderController.request(String itemId)
[f6be055c] |--->OrderService.orderItem(String itemId)
[f6be055c] | |--->OrderRepository.save(String itemId)
[f6be055c] | |<---OrderRepository.save(String itemId) time=1002ms
[f6be055c] |<---OrderService.orderItem(String itemId) time=1002ms
[f6be055c] OrderController.request(String itemId) time=1003ms
인터페이스 기반 프록시와 클래스 기반 프록시
프록시를 사용한 덕분에 원본 코드를 전혀 손대지 않고 V1, V2 애플리케이션에 LogTrace 기능을 적용할 수 있었다.
인터페이스 기반 프록시 vs 클래스 기반 프록시
- 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
- 클래스 기반 프록시는 해당 클래스에만 적용할 수 있다. 인터페이스 기반 프록시는 인터페이스만 같으면 모든 곳에 적용할 수 있다.
- 클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
- 부모 클래스의 생성자를 호출해야 한다.
- 클래스에 final 키워드가 붙으면 상속이 불가능하다 (다른 말로, 프록시를 만드는 게 불가능)
- 메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. (다른 말로, 프록시를 만들 순 있어도 추가 기능을 부여 못함)
이렇게 보면, 인터페이스 기반의 프록시가 더 좋아보인다. 맞다. 인터페이스 기반의 프록시는 상속이라는 제약에서 자유롭다. 프로그래밍 관점에서도 인터페이스를 사용하는 것이 역할과 구현을 명확하게 나누기 때문에 더 좋다. 인터페이스 기반 프록시의 단점은 인터페이스가 필요하다는 그 자체이다. 인터페이스가 없으면 인터페이스 기반 프록시를 만들 수 없다.
좋은 설계 얘기
이론적으로는 모든 객체에 인터페이스를 도입해서 역할과 구현을 나누는 것이 좋다. 이렇게 하면 역할과 구현을 나누어서 구현체를 매우 편리하게 변경할 수 있다. 하지만, 실제로는 구현을 거의 변경할 일이 없는 클래스도 많다. 인터페이스를 도입하는 것은 구현을 변경할 가능성이 있을 때 효과적인데, 구현을 변경할 가능성이 거의 없는 코드에 무작정 인터페이스를 사용하는 것은 번거롭고 그렇게 실용적이지 않다. 이런곳에는 실용적인 관점에서 인터페이스를 사용하지 않고 구체 클래스를 바로 사용하는 것이 좋다 생각한다.
결론
실무에서는 프록시를 적용할 때 V1처럼 인터페이스도 있고, V2처럼 구체 클래스도 있다. 따라서 2가지 상황을 모두 대응할 수 있어야 한다.
너무 많은 프록시 클래스
지금까지 프록시를 사용해서 기존 코드를 변경하지 않고, 로그 추적기라는 부가 기능을 적용할 수 있었다. 그런데 문제는 프록시 클래스를 너무 많이 만들어야 한다는 점이다. 잘보면 프록시 클래스가 하는 일은 LogTrace를 사용하는 것인데 그 로직이 모두 똑같다. 대상 클래스만 다를 뿐이다. 만약, 적용해야 하는 대상 클래스가 100개라면 프록시 클래스도 100개를 만들어야 한다. 프록시 클래스를 하나만 만들어서 모든 곳에 적용하는 방법은 없을까? 이 방법이 바로 JDK 동적 프록시이다.
'Spring Advanced' 카테고리의 다른 글
Advisor, Advice, Pointcut (0) | 2023.12.15 |
---|---|
Proxy/Decorator Pattern 2 (JDK 동적 프록시) (0) | 2023.12.14 |
Template Callback Pattern (0) | 2023.12.12 |
Strategy Pattern (2) | 2023.12.12 |
Template Method Pattern (2) | 2023.12.12 |