스프링 핵심 (고급)

Park sang woo·2022년 12월 12일
0

인프런 공부

목록 보기
7/13
post-thumbnail

✠ 로그 추적기

거대한 프로젝트에서 전체 코드 수가 너무 많고 클래스 수도 백개 이상일 때 로그 추적기를 만들어봄. 애플리케이션이 개발되고 시간이 지나면서 모니터링과 운영이 중요해지는 단계가 온다. 그래서 어떤 부분에서 병목이 발생하고 예외가 발생하는지를 로그를 통해 확인하는 것이 중요해지고 있다.
로그를 미리 남겨두면 손쉽게 찾을 수 있으니 이 부분을 개선하고 자동화한다.



☪ 로그 남기는 방법

  1. 모든 public 메서드의 호출과 응답 정보를 로그로 출력한다.
  2. 애플리케이셔느이 흐름을 변경하면 안된다. 로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안된다.
  3. 메서드 호출에 걸린 시간을 남겨야 한다.
  4. 정상 흐름과 예외 흐름을 구분해야 한다. 즉 예외 발생 시 예외 정보 남김.
  5. 메서드 호출의 깊이(level)을 표현해야 한다.
  6. HTTP 요청을 구분할 수 있어야 한다. -> HTTP 요청 단위로 특정 ID를 남겨서 어떤 요청에서 시작된 것인지 구분이 가능해야 한다. (특정 ID => 트랜잭션 ID)

모든 로직에 직접 로그를 남겨도 되지만 트랜잭션 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();
    }

}


  1. begin() 메서드
    ✔ 로그 시작하면 로그 메시지를 파라미터로 받아서 시작 로그를 출력하고 응답 결과로 현재 현재 로그의 상태인 TraceStatus를 반환
  1. end() 메서드
    ✔ 예외 없으면 정상 종료한다. 종료 시에도 시작할 때와 동일한 로그 메시지를 출력할 수 있다.
    ✔ 파라미터로 시작 로그의 상태 TraceStatus를 전달받음.
  1. exception() 메서드
    ✔ 로그를 예외로 종료. TraceStatus, Exception 정보를 함께 전달 받아서 실행시간, 예외 정보를 포함한 결과 로그를 출력.

테스트 결과
정상 종료
[48f13443] hello
[48f13443] hello time=12ms
예외 발생
[2256a079] hello
[2256a079] hello time=9ms ex=java.lang.IllegalStateException

테스트는 자동으로 검증하는 과정이 필요하다. 그래서 위 테스트 결과는 검증하는 과정이 없고 결과를 콘솔로 직접 확인해야 하기 때문에 온전한 테스트는 아니다.







✠ 만든 HelloTraceV1 적용

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을 통해 메서드 호출의 깊이를 표현하게 된다.







✠ HelloTraceV2 적용

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 코드에는 영향을 주지 않는다.




☪ 필드에 전략을 보관하는 방식 (ContextV1)

@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();
    }




☪ 전략을 파라미터로 전달 받는 방식 (ContextV2)

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;
        });
    }




☪ V1 예제

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();
    }
}



☪ V2 예제

인터페이스 없이 구현체로만 스프링 빈으로 수동 등록
이것만 수정하고 @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();
    }
}



☪ V3 예제

컴포넌트 스캔으로 스프링 빈을 자동 등록 -> @Controller, @Repository, @Service 애노테이션 사용.






✠ 프록시

클라이언트가 요청한 결과를 서버에 직접 요청하는 것이 아니라 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 예시로 내가 직접 마트에서 장을 볼 수도 있지만 대리자를 통해 대신 장을 봐달라고 부탁하는 것이다. 여기서 대리자를 프록시라고 한다.
직접 호출과 다르게 간접 호출을 하면 대리자가 중간에 여러가지 일을 할 수도 있다.
런타임에 클라이언트 객체에 DI를 사용하여 클라이언트의 코드 변경없이 유연하게 프록시를 주입.

☪ client -> proxy -> server



☪ 프록시의 주요 기능 -> 접근 제어, 부가 기능

✪접근 제어
✹ 권한에 따른 접근 차단 : 클라이언트가 권한이 없으면 예외 떠뜨리거나 반환함. 그러면 서버에 접근 불가.
✹ 캐싱 : 클라이언트가 프록시에 요청을 했는데 프록시가 그 데이터가 있다고 하면 서버에 접근 하지 않고 바로 반환.
✹ 지연 로딩 : 클라이언트가 프록시를 가지고 사용하다가 실제 요청이 있을 때 데이터를 조회.



