Home Spring Framework - AOP 주의사항
Post
Cancel

Spring Framework - AOP 주의사항

프록시와 내부 호출 문제

스프링은 프록시 방식의 AOP를 사용한다. 따라서 AOP를 적용하려면 항상 프록시를 통해서 대상 객체(Target)을 호출해야 한다. 이렇게 해야 프록시에서 먼저 어드바이스를 호출하고 이후 대상 객체를 호출한다. 만약 프록시를 거치지 않고 대상 객체를 직접 호출하게되면 AOP가 적용되지 않고 어드바이스도 호출되지 않는다.

스프링은 의존관계 주입시, AOP가 적용된 객체라면 대상 객체 대신에 프록시를 스프링 빈으로 등록하기 떄문에 외부에서 대상 객체를 직접 호출하는 문제는 발생하지 않는다. 그러나 대상 객체의 내부에서 내부 메소드 호출이 발생하면 프록시를 거치치 않고 대상 객체를 직접 호출하는 문제가 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@Component
public class CallServiceV0 {

  public void external() {
    log.info("call external");

    // this.internal()로 호출됨
    internal();
  }

  public void internal() {
    log.info("call internal");
  }
}
1
2
3
4
5
6
7
8
9
@Slf4j
@Aspect
public class CallLogAspect {

