거대한 프로젝트에서 전체 코드 수가 너무 많고 클래스 수도 백개 이상일 때 로그 추적기를 만들어봄. 애플리케이션이 개발되고 시간이 지나면서 모니터링과 운영이 중요해지는 단계가 온다. 그래서 어떤 부분에서 병목이 발생하고 예외가 발생하는지를 로그를 통해 확인하는 것이 중요해지고 있다.
로그를 미리 남겨두면 손쉽게 찾을 수 있으니 이 부분을 개선하고 자동화한다.
모든 로직에 직접 로그를 남겨도 되지만 트랜잭션 ID와 깊이를 표현하는 방법으로 기존 정보를 이어 받기 위해서는 ??하는 것이 좋다.
⁂ id + level 을 TraceId라고 해놓음.
UUID를 반환하는 createId() 메서드, 다음 Id를 편하게 만들어주는 메서드, 이전 Id로 id는 같은지 레벨 하나 줄이는 메서드, 첫 번째 레벨인지를 판단하는 메서드를 만들어줌. (+ getter, setter)
☄ 로그 상태 정보를 가지고 있는 TraceStatus 도 만들어줌.
@Slf4j
@Component
public class HelloTraceV1 {
private static final String START_PREFIX = "-->";
private static final String COMPLETE_PREFIX = "<--";
private static final String EX_PREFIX = "<X-";
public TraceStatus begin(String message) {
TraceId traceId = new TraceId();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
return new TraceStatus(traceId, startTimeMs, message);
}
public void end(TraceStatus status) {
complete(status, null);
}
public void exception(TraceStatus status, Exception e) {
complete(status, e);
}
private void complete(TraceStatus status, Exception e) {
// 종료 시간.
Long stopTimeMs = System.currentTimeMillis();
// 총 걸린 시간.
long resultTimeMs = stopTimeMs - status.getStartTimeMs();
TraceId traceId = status.getTraceId();
if (e == null) { //예외 없으면
log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs);
} else {
log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs, e.toString());
}
}
//level에 따라 spacebar 추가하고 화살표.
//level=0 이면
//level=1 이면 |-->
//level=2 이면 | |-->
//예외 발생
//level=1 이면 ex |<X-
//level=2 이면 ex | |<X-
private static String addSpace(String prefix, int level) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < level; i++) {
sb.append((i == level - 1) ? "|" + prefix : "| ");
}
return sb.toString();
}
}
테스트 결과
정상 종료
[48f13443] hello
[48f13443] hello time=12ms
예외 발생
[2256a079] hello
[2256a079] hello time=9ms ex=java.lang.IllegalStateException
테스트는 자동으로 검증하는 과정이 필요하다. 그래서 위 테스트 결과는 검증하는 과정이 없고 결과를 콘솔로 직접 확인해야 하기 때문에 온전한 테스트는 아니다.
OrderControllerV1
@RestController //@Controller + @ResponseBody
@RequiredArgsConstructor
public class OrderControllerV1 {
private final OrderServiceV1 orderServiceV1;
private final HelloTraceV1 trace;
@GetMapping("/v1/request")
public String request(String itemId) {
TraceStatus status = null;
//예외 발생했을 때 요청은 가지만 exception 호출이 오지 않는다.
//예외가 터져도 로그를 출력해주어야 한다.
try{
status = trace.begin("OrderController.request() 시작");
orderServiceV1.orderItem(itemId);
trace.end(status);
return "ok";
}catch(Exception e){
//status 를 받기 위해서 try에 있는 status를 밖으로 빼줌.
trace.exception(status, e);
// 이렇게만 하면 예외를 먹어버리고 밖으로 예외가 나가지 않음
throw e; //예외를 꼭 다시 던져주기.
}
}
}
trace.exception()으로 예외까지 처리해야 하므로 지저분한 try~catch 가 추가된다.
★ OrderControllerV1, OrderServiceV1, OrderRespositoryV1 에도 try~catch 추가하고 기존 핵심 로직 그대로 해서 수정.
(트랜잭션 ID 넘겨서 동기화 하는 것과 레벨 작업은 아직 안 함.)
이제 메서드 호출의 깊이를 표현하고 같은 HTTP 요청이면 같은 트랜잭션 ID를 남겨야 한다.
HelloTraceV2 로 V1에서 이거 메서드 하나를 추가함.
public TraceStatus beginSync(TraceId beforeTraceId, String message) {
TraceId nextId = beforeTraceId.createNextId();
Long startTimeMs = System.currentTimeMillis();
log.info("[{}] {}{}", nextId.getId(), addSpace(START_PREFIX, nextId.getLevel()), message);
return new TraceStatus(nextId, startTimeMs, message);
}
createNextId() 사용했으니 트랜잭션ID를 유지하고 level을 통해 메서드 호출의 깊이를 표현하게 된다.
beginSync() 메서드에서 createNextId() 적용하여 트랜잭션 ID 유지하고 깊이 표한하기위해 파라미터로 TraceId traceId 추가.
//여기 status.getTraceId() 해주고 save와 orderItem 메서드에 파라미터 추가
orderServiceV1.orderItem(status.getTraceId(), itemId);
public void save(TraceId traceId, String itemId)
public void orderItem(TraceId traceId, String itemId)
beginSync() 실행하고 traceId를 생성하면서 트랜잭션 ID는 유지하고 level은 1 증가한다.
HTTP 요청을 구분하고 깊이를 표현하기 위해 TraceId를 파라미터로 넘기는 작업을 했는데 번거로움이 있기 때문에 다른 대안이 필요하다.
traceId 동기화. -> 파라미터로 넘기듯이 traceId를 보관할 곳이 필요하다. -> traceIdHolder라 해줌. 그리고 syncTraceId()를 begin() 메서드에서 호출
private TraceId traceIdHolder;
private void syncTraceId() {
if (traceIdHolder == null) { //traceId 가 없으면
traceIdHolder = new TraceId(); // 새로운 traceId를 넣어줌
}else{
traceIdHolder = traceIdHolder.createNextId();
}
}
정상 종료되어 로그가 끝나면 traceIdHoder를 파괴. -> 만약 최초 호출(level=0)이면 내부에서 관리하는 traceId를 제거한다.
releaseTraceId()는 로그가 끝나는 메서드인 complete() 메서드에서 호출.
메서드를 추가로 호출할 때는 level이 하나 증가해야 하지만 메서드 호출이 끝나면 level이 하나 감소해야 한다.
private void releaseTraceId() {
if (traceIdHolder.isFirstLevel()) {
traceIdHolder = null; // 로그가 끝났으므로 파괴.
}else{
//이전 ID 넘겨줌.
traceIdHolder = traceIdHolder.createPreviousId();
}
}
결과 -> 예외 발생 시
[e604c6ea] hello1
[e604c6ea] |-->hello2
[e604c6ea] |<X-hello2 time=1ms ex=java.lang.IllegalStateException
FieldLogTrace를 애플리케이션에 적용. -> 파라미터 있던 것들 모두 없앰. 그리고 begin()으로 바꿈.
traceIdHolder 필드를 사용한 덕분에 파라미터 추가가 없는 로그 추적기가 완성되었는데 그래도 동시성 문제가 있다.
save() 메서드에서 저장할 때 1초 걸린다고 가정을 했다. 근데 1초 안에 localhost:8080을 2번 호출을 하게 되면 트랜잭션 ID가 모두 동일하고 level은 하나의 쓰레드가 level 3부터 시작해버린다
[nio-8080-exec-1] [8bc8790d] OrderController.request() 시작
[nio-8080-exec-1] [8bc8790d] |-->OrderService.orderItem() 시작
[nio-8080-exec-1] [8bc8790d] | |-->OrderRepository.save() 시작
[nio-8080-exec-3] [8bc8790d] | | |-->OrderController.request() 시작
[nio-8080-exec-3] [8bc8790d] | | | |-->OrderService.orderItem() 시작
[nio-8080-exec-3] [8bc8790d] | | | | |-->OrderRepository.save() 시작
[nio-8080-exec-4] [8bc8790d] | | | | | |-->OrderController.request() 시작
(FieldLogTrace) 싱글톤으로 등록된 스프링 빈이므로 객체의 인스턴스가 애플리케이션에 딱 하나 존재해야 한다. 하나만 있는 인스턴스의 (traceIdHolder) 필드를 여러 쓰레드가 동시에 접근하기 때문에 문제가 발생한다.
(괄호는 예시)
즉 3번 쓰레드와 4번 쓰레드가 있다고 가정한다면 하나의 필드에 동시에 들어와서 3번 쓰레드 요청한 후 4번 쓰레드 이어서 요청한 후에 3번 요청이 끝난 시점에 응답하고 4번도 요청이 끝난 시점에 응답이 가버린다.
[nio-8080-exec-3] [aaaaaaaa] OrderController.request()
[nio-8080-exec-3] [aaaaaaaa] |-->OrderService.orderItem()
[nio-8080-exec-3] [aaaaaaaa] | |-->OrderRepository.save()
[nio-8080-exec-4] [aaaaaaaa] | | -->OrderController.request()
[nio-8080-exec-4] [aaaaaaaa] | | | |-->OrderService.orderItem()
[nio-8080-exec-4] [aaaaaaaa] | | | | |-->OrderRepository.save()
[nio-8080-exec-3] [aaaaaaaa] | |-->OrderRepository.save() time=1005ms
[nio-8080-exec-3] [aaaaaaaa] |<--OrderService.orderItem() time=1005ms
[nio-8080-exec-3] [aaaaaaaa] OrderController.request() time=1005ms
[nio-8080-exec-4] [aaaaaaaa] | | | |<--OrderRepository.save()
time=1005ms
...
[nio-8080-exec-4] [aaaaaaaa] | |<--OrderService.orderItem() time=1005ms
[nio-8080-exec-4] [aaaaaaaa] | |<--OrderController.request() time=1005ms
올바른 요청.
[nio-8080-exec-3] [52808e46] OrderController.request()
[nio-8080-exec-3] [52808e46] |-->OrderService.orderItem()
[nio-8080-exec-3] [52808e46] | |-->OrderRepository.save()
[nio-8080-exec-4] [4568423c] OrderController.request()
[nio-8080-exec-4] [4568423c] |-->OrderService.orderItem()
[nio-8080-exec-4] [4568423c] | |-->OrderRepository.save()
[nio-8080-exec-3] [52808e46] | |<--OrderRepository.save() time=1001ms
[nio-8080-exec-3] [52808e46] |<--OrderService.orderItem() time=1001ms
[nio-8080-exec-3] [52808e46] OrderController.request() time=1003ms
[nio-8080-exec-4] [4568423c] | |<--OrderRepository.save() time=1000ms
[nio-8080-exec-4] [4568423c] |<--OrderService.orderItem() time=1001ms
[nio-8080-exec-4] [4568423c] OrderController.request() time=1001ms
참고 -> 스프링 버전 2.7.6으로 해야 테스트에서 lombok 사용하기 위한 라이브러리 설치가 가능함.
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
동시성 문제 예시.
@Test
void field() {
log.info("main start");
// 쓰레드 2개가 동시성 문제가 생기므로 쓰레드 2개를 만든다.
//Runnable : 쓰레드 실행 로직.
// Runnable userA = new Runnable(){
// @Override
// public void run() {
// fieldService.logic("userA");
// }
// } 이 로직은 밑에 로직과 같다. 줄여쓴 것일 뿐.
Runnable userA = () -> {
fieldService.logic("userA");
};
Runnable userB = () -> {
fieldService.logic("userB");
};
//threadA는 userA라는 로직을 갖고 있고 threadB는 userB라는 로직을 갖고 있음.
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
// A 시작하고 2초 대기.
// A 로직에서 1초 쉬므로 여유를 두고 B를 실행하기 위함.
//sleep(2000); //동시성 발생 X
// A가 끝나기 전에 B가 시작된다.
sleep(100); //동시성 발생 X, 1000으로 해도 동시성 발생.
threadB.start();
sleep(3000); //메인 쓰레드 종료 대기
log.info("main exit");
}
private void sleep(int millis) {
try{
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
✦ thread-A가 nameStore에 userA를 저장. -> thread-B가 nameStore에 userB를 0.1초 후 저장. -> 기존에 nameStore에 보관되어 있는 userA 값은 제거되고 userB 값이 저장됨. -> thread-A가 userA 값을 꺼내려는데 nameStore에 userB 값만 있음. -> 결국 thread-A는 userA의 값이 아닌 userB의 값을 조회하게 되고 thread-B도 userB의 값을 조회하게 됨.
즉 A 실행이 1초 -> 그리고 A가 끝나기 전에 0.1초 만에 B가 실행 -> 동시성 문제 발생 O
[thread-A] 저장 name=userA -> nameStore=null
[thread-B] 저장 name=userB -> nameStore=userA
[thread-A] 조회 nameStore=userB
[thread-B] 조회 nameStore=userB
[thread-A] 저장 name=userA -> nameStore=null
[thread-A] 조회 nameStore=userA
[thread-B] 저장 name=userB -> nameStore=userA
[thread-B] 조회 nameStore=userB
이렇게 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라고 한다. 이런 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않지만 트래픽이 점점 많아질수록 자주 발생한다. 특히 스프링 빈처럼 싱글톤 객체의 필드를 변경하여 사용할 때 이러한 동시성 문제를 조심해야 한다.
동시성 문제는 지역 변수에서는 발생하지 않는다. 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당된다. 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤에서 자주 발생) 또는 static 같은 공용 필드에 접근할 때 발생한다. 동시성 문제는 어디선가 값을 변경하기 때문에 발생하므로 값을 읽기만 하면 발생하지 않는다. ✫ 그래서 쓰레드 로컬이라는 방식을 사용한다.
각 쓰레드마다 별도의 내부 저장소를 제공하여 같은 인스턴스의 쓰레드 로컬 필드에 접근을 해도 문제가 없다.
위 예제처럼 기존과 로직은 같은데 nameStore 필드가 일반 String 타입에서 ThreadLocal을 사용하도록 변경함.
ThreadLocal 사용법
✦ 값 저장 : ThreadLocal.set()
✦ 값 조회 : ThreadLocal.get()
✦ 값 제거 : ThreadLocal.remove()
private ThreadLocal<String> nameStore = new ThreadLocal<>();
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore.get());
nameStore.set(name); //set으로 ThreadLocal에 저장.
sleep(1000);
log.info("조회 nameStore={}", nameStore.get());
return nameStore.get(); // 꺼낼 때는 get으로
}
결과 -> A 끝나기 전에 B가 실행되지만 별도의 저장도가 있어 A와 B의 nameStore에 null인 상태로 저장이 된다. 별도 저장되었기 때문에 제대로 값을 조회할 수 있다.
[thread-A] 저장 name=userA -> nameStore=null
[thread-B] 저장 name=userB -> nameStore=null
[thread-A] 조회 nameStore=userA
[thread-B] 조회 nameStore=userB
주의 -> 해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove()를 호출해서 쓰레드 로컬에 저장된 값을 반드시 제거해주어야 한다. 그렇지 않으면 thread-B가 thread-A의 데이터를 확인하게 되는 심각한 문제가 발생한다. 예제에서는 traceId.isFirstLevel() 메서드에서 level이 0인 경우에 remove()를 호출해서 쓰레드 로컬에 저장된 값을 제거해준다.
로그 추적기를 넣었더니 핵심 로직보다 로그 추적기 코드가 훨씬 더 많다. 클래스가 수백개면 모든 클래스마다 로그 추적기를 달게 되어 번거롭다. 그래서 효율적으로 처리하기 위한 방법으로 로그 추적기를 보면 아래와 같이 핵심 로직 한 줄만 추가해줄 뿐 나머지는 동일한 구조가 보인다.
TraceStatus status = null;
//예외 발생했을 때 요청은 가지만 exception 호출이 오지 않는다.
//예외가 터져도 로그를 출력해주어야 한다.
try{
status = trace.begin("OrderController.request() 시작");
//핵심 로직
trace.end(status);
return "ok";
}catch(Exception e){
trace.exception(status, e);
throw e; //예외를 꼭 다시 던져주기.
}
그래서 변하는 부분(핵심 로직)과 변하지 않은 부분(로그 추적기)을 분리한다. 이 둘을 분리해서 모듈화한다. 템플릿 메서드 패턴이 이 문제를 해결해주는 디자인 패턴이다.
현 예제처럼 SubClassLogic1,2를 만들어서 핵심 로직 같은 변하는 부분을 상속을 이용해 자식 클래스로 만들어서 해결할 수 있다. 그런데 클래스를 계속 만들어야 한다는 단점이 있으므로 이를 보완하기 위해 익명 내부 클래스를 사용한다.
익명 내부 클래스를 사용하면 객체 인스턴스를 생성하면서 동시에 생성할 클래스를 상속 받은 자식 클래스를 정의할 수 있다.
Controller, Service, Repository의 이 로그 추적기를 사용한 코드를 추상 템플릿에서 사용하도록 바꾸면 된다. (try~catch -> AbstractTemplate 으로 수정)
그러면 변경해야 하는 부분만 바꾸고 나머지는 변경없이 둘 수 있다. 예시로 로그를 남기는 로직을 변경해야 한다면 AbstractTemplate 코드만 변경하면 된다. 만약 템플릿 메서드 패턴 사용없이 로그를 남기는 과정을 한다면 모든 클래스의 이 로직을 찾아서 모두 변경해주어야 하는 번거로움이 있다.
private final OrderServiceV3 orderServiceV3;
private final LogTrace trace;
@GetMapping("/v3/request")
public String request(String itemId) {
TraceStatus status = null;
//예외 발생했을 때 요청은 가지만 exception 호출이 오지 않는다.
//예외가 터져도 로그를 출력해주어야 한다.
try{
status = trace.begin("OrderController.request() 시작");
orderServiceV3.orderItem(itemId);
trace.end(status);
return "ok";
}catch(Exception e){
//status 를 받기 위해서 try에 있는 status를 밖으로 빼줌.
trace.exception(status, e);
// 이렇게만 하면 예외를 먹어버리고 밖으로 예외가 나가지 않음
throw e; //예외를 꼭 다시 던져주기.
}
}
@GetMapping("/v4/request")
public String request(String itemId) {
AbstractTemplate<String> template = new AbstractTemplate<>(trace) {
@Override
protected String call() {
orderServiceV4.orderItem(itemId);
return "ok";
}
};
return template.execute("OrderController.request()");
}
템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에서 오는 단점들을 그대로 안고간다. 특히 자식
클래스가 부모 클래스와 컴파일 시점에 강하게 결합되는 의존관계에 대한 문제가 있다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는다는 것이다. 자식 클래스 입장에서는 부모 클래스의 기능을 전혀 사용하지 않는데 부모 클래스를 알아야한다는 것은 좋은 설계가 아니다. 그리고 이런 잘못된 의존관계 때문에 부모 클래스를 수정하면, 자식 클래스에도 영향을 줄 수 있다.
그래서 템플릿 메서드 패턴과 비슷한 역할을 하면서 상속의 단점을 제거할 수 있는 전략 패턴을 사용한다.
✠ 템플릿 메서드 패턴은 부모 클래스에 변하지 않는 템플릿을 두고, 변하는 부분을 자식 클래스에 두어서 상속으로 문제를 해결했지만 전략 패턴은 변하지 않는 부분을 Context로 두고 변하는 부분을 Strategy라는 인터페이스로 두어서 해당 인터페이스를 구현하여 문제를 해결한다.
컨텍스트는 크게 변하지 않지만 그 문맥 속에서 strategy를 통해 일부 전략(핵심 로직)이 변경된다.
Context는 내부에 Strategy strategy 필드를 가지고 있고 이 필드에 변하는 부분인 Strategy의 구현체를 주입하면 된다. 전략 패턴의 핵심은 Context 는 Strategy 인터페이스에만 의존한다는 점이다. 덕분에 Strategy 의 구현체를 변경하거나 새로 만들어도 Context 코드에는 영향을 주지 않는다.
@Slf4j
public class ContextV1 {
//Context는 변하지 않는 로직으로 템플릿 역할을 하는 코드.
private Strategy strategy;
public ContextV1(Strategy strategy) {
this.strategy = strategy;
}
// 기본적인 큰 문맥에 대한 로직.
public void execute() {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행 (변하는 부분)
strategy.call();
//비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}", resultTime);
}
}
public interface Strategy {
//변하는 부분
void call();
}
ContextV1Test 테스트에서 일반적, 익명 내부 클래스, 좀 더 편하게 사용, 람다를 사용하여 전략 패턴 테스트.
//람다 사용
@Test
void strategyV4() {
//컨텍스트 만듦
ContextV1 context1 = new ContextV1(() -> log.info("비즈니스 로직1 실행"));
context1.execute();
// 두 번째
ContextV1 context2 = new ContextV1(() -> log.info("비즈니스 로직2 실행"));
context2.execute();
}
contextV1처럼 private Strategy strategy; 하여 필드에 전략을 보관하는 방식을 사용할 수도 있지만 필드를 없애고 public void execute(Strategy strategy) 처럼 전략을 파라미터로 전달 받는 방식도 있다.
Context를 실행할 때마다 전략을 인수로 전달하여 클라이언트는 Context를 실행하는 시점에 원하는 Strategy를 전달할 수 있다. 따라서 유연하게 원하는 전략을 변경할 수 있다.
@Slf4j
public class ContextV2 {
// 기본적인 큰 문맥에 대한 로직.
public void execute(Strategy strategy) {
long startTime = System.currentTimeMillis();
//비즈니스 로직 실행 (변하는 부분)
strategy.call();
//비즈니스 로직 종료
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}", resultTime);
}
}
//람다로
@Test
void strategyV3() {
ContextV2 context = new ContextV2();
context.execute(() -> log.info("비즈니스 로직1 실행"));
context.execute(() -> log.info("비즈니스 로직2 실행"));
}
ContextV1은 선 조립, 후 실행 방법에 적합하다. 선 조립, 후 실행 방법은 ~~ 이다. Context를 실행하는 시점에는 이미 조립이 끝났기 때문에 전략을 신경쓰지 않고 단순히 실행만 하면 된다.
ContextV2는 실행할 때마다 전략을 유연하게 변경할 수 있다. 그렇지만 실행할 때마다 전략을 계속 지정해주어야 한다.
(결국에는 상황에 맞게 사용)
ContextV2 는 변하지 않는 템플릿 역할을 한다. 그리고 변하는 부분은 파라미터로 넘어온 Strategy 의 코드를 실행해서 처리한다. 이렇게 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백이라 한다. (ContextV2와 같이 파라미터로 전달받는 방식이 템플릿 콜백 패턴이다.)
즉 callback 은 코드가 호출은 되는데 코드를 넘겨준 곳의 뒤에서 실행된다는 뜻이다.
스프링에서는 JdbcTemplate , RestTemplate , TransactionTemplate , RedisTemplate 처럼 다양한 템플릿 콜백 패턴이 사용된다. 스프링에서 이름에 XxxTemplate 가 있다면 템플릿 콜백 패턴으로 만들어져있다고 생각하면 된다.
전략 패턴의 message 와 callback 파라미터를 넘겨주면 됨.
@RestController //@Controller + @ResponseBody
public class OrderControllerV5 {
private final OrderServiceV5 orderServiceV5;
private final TraceTemplate template;
@Autowired //생성자 하나면 생략 가능.
public OrderControllerV5(OrderServiceV5 orderServiceV5, LogTrace trace) {
this.orderServiceV5 = orderServiceV5;
this.template = new TraceTemplate(trace);
}
@GetMapping("/v5/request")
public String request(String itemId) {
return template.execute("OrderController.request()", new TraceCallback<>() {
@Override
public String call() {
orderServiceV5.orderItem(itemId);
return "ok";
}
});
}
}
익명 내부 클래스에서 람다로
public void orderItem(String itemId) {
template.execute("OrderService.orderItem()", () -> {
orderRepositoryV5.save(itemId);
return null;
});
}
Controller, Repository, Service를 모두 인터페이스를 도입하고 구현체를 만들었다. 그런데 Controller 클래스는 @Controller 애노테이션을 적용하지 않고 @RequestMapping을 적용했다. (스프링은 @Controller 또는 @RequestMapping이 있어야 스프링 컨트롤러로 인식하기 한다.)
그리고 빈으로 수동 등록 함.
@Import(AppV1Config.class) // configuration 한 것을 import 하여 클래스를 스프링 빈으로 동록한다.
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {
public static void main(String[] args) {
SpringApplication.run(ProxyApplication.class, args);
}
}
@ComponentScan의 기능과 같다. 컴포넌트 스캔을 시작할 위치를 지정한다.
이 값을 설정하면 해당 패키지와 그 하위 패키지를 컴포넌트 스캔한다.
이 값을 사용하지 않으면 ProxyApplication에 있는 패키지와 그 하위 해키지를 스캔한다.
지금 예제는 config 패키지를 계속 수정해 나갈 것이기 때문에 스캔하지 않고 app패키지와 그 하위 패키지들을 스캔하도록 설정했다.
수동 등록
@Configuration // 빈으로 수동 등록
public class AppV1Config {
@Bean
public OrderControllerV1 orderControllerV1() {
return new OrderControllerV1Impl(orderServiceV1());
}
@Bean
public OrderServiceV1 orderServiceV1() {
return new OrderServiceV1Impl(orderRepostioryV1());
}
@Bean
public OrderRepostioryV1 orderRepostioryV1() {
return new OrderRepositoryV1Impl();
}
}
인터페이스 없이 구현체로만 스프링 빈으로 수동 등록
이것만 수정하고 @Import
@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();
}
}
컴포넌트 스캔으로 스프링 빈을 자동 등록 -> @Controller, @Repository, @Service 애노테이션 사용.
클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 예시로 내가 직접 마트에서 장을 볼 수도 있지만 대리자를 통해 대신 장을 봐달라고 부탁하는 것이다. 여기서 대리자를 프록시라고 한다.
직접 호출과 다르게 간접 호출을 하면 대리자가 중간에 여러가지 일을 할 수도 있다.
런타임에 클라이언트 객체에 DI를 사용하여 클라이언트의 코드 변경없이 유연하게 프록시를 주입.
✪접근 제어
✹ 권한에 따른 접근 차단 : 클라이언트가 권한이 없으면 예외 떠뜨리거나 반환함. 그러면 서버에 접근 불가.
✹ 캐싱 : 클라이언트가 프록시에 요청을 했는데 프록시가 그 데이터가 있다고 하면 서버에 접근 하지 않고 바로 반환.
✹ 지연 로딩 : 클라이언트가 프록시를 가지고 사용하다가 실제 요청이 있을 때 데이터를 조회.
✦ 아버지께 자동차 주유를 부탁했는데, 아버지가 주유 뿐만 아니라 세차까지 하고 왔다. 클라이언트가 기대한 것 외에 세차라는 부가 기능까지 얻게 되었다. (부가 기능 추가)
✦ 대리자가 또 다른 대리자를 부를 수도 있다. 예를 들어서 내가 동생에게 라면을 사달라고 했는데,
동생은 또 다른 누군가에게 라면을 사달라고 다시 요청할 수도 있다. 중요한 점은 클라이언트는 대리자를 통해서 요청했기 때문에 그 이후 과정은 모른다는 점이다. 동생을 통해서 라면이 나에게 도착하기만 하면 된다. (프록시 체인)
프록시와 프록시 패턴은 다른 것이다. 프록시 패턴은 GOF 디자인 패턴으로 접근 제어가 목적이다.
데코레이터 패턴도 프록시를 사용하여 새로운 기능을 추가하는 것이 목적이다.
Client는 Subject 인터페이스를 의존하고 RealSubject 클래스에서 구현. (RealSubject가 구현체) 그런데 이 데이터가 한번 조회하면 변하지 않는 데이터라면 어딘가에 보관해두고 이미 조회한 데이터를 사용하는 것이 성능상 좋다. -> 캐시 사용
런타임에서 client가 realSubject를 참조하는 것이 아닌 proxy를 참조하고 proxy가 realSubject를 참조하도록 proxy를 끼워넣는다.
Client
public class ProxyPatternClient {
private Subject subject;
public ProxyPatternClient(Subject subject) {
this.subject = subject;
}
public void execute() {
subject.operation();
}
}
RealSubject
@Slf4j
public class RealSubject implements Subject {
@Override
public String operation() {
log.info("실제 객체 호출");
sleep(1000); //데이터 조회하는데 1초 걸림.
return "data";
}
private void sleep(int millies) {
try {
Thread.sleep(millies);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
프록시
클라이언트가 프록시를 호출하면 프록시가 최종적으로 실제 객체를 호출해야 한다. 따라서 내부에 실제 객체의 참조를 가지고 있어야 한다.
@Slf4j
public class CacheProxy implements Subject {
// 실제 realSubject를 접근할 수 있어야 한다.
private Subject target; //프록시가 호출해야 하는 대상 -> 실제 객체
private String cacheValue;
//의존 관계 주입을 위한 생성자.
public CacheProxy(Subject target) {
this.target = target;
}
@Override
public String operation() {
log.info("프록시 호출");
if (cacheValue == null) { //처음 호출할 때 cacheValue는 없음.
//target은 realSubject로 realSubject의 operation()을 호출
cacheValue = target.operation();
}
return cacheValue;
}
}
cacheValue에 값이 없으면 target을 호출하여 target은 realSubject로 realSubject의 operation()을 호출하고 값을 구한다. 그리고 구한 값을 cacheValue에 저장하고 반환한다. cacheValue가 있으면 실제 객체(target)를 전혀 호출하지 않고(접근 제어됨.) 캐시 값을 그대로 반환한다. 따라서 처음 조회 이후에는 캐시 덕분에 빠르게 데이터를 조회할 수 있다.
캐시 테스트
//캐시 적용
@Test
void cacheProxyTest() {
Subject realSubject = new RealSubject();
//proxy가 realSubject를 의존 관계 주입.
Subject cacheProxy = new CacheProxy(realSubject);
//클라이언트가 proxy를 의존 관계 주입.
ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
client.execute();
client.execute();
client.execute();
}
결과 - 조회 시간이 엄청 단축된다.
17:59:56.949 - 프록시 호출
17:59:56.953 - 실제 객체 호출
17:59:57.959 - 프록시 호출
17:59:57.959 - 프록시 호출
요청 값이나, 응답 값을 중간에 변형(응답 값을 꾸며줌.) -> 메시지 데코레이터를 사용해서 메시지를 추가
MessageDecorator
@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 + "&&&&&7";
//출력하면 *****data&&&&&7
log.info("MessageDecorator 꾸미기 전={}, 적용 후={}", result, decoResult);
return decoResult;
}
}
응답 값 꾸밈 데코레이터 테스트
@Test
void decorator1() {
Component realComponent = new RealComponent();
//real을 의존 관계 주입.
Component messageDecorator = new MessageDecorator(realComponent);
DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);
// client가 messageDecorator를 통해서 realComponent를 호출.
client.execute();
}
결과
MessageDecorator 꾸미기 전=data, 적용 후=*data&&&&&7
실행 시간을 측정하는 데코레이터를 추가
TimeDecorator 추가
@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;
}
}
응답 값 꾸밈 + 실행 시간 측정하는 데코레이터 테스트
@Test
void decorator2() {
Component realComponent = new RealComponent();
//messageDecorator는 realComponent를 의존.
Component messageDecorator = new MessageDecorator(realComponent);
//timeDecorator는 messageDecorator를 의존.
Component timeDecorator = new TimeDecorator(messageDecorator);
//클라이언트는 timeDecorator를 의존
DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
client.execute();
}
결과
TimeDecorator 실행
MessageDecorator 실행
RealComponent를 실행
MessageDecorator 꾸미기 전=data, 적용 후=*data&&&&&7
TimeDecorator 종료 / resultTime=15ms
result=*data&&&&&7
프록시를 사용하면 기존 코드를 전혀 수정하지 않고 로그 추적 기능을 도입할 수 있다.
V1의 인터페이스로 Controller, Service, Repository 생성.
기존에는 스프링 빈이 orderControllerV1Impl, orderServiceV1Impl 같은 실제 객체를 반환했지만 이제는 프록시를 사용해야 하므로 프록시 생성하고 이 프록시를 실제 스프링 빈 대신 등록한다.
(실제 객체는 스프링 빈으로 등록하지 X. -> 프록시는 내부에 실제 객체를 참조하고 있음.)
ex) OrderServiceInterfaceProxy 는 내부에 실제 대상 객체인 OrderServiceV1Impl 을 가지고 있음.
@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);
}
}
Controller, Service, Repository 는 V2의 Controller, Service, Repository 를 상속받는다.
public class OrderServiceConcreteProxy extends OrderServiceV2 {
private final OrderServiceV2 target;
private final LogTrace logTrace;
public OrderServiceConcreteProxy(OrderServiceV2 target, LogTrace logTrace) {
super(null);
this.target = target;
this.logTrace = logTrace;
}
@Override
public void orderItem(String itemId) {
TraceStatus status = null;
try {
status = logTrace.begin("OrderRepository.request() 실행");
//target 호출
target.orderItem(itemId);
logTrace.end(status);
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
OrderServiceV2 를 상속 받을려니까 OrderServiceV2에 파라미터가 있는 생성자가 있다. (기본 생성자면 안해도 됨.)
부모에 저런 생성자가 있으면 그 생성자를 호출해야 한다.
문법 상 어쩔 수 없이 한 것으로 자바 기본 문법에 의해 자식 클래스를 생성할 때는 항상 super()로 부모 클래스의 생성자를 호출해야 한다.
생략하면 기본 생성자가 호출되고 프록시 역할만 할 것이고 부모 객체의 기능은 사용하지 않기 때문에 그냥 null로 하였다.
그리고 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);
}
}
로그 추적기라는 부가 기능을 적용할 때 이 로그 추적을 위한 프록시 클래스들의 코드는 거의 같은 모양을 하고 있는데도 대상 클래스 수만큼 로그 추적을 위한 프록시 클래스를 만들어야 했다.
자바가 기본으로 제공하는 JDK 동적 프록시 기술이나 CGLIB 같은 프록시 생성 오픈 소스 기술을 활용하면 프록시 객체를 동적으로 만들어낼 수 있다. (프록시 클래스를 계속 만들지 않아도 됨.)
프록시를 적용할 코드를 하나만 만들어두고 동적 프록시 기술을 사용해서 프록시 객체를 찍어내면 된다.
log.info("start")
String result1 = target.callA(); // 호출하는 메서드가 다름
log.info("result1 = (), result1");
log.info("start")
String result2 = target.callB(); // 호출하는 메서드가 다름
log.info("result2 = (), result2");
리플렉션 사용하면 클래스나 메서드의 메타정보를 동적으로 흭득하고, 코드도 동적으로 호출할 수 있다.
현재 같은 코드인데 호출하는 메서드만 다를 뿐이다.
callA와 callB는 소스코드에 박혀있는 메서드로 정적인 정보다. 이것을 실시간으로 바꿀 수가 없다.
그래서 컴파일 되고 난 다음에 실제 Java가 실행될 때 바꿀 수가 없기 때문에 이것을 가능하게 하는 것이 Reflection 이다.
람다를 사용해서 해결할 수도 있다. (일단 여기서는 Reflection을 알기 위해 람다 없이 진행.)
Hello는 클래스.
ReflectionTest
// 클래스 정보 흭득.
// 정보를 흭득해서 동적으로 바꿀 수 있다.
// hello.proxy.jdkdynamic.ReflectionTest는 경로. 내부 클래스는 구분을 위해 $로 구분
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello")
Hello target = new Hello();
// callA의 method 정볼르 얻음.
Method methodCallA = classHello.getMethod("callA");
// 동적으로 콜이 가능.
// target 인스턴스에 있는 callA롤 호출하게 되는 것.
methodCallA.invoke(target);
// callB도 수행
Class classHello = Class.forName("hello.proxy.jdkdynamic.ReflectionTest$Hello")
Hello target = new Hello();
// callB의 method 정볼르 얻음.
Method methodCallB = classHello.getMethod("callB");
// 동적으로 콜이 가능.
// target 인스턴스에 있는 callB를 호출하게 되는 것.
// 흭득한 메서드 메타정보로 실제 인스턴스의 메서드를 호출한다.
methodCallB.invoke(target);
callA, callB를 보면 기존에는 소스코드로 박혀있었다면 지금은 문자로 바꾼 것이다.
바꿨다는 말은 나중에 파라미터로 넘길 수도 있고 인수로 넘길 수도 있다는 것이다.
이렇게 하는 이유는 클래스나 메서드 정보를 동적으로 변경할 수 있기 때문이다.
callA()와 callB() 메서드를 직접 호출하는 부분이 Method
로 대체되어 추상화되어 공통 로직을 만들 수 있게 된다.
private vodi dynamicCall(Method method, Object target) throws Exception{
log.info("start");
Object result = method.invoke(target);
}
이런 방식으로 reflection 사용해서 메타 정보로 바꿔치는 게 가능하다.
Hello target = new Hello();
Method methodCallA = classHello.getMethod("callA");
dynamicCall(methodCallA, target);
장점
정리 : target.callA()와 target.callB() 코드를 리플렉션을 사용해서
Method
라는 메타정보로 추상화한 것이다. -> 공통 로직을 만듦.
그렇지만 클래스와 메서드의 메타 정보를 사용해서 애플리케이션을 유연하게 만들 수 있기는 하지만 런타임에 동작하기 때문에 컴파일 시점에 오류를 잡을 수 없다.
ex) callA가 아닌 callZ 이런 식으로 쓴다고 하면 실행이 되고 나서 오류를 발생시킨다.
개발자가 직접 프록시 클래스를 만들지 않아도 된다.
프록시 객체를 동적으로 런타임에 개발자 대신 만들어준다. 그리고 동적 프록시에 원하는 실행 로직을 지정할 수 있다.
JDK 동적 프록시는 인터페이스를 기반으로 프록시를 동적으로 만들어주므로 인터페이스가 필수다.
실행 로직을 위해 InvocationHandler 인터페이스를 제공함.
AImpl, BImpl 각각 프록시를 만들지 않고 프록시는 JDK 동적 프록시를 사용해서 동적으로 만들고 TimeInvocationHandler는 공통으로 사용했다.
AImpl, AInterface, BImpl, BInterface를 생성하고 JDK 동적 프록시에 적용할 로직인 InvocationHandler를 인터페이스(스프링에 구현 되어 있음.)를 구현하여 TimeInvocationHandler를 만들어주었다.
@Slf4j
public class TimeInvocationHandler implements InvocationHandler {
// 프록시는 항상 호출할 대상이 있어야 한다.
private final Object target;
public TimeInvocationHandler(Object target) {
this.target = target;
}
// JDK 동적 프록시가 실행할 로직은 어떤 메서드가 호출되는지가 넘어온다.
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
// method 호출하는 부분이 동적이기 때문에 가능한 것.
Object result = method.invoke(target, args);//메서드 호출할 때 인수들 넘겨주어야 하므로 args로 한다. - 어떻게 될지 모르기 때문에 넣어준 것.
//메서드 호출하는 부분이 동적이기 때문에 가능함.
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
invoke(Object proxy, Method method, Object[] args)
InvocationHandler에서 invoke() 하면 제공되는 파라미터는 Object proxy(프록시 자신), Method method(호출한 메서드), Object[] args(메서드를 호출할 때 전달한 인수) 이다.
테스트
@Test
void dynamicA() {
AInterface target = new AImpl();
// 프록시가 호출할 로직.
TimeInvocationHandler handler = new TimeInvocationHandler(target);
// 동적 프록시 객체 생성.
// AInterface.class.getClassLoader() -> 프록시가 어디에 생성될지 클래스 로더를 지정.
// 어떤 인터페이스 기반으로 프록시 만들지 지정.
// 인터페이스가 여러 개 구현할 수 있기 때문에 배열로 만든 것.
AInterface proxy = (AInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(),
new Class[]{AInterface.class},
handler);
proxy.call();
log.info("targetClass={}", target.getClass());
// AInterface라는 것을 구현받아서 프록시가 만들어진 것이다.
// JDK 프록시가 이 때 만들어준 프록시 클래스이다.
log.info("proxyClass={}", proxy.getClass());
//결과 : proxyClass=class com.sun.proxy.$Proxy9
}
@Test
void dynamicB() {
BInterface target = new BImpl();
TimeInvocationHandler handler = new TimeInvocationHandler(target);
//프록시 생성.
BInterface proxy = (BInterface) Proxy.newProxyInstance(AInterface.class.getClassLoader(),
new Class[]{BInterface.class},
handler);
proxy.call();
log.info("targetClass={}", target.getClass());
// BInterface라는 것을 구현받아서 프록시가 만들어진 것이다.
// JDK 프록시가 이 때 만들어준 프록시 클래스이다.
log.info("proxyClass={}", proxy.getClass());
//결과 : proxyClass=class com.sun.proxy.$Proxy9
}
new TimeInvocationHandler(target);
동적 프록시에 적용할 핸들러 로직
targetClass=class hello.proxy.jdkdynamic.code.AImpl
proxyClass=class com.sun.proxy.$Proxy12
$Proxy12 가 구현하고 있는 인터페이스는 A인터페이스를 구현받아서 프록시가 만들어진 것.
프록시에 call을 호출하게 되면 프록시는 Handler에 있는 로직을 수행한다. -> TimeInvocationHandler에 있는 invoke를 수행.
실행 순서
- 클라이언트는 JDK 동적 프록시의 call() 을 실행한다.
- JDK 동적 프록시는
InvocationHandler.invoke()
를 호출한다.TimeInvocationHandler
가 구현체로 있으로TimeInvocationHandler.invoke()
가 호출된다.TimeInvocationHandler
가 내부 로직을 수행하고,method.invoke(target, args)
를 호출해서 target 인 실제 객체( AImpl )를 호출한다.- AImpl 인스턴스의 call() 이 실행된다.
- AImpl 인스턴스의 call() 의 실행이 끝나면 TimeInvocationHandler 로 응답이 돌아온다. 시간 로그를 출력하고 결과를 반환한다.
동적 프록시로 적용 대상만큼 프록시 객체를 만들지 않아도 되고 같은 부가 로직을 한번만 개발해서 공통으로 적용할 수 있다.
각각 필요한 InvocationHandler
만 만들어서 넣어주면 된다.
동적 프록시 적용하고 실행했는데 no-log를 해도 로그가 남아 문제가 있음.
그래서 메소드 명이 정한 패턴일 때만 로그를 남기도록 수정.
스프링에는 PatternMatchUtils
를 사용해서 패턴이 매칭이 되는지를 확인.
InvocationHandler
public class LogTraceBasicHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
private final String[] pattern;
public LogTraceBasicHandler(Object target, LogTrace logTrace) {
this.target = target;
this.logTrace = logTrace;
this.pattern = pattern;
}
// 메서드 이름 필터
String methodName = method.getName();
if(!PatternMatchUtils.simpleMatch(pattenrs, methodName)){
return method.invoke(target, args);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//로그 추적기 로직
TraceStatus status = null;
try {
// 메시지가 모두 "OrderController.request()"와 똑같은 메시지로 나가지 않게 하기 위함.
// 메서드에서 선언한 클래스 정보를 SimpleName으로 가져오고 . 출력 후 메서드 이름을 가져옴
String message = method.getDeclaringClass().getSimpleName() +
"." + method.getName() + "()";
//OrderController.request() 이것처럼 됨.
status = logTrace.begin(message);
//로직 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
특정 메서드 이름이 매칭되는 경우에만 LogTrace 로직을 실행. -> 매칭 안 되면 실제 로직을 바로 호출.
ex) xxx* 이런 식으로 가능./
수동 빈 등록
@Configuration
public class DynamicPoxyBasicConfig {
private static final String[] PATTERNS = {"request*", "order*", "save*"};
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace, PATTERNS));
//동적 프록시 생성
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceFilterHandler(orderController, logTrace, PATTERNS));
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
//동적 프록시 생성
OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceFilterHandler(orderService, logTrace, PATTERNS));
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
//동적 프록시 생성
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS));
return proxy;
}
}
프록시 적용했더니 OrderControllerV1에 있는 메서드에 프록시가 모두 적용이 되어 로직이 호출이 되어 버린다. 즉 로그 추적을 남기지 않을려는 부분도 로그 추적이 남게 된다.
그래서 패턴을 적용하여 메서드 명이 매칭되지 않으면 실제를 호출해준다.
InvocationHandler
public class LogTraceFilterHandler implements InvocationHandler {
private final Object target;
private final LogTrace logTrace;
// 메서드명이 이러한 패턴일 때만 로그를 남기도록 설정.
private final String[] patterns;
public LogTraceFilterHandler(Object target, LogTrace logTrace, String[] patterns) {
this.target = target;
this.logTrace = logTrace;
this.patterns = patterns;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
//메서드 이름 필터
String methodName = method.getName();
//save, reque* 같은 패턴을 만듦
if (!PatternMatchUtils.simpleMatch(patterns, methodName)) {
//patterns 와 methodName이 매칭되지 않으면
return method.invoke(target, args);
}
//로그 추적기 로직
TraceStatus status = null;
try {
// 메시지가 모두 "OrderController.request()"와 똑같은 메시지로 나가지 않게 하기 위함.
// 메서드에서 선언한 클래스 정보를 SimpleName으로 가져오고 . 출력 후 메서드 이름을 가져옴
String message = method.getDeclaringClass().getSimpleName() +
"." + method.getName() + "()";
//OrderController.request() 이것처럼 됨.
status = logTrace.begin(message);
//로직 호출
Object result = method.invoke(target, args);
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
수동 빈 등록
@Configuration
public class DynamicProxyFilterConfig {
//패턴 적용
private static final String[] PATTERNS = {"request*", "order*", "save*"};
//앞에가 request, order, save로 시작해야 로그를 남기도록 패턴을 지정.
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1 orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
//동적 프록시 생성
OrderControllerV1 proxy = (OrderControllerV1) Proxy.newProxyInstance(OrderControllerV1.class.getClassLoader(),
new Class[]{OrderControllerV1.class},
new LogTraceFilterHandler(orderController, logTrace, PATTERNS));
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1 orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
//동적 프록시 생성
OrderServiceV1 proxy = (OrderServiceV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderServiceV1.class},
new LogTraceFilterHandler(orderService, logTrace, PATTERNS));
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1 orderRepository = new OrderRepositoryV1Impl();
//동적 프록시 생성
OrderRepositoryV1 proxy = (OrderRepositoryV1) Proxy.newProxyInstance(OrderRepositoryV1.class.getClassLoader(),
new Class[]{OrderRepositoryV1.class},
new LogTraceFilterHandler(orderRepository, logTrace, PATTERNS));
return proxy;
}
}
인터페이스 없이 클래스만 있는 경우. 동적 프록시를 적용하는 방법이다.
CGLIB를 직접 사용하는 경우는 거의 없고 ProxyFactory
라는 것이 편리하게 사용 가능하도록 도와주기 때문에 무엇인지 개념만 잡으면 된다.
public class TimeMethodInterceptor implements MethodInterceptor{
private final Object target;
public TimeMethodInterceptor(Object object){
this.target=target;
}
@Override
public Object invoke(Object obj, Method method, Object[] args, Method methodProxy) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
// CGLIB에서는 MethodProxy를 사용하면 속도가 좀 더 빠름. (권장)
Object result = methodProxy.invoke(target, args);
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
TimeMethodInterceptor
는 MethodInterceptor
인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의.
proxy.invoke(target, args)
는 실제 대상을 동적으로 호출.
@Test
void cglib(){
ConcreteService target = new ConcreteService();
// CGLIB를 만드는 코드.
Enhancer enhancer = new Enhancer();
// 구체 클래스를 기반으로 컨크리트 서비스를 상속받은 프록시를 만들어야 한다.
enhancer.setSuperclass(ConcreteService.class);
enhancer.setCallback(new TimeMethodInterceptor(target));
ConcreteService proxy = (ConcreteService) enhancer.create(); // 이렇게 하면 프록시가 생성된다.
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.call();
}
call 하면 TimeProxy가 실행되고 ConcreteService 호출한 뒤 TimeProxy 종료하고 시간 찍음.
Enhancer
: CGLIB는 Enhancer를 사용해서 프록시를 생성.enhancer.setSuperclass(ConcreteService.class)
: CGLIB는 구체 클래스를 상속 받아서 프록시를 생성할 수 있다. (어떤 구체 클래스를 상속받을지를 지정.)enhancer.setCallback(new TimeMethodInterceptor(target))
: 프록시에 적용할 실행 로직을 할당.
CGLIB는 구체 클래스를 상속해서 프록시를 만든다.
JDK 동적 프록시와 CGLIB 두 기술을 함께 사용할 때 부가 기능을 제공하기 위해서 JDK 동적 프록시가 제공하는 InvocationHandler 와 CGLIB가 제공하는 MethodInterceptor 를 각각 중복으로 만들어서 관리하기는 번거롭다. 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공하기 위해서 프록시 팩토리를 사용한다.
동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리라는 기능이 있다.
프록시 팩토리 하나로 편리하게 동적 프록시를 생성한다. 프록시 팩토리는 인터페이스가 있으면 jdk 동적 프록시를 사용하고, 구체 클래스만 있다면 CGLIB를 사용한다. 이 설정을 변경할 수도 있다.
InvocationHandler나 MethodInterceptor를 신경쓰지 않고 그냥 Advice만 만들면 된다. (InvocationHandler, MethodInterceptor는 Advice를 호출하기 때문에)
MethodInvocation을 사용하지만 위치가 다름.
pakeage org.aopalliance.intercept; 임.
@Slf4j
public class TimeAdvice implements MethodInterceptor {
//기존에는 target을 항상 넣어줬지만 안 줘도 가능. 프록시 팩토리에서 target을 넣어줌.
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeProxy 실행");
long startTime = System.currentTimeMillis();
//method.invoke(target, args) 할 필요 없음.
//target 클래스를 호출하고 그 결과를 받음.
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("TimeProxy 종료 resultTime={}", resultTime);
return result;
}
}
테스트
인터페이스가 있으면 JDK 동적 프록시 사용
@Test
@DisplayName("인터페이스가 있으면 JDK 동적 프록시 사용")
void interfaceProxy() {
ServiceInterface target = new ServiceImpl();
//프록시 팩토리 만들 때 target을 삽입.
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
//프록시 팩토리 사용할 때만 사용 가능. 프록시 적용됐는지 확인.
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
}
구체 클래스만 있으면 CGLIB 사용
@Test
@DisplayName("구체 클래스만 있으면 CGLIB 사용")
void concreteProxy() {
ConcreteService target = new ConcreteService();
//프록시 팩토리 만들 때 target을 삽입.
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
ConcreteService proxy = (ConcreteService) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.call();
//프록시 팩토리 사용할 때만 사용 가능. 프록시 적용됐는지 확인.
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고 클래스 기반 프록시 사용 (실무에서 자주 사용하니 알아두는 것이 좋음)
@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고 클래스 기반 프록시 사용")
void proxyTargetClass() {
ServiceInterface target = new ServiceImpl();
//프록시 팩토리 만들 때 target을 삽입.
ProxyFactory proxyFactory = new ProxyFactory(target);
//인터페이스가 있든 없든 CGLIB를 사용하고 싶다면 (중요)
//인터페이스가 있어도 강제로 CGLIB 사용. 그리고 인터페이스가 아닌 클래스 기반의 프록시를 만들어줌.
proxyFactory.setProxyTargetClass(true);
proxyFactory.addAdvice(new TimeAdvice());
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("targetClass={}", target.getClass());
log.info("proxyClass={}", proxy.getClass());
proxy.save();
//프록시 팩토리 사용할 때만 사용 가능. 프록시 적용됐는지 확인.
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다. 따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다. 이유는 ~~?
✶ 어드바이스 : 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라 생각.
✶ 어드바이저 : 하나의 포인트컷과 하나의 어드바이스를 가지고 있다고 하는 것이다.
프록시 팩토리를 통해 프록시를 생성할 때 어드바이저를 제공하면 어디에 어떤 기능을 제공할 지 알 수 있다.
-> 부가 기능을 적용하는데 포인트컷으로 어디에 적용할지 선택하고 어드바이스로 어떤 로직을 적용할지 선택하고 이 2가지를 모두 알고 있는 것이 어드바이저.
new DefaultPointcutAdvisor : Advisor 인터페이스의 가장 일반적인 구현체.
생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면 끝.
Pointcut.TRUE, new TimeAdvice() 를 넣어줬음.
proxyFactory.addAdvisor(advisor) : 프록시 팩토리에 적용할 어드바이저를 지정. 따라서 어디에 어떤 부가 기능을 적용해야 할지 어드바이스 하나로 알 수 있다. (프록시 팩토리 사용할 때 어드바이저는 필수!!)
@Test
void advisorTest1() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
//포인트 컷과 어드바이스 둘 다 넣음
//Pointcut.TRUE : 항상 참인 포인트 컷
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
ProxyFactory가 Advisor를 알고 있고 Advisor가 내부에 Pointcut과 Advice를 가지고 있다고 생각하면 됨.
ServiceInterface에 save() 메서드와 find() 메서드가 있는데 둘 중에 하나만 어드바이스 로직을 적용하고자 할 때 포인트컷을 사용하면 된다.
Pointcut이 true면 Advice 호출하여 부가 기능 적용하고 메서드를 호출한다.
Pointcut이 false면 Advice 호출하지 않고 그냥 메서드를 호출한다.
//포인트컷
@Test
@DisplayName("직접 만든 포인트컷")
void advisorTest2() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
//포인트 컷과 어드바이스 둘 다 넣음
//Pointcut.TRUE : 항상 참인 포인트 컷
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
//Pointcut.TRUE가 아닌 직접 만든 포인트컷
static class MyPointcut implements Pointcut {
@Override
public ClassFilter getClassFilter() {
return ClassFilter.TRUE;
}
@Override
public MethodMatcher getMethodMatcher() {
return new MyMethodMatcher();
}
}
//MethodMatcher 직접 만듦.
static class MyMethodMatcher implements MethodMatcher {
private String matchName = "save";
/**
matches 메서드에 method, targetClass 정보가 넘어오는데 이 정보로 어드바이스를
적용할지 안 할지 판단할 수 있다.
*/
@Override
public boolean matches(Method method, Class<?> targetClass) {
boolean result = method.getName().equals(matchName);//메서드 이름이 save인 경우에만 적용.
log.info("포인트컷 호출 method={}, targetClass={}", method.getName(), targetClass);
log.info("포인트컷 결과 result={}", result);
return result;
}
@Override //무시해도 됨.
public boolean isRuntime() {
return false;
}
@Override //무시해도 됨.
public boolean matches(Method method, Class<?> targetClass, Object... args) {
return false;
}
}
@Test
@DisplayName("스프링이 제공하는 포인트컷 사용")
void advisorTest3() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
//여기에 포인트컷 사용.
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("save"); //메서드 이름이 save인 경우에만 적용.
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save(); //TimeProxy 적용.
proxy.find();
}
포인트컷은 매우 다양하다. (대표적인 5가지만)
☪ 1. NameMatchMethodPointcut : 메서드 이름을 기반으로 매칭.
☪ 2. JdkRegexpMethodPointcut : JDK 정규 표현식을 기반으로 포인트컷을 매칭.
☪ 3. TruePointcut : 항상 참을 반환
☪ 4. AnnotationMatchingPointcut : 애노테이션으로 매칭
aspectJ 표현식으로 매칭.
이게 제일 중요
여러 어드바이저를 하나의 target 에 적용하는 것이다.
client -> proxy2(advisor2) -> proxy1(advisor1) -> target
@Test
@DisplayName("여러 프록시")
void multiAdvisorTest1() {
//client -> proxy2(advisor2) -> proxy1(advisor1) -> target
//프록시1 생성.
ServiceImpl target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
//어드바이스를 TimeAdvice()가 아닌 직접 만든 advice로 해봄.
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();
//프록시2 생성, target -> proxy1 입력
//프록시2에서 프록시1 호출하므로 target이 아닌 proxy1로 해야 함.
ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
proxyFactory2.addAdvisor(advisor2);
ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();
//실행
proxy2.save();
}
static class Advice1 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("advice1 호출");
return invocation.proceed(); // ?
}
}
static class Advice2 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("advice2 호출");
return invocation.proceed();
}
}
적용해야 하는 어드바이저 개수만큼 프록시도 똑같이 생성을 해주어야 한다. 그래서 프록시 팩토리에 여러 어드바이저를 넣는다
AOP 적용 수 만큼 프록시가 생성되지 않는다. 착각하지 말자. 스프링은 AOP를 적용할 때, 최적화를 진행해서 프록시는 하나만 만들고 하나의 프록시에 여러 어드바이저를 적용한다. 하나의 target에 여러 AOP가 동시에 적용되어도 스프링 AOP는 target마다 하나의 프록시만 생성한다.
프록시 하나 어드바이스 여러 개.
LogTraceAdvice 생성
public class LogTraceAdvice implements MethodInterceptor {
private final LogTrace logTrace;
public LogTraceAdvice(LogTrace logTrace) {
this.logTrace = logTrace;
}
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
TraceStatus status = null;
try{
Method method = invocation.getMethod();
String message = method.getDeclaringClass().getSimpleName() + "." +
method.getName() + "()";
status = logTrace.begin(message);
//로직 호출
Object result = invocation.proceed();
logTrace.end(status);
return result;
} catch (Exception e){
logTrace.exception(status, e);
throw e;
}
}
}
의존 관계 주입
@Slf4j
@Configuration
public class ProxyFactoryConfigV1 {
@Bean
public OrderControllerV1 orderControllerV1(LogTrace logTrace) {
OrderControllerV1Impl orderController = new OrderControllerV1Impl(orderServiceV1(logTrace));
ProxyFactory factory = new ProxyFactory(orderController);
factory.addAdvisor(getAdvisor(logTrace));
OrderControllerV1 proxy = (OrderControllerV1) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
return proxy;
}
@Bean
public OrderServiceV1 orderServiceV1(LogTrace logTrace) {
OrderServiceV1Impl orderService = new OrderServiceV1Impl(orderRepositoryV1(logTrace));
ProxyFactory factory = new ProxyFactory(orderService);
factory.addAdvisor(getAdvisor(logTrace));
OrderServiceV1 proxy = (OrderServiceV1) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderService.getClass());
return proxy;
}
@Bean
public OrderRepositoryV1 orderRepositoryV1(LogTrace logTrace) {
OrderRepositoryV1Impl orderRepository = new OrderRepositoryV1Impl();
//프록시 팩토리 생성
ProxyFactory factory = new ProxyFactory(orderRepository);
factory.addAdvisor(getAdvisor(logTrace));
OrderRepositoryV1 proxy = (OrderRepositoryV1) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderRepository.getClass());
return proxy;
}
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor 반환.
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
그리고 수동 등록
@Slf4j
@Configuration
public class ProxyFactoryConfigV2 {
@Bean
public OrderControllerV2 orderControllerV2(LogTrace logTrace) {
OrderControllerV2 orderController = new OrderControllerV2(orderServiceV2(logTrace));
ProxyFactory factory = new ProxyFactory(orderController);
factory.addAdvisor(getAdvisor(logTrace));
OrderControllerV2 proxy = (OrderControllerV2) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderController.getClass());
return proxy;
}
@Bean
public OrderServiceV2 orderServiceV2(LogTrace logTrace) {
OrderServiceV2 orderService = new OrderServiceV2(orderRepositoryV2(logTrace));
ProxyFactory factory = new ProxyFactory(orderService);
factory.addAdvisor(getAdvisor(logTrace));
OrderServiceV2 proxy = (OrderServiceV2) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderService.getClass());
return proxy;
}
@Bean
public OrderRepositoryV2 orderRepositoryV2(LogTrace logTrace) {
OrderRepositoryV2 orderRepository = new OrderRepositoryV2();
ProxyFactory factory = new ProxyFactory(orderRepository);
factory.addAdvisor(getAdvisor(logTrace));
OrderRepositoryV2 proxy = (OrderRepositoryV2) factory.getProxy();
log.info("ProxyFactory proxy={}, target={}", proxy.getClass(), orderRepository.getClass());
return proxy;
}
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
ProxyFactoryConfigV1,V2같은 설정 파일이 매우 많다. 스프링 빈이 100개 있으면 프록시를 통해 부가 기능을 적용하기 위해서 100개의 동적 프록시 생성 코드를 만들어야 한다. 그리고 컴포넌트 스캔을 사용할 때 직접 등록하고 프록시르 적용하는 코드까지 빈 생성 코드에 넣어야 한다.
컴포넌트 스캔을 사용하는 경우 프록시 적용이 불가능하다. 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문이다.
부가 기능이 있는 프록시를 실제 객체 대신 스프링 컨테이너에 빈으로 등록을 하고 난 다음에 실제 객체는 프록시를 통해서 호출이 되어야 한다.
이 2가지 문제점을 해결해준다.
등록하고 나면 스프링 컨테이너를 통해 빈 이름으로 beanA를 조회하게 되면 A 객체가 반환된다.
생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶을 때 사용한다. 빈을 생성한 후에 무언가를 처리하는 용도로 사용한다.
기능이 막강하기 때문에 알아두는 것이 좋다.
객체에다가 메서드를 호출하거나 값을 넣을 수도 있고 완전히 다른 객체로 바꿔치기하여 빈 저장소에 등록하는 것도 가능하다.
사용하려면 BeanPostProcessor 인터페이스를 구현,상속하고, 스프링 빈으로 등록한다.
postProcessBeforeInitialization : 객체 생성 이후에 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서이다.
postProcessAfterInitialization : 객체 생성 이후에 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서이다.
@PostContruct : 스프링 빈 생성 이후에 빈을 초기화 하는 역할을 한다. 빈의 초기화
라는 것은 단순히 @PostConstruct 애노테이션이 붙은 초기화 메서드를 한번 호출만 하면 된다. 쉽게 이야기해서 생성된 빈을 한번 조작하는 것이다.
@Slf4j
public class BeanPostProcessorTest {
// 일반적인 스프링 빈 등록. (빈 후처리기 사용X)
@Test
void basicConfig() {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
//A는 빈으로 등록하여 조회
/**
* beanA의 이름으로 A 객체를 찾아야 하는데
* B 객체가 등록이 되서 찾을 수가 없다는 에러 발생.
*/
// A a = applicationContext.getBean("beanA", A.class);
// a.helloA();
//beanA의 이름으로 B 객체를 빈으로 등록
B b = applicationContext.getBean("beanA", B.class);
b.helloB();
//A는 빈으로 등록되지 않음.
assertThrows(NoSuchBeanDefinitionException.class, () -> applicationContext.getBean(A.class));
}
//설정 파일
@Configuration
static class BeanPostProcessorConfig {
@Bean(name = "beanA")
public A a() {
return new A();
}
//빈 후처리기 등록
@Bean
public AToBPostProcessor helloPostProcessor() {
return new AToBPostProcessor();
}
}//BasicConfig을 AnnotationConfigApplicationContext 인 스프링 컨테이너에 넣어주면 빈으로 등록이 된다.
//Configuration 되면 Bean이 스프링 컨테이너가 호출하고 "beanA"라는 이름으로 A객체를 등록한다.
static class A {
public void helloA() {
log.info("hello A");
}
}
static class B {
public void helloB() {
log.info("hello B");
}
}
// 빈 후처리기 사용O, AToBPostProcessor 이것이 빈후처리기.
static class AToBPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
log.info("beanName={}, bean={}", beanName, bean);
if (bean instanceof A) { //A의 인스턴스라면
return new B(); //A가 넘어오면 B를 반환하는 것.
}
return bean;
//그리고 스프링 컨테이너에 등록.
}
}
}
일반적으로 스프링 컨테이너가 등록하는, 특히 컴포넌트 스캔의 대상이 되는 빈들은 중간에 조작(해당 객체의 특정 메서드를 호출)할 방법이 없는데 빈 후처리기를 사용하면 개발자가 등록하는 모든 빈을 중간에 조작할 수 있다. 이 말은 빈 객체를 프록시로 교체하는 것도 가능하다는 뜻이다.
수동으로 등록하는 Bean과 컴포넌트 스캔을 사용하는 빈까지 모두 프록시를 적용 가능하다.
설정 파일에 있는 수많은 프록시 생성 코드도 한 번에 제거할 수 있다.
PackageLogTracePostProcessor클래스는 원본 객체를 프록시 객체로 반환하는 역할을 한다.
모든 스프링 빈들에 프록시를 적용할 필요는 없다. 빈들이 매우 많이 등록이 되어 있다. 스프링 내부에서 사용하는 빈들까지 전부 등록이 되는 것이다. 그래서 특정 패키지에만 프록시를 만들도록 제한을 해준다.
@Slf4j
public class PackageLogTracePostProcessor implements BeanPostProcessor {
private final String basePackage;
private final Advisor advisor;
public PackageLogTracePostProcessor(String basePackage, Advisor advisor) {
this.basePackage = basePackage;
this.advisor = advisor;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
//Bean의 초기화가 다 끝나고나서 프록시를 적용하기 위해 After 사용.
log.info("param beanName={} bean={}", beanName, bean.getClass());
//프록시 적용 대상 여부 체크
//프록시 적용 대상이 아니면 원본을 그대로 진행
String packageName = bean.getClass().getPackageName();
//startsWith 특정 문자로 시작하는지 체크
if (!packageName.startsWith(basePackage)) { //같지 않으면
return bean;
}//다른 곳에서 온 객체면 원본을 반환해서 원본을 그대로 스프링 빈으로 등록.
//프록시 대상이면 프록시를 만들어서 반환.
//프록시를 적용하기 위해 반복적으로 넣어서 지저분한 코드가 여기에 다 들어간다.
ProxyFactory proxyFactory = new ProxyFactory(bean); //bean이 target
proxyFactory.addAdvisor(advisor); //v1,2,3에 프록시 팩토리가 다 적용되고 같은 advisor가 다 적용됨.
Object proxy = proxyFactory.getProxy();
log.info("create proxy : target={}, proxy={}", bean.getClass(), proxy.getClass());
return proxy;
}
}
빈 등록
@Slf4j
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class BeanPostProcessorConfig {
@Bean
public PackageLogTracePostProcessor logTracePostProcessor(LogTrace logTrace) {
return new PackageLogTracePostProcessor("hello.proxy.app", getAdvisor(logTrace));
//프록시를 적용할 패키지 정보( hello.proxy.app )
}
private Advisor getAdvisor(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor 반환.
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
logTracePostProcessor는 특정 패키지를 기준으로 프록시를 생성하는 빈 후처리기를 스프링 빈으로 등록한다. 빈 후처리기는 스프링 빈으로 등록만 하면 자동으로 동작한다. 프록시를 적용할 패키지 정보(hello.proxy.app 하위)와 어드바이저(getAdvisor(logTrace) )를 넘겨준다.
실행해보면 스프링 부트가 기본으로 등록하는 수 많은 빈들이 빈 후처리기를 통과하는데 여기에 모두 프록시를 적용하면 안 되기 때문에 꼭 필요한 곳에만 프록시를 적용해야 한다.
(basePackage를 사용해서 v1~v3 애플리케이션 관련 빈들만 프록시 적용함.)
이제는 프록시를 생성하고 빈으로 등록하는 것을 빈 후처리기가 모두 처리해주므로 프록시를 생성하는 코드가 설정 파일에 필요가 없고, 순수한 빈 등록만 생각하면 끝.
프록시 적용 대상을 정하기 위해 포인트컷을 사용한다.
build.gradel에 추가
implementation 'org.springframework.boot:spring-boot-starter-aop'
이 라이브러리를 추가하면 aspectjweaver 라는 aspectJ 관련 라이브러리를 등록하고, 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다.
스프링 부트 자동 설정으로 빈 후처리기가 스프링 빈에 자동으로 등록된다.
빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다. Advisor만 알고 있으면 그 안에 있는 Pointcut으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다.
AspectJExpressionPointcut (실무에서 정말 자주 사용.)
AspectJ라는 AOP에 특화된 호인트컷 표현식을 적용할 수 있다.
@Configuration
public class AutoProxyConfig {
//@Bean //advisor만 빈 등록해주면 끝난다.
//자동 프록시 생성기라는 BeanPostProcessor는 이미 스프링이 자동으로 등록을 한다.
public Advisor advisor1(LogTrace logTrace) {
//pointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save");
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor 반환.
return new DefaultPointcutAdvisor(pointcut, advice);
}
@Bean
public Advisor advisor2(LogTrace logTrace) {
//pointcut
//위와는 다르게 AspectJExpressionPointcut을 사용.
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression("execution(* hello.proxy.app..*(..)) && execution(* hello.proxy.app..noLog(..))");
//app 하위 폴더에 있어야 프록시 적용 대상이 되도록 설정.
//그리고 && 로 해서 추가로 noLog 메서드는 로그를 남기지 않도록 설정.
//advice
LogTraceAdvice advice = new LogTraceAdvice(logTrace);
//advisor 반환.
return new DefaultPointcutAdvisor(pointcut, advice);
}
}
execution( hello.proxy.app..(..)) ➔ AspectJ 포인트컷 표현식
프록시를 모든 곳에 생성하고 적용하는 것은 비용 낭비이므로 필요한 곳에만 최소한의 프록시를 적용한다. 그래서 포인트컷으로 한번 필터링해서 어드바이스가 사용될 가능성이 있는 곳에만 프록시를 생성한다.
스프링 빈이 advisor가 제공하는 포인트컷의 조건을 모두 만족해도 프록시 자동 생성기는 프록시를 하나만 생성한다. (프록시 팩토리가 생성하는 프록시는 내부에 여러 advisor들을 포함할 수 있기 때문에.)
@Aspect 애노테이션을 사용해서 편리하게 포인트컷과 어드바이스로 구성되어 있는 어드바이저 생성을 할 수 있다.
@Slf4j
@Aspect
public class LogTraceAspect {
private final LogTrace logTrace;
public LogTraceAspect(LogTrace logTrace) {
this.logTrace = logTrace;
}
//밑에 어드바이스 로직과 execution 한 부분을 합치면 Advisor
@Around("execution(* hello.proxy.app..*(..))") //포인트컷
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable{
//여기에 어드바이스 로직이 들어감.
//로그 남기는 로직
TraceStatus status = null;
try {
String message = joinPoint.getSignature().toShortString();
status = logTrace.begin(message);
//로직 호출
Object result = joinPoint.proceed(); //실제 호출 대상인 target을 호출해준다.
logTrace.end(status);
return result;
} catch (Exception e) {
logTrace.exception(status, e);
throw e;
}
}
}
✫ @Around에 포인트컷 표현식을 넣는다. 표현식은 AspectJ로.
✫ @Around의 메서드는 어드바이스.
✫ ProceedingJoinPoint joinPoint : 어드바이스에서 살펴본 MethodInvocation invocation 과 유사. 내부에 실제 호출 대상, 전달 인자, 그리고 어떤 객체와 어떤 메서드가 호출되었는지 정보가 포함되어 있음.
✫ joinPoint.proceed() : 실제 호출 대상(target)을 호출.
자동 프록시 생성기는 Advisor를 자동으로 찾아서 필요한 곳에 프록시를 생성 + 적용을 하지만 @Aspect를 찾아서 Advisor로 만들어주는 기능도 있다.
✫ 스프링 컨테이너에서 @Aspect 애노테이션이 붙은 Advisor 빈을 모두 조회.
✫ @Aspect Advisor를 조회하고 나면 @Aspect Advisor 빌더를 통해 @Aspect 애노테이션 정보를 기반으로 Advisor를 생성하고, @Aspect Advisor 빌더 내부에 저장된 Advisor를 모두 조회.
(나머지 생성, 전달, 프록시 적용 등은 같음.)
애스팩트
부가 기능과 이 기능을 어디에 적용할 지 선택하는 기능을 합해서 하나의 모듈로 만든 것을 애스팩트라 한다. 즉 부가 기능을 어디에 적용할지 정의한 것이다. @Aspect도 애스팩트에 해당된다.
애스팩트를 사용한 프로그래밍 방식을 AOP(관점 지향 프로그래밍)라 한다.
횡단 관심사(하나의 부가 기능이 여러 곳에 동일하게 사용됨.)를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.
AOP를 사용하면 핵심 기능과 부가 기능이 코드상 완전히 분리되서 관리되므로 부가 기능 로직은 다른 방식으로 실제 로직에 추가해야 한다.
1. 컴파일 시점
소스 코드를 컴파일러를 사용해서 .class를 만드는 시점에 부가 기능 로직을 추가한다.(AspectJ가 제공하는 특별한 컴파일러 사용.)
부가 기능 코드가 핵심 기능이 있는 컴파일된 코드 주변에 실제로 붙어 버린다.
(위빙 : 원본 로직에 부가 기능 로직이 추가되는 것.)
단점 : 컴파일 시점에 부가 기능을 적용하려면 특별한 컴파일러도 필요하고 복잡하다.
2. 클래스 로딩 시점
자바를 실행하면 자바 언어는 .class 파일을 JVM 내부의 클래스 로더에 보관하는데 .class 를 조작하여 JVM에 저장하여 JVM에 저장하기 전에 조작할 수 있는 기능을 제공한다.
단점 : 자바를 실행할 때 특별한 옵션( java -javaagent )을 통해 클래스 로더 조작기를
지정해야 하는데, 이 부분이 번거롭고 운영하기 어렵다.
3. 런타임 시점(프록시)
실제 대상 코드는 그대로 유지된다. 대신에 프록시를 통해 부가 기능이 적용된다. 따라서 항상 프록시를 통해야 부가 기능을 사용할 수 있다. 스프링 AOP는 이 방식을 사용한다.
자바 언어가 제공하는 범위 안에서 부가 기능을 적용.
적용 가능 지점(JoinPoint) : 생성자, 필드 값 접근, static 메서드 접근, 메서드 실행
JoinPoint : AOP를 적용할 수 있는 지점.
프록시 방식을 사용하는 스프링 AOP는 메서드 실행 지점에만 AOP를 적용할 수 있다.
' ✮ 프록시는 메서드 오버라이딩 개념으로 동작한다. 따라서 생성자나 static 메서 드, 필드 값 접근에는 프록시 개념이 적용될 수 없다.
' ✮ 스프링 AOP는 프록시를 사용하므로 프록시는 결국 메서드를 실행하는 지점에서만 다음 타겟을 호출할 수 있기 때문에 프록시를 사용하는 스프링 AOP의 조인 포인 트는 메서드 실행으로 제한된다.
빈으로 등록이 될 때 프록시를 생성되기 때문에 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용할 수 있다.
런타임 시점 : 런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음. -> 자바의 메인( main ) 메서드가 이미 실행된 다음.
implementation 'org.springframework.boot:spring-boot-starter-aop'
OrderRepository, OrderService 생성.
AspectV1
@Slf4j
@Aspect
public class AspectV1 {
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
//joinPoint 시그티처 -> 반환 관련, 파라미터들에 대한 메서드를 출력.
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed(); //타깃 호출.
}
}
테스트
@Slf4j
@SpringBootTest
@Import(AspectV1.class) //@Bean 으로 빈 등록해도 되지만 여기서도 가능.
public class AopTest {
@Autowired
OrderService orderService;
@Autowired
OrderRepository orderRepository;
@Test
void aopInfo() {
log.info("isAopProxy, orderService={}",
AopUtils.isAopProxy(orderService)); //Aop 프록시 적용됐는지
log.info("isAopProxy, orderRepository={}",
AopUtils.isAopProxy(orderRepository));
}
@Test
void success() { //성공로직
orderService.orderItem("itemA");
}
@Test
void exception() { //
assertThatThrownBy(() -> orderService.orderItem("ex"))
.isInstanceOf(IllegalStateException.class);
}
}
결과
isAopProxy, orderService=true
isAopProxy, orderRepository=true
hello.aop.order.OrderService.orderItem(String)
[orderService] 실행
[log] String hello.aop.order.OrderRepository.save(String)
[orderRepository] 실행
doLog가 어드바이스 적용되었다.
@Aspect 는 애스펙트라는 표식이지 컴포넌트 스캔이 되는 것은 아니다. AspectV1을 AOP로 사용하려면 빈으로 등록을 해야 한다.
@Around("execution(* hello.aop.order..*(..))")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
//joinPoint 시그티처 -> 반환 관련, 파라미터들에 대한 메서드를 출력.
log.info("[log] {}", joinPoint.getSignature());
return joinPoint.proceed(); //타깃 호출.
}
//포인트컷 분리
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder() {}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("포인트컷 분리 -> [log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
✫ 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(signature)라 한다.
✫ 메서드의 반환 타입은 void 여야 한다.
✫ 코드 내용은 비워둔다.
✫ 내부에서만 사용하면 private 을 사용해도 되지만, 다른 애스팩트에서 참고하려면 public 을 사용해야 한다.
장점 : 분리하면 하나의 포인트컷 표현식을 여러 어드바이스에서 함께 사용할 수 있고 다른 클래스에 있는 외부 어드바이스에서도 포인트컷을 함께 사용할 수 있다.
@Slf4j
@Aspect
public class AspectV3 {
//포인트컷 분리
@Pointcut("execution(* hello.aop.order..*(..))")
private void allOrder() {}
//트랜잭션은 서비스 계층의 비즈니스 로직이 실행될 때 트랜잭션을 걸고 비즈니스 로직 끝날 때 트랜잭션을 commit하는 롤백을 결정한다.
//비즈니스 로직은 서비스 계층에 들어가기 때문에 트랜잭션을 서비스에서 시작한다.
//클래스 이름 패턴이 *Service 인 것들
//..이 패키지명, *Service가 클래스 이름
@Pointcut("execution(* *..*Service.*(..))")
public void allService() {}
@Around("allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("포인트컷 분리 -> [log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
//hello.aop.order 패키지와 하위 패키지면서 클래스 이름 패턴이 *Service인 것
@Around("allOrder() && allService()")
public Object deTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally{
log.info("[리소스 릴리즈.] {}", joinPoint.getSignature());
}
}
}
@Around("allOrder() && allService()") 처럼 포인트컷은 &&, ||, ! 3가지 조합이 가능하다.
OrderService에만 doTransaction() 어드바이스를 적용했고 doLog() 어드바이스는 OrderService, OrderRepository 모두 적용함.
@Slf4j
@Aspect
public class Pointcuts { //포인트컷을 외부로 끌어서 실행해봄.
@Pointcut("execution(* hello.aop.order..*(..))")
public void allOrder() {
}
@Pointcut("execution(* *..*Service.*(..))")
public void allService() {
}
@Pointcut("allOrder() && allService()")
public void orderAndService(){}
}
포인트컷을 여러 어드바이스에서 함께 사용할 때 효과적이다.
@Around() 에 패키지 명과 클래스 이름, 포인트컷 시그니처를 모두 지정.
@Slf4j
@Aspect
public class AspectV4Pointcuts {
//포인트컷 참조
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("포인트컷 분리 -> [log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
//hello.aop.order 패키지와 하위 패키지면서 클래스 이름 패턴이 *Service인 것
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object deTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally{
log.info("[리소스 릴리즈.] {}", joinPoint.getSignature());
}
}
}
doLog() 와 doTransaction() 순서 바꾸기.
정적 클래스를 만들어서 doLog()와 doTransaction() 메서드 넣어 분리해주고 @Order() 로 순서 정하면 끝.
@Slf4j
@Aspect
public class AspectV5Order {
@Order(2)
public static class LogAspect{
@Around("hello.aop.order.aop.Pointcuts.allOrder()")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("포인트컷 분리 -> [log] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
@Order(1)
public static class TxAspect{
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
throw e;
} finally{
log.info("[리소스 릴리즈.] {}", joinPoint.getSignature());
}
}
}
}
그러면 트랜잭션이 먼저 실행되고 Log를 실행한다.
@Slf4j
@Aspect
public class AspectV6Advice {
@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
try {
//@Before : JoinPoint 실행하기 전의 부분을 담당.
log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
Object result = joinPoint.proceed();
//@After Returning : 조인 포인트가 정상 완료한 후의 부분을 담당.
log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
return result;
} catch (Exception e) {
//@AfterThrowing : 메서드가 예외를 던지는 경우를 담당.
log.info("[트랜잭션 콜백] {}", joinPoint.getSignature());
throw e;
} finally {
//@After : 조인 포인트가 정상 또는 예외에 관계없이 실행.
log.info("[리소스 릴리즈] {}", joinPoint.getSignature());
}
}
@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
public void deBefore(JoinPoint joinPoint) {
log.info("[before] {}", joinPoint.getSignature());
}
@AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()",
returning = "result")
public void doReturn(JoinPoint joinPoint, Object result) {
//result가 매칭되서 return 값이 들어온다.
log.info("[return] {}", joinPoint.getSignature(), result);
//@AfterReturning은 return값을 조작할 수는 있지만 바꿀 수는 없다.
}
@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()",
throwing = "ex")
public void doThrowing(JoinPoint joinPoint, Exception ex) {
log.info("[ex] {} message {}", ex);
}
//@After는 그냥 finally 로직이라 생각하면 됨.
@After(value = "hello.aop.order.aop.Pointcuts.orderAndService()")
public void doAfter(JoinPoint joinPoint) {
log.info("[after] {}", joinPoint.getSignature());
}
}
다른 것들은 JoinPoint를 사용했지만 @Around에서만 ProceedingJoinPoint를 사용했다.
ProceedingJoinPoint의 proceed()는 다음 어드바이스나 target을 호출해주기 때문에 없으면 호출을 해주지 못해 다음이 실행되지가 않는다. 그래서 @Around 만큼은 ProceedingJoinPoint를 사용해야 한다.
@Before는 메서드 종료 시 자동으로 다음 타겟이 호출되기 때문에 proceed() 할 필요가 없다.
JoinPoint 인터페이스의 주요 기능
1. getArgs() : 메서드 인수를 반환.
2. getThis() : 프록시 객체를 반환.
3. getTarget() : 대상 객체를 반환.
4. getSignature() : 조인되는 메서드에 대한 설명을 반환.
5. toString() : 조인되는 방법에 대한 유용한 설명을 반환.
동일한 @Aspect 안에 있을 때 실행 순서
@Around ➜ @Before ➜ @After ➜ @AfterReturning ➜ @AfterThrowing
만약 @Aspect 안에 동일한 종류의 어드바이스가 2개 이상 있으면 순서가 보장되지 않으므로 @Aspect를 분리하여 @Order로 순서를 정한다.
좋은 설계는 제약이 있는 것이다. @Around만 사용하지 않고 @Before ~ @AfterThrowing 같은 어드바이스를 사용하여 제약을 두는 이유는 만약 @Around를 사용했는데 중간에 다른 개발자가 해당 코드를 수정해서 호출하지 않으면 큰 장애가 발생하기 때문에 실수를 미연에 방지하기 위함이다. @Before를 사용했다면 이런 문제가 발생하지 않는다.
이러한 제약 덕분에 역할이 명확해진다.
애스팩트J로 포인트컷 적용할 때 execution 같은 것들이다.
AspectJExpressionPointcut 는 상위에 Pointcut 인터페이스를 가진다
@Target(ElementType.TYPE) //AOP는 @Target이 필요
-> Java compiler 가 annotation 이 어디에 적용될지 결정하기 위해 사용. 해당 어노테이션은 타입 선언 시 사용한다는 의미.
@Retention(RetentionPolicy.RUNTIME)
-> 애노테이션이 실제로 적용되고 유지되는 범위를 의미. 그 범위가 RUNTIME으로 런타임까지 남아있는다 라는 의미.
@Slf4j
public class ExecutionTest {
//포인트컷 표현식을 처리해주는 클래스
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
Method helloMethod;
@BeforeEach
public void init() throws NoSuchMethodException {
//MemberServiceImpl에 있는 hello 메서드를 대상으로 포인트컷이 되는지 안 되는지 여부를 확인.
helloMethod = MemberServiceImpl.class.getMethod("hello", String.class);
//hello 메서드에서 param을 String으로 해놨기 때문에 String.class.
}
//MemberServiceImpl.hello(String) 메서드의 정보를 출력
@Test
void printMethod() {
log.info("helloMethod={}", helloMethod);
//결과
//public java.lang.String hello.aop.member.MemberServiceImpl.hello(java.lang.String)
}
//hello 메서드와 가장 정확하게 매칭되는 execution 표현식을 사용.
@Test
void exactMatch() {
//setExpression()으로 포인트컷 표현식을 적용.
pointcut.setExpression("execution(public String hello.aop.member.MemberServiceImpl.hello(String))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
//메서드와 포인트컷 표현식의 모든 내용이 정확하게 매칭이 되는지 확인.
}
//상관없이 모두 매칭
@Test
void allMatch() {
//반환타입 *, 메서드 이름도 *, 파라미터는 .. 으로 설정함.
pointcut.setExpression("execution(* *(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//메서드 이름으로 매칭
@Test
void nameMatch() {
pointcut.setExpression("execution(* hello(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//패턴으로 매칭.
@Test
void nameMatchPattern1() {
pointcut.setExpression("execution(* he*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void nameMatchPattern2(){ //hello가 매칭됨. SQL의 *처럼.
pointcut.setExpression("execution(* *el*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//매칭 실패
@Test
void nameMatchPattern3(){
pointcut.setExpression("execution(* nono(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
//패키지 정확하게 매칭
@Test
void packageExactMatch1(){
pointcut.setExpression("execution(* hello.aop.member.MemberServiceImpl.hello(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//*로 패키지 안 모두를 매칭시킴.
@Test
void packageExactMatch2(){
pointcut.setExpression("execution(* hello.aop.member.*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
// 패키지 매칭 실패. -> member 패키지가 없어서 실패
//.이 하나 더 들어가서 ..로 하위 패키지로 만들면 성공.
@Test
void packageExactMatch3(){
pointcut.setExpression("execution(* hello.aop.*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//하위 패키지로 성공시키기. -> member 하위가 모두 성공됨.
@Test
void packageMatchSubPackage1(){
pointcut.setExpression("execution(* hello.aop.member..*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
@Test
void packageMatchSubPackage2(){
pointcut.setExpression("execution(* hello.aop..*.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
}
. : 정확하게 해당 위치의 패키지
.. : 해당 위치의 패키지아 그 하위 패키지들도 포함.
자식 타입(MemberServiceImpl)이어도 부모 타입의 MemberService와 매칭이 된다.
//execution 타입 매칭
@Test
void typeExactMatch() {
pointcut.setExpression("execution(* hello.aop.member.MemberServiceImpl.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
//hellomethod는 MemberServiceImpl.class 타입 안에 있기 때문에 정확하게 매칭을 한다.
}
//부모 타입은 자식 타입을 품을 수 있다.
@Test
void typeMatchSuperType() {
pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
//자식 타입(MemberServiceImpl)이어도 부모 타입의 MemberService와 매칭이 된다.
}
자식 타입이 매칭되기는 하지만 부모 타이에서 선언한 메서드까지만 매칭이 된다.
즉 hello만 가능하고 internal 메서드는 불가능.
@Test
void typeMatchInternal() throws NoSuchMethodException {
pointcut.setExpression("execution(* hello.aop.member.MemberService.*(..))");
//MemberServiceImpl에 있는 internal메서드를 뽑아옴.
Method internalMethod = MemberServiceImpl.class.getMethod("internal", String.class);
assertThat(pointcut.matches(internalMethod, MemberServiceImpl.class)).isFalse();
}
//파라미터 매칭
//String 타입의 파라미터 매칭 -> hello 메서드 파라미터가 String이기 때문에 True
@Test
void argsMatch() {
//파라미터 타입이 String
pointcut.setExpression("execution(* *(String))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//파라미터가 없음 -> 타입이 없기 때문에.
@Test
void argsMatchNoArgs() {
pointcut.setExpression("execution(* *())");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isFalse();
}
//정확히 하나의 파라미터 허용, 모든 타입 허용 -> 파라미터 타입을 *로 설정함.
//파라미터가 여러 개라면 바로 실패
@Test
void argsMatchStar() {
pointcut.setExpression("execution(* *(*))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//개수 상관없이 모든 파라미터 허용, 모든 타입을 허용
@Test
void argsMatchAll() {
pointcut.setExpression("execution(* *(..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
//파라미터가 String으로 시작함. 그리고 개수 상관없이 모든 파라미터 허용.
@Test
void argsMatchComplex() {
pointcut.setExpression("execution(* *(String, ..))");
assertThat(pointcut.matches(helloMethod, MemberServiceImpl.class)).isTrue();
}
(String) : 정확하게 String 타입 파라미터
() : 파라미터가 없어야 한다.
() : 정확히 하나의 파라미터, 단 모든 타입을 허용한다.
(, *) : 정확히 두 개의 파라미터, 단 모든 타입을 허용한다.
(String, ..) : String 타입으로 시작해야 한다. 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다.
여기까지가 execution 포인트컷 지시자.
execution과 거의 같지만 execution 에서 타입 부분만 사용한다.
@Test
void withinStar() {
pointcut.setExpression("within(hello.aop.member.*Service*)");
assertThat(pointcut.matches(helloMethod,
MemberServiceImpl.class)).isTrue();
}
주의할 점 : within은 표현식에 부모 타입을 지정하면 안된다. 정확하게 타입이 맞아야 한다.
@Test
@DisplayName("타켓의 타입에만 직접 적용, 인터페이스를 선정하면 안된다.")
void withinSuperTypeFalse() {
pointcut.setExpression("within(hello.aop.member.MemberService)");
assertThat(pointcut.matches(helloMethod,
MemberServiceImpl.class)).isFalse();
}
기본 문법은 execution과 유사하지만 excution은 클래스에 선언된 정보를 기반으로 파라미터 타입이 정확하게 매칭되어야 하지만 args는 실제 넘어온 파라미터 객체 인스턴스를 보고 판단하고 부모 타입을 허용한다.
한가지만 보면 execution은 파라미터 타입이 정확해야 한다. 부모 타입으로도 Object로도 매칭이 되지 않는다. 하지만 args는 매칭이 된다.
타입에 있는 애노테이션으로 AOP 적용 여부를 판단한다.
@target : 인스턴스의 모든 메서드를 조인 포인트로 적용.
@within : 해당 타입 내에 있는 메서드만 조인 포인트로 적용.
쉽게 해서 @target이 있는 childMethod()는 어드바이스를 부모 parentMethod()까지 적용하고 @within이 있는 childMethod()는 어드바이스를 부모 parentMethod()를 적용하지 않는다.
@annotation : 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭. 메서드(조인 포인트)에 애노테이션이 있으면 매칭한다.
@Slf4j
@Import(AtAnnotationTest.AtAnnotationAspect.class)
@SpringBootTest
public class AtAnnotationTest {
@Autowired
MemberService memberService;
@Test
void success() {
log.info("memberService Proxy={}", memberService.getClass());
memberService.hello("helloA");
}
//프록시
@Aspect
static class AtAnnotationAspect {
//@MethodAop이 있으면 @MethodAop가 있는 메서드에 이것을 적용을 한다.
@Around("@annotation(hello.aop.member.annotation.MethodAop)")
public Object doAtAnnotation(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[@annotation] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
}
스프링 빈의 이름으로 매칭하여 AOP 적용 여부를 지정.
이름이 확정적이거나 바뀌지 않을 때 사용하면 효과적.
@Slf4j
@Import(BeanTest.BeanAspect.class)
@SpringBootTest
public class BeanTest {
@Autowired
OrderService orderService;
@Test
void success() {
orderService.orderItem("itemA");
}
@Aspect
static class BeanAspect {
@Around("bean(orderService) || bean(*Repository)")
public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("[bean] {}", joinPoint.getSignature());
return joinPoint.proceed();
}
}
}
@Before("allMember() && args(arg, ..)")
public void logArgs3(String arg) {
log.info("[logArgs3] arg={}", arg);
}
포인트컷의 이름과 매개변수의 이름을 맞추어야 한다. (arg로 똑같이 맞춰줌.)
logArgs3라는 메서드에서 파라미터 타입이 String으로 되어 있기 때문에 args(arg, ..)의 arg도 String이 된다. (타입이 매칭되어야 함.)
logArgs1() : joinPoint.getArgs()[0] 와 같이 매개변수를 전달 받는다.
logArgs2() : args(arg,..) 와 같이 매개변수를 전달 받는다.
logArgs3() : @Before 를 사용한 축약 버전이다. 추가로 타입을 String 으로 제한했다.
this : 프록시 객체를 전달 받는다.
target : 실제 대상 객체를 전달 받는다.
@target , @within : 타입의 애노테이션을 전달 받는다.
@annotation(annotation) : 메서드의 애노테이션을 전달 받는다. 여기서는 annotation.value() 로 해당. @MethodAop에 들어있는 값(test value)을 꺼낼 수 있다.
애노테이션의 값을 출력하는 모습을 확인할 수 있다.
다시 보기
따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(target)을 호출해야 한다. 그래야 프록시에서 먼저 어드바이스를 호출하고, 이후에 대상 객체를 호출한다. 프록시를 거치지 않으면(대상 객체를 직접 호출) AOP가 적용되지 않고 어드바이스도 호출되지 않는다.
AOP를 적용하려면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시 항상 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다.
즉 프록시를 호출하면 프록시에서 target을 호출하고, target 안에서 메서드를 호출하여 target은 자기 자신을 호출하게 된다.
컴포넌트 스캔
@Slf4j
@Component
public class CallServiceV0 {
public void external() {
log.info("call external");
internal(); //내부 메서드 호출
}
public void internal() {
log.info("call internal");
}
}
어드바이스
@Slf4j
@Aspect
public class CallLogAspect {
@Before("execution(* hello.aop.internalcall..*.*(..))")
public void doLog(JoinPoint joinPoint) {
log.info("aop={}", joinPoint.getSignature());
}
}
테스트
@Slf4j
@Import(CallLogAspect.class)
@SpringBootTest
public class CallServiceV0Test {
@Autowired
CallServiceV0 callServiceV0;
//CallServiceV0는 @Component로 컴포넌트 스캔 대상이 된다.
//그런데 Aspect에 걸린다. (어드바이스 대상이 internalcall 패키지와 하위 모두이기 때문에.)
//그래서 프록시가 먹혀서 스프링 컨테이너에 프록시가 올라간다. (callServiceV0는 프록시이다.)
@Test
void external() {
//log.info("target={}", callServiceV0.getClass()); //프록시 잡혀있음.
callServiceV0.external();
}
@Test
void internal() {
callServiceV0.internal();
}
}
결과에서 aop가 external에 적용된 다음 external을 호출한다. 하지만 그다음에 internal에는 aop가 적용되지 않고 그냥 internal이 호출된다.
callServiceV0.external()을 실행할 때 클라이언트가 프록시를 호출하면 어드바이스가 호출되지만 AOP Proxy가 target의 external을 호출하게 되면 callServiceV0.external()안에서 internal()을 호출할 때 문제가 발생한다. (CallLogAspect() 어드바이스가 호출되지 않음.)
실제 external()을 호출하고 internal()을 자기 자신 것을 호출하기 때문이다.(this.internal)
즉 internal() 이 아닌 this.internal()을 호출하는 것이다. 결국 AOP가 적용되지 않은 internal()이 호출되지 않는다.
외부에서 internal()을 호출하게 되면 프록시를 거치게 되어 AOP가 적용된 상태로 호출된다.
스프링은 프록시 방식의 AOP를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다. 즉 위처럼 자기 자신의 internal()을 호출하게 되면 프록시가 끼어들 수 있는 지점이 없어서 방법이 없다.
사진처럼 자기 자신을 의존 관계에 주입시켜버리는 것.
그러면 프록시가 external 호출하게 되면 프록시의 어드바이스가 호출되고 그 다음 실제 target의 external을 호출한다. 그리고 this.internal이 아닌 의존 관계 주입받은 프록시의 internal을 호출하게 되고 마지막으로 실제 target의 internal을 호출한다.
@Slf4j
@Component
public class CallServiceV1 {
private CallServiceV1 callServiceV1;
//여기 생성자를 만들면 에러 발생한다 -> 자기 자신을 의존관계로 주입 받을건데
//아직 생성되지도 않은 자기의 생성자를 주입할 수는 없다. 그래서 순환 사이클 문제가 발생.
// @Autowired
// public CallServiceV1(CallServiceV1 callServiceV1) {
// this.callServiceV1 = callServiceV1;
// }
//방법 -> setter로 의존 관계 주입.
@Autowired
public void setCallServiceV1(CallServiceV1 callServiceV1) {
log.info("callService1 setter={}", callServiceV1.getClass());
this.callServiceV1 = callServiceV1;
}
//callServiceV1를 의존 관계 주입받으면 스프링 컨테이너에 프록시가 들어있다.
public void external() {
log.info("call external");
callServiceV1.internal(); //외부 메서드 호출
//즉 프록시를 통해서 internal() 호출.
}
public void internal() {
log.info("call internal");
}
}
생성자 주입을 하지 않고 setter로 주입을 했는데 이유는 자기 자신을 주입해야 하기 때문이었다. 그래서 수정자 주입 또는 지연 조회를 사용하면 된다.
즉 스프링 빈을 지연해서 조회. -> ObjectProvider(Provider), ApplicationContext 사용.
ApplicationContext 사용
@Slf4j
@Component
public class CallServiceV2 {
//스프링이 주입받을 수 있도록 제공함.
private ApplicationContext applicationContext;
public CallServiceV2(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
public void external() {
log.info("call external");
//getBean으로 꺼냄.
CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
callServiceV2.internal();
}
public void internal() {
log.info("call internal");
}
}
하지만 ApplicationContext의 매우 많은 기능이 다 필요한 것이 아니기에 지연해서 callService2를 조회하는 기능만 사용하기 위해 ObjectProvider를 사용.
(ObjectProvider는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아니라 실제 객체를 사용하는 시점으로 지연할 수 있다.)
ObjectProvider
@Slf4j
@Component
public class CallServiceV2_2 {
//ObjectProvider 사용.
private final ObjectProvider<CallServiceV2_2> callServiceProvider;
public CallServiceV2_2(ObjectProvider<CallServiceV2_2> callServiceProvider) {
this.callServiceProvider = callServiceProvider;
}
public void external() {
log.info("call external");
//지연시킴
CallServiceV2_2 callServiceV2 = callServiceProvider.getObject();
callServiceV2.internal();
}
public void internal() {
log.info("call internal");
}
}
구조를 변경해서 내부 호출이 발생하지 않도록 하는 방법을 가장 권장.
external()과 internal()을 다른 클래스로 분리한다.
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {
//internal()이 있는 InternalService를 주입.
private final InternalService internalService;
public void external() {
log.info("call external");
internalService.internal();
//구조 자체를 분리하여 internal()을 외부 호출.
//내부 호출을 InternalService라는 별도의 클래스로 분리하여 외부로 호출함.
}
}
InternalService에 public 메서드 internal()을 생성.
실제 target이 내부 호출이 아닌 외부 호출을 하게 된다. 그래서 내부 호출 자체가 사라지고 internalService를 호출하여 AOP가 자연스럽게 적용된다.
AOP는 public 메서드에만 적용한다. private 메서드처럼 작은 단위에는 AOP를 적용하지 않는다. AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다. AOP가 잘 적용되지 않으면 내부 호출을 의심해 본다.
JDK 동적 프록시 : 인터페이스 필수, 인터페이스를 기반으로 프록시 생성.
CGLIB : 구체 클래스를 기반으로 프록시를 생성.
인터페이스가 있어도 JDK, CGLIB 둘 중에 하나 선택해서 사용.
JDK는 구체 클래스로 타입 캐스팅이 불가능하다.
문제는 Impl로 캐스팅을 하면 에러 발생. (ClassCastException 에러 발생.)
JDK는 인터페이스를 구현한 것이지 Impl은 뭔지를 모른다. 그래서 구체 타입으로는 캐스팅이 불가능하다.
(memberServiceProxy가 JDK 프록시.)
JDK로 프록시 생성
@Slf4j
@SpringBootTest
public class ProxyCastingTest {
@Test
void jdkProxy() {
//구체 클래스, 인터페이스 모두 있음.
MemberServiceImpl memberService = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory();
proxyFactory.setProxyTargetClass(false); //JDK 동적 프록시 사용
//프록시를 인터페이스로 타입 캐스팅하면 성공함.
//MemberService가 인터페이스.
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
//MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
});
//memberServiceProxy가 JDK 프록시 이다.
}
}
CGLIB로 프록시 생성
@Test
void cglibProxy() {
//구체 클래스, 인터페이스 모두 있음.
MemberServiceImpl memberService = new MemberServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory();
proxyFactory.setProxyTargetClass(true); //CGLIB 동적 프록시.
//프록시를 인터페이스로 타입 캐스팅하면 성공함.
//MemberService가 인터페이스.
MemberService memberServiceProxy = (MemberService) proxyFactory.getProxy();
//cglib는 구체 클래스를 기반으로 프록시를 생성하여 에러 발생 안 함.
assertThrows(ClassCastException.class, () -> {
MemberServiceImpl castingMemberService = (MemberServiceImpl) memberServiceProxy;
});
}
스프링 부트는 기본적으로 CGLIB를 사용하므로 JDK 사용하도록 옵션을 넣어줌.
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})
@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})
@Import(ProxyDIAspect.class)
public class ProxyDITest {
@Autowired
MemberService memberService;
@Autowired
MemberServiceImpl memberServiceImpl;
@Test
void go() {
log.info("memberService class={}", memberService.getClass());
log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
memberService.hello("hello");
}
}
@Autowired
MemberServiceImpl memberServiceImpl;
JDK 동적 프록시 한계
이 부분이 있으면 프록시를 주입을 해야 하는데 주입하지 못한다는 에러가 발생한다.
(삭제하면 테스트 통과!!!)
에러 이유 ⇢ JDK 동적 프록시에 구체 클래스 타입을 주입했기 때문인데 이유는 memberServiceImpl에 주입되어야 할 타입은 hello.aop.member.MemberServiceImpl인데 실제 넘어온 타입이 Proxy이기 때문이다. 결국 MemberServiceImpl 타입이 뭔지 전혀 모르기 때문에 타입에 주입할 수가 없다. 따라서 타입 예외가 발생한 것이다.
MemberServiceImpl에 기본 생성자가 필수다. (CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아님.)
CGLIB는 구체 클래스를 상속받는다. 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되면 super()가 자동으로 생성됨.)
첫 번째 생성자 호출 ⇢ 실제 target의 객체를 생성하여 MemberServiceImpl에 생성자를 생성.
두 번째 생성자 호출 ⇢ 프록시 객체를 생성할 때 부모 클래스의 생성자 호출 (자동으로 CGLIB가 만드는 프록시의 생성자.)
final 키워드가 클래스에 있으면 상속이 불가!!!. 메서드에 있으면 오버라이딩 불가.
CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다.
그런데 프레임워크 같은 개발이 아닌 일반적인 웹 애플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않기 때문에 큰 문제는 X.
objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성하여 문제 해결.
역시 objenesis 라이브러리로 해결.