**✪부가 기능 추가** ✹ 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행. ✹ 요청 값이나 응답 값을 중간에 변형하거나 실행 시간을 측정해서 추가 로그를 남김.

**≛예시** ✦ 엄마에게 라면을 사달라고 부탁 했는데, 엄마는 그 라면은 이미 집에 있다고 할 수도 있다. 그러면 기대한 것보다 더 빨리 라면을 먹을 수 있다. (접근 제어, 캐싱)

✦ 아버지께 자동차 주유를 부탁했는데, 아버지가 주유 뿐만 아니라 세차까지 하고 왔다. 클라이언트가 기대한 것 외에 세차라는 부가 기능까지 얻게 되었다. (부가 기능 추가)

✦ 대리자가 또 다른 대리자를 부를 수도 있다. 예를 들어서 내가 동생에게 라면을 사달라고 했는데,
동생은 또 다른 누군가에게 라면을 사달라고 다시 요청할 수도 있다. 중요한 점은 클라이언트는 대리자를 통해서 요청했기 때문에 그 이후 과정은 모른다는 점이다. 동생을 통해서 라면이 나에게 도착하기만 하면 된다. (프록시 체인)







✠ 프록시 패턴, 데코레이터 패턴

프록시와 프록시 패턴은 다른 것이다. 프록시 패턴은 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);

장점

  • 추상화가 가능하다.
  • method 정보를 넘기거나 공통화 시키는 게 가능하다. -> 여러 개의 공통 로직을 한 번에 처리가 가능.

정리 : target.callA()와 target.callB() 코드를 리플렉션을 사용해서 Method 라는 메타정보로 추상화한 것이다. -> 공통 로직을 만듦.
그렇지만 클래스와 메서드의 메타 정보를 사용해서 애플리케이션을 유연하게 만들 수 있기는 하지만 런타임에 동작하기 때문에 컴파일 시점에 오류를 잡을 수 없다.


ex) callA가 아닌 callZ 이런 식으로 쓴다고 하면 실행이 되고 나서 오류를 발생시킨다.






✠ JDK 동적 프록시

개발자가 직접 프록시 클래스를 만들지 않아도 된다.
프록시 객체를 동적으로 런타임에 개발자 대신 만들어준다. 그리고 동적 프록시에 원하는 실행 로직을 지정할 수 있다.


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(메서드를 호출할 때 전달한 인수) 이다.


Object result = method.invoke() : 리플렉션을 사용해서 target 인스턴스의 메서드를 실행한다. 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);
동적 프록시에 적용할 핸들러 로직



**`Proxy.newProxyInstance(AInterface.class.getClassLoader(), new Class[] {AInterface.class}, handler)`** 동적 프록시는 java.lang.reflect.Proxy를 통해서 생성할 수 있다. 클래스 로더 정보, 인터페이스, 그리고 핸들러 로직을 넣어주면 된다. 그러면 해당 인터페이스를 기반으로 동적 프록시를 생성하고 그 결과를 반환한다.

실행 결과

targetClass=class hello.proxy.jdkdynamic.code.AImpl
proxyClass=class com.sun.proxy.$Proxy12

$Proxy12 가 구현하고 있는 인터페이스는 A인터페이스를 구현받아서 프록시가 만들어진 것.
프록시에 call을 호출하게 되면 프록시는 Handler에 있는 로직을 수행한다. -> TimeInvocationHandler에 있는 invoke를 수행.




실행 순서

  1. 클라이언트는 JDK 동적 프록시의 call() 을 실행한다.
  2. JDK 동적 프록시는 InvocationHandler.invoke() 를 호출한다. TimeInvocationHandler 가 구현체로 있으로 TimeInvocationHandler.invoke() 가 호출된다.
  3. TimeInvocationHandler 가 내부 로직을 수행하고, method.invoke(target, args) 를 호출해서 target 인 실제 객체( AImpl )를 호출한다.
  4. AImpl 인스턴스의 call() 이 실행된다.
  5. 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 (인터페이스 없이 동적 프록시 적용)

인터페이스 없이 클래스만 있는 경우. 동적 프록시를 적용하는 방법이다.


  • CGLIB는 바이트코드를 조작해서 동적으로 클래스르 생성하는 기술을 제공.
  • CGLIB 사용하면 구체 클래스만 가지고 동적 프록시 만들 수 있다.
  • 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다.
  • 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용 가능하다.