  @Before("execution(* hello.aop.internalcall..*.*(..))")
  public void doLog(JoinPoint joinPoint) {
    log.info("aop={}", joinPoint.getSignature());
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
@Import(CallLogAspect.class)
@SpringBootTest
public class CallServiceV0Test {

  @Autowired
  CallServiceV0 callService;

  @Test
  void external() {
    callService.external();
  }
}
# external() Test
2023-11-08 14:17:10.280  INFO 60643 --- [           main] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.CallServiceV0.external()
2023-11-08 14:17:10.289  INFO 60643 --- [           main] hello.aop.internalcall.CallServiceV0     : call external
2023-11-08 14:17:10.289  INFO 60643 --- [           main] hello.aop.internalcall.CallServiceV0     : call internal

# internal() Test
2023-11-08 14:17:10.297  INFO 60643 --- [           main] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.CallServiceV0.internal()
2023-11-08 14:17:10.297  INFO 60643 --- [           main] hello.aop.internalcall.CallServiceV0     : call internal


실행 결과를 확인해보면 callService.external() 실행시 프록시가 호출되어 CallLogAspect의 어드바이스가 호출되었고, AOP Proxy는 실제 대상 객체인 target.external() 을 호출하여 로그가 출력되었다. 그러나 이후 external() 메소드 내부에서 internal() 호출시 CallLogAspect의 어드바이스가 호출되지 않았다.

이는 자바에서는 메소드 앞에 별도의 참조가 없나면 this라는 뜻으로 자기자신의 인스턴스를 가리키기 떄문이다. 즉, 자기 자신의 내부 메소드를 호출하는 this.internal()이 되어 프록시를 거치지 않고 실제 대상 객체의 인스턴스의 internal() 메소드를 바로 호출하기 때문이다.

Alt text

스프링은 프록시 방식의 AOP를 사용하고, 프록시 방식의 AOP는 메소드 내부 호출에는 프록시를 적용할 수 없으므로 위와 같은 문제가 발생하게 된다.

대안 1 - 자기 자신 주입

내부 호출을 해결하는 가장 간단한 방법은 자기 자신을 의존관계 주입 받는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * 참고: 생성자 주입은 순환 사이클을 만들기 때문에 실패한다.
 */
@Slf4j
@Component
public class CallServiceV1 {

  private CallServiceV1 callService;

  @Autowired
  public void setCallServiceV1(CallServiceV1 callService) {
    this.callService = callService;
  }

  public void external() {
    log.info("call external");

    // 자기 자신을 주입 받고 해당 객체로 메소드 호출
    callService.internal();
  }

  public void internal() {
    log.info("call internal");
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
@Import(CallLogAspect.class)
@SpringBootTest(properties = "spring.main.allow-circular-references=true")
public class CallServiceV1Test {

  @Autowired
  CallServiceV1 callService;

  @Test
  void external() {
    callService.external();
  }
}
2023-11-08 14:41:18.835  INFO 61926 --- [           main] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.CallServiceV1.external()
2023-11-08 14:41:18.843  INFO 61926 --- [           main] hello.aop.internalcall.CallServiceV1     : call external
2023-11-08 14:41:18.843  INFO 61926 --- [           main] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.CallServiceV1.internal()
2023-11-08 14:41:18.843  INFO 61926 --- [           main] hello.aop.internalcall.CallServiceV1     : call internal

Alt text

간편한 방법이지만, 위 방법은 스프링 부트 2.6부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었기 떄문에 2.6 이상의 스프링 부트에서는 아래와 같이 스프링 옵션을 주어야 동작 가능하다.

  • spring.main.allow-circular-references=true
지연 조회

위 예시에서 생성자 주입이 실패하는 이유는 자기 자신을 생성하면서 주입해야 하기 떄문이다. 이 경우 setter 주입을 사용하거나 지연조회를 사용하면 된다.

setter 주입의 경우 위 예시와 같은 방법이며 지연조회의 경우는 ObjectProvider(Provider), ApplicationContext를 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
 * ObjectProvider(Provider), ApplicationContext를 사용해서 지연(LAZY) 조회
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

  private final ObjectProvider<CallServiceV2> callServiceProvider;

  public void external() {
    log.info("call external");

    CallServiceV2 callService = callServiceProvider.getObject();
    callService.internal();
  }

  public void internal() {
    log.info("call internal");
  }
}

ObjectProvider는 객체를 스프링 컨테이너에서 조회하는 것을 스프링 빈 생성 시점이 아닌 실제 객체를 사용하는 지점으로 지연시킬 수 있다.

대안 3 - 구조 변경

앞의 방법은 자기 자신을 주입하는 방법으로 순환참조하는 형식이어서 권장되는 방법은 아니다. 가장 나은 대한은 내부 호출이 발생하지 않도록 아예 클래스를 나눠 구조를 분리하는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
 * 구조를 변경(분리)
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {

  private final InternalService internalService;

  public void external() {
    log.info("call external");
    internalService.internal();
  }
}
1
2
3
4
5
6
7
8
@Slf4j
@Component
public class InternalService {

  public void internal() {
    log.info("call internal");
  }
}
2023-11-08 15:52:20.143  INFO 63553 --- [           main] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.CallServiceV3.external()
2023-11-08 15:52:20.149  INFO 63553 --- [           main] hello.aop.internalcall.CallServiceV3     : call external
2023-11-08 15:52:20.149  INFO 63553 --- [           main] h.aop.internalcall.aop.CallLogAspect     : aop=void hello.aop.internalcall.InternalService.internal()
2023-11-08 15:52:20.152  INFO 63553 --- [           main] hello.aop.internalcall.InternalService   : call internal

Alt text

내부 호출 자체가 사라지고 callService -> internalService를 호출하는 구조로 변경되었다. internalService가 별도의 클래스로 빠져나오면서 자연스럽게 AOP가 적용된다.

여기서 구조를 변경한다는 것은 단순히 분리하는 것 뿐만 아니라 다양한 방법이 있을수 있다. 예를 들면 다음과 같이 클라이언트에서 둘다 호출하는 방법도 있다. 클라이언트 -> external() 클라이언트 -> internal()

참고 AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. 즉, 인터페이스에 메소드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 풀어서 이야기 한다면 AOP는 public 메소드에만 적용하고 private 메소드 처럼 작은 단위에는 AOP를 적용하지 않는다. AOP를 적용하기 위해 private 메소드를 외부 클래스로 변경하고 public 으로 변경하는 일은 거의 없다.

프록시 기술과 한계

타입 캐스팅

JDK 동적 프록시와 CGLIB을 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다. JDK 동적 프록시는 인터페이스가 필수이고 인터페이스를 기반으로 프록시를 생성한다. CGLIB은 구체 클래스를 기반으로 프록시를 생성한다.

즉, 인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB을 사용해야 하고 인터페이스가 있는 경우에는 JDK 동적 프록시나 CGLIB 둘중에 하나를 선택할 수 있다.

JDK 동적 프록시의 한계

인터페이스 기반으로 프록시를 생성하는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Test
void jdkProxy() {
  MemberServiceImpl target = new MemberServiceImpl();
  ProxyFactory factory = new ProxyFactory(target);

  // JDK 동적 프록시 사용 설정
  factory.setProxyTargetClass(false);

  // 프록시를 인터페이스로 캐스팅 성송
  MemberService memberServiceProxy = (MemberService)factory.getProxy();

  log.info("proxy class={}", memberServiceProxy.getClass());

  // JDK 동적 프록시를 구현 클래스로 캐스팅 시도시 실패
  // ClassCastException 예외 발생
  Assertions.assertThrows(ClassCastException.class,
    () -> {
      MemberServiceImpl memberServiceImplProxy = (MemberServiceImpl)factory.getProxy();
    });
}

Alt text

JDK 동적 프록시 캐스팅 Alt text

JDK Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅하려 하니 ClassCastException 예외가 발생한다. 예외가 발생하는 이유는 JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 떄문에 JDK Proxy는 MemberServiceImpl의 존재를 알 수 없기 떄문이다.

이에 반해 CGLIB을 사용하는 경우는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Test
void cglibProxy() {
  MemberServiceImpl target = new MemberServiceImpl();
  ProxyFactory factory = new ProxyFactory(target);

  // CGLIB 프록시 사용
  factory.setProxyTargetClass(true);

  // 프록시를 인터페이스로 캐스팅 성송
  MemberService memberServiceProxy = (MemberService)factory.getProxy();

  log.info("proxy class={}", memberServiceProxy.getClass());

  // 구현 클래스로 캐스팅 성공
  MemberServiceImpl memberServiceImplProxy = (MemberServiceImpl)factory.getProxy();

  Assertions.assertInstanceOf(MemberService.class, memberServiceProxy);
  Assertions.assertInstanceOf(MemberServiceImpl.class, memberServiceImplProxy);
}

Alt text

CGLIB 프록시 캐스팅 Alt text

이 경우에는 CGLIB Proxy를 대상 클래스인 MemberServiceImpl 타입으로 캐스팅이 가능하다. CGLIB은 구체 클래스인 MemberServiceImpl 기반으로 프록시를 생성하기 떄문에, 구체 클래스인 MemberServiceImpl과 MemberServiceImpl의 인터페이스인 MemberService으로도 타입 캐스팅이 가능하다.

이러한 캐스팅문제는 의존관계 주입시에 발생하게 된다.

의존관계 주입

JDK 동적 프록시 사용시 의존관계 주입을 할때 생기는 문제의 예시 코드를 보면 아래와 같다.

1
2
3
4
5
6
7
8
9
@Slf4j
@Aspect
public class ProxyDIAspect {

  @Before("execution(* hello.aop..*.*(..))")
  public void doTrace(JoinPoint joinPoint) {
    log.info("[ProxyDIAspect] {}", joinPoint.getSignature());
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Import(ProxyDIAspect.class)
// JDK 동적 프록시, DI 예외 발생
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"})
// CGLIB 프록시, 성공
// @SpringBootTest(properties = {"spring.aop.proxy-target-class=true"})
public class ProxyDITest {

  // JDK 동적 프록시 OK, CGLIB OK
  @Autowired
  MemberService memberService;

  // JDK 동적 프록시 X, CGLIB OK
  @Autowired
  MemberServiceImpl memberServiceImpl;

  @Test
  void go() {
    log.info("memberService class={}", memberService.getClass());
    log.info("memberServiceImpl class={}", memberServiceImpl.getClass());
    memberServiceImpl.hello("hello");
  }
}

위 예시 코드의 경우, 실행해보면 의존관계를 주입하는 시점에 타입과 관련된 에러가 발생하게 된다.

BeanNotOfRequiredTypeException: Bean named 'memberServiceImpl' is expected to be of type 'hello.aop.member.MemberServiceImpl' but was actually of type 'com.sun.proxy.$Proxy54'

즉, memberSericeImpl에 주입되길 기대하는 타입은 ‘hello.aop.member.MemberServiceImpl’ 이지만, 실제 주입시 넘어온 타입은 ‘com.sun.proxy.$Proxy54’ 이여서 예외가 발생한 것이다. 이에 대하여 동작에 대한 설명은 아래와 같다.

Alt text

  • @Autowired MemberService memberService
    • JDK Proxy는 MemberService 인터페이스를 기반으로 만들어지기 때문에 해당 타입으로 캐스팅할 수 있으며 주입도 가능
  • @Autowired MemberService memberServiceImpl
    • 해당 부분에서 예외 발생
    • JDK Proxy는 MemberService 인터페이스를 기반으로 만들어지기 때문에 memberServiceImpl 타입에 대해 알지 못하므로 캐스팅할 수 없고, 주입 또한 불가능

위 상황에 대하여 CGLIB 프록시를 구체 클래스에 주입시에는 정상적으로 의존관계 주입이 동작하게 된다.

Alt text

  • @Autowired MemberService memberService
    • CGLIB Proxy는 MemberServiceImpl 구체 클래스 기반으로 만들어지고, MemberServiceImpl 은 MemberService 인터페이스를 구현했기 때문에 타입 캐스팅 및 주입 가능
  • @Autowired MemberService memberServiceImpl
    • CGLIB Proxy는 MemberServiceImpl 구체 클래스를 기반으로 만들어지기 때문에 해당 타입으로 캐스팅할 수 있으며 주입도 가능


위 내용에 대하여 정리하자면, JDK 동적 프록시는 인터페이스가 아닌 구현 객체 타입에 의존관계를 주입할 수 없다. 반대로 CGLIB 프록시는 대상 객체(구현 객체) 타입에 의존관계를 주입할 수 있다.


객체지향 관점으로 바라보게 되면, 실제로 개발할 떄는 인터페이스가 았다면 인터페이스를 기반으로 의존관계 주입을 받는 것이 맞다. 인터페이스를 기반으로 의존관계를 주입받아야 DI 받는 클라이언트 코드의 변경 없이 구현 클래스를 변경할 수 있기 때문이다. 구현 클래스에 의존관계를 주입받게 되면 향후 구현 클래스를 변경할떄 의존관계 주입을 받는 클라이언트 코드도 함꼐 변경해야 한다.

그러므로 올바르게 잘 설계된 어플리케시연 이라면 위와 같은 문제가 자주 발생하지 않는다. 그러나 가끔 테스트 또는 여러 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 생기게 되는데, 이떄는 CGLIB을 통하여 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.

CGLIB

스프링에서 CGLIB은 구체 클래스를 상속 받아서 AOP 프록시를 생성할 때 사용한다. CGLIB은 구체 클래스를 상속 받기 때문에 아래와 같은 문게가 발생한다.

  • 대상 클래스에 기본 생성자 필수
  • 생성자 2번 호출 문제
  • final 키워드 클래스, 메소드 사용 불가
대상 클래스에 기본 생성자 필수

CGLIB은 구체 클래스를 상속 받는다. 자바에서 상속을 받으면 자식 클래스의 생성자를 호출할떄 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되어 있따면 자식 클래스의 생성자 첫줄에 부모 클래스의 기본 생성자를 호출하는 super()가 자동으로 들어간다.)

CGLIB을 사용할 떄 CGLIB가 만드는 프록시의 생성자는 개발자가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 그러므로 대상 클래스의 기본 생성자를 만들어야 CGLIB 프록시가 기본 생성자를 호출할 수 있다.

생성자 2번 호출 문제

CGLIB은 구체 클래스를 상속 받는다. 자바에서 상속을 받으면 자식 클래스의 생성자를 호출할떄 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. 이떄 아래와 같이 두번의 생성자가 호출되어 진다.

  • 실제 target 객체를 생성할 떄
  • 프록시 객체를 생성할 떄 부모 클래스의 생성자 호출

Alt text

final 키워드 클래스, 메소드 사용 불가

final 키워드가 클래스에 있다면 상속이 불가능하고, 메소드에 있다면 오버라이딩이 불가능하다. 그러므로 상속을 기반으로 하는 CGLIB 프록시 방식은 두경우에는 프록시가 생성되지 않거나 정상동작 하지 않는다.

그러나 실무에선 프레임워크와 같은 개발이 아닌 일반 어플리케이션을 개발할 떄는 보통 final 키워드를 잘 사용하지 않기 떄문에, 이부분에 대해선 크게 문제되지 않는다.

스프링의 해결책

스프링 4.0 부터 objenesis 라는 라이브러리를 사용하여 기본생성자 없이 객체 생성이 가능해졌다. 기본 생성자없이 객체 생성이 가능해 졌으므로 CGLIB의 기본생성자가 필수인 문제와 생성자 2번 호출 문제가 해결되었다.

스프링 부트 2.0 부터는 CGLIB을 기본으로 사용하도록 되어서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결하였다. 스프링 부트 2.0 버전부터는 별도의 설정이 없다면 AOP를 적용할떄 proxyTargetClass=true 으로 설정하여 사용한다. 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB을 사용하여 구체클래스를 기반으로 프록시를 생성한다.


참고

  • 스프링 핵심 원리 - 고급편(김영한)
This post is licensed under CC BY 4.0 by the author.

Spring Framework - AOP 구현 예시

simpleDB - 메모리