CGLIB를 직접 사용하는 경우는 거의 없고 ProxyFactory라는 것이 편리하게 사용 가능하도록 도와주기 때문에 무엇인지 개념만 잡으면 된다.


  • CGLIB는 MethodInterceptor 인터페이스를 제공함.
  • obj : CGLIB가 적용된 객체
  • method : 호출된 메서드
  • args : 메서드를 호출하면서 전달된 인수
  • proxy : 메서드 호출에 사용


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;
    }
}

TimeMethodInterceptorMethodInterceptor 인터페이스를 구현해서 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;
        }
    }





스프링은 Pointcut 인터페이스를 제공

@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 : 애노테이션으로 매칭


☪ 5. AspectJExpressionPointcut

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);
    }
}





✠ 빈 후처리기

  1. ProxyFactoryConfigV1,V2같은 설정 파일이 매우 많다. 스프링 빈이 100개 있으면 프록시를 통해 부가 기능을 적용하기 위해서 100개의 동적 프록시 생성 코드를 만들어야 한다. 그리고 컴포넌트 스캔을 사용할 때 직접 등록하고 프록시르 적용하는 코드까지 빈 생성 코드에 넣어야 한다.

  2. 컴포넌트 스캔을 사용하는 경우 프록시 적용이 불가능하다. 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문이다.
    부가 기능이 있는 프록시를 실제 객체 대신 스프링 컨테이너에 빈으로 등록을 하고 난 다음에 실제 객체는 프록시를 통해서 호출이 되어야 한다.
    이 2가지 문제점을 해결해준다.




등록하고 나면 스프링 컨테이너를 통해 빈 이름으로 beanA를 조회하게 되면 A 객체가 반환된다.

빈 후처리기 - BeanPostProcessor

생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶을 때 사용한다. 빈을 생성한 후에 무언가를 처리하는 용도로 사용한다.


기능이 막강하기 때문에 알아두는 것이 좋다.
객체에다가 메서드를 호출하거나 값을 넣을 수도 있고 완전히 다른 객체로 바꿔치기하여 빈 저장소에 등록하는 것도 가능하다.



☪ 빈 후처리기 사용해서 다른 객체로 바꿔치기 한 코드

사용하려면 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 관련 클래스를 자동으로 스프링 빈에 등록한다.



☪ 자동 프록시 생성기 - AutoProxyCreator

스프링 부트 자동 설정으로 빈 후처리기가 스프링 빈에 자동으로 등록된다.
빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다. Advisor만 알고 있으면 그 안에 있는 Pointcut으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다.





  1. 스프링이 스프링 빈 대상이 되는 객체를 생성 (@Bean, 컴포넌트 스캔 모두 포함)
  2. 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달.
  3. 자동 프록시 생성기(빈 후처리기)는 스프링 컨테이너에서 모든 Advisor를 조회.
  4. Advisor에 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단. 이때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭해본다. 그래서 조건이 하나라도 만족하면 프록시 적용 대상이 된다.
    (메서드가 많아도 하나만 포인트컷 조건에 만족하면 프록시 적용 대상이 된다는 뜻.)
  5. 프록시 적용 대상이면 프록시 생성하고 반환해서 프록시를 스프링 빈으로 등록. 프록시 적용 대상이 아니면 원본 객체를 그대로 반환해서 스프링 빈으로 등록.
  6. 반환된 객체는 스프링 빈으로 등록.

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 포인트컷 표현식

  • : 모든 반환 타입
    app.. : 해당 패키지와 그 하위 패키지
    (..) : 모든 메서드 이름, (..) 파라미터는 상관X.






✠ 포인트컷 사용.

  1. 프록시 적용 여부 판단.
    ✦자동 프록시 생성기는 포인트컷을 사용해서 빈이 프록시를 생성할 필요 여부를 판단한다.
    ✦클리스 + 메서드 조건을 모두 비교한다. 모든 메서드를 체크하여 포인트컷 조건에 하나하나 매칭해본다. 조건에 하나라도 맞으면 프록시 생성.
  2. 어드바이스 적용 여부 판단.
    ✦프록시가 호출되었을 때 부가 기능인 어드바이스를 적용할지 말지를 결정.

프록시를 모든 곳에 생성하고 적용하는 것은 비용 낭비이므로 필요한 곳에만 최소한의 프록시를 적용한다. 그래서 포인트컷으로 한번 필터링해서 어드바이스가 사용될 가능성이 있는 곳에만 프록시를 생성한다.






✠ 하나의 프록시, 여러 Advisor 적용

스프링 빈이 advisor가 제공하는 포인트컷의 조건을 모두 만족해도 프록시 자동 생성기는 프록시를 하나만 생성한다. (프록시 팩토리가 생성하는 프록시는 내부에 여러 advisor들을 포함할 수 있기 때문에.)







✠ @Aspect 프록시 적용

@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를 모두 조회.
(나머지 생성, 전달, 프록시 적용 등은 같음.)







✠ AOP

애스팩트
부가 기능과 이 기능을 어디에 적용할 지 선택하는 기능을 합해서 하나의 모듈로 만든 것을 애스팩트라 한다. 즉 부가 기능을 어디에 적용할지 정의한 것이다. @Aspect도 애스팩트에 해당된다.


애스팩트를 사용한 프로그래밍 방식을 AOP(관점 지향 프로그래밍)라 한다.
횡단 관심사(하나의 부가 기능이 여러 곳에 동일하게 사용됨.)를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.



☪ AOP 적용 방식.

AOP를 사용하면 핵심 기능과 부가 기능이 코드상 완전히 분리되서 관리되므로 부가 기능 로직은 다른 방식으로 실제 로직에 추가해야 한다.

1. 컴파일 시점
소스 코드를 컴파일러를 사용해서 .class를 만드는 시점에 부가 기능 로직을 추가한다.(AspectJ가 제공하는 특별한 컴파일러 사용.)
부가 기능 코드가 핵심 기능이 있는 컴파일된 코드 주변에 실제로 붙어 버린다.
(위빙 : 원본 로직에 부가 기능 로직이 추가되는 것.)

단점 : 컴파일 시점에 부가 기능을 적용하려면 특별한 컴파일러도 필요하고 복잡하다.


2. 클래스 로딩 시점
자바를 실행하면 자바 언어는 .class 파일을 JVM 내부의 클래스 로더에 보관하는데 .class 를 조작하여 JVM에 저장하여 JVM에 저장하기 전에 조작할 수 있는 기능을 제공한다.


단점 : 자바를 실행할 때 특별한 옵션( java -javaagent )을 통해 클래스 로더 조작기를
지정해야 하는데, 이 부분이 번거롭고 운영하기 어렵다.

3. 런타임 시점(프록시)
실제 대상 코드는 그대로 유지된다. 대신에 프록시를 통해 부가 기능이 적용된다. 따라서 항상 프록시를 통해야 부가 기능을 사용할 수 있다. 스프링 AOP는 이 방식을 사용한다.
자바 언어가 제공하는 범위 안에서 부가 기능을 적용.


☪ AOP 적용 위치

적용 가능 지점(JoinPoint) : 생성자, 필드 값 접근, static 메서드 접근, 메서드 실행
JoinPoint : AOP를 적용할 수 있는 지점.

프록시 방식을 사용하는 스프링 AOP는 메서드 실행 지점에만 AOP를 적용할 수 있다.
' ✮ 프록시는 메서드 오버라이딩 개념으로 동작한다. 따라서 생성자나 static 메서 드, 필드 값 접근에는 프록시 개념이 적용될 수 없다.
' ✮ 스프링 AOP는 프록시를 사용하므로 프록시는 결국 메서드를 실행하는 지점에서만 다음 타겟을 호출할 수 있기 때문에 프록시를 사용하는 스프링 AOP의 조인 포인 트는 메서드 실행으로 제한된다.



빈으로 등록이 될 때 프록시를 생성되기 때문에 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용할 수 있다.
런타임 시점 : 런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음. -> 자바의 메인( main ) 메서드가 이미 실행된 다음.







✠ AOP 구현

☪ aop 사용할 때는 항상 build.gradle에 넣어준다.

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로 사용하려면 빈으로 등록을 해야 한다.



☪ 스프링 빈으로 등록하는 방법

  1. @Bean으로 직접 등록
  2. @Component로 컴포넌트 스캔 사용해서 자동 등록
  3. @Import -> 설정 파일을 추가할 때 사용.





✠ 포인트컷 분리

☪ 기존

@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();
    }

☪ @Pointcut

✫ 메서드 이름과 파라미터를 합쳐서 포인트컷 시그니처(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를 실행한다.







✠ 어드바이스 종류

  1. @Around : 메서드 호출 전후에 수행. 가장 강력한 어드바이스. 조인 포인트 실행 여부 선택, 반환값 반환, 예외 변환 등. (어드바이스의 첫 번째 파라미터는 ProceedingJoinPoint를 사용. -> 규칙임.)
  2. @Before : 조인 포인트 실행 이전에 실행.
  3. @AfterReturning : 조인 포인트가 정상 완료 후 실행.
  4. After Throwing : 메서드가 예외를 던지는 경우 실행.
  5. After : 조인 포인트가 정상 또는 예외에 관계없이 실행.

@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으로 런타임까지 남아있는다 라는 의미.





✠ Execution 문법 (자주 사용.)

@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();
    }
}

. : 정확하게 해당 위치의 패키지
.. : 해당 위치의 패키지아 그 하위 패키지들도 포함.







✠ execution 부모,자식 타입 매칭

자식 타입(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();
       
    }






✠ execution 파라미터 매칭

//파라미터 매칭
    //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 포인트컷 지시자.






✠ 포인트컷 지시자 within (거의 사용하지 않음.)

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();
}





✠ args (거의 사용 X)

☪ 단독으로는 사용X, 파라미터 바인딩에서 주로 사용.

기본 문법은 execution과 유사하지만 excution은 클래스에 선언된 정보를 기반으로 파라미터 타입이 정확하게 매칭되어야 하지만 args는 실제 넘어온 파라미터 객체 인스턴스를 보고 판단하고 부모 타입을 허용한다.


한가지만 보면 execution은 파라미터 타입이 정확해야 한다. 부모 타입으로도 Object로도 매칭이 되지 않는다. 하지만 args는 매칭이 된다.






✠ @target, @within

타입에 있는 애노테이션으로 AOP 적용 여부를 판단한다.
@target : 인스턴스의 모든 메서드를 조인 포인트로 적용.
@within : 해당 타입 내에 있는 메서드만 조인 포인트로 적용.


쉽게 해서 @target이 있는 childMethod()는 어드바이스를 부모 parentMethod()까지 적용하고 @within이 있는 childMethod()는 어드바이스를 부모 parentMethod()를 적용하지 않는다.






✠ @annotation

@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();
        }
    }
}





✠ 포인트컷 지시자 Bean

스프링 빈의 이름으로 매칭하여 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)을 꺼낼 수 있다.
애노테이션의 값을 출력하는 모습을 확인할 수 있다.






✠ this, target

다시 보기






✠ @Retry






✠ 스프링 AOP - 실무 주의사항

☪ 스프링은 프록시 방식의 AOP를 사용한다.

따라서 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를 사용한다. 프록시 방식의 AOP는 메서드 내부 호출에 프록시를 적용할 수 없다. 즉 위처럼 자기 자신의 internal()을 호출하게 되면 프록시가 끼어들 수 있는 지점이 없어서 방법이 없다.





✠ 프록시와 내부 호출 - 대안1

사진처럼 자기 자신을 의존 관계에 주입시켜버리는 것.

그러면 프록시가 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");
    }
}





✠ 프록시와 내부 호출 - 대안2

생성자 주입을 하지 않고 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");
    }
}





✠ 프록시와 내부 호출 - 대안3

구조를 변경해서 내부 호출이 발생하지 않도록 하는 방법을 가장 권장.
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 타입이 뭔지 전혀 모르기 때문에 타입에 주입할 수가 없다. 따라서 타입 예외가 발생한 것이다.






✠ CGLIB 단점

✡ 대상 클래스에 기본 생성자가 필수

MemberServiceImpl에 기본 생성자가 필수다. (CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아님.)
CGLIB는 구체 클래스를 상속받는다. 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되면 super()가 자동으로 생성됨.)



✡ 생성자 2번 호출 문제

첫 번째 생성자 호출 ⇢ 실제 target의 객체를 생성하여 MemberServiceImpl에 생성자를 생성.

두 번째 생성자 호출 ⇢ 프록시 객체를 생성할 때 부모 클래스의 생성자 호출 (자동으로 CGLIB가 만드는 프록시의 생성자.)



✡ final 키워드 클래스 메서드 사용 불가. (크게 문제는 X.)

final 키워드가 클래스에 있으면 상속이 불가!!!. 메서드에 있으면 오버라이딩 불가.
CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다.
그런데 프레임워크 같은 개발이 아닌 일반적인 웹 애플리케이션을 개발할 때는 final 키워드를 잘 사용하지 않기 때문에 큰 문제는 X.






✡ 대상 클래스에 기본 생성자가 필수

objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성하여 문제 해결.


✡ 생성자 2번 호출 문제

역시 objenesis 라이브러리로 해결.

profile
일상의 인연에 감사하라. 기적은 의외로 가까운 곳에 있을지도 모른다.

0개의 댓글