728x90
반응형
SMALL

참고자료

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com

 

프록시와 내부 호출 - 문제

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

 

AOP를 적용하면 스프링은 대상 객체 대신에 프록시를 스프링 빈으로 등록한다. 따라서 스프링은 의존관계 주입시에 항상 프록시 객체를 주입한다. 프록시 객체가 주입되기 때문에 대상 객체를 직접 호출하는 문제는 일반적으로 발생하지 않는다. 하지만 대상 객체의 내부에서 메서드 호출이 발생하면 프록시를 거치지 않고 대상 객체를 직접 호출하는 문제가 발생한다. 실무에서 반드시 한번은 만나서 고생하는 문제이기 때문에 꼭 이해하고 넘어가자.

 

예제를 통해서 내부 호출이 발생할 때 어떤 문제가 발생하는지 알아보자. 

 

CallServiceV0

package cwchoiit.springadvanced.aop.internalcall;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CallServiceV0 {

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

    public void internal() {
        log.info("internal");
    }
}
  • CallServiceV0.external()을 호출하면 내부에서 internal() 이라는 자기 자신의 메서드를 호출한다. 자바 언어에서 메서드를 호출할 때 대상을 지정하지 않으면 앞에 자기 자신의 인스턴스를 뜻하는 this가 붙게 된다. 그러니까 여기서는 this.internal() 이라고 이해하면 된다.

InternalAspect

package cwchoiit.springadvanced.aop.internalcall.aop;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Slf4j
@Aspect
public class InternalAspect {

    @Before("execution(* cwchoiit.springadvanced.aop.internalcall..*.*(..))")
    public void before(JoinPoint joinPoint) {
        log.info("[Before] {}", joinPoint.getSignature());
    }
}
  • CallServiceV0에 AOP를 적용하기 위해서 간단한 @Aspect를 하나 만들자.
  • 이렇게 하면, CallServiceV0external(), internal() 모두 AOP 적용 대상이 된다.

CallServiceV0Test

package cwchoiit.springadvanced.aop.internalcall;

import cwchoiit.springadvanced.aop.internalcall.aop.InternalAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
@Import(InternalAspect.class)
@SpringBootTest
class CallServiceV0Test {

    @Autowired
    CallServiceV0 callServiceV0;

    @Test
    void external() {
        log.info("target = {}", callServiceV0.getClass());
        callServiceV0.external();
    }

    @Test
    void internal() {
        log.info("target = {}", callServiceV0.getClass());
        callServiceV0.internal();
    }
}
  • 이제 앞서 만든 CallServiceV0을 실행할 수 있는 테스트 코드를 만들자.
  • @Import(InternalAspect.class)를 사용해서 앞서 만든 @Aspect를 스프링 빈으로 등록한다. 이렇게 해서 CallServiceV0에 AOP 프록시를 적용한다.

먼저, callServiceV0.external()을 실행해보자. 이 부분이 중요하다.

실행 결과 - external()

target = class cwchoiit.springadvanced.aop.internalcall.CallServiceV0$$SpringCGLIB$$0
[Before] void cwchoiit.springadvanced.aop.internalcall.CallServiceV0.external()
external
internal
  • 실행 결과를 보면, callServiceV0.external()을 실행할 때는 프록시를 호출한다. 따라서 InternalAspect 어드바이스가 호출된 것을 확인할 수 있다.
  • 그리고 AOP Proxy는 target.external()을 호출한다.
  • 그런데 여기서 문제는, callServiceV0.external() 안에서 internal()을 호출할 때 발생한다. 이때는 실행 결과를 보면 알 수 있듯 어드바이스가 호출되지 않는다!

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this 라는 뜻으로 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메서드를 호출하는 this.internal()이 되는데, 여기서 this는 실제 대상 객체(target)의 인스턴스를 뜻한다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않는다. 따라서 어드바이스도 적용할 수 없다. 아래 그림을 보면 좀 더 이해가 될 것이다.

 

이번에는 외부에서 internal()을 호출하는 테스트를 실행해보자.

실행 결과 - internal()

target = class cwchoiit.springadvanced.aop.internalcall.CallServiceV0$$SpringCGLIB$$0
[Before] void cwchoiit.springadvanced.aop.internalcall.CallServiceV0.internal()
internal

  • 외부에서 호출하는 경우, 프록시를 거치기 때문에 internal()InternalAspect 어드바이스가 적용된 것을 확인할 수 있다.

 

이런 문제를 프록시 내부 호출 문제라고 한다. 일단 이 문제가 왜 발생하는지 이해하는 게 가장 중요하다. 

참고로, 실제 코드에 AOP를 직접 적용하는 AspectJ를 사용하면 이런 문제가 발생하지 않는다. 프록시를 통하는 것이 아니라 해당 코드에 직접 AOP 적용 코드를 바이트 조작으로 코드를 직접 아예 붙여버리기 때문에 내부 호출과 무관하게 AOP를 적용할 수 있다. 그러나 이 방법은 매우 복잡하고 JVM 옵션을 주어야 하는 부담이 있기 때문에 사용하지 않고 이 문제를 해결할 대안이 여럿 있기 때문에 그 대안을 알아보자.

 

프록시와 내부 호출 - 대안1 (자기 자신 주입)

결론부터 말하면 이 대안은 좋은 대안은 아니다. 그러나 방법 중 하나이긴 하다. 우선, 스프링 부트 2.6 이상부터는 순환 참조를 기본적으로 금지하도록 정책이 변경되었기 때문에 이 자기 자신을 주입하려면 다음과 같이 application.yaml 파일에 이 옵션을 추가해줘야 한다.

spring:
  main:
    allow-circular-references: true

 

CallServiceV1

package cwchoiit.springadvanced.aop.internalcall;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CallServiceV1 {

    private CallServiceV1 callServiceV1;

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

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

    public void internal() {
        log.info("internal");
    }
}
  • 이 방법은 생성자 주입이 아니다. 생성자 주입은 아예 불가능하다. 왜냐하면? 내가 빈으로 만들어져야 생성자에 주입을 할텐데 나를 만들기 전에 생성자에 나를 주입한다? 닭이 먼저냐 달걀이 먼저냐의 문제가 되는 것이다.
  • 그래서, 세터 주입을 사용하는데 세터 주입은 스프링이 띄워지고 빈으로 등록된 후에 주입이 가능하기 때문에 오류가 발생하지 않는다. 
  • 세터에 자기 자신을 주입하고 있는 모습을 확인할 수 있다. 여기서 주입 받는 것은 '프록시'다. external() 안에서 자기 자신을 호출하는 게 아니라 또 프록시의 internal()을 호출하기 때문에 문제를 해결할 수 있다.

CallServiceV1Test

package cwchoiit.springadvanced.aop.internalcall;

import cwchoiit.springadvanced.aop.internalcall.aop.InternalAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Slf4j
@Import(InternalAspect.class)
@SpringBootTest
class CallServiceV1Test {

    @Autowired
    CallServiceV1 callServiceV1;

    @Test
    void external() {
        log.info("target = {}", callServiceV1.getClass());
        callServiceV1.external();
    }

    @Test
    void internal() {
        log.info("target = {}", callServiceV1.getClass());
        callServiceV1.internal();
    }
}

실행 결과

target = class cwchoiit.springadvanced.aop.internalcall.CallServiceV1$$SpringCGLIB$$0
[Before] void cwchoiit.springadvanced.aop.internalcall.CallServiceV1.external()
external
[Before] void cwchoiit.springadvanced.aop.internalcall.CallServiceV1.internal()
internal

  • 실행 결과를 보면, 이제는 internal()을 호출할 때 자기 자신의 인스턴스를 호출하는 것이 아니라 프록시 인스턴스를 통해서 호출하는 것을 확인할 수 있다. 당연히 AOP도 잘 적용된다.

 

프록시와 내부 호출 - 대안2 (지연 조회)

CallServiceV2

package cwchoiit.springadvanced.aop.internalcall;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

    private final ApplicationContext applicationContext;

    public void external() {
        log.info("external");
        CallServiceV2 callServiceV2 = applicationContext.getBean(CallServiceV2.class);
        callServiceV2.internal();
    }

    public void internal() {
        log.info("internal");
    }
}
  • ApplicationContext를 직접 주입받고, 여기서 자기 자신의 빈을 꺼내는 것이다. 그럼 똑같이 프록시가 꺼내질 것이고 문제가 해결된다. 그런데 ApplicationContext이건 너무 거대하다. 그냥 스프링 하나가 있다고 보면 되는데 우리는 굳이 이렇게 큰 것을 가져올 필요가 없다.
  • 그래서 다음과 같이 ObjectProvider를 사용해보자.

CallServiceV2 

package cwchoiit.springadvanced.aop.internalcall;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV2 {

    private final ObjectProvider<CallServiceV2> callServiceV2Provider;

    public void external() {
        log.info("external");
        CallServiceV2 callServiceV2 = callServiceV2Provider.getObject();
        callServiceV2.internal();
    }

    public void internal() {
        log.info("internal");
    }
}
  • 이렇게 ObjectProvider를 사용하면 된다. 딱 원하는 빈 하나만을 가져올 수 있도록 말이다.
  • 이렇게 딱 필요한 시점에 필요한 빈을 가져오니까 조회를 늦게 한다고 해서 지연 조회라고 한다.

 

프록시와 내부 호출 - 대안3 (구조 변경) ⭐️

앞선 방법들은 자기 자신을 호출하거나 또는 Provider를 사용해야 하는것처럼 조금 어색한 모습을 만들었다. 가장 나은 대안은 내부 호출이 발생하지 않도록 구조를 변경하는 것이다. 실제 이 방법을 가장 권장한다.

 

CallServiceV3

package cwchoiit.springadvanced.aop.internalcall;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class CallServiceV3 {

    private final CallServiceInternal internalCallService;

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

CallServiceInternal

package cwchoiit.springadvanced.aop.internalcall;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class CallServiceInternal {

    public void internal() {
        log.info("internal");
    }
}
  • 구조를 변경한다는 게 별게 아니라 그냥 internal() 메서드를 따로 빼버리고 별도의 클래스로 만들고 이 클래스를 주입받아 사용하면 된다. 
  • 결국 자기 자신을 호출하지만 않으면 되는 것이다.

CallServiceV3Test

package cwchoiit.springadvanced.aop.internalcall;

import cwchoiit.springadvanced.aop.internalcall.aop.InternalAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

@Slf4j
@Import(InternalAspect.class)
@SpringBootTest
class CallServiceV3Test {

    @Autowired
    CallServiceV3 callServiceV3;

    @Test
    void external() {
        log.info("target = {}", callServiceV3.getClass());
        callServiceV3.external();
    }
}

실행 결과

target = class cwchoiit.springadvanced.aop.internalcall.CallServiceV3$$SpringCGLIB$$0
[Before] void cwchoiit.springadvanced.aop.internalcall.CallServiceV3.external()
external
[Before] void cwchoiit.springadvanced.aop.internalcall.CallServiceInternal.internal()
internal

  • 내부 호출 자체가 사라지고, callService → internalService를 호출하는 구조로 변경되었다. 덕분에 자연스럽게 AOP가 적용된다.

 

참고로, AOP는 주로 트랜잭션 적용이나 주요 컴포넌트의 로그 출력 기능에 사용된다. 쉽게 이야기해서 인터페이스에 메서드가 나올 정도의 규모에 AOP를 적용하는 것이 적당하다. 더 풀어서 이야기하면, AOP는 public 메서드에만 적용한다. private 메서드처럼 작은 단위에는 적용하지 않는다. 적용할 수도 없다. AOP 적용을 위해 private 메서드를 외부 클래스로 변경하고 public으로 변경하는 일은 거의 없다. 그러나 위 예제와 같이 public 메서드에서 public 메서드를 내부 호출하는 경우에는 문제가 발생한다. AOP가 잘 적용되지 않으면 내부 호출을 의심해보자.

 

프록시 기술과 한계 - 타입 캐스팅

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

 

물론, 인터페이스가 없고 구체 클래스만 있는 경우에는 CGLIB를 사용해야 한다. 그런데 인터페이스가 있는 경우에는 둘 중 하나를 선택해서 프록시를 만들 수 있다.

 

  • proxyTargetClass=false → JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성
  • proxyTargetClass=trueCGLIB를 사용해서 구체 클래스 기반 프록시 생성
  • 참고로, 옵션과 무관하게 인터페이스가 없으면 무조건 CGLIB를 사용한다.

JDK 동적 프록시 한계

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

ProxyCastingTest

package cwchoiit.springadvanced.aop.proxyvs;

import cwchoiit.springadvanced.aop.member.MemberService;
import cwchoiit.springadvanced.aop.member.MemberServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.aop.framework.ProxyFactory;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
public class ProxyCastingTest {

    @Test
    void jdkProxy() {
        MemberService target = new MemberServiceImpl();
        ProxyFactory proxyFactory = new ProxyFactory(target);
        proxyFactory.setProxyTargetClass(false);

        MemberService proxy = (MemberService) proxyFactory.getProxy();

        assertThatThrownBy(() -> {
            MemberServiceImpl castingProxy = (MemberServiceImpl) proxy;
        }).isInstanceOf(ClassCastException.class); // 에러 발생 (구체 타입으로 캐스팅 불가)
    }
}
  • 테스트 코드를 보면, 인터페이스로는 캐스팅이 가능하고 구체 클래스로 캐스팅을 할 때 ClassCastException이 발생할 것으로 예측하고 있다.
  • 실행 결과는 참이다.

  • JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성한다. 그래서 당연히 인터페이스로는 캐스팅이 가능하다. 그런데 인터페이스는 자기를 구현한 구현체에 대한 정보를 아무것도 모른다. 당연하다. 그러면 프록시가 구체 클래스로 캐스팅 할 수 있을까? 없다. 아무것도 모르는데 어떻게 캐스팅을 하겠는가? 따라서 당연히 캐스팅 에러가 발생한다.
  • 위 그림만 봐도 바로 이해가 될 것이다. JDK 동적 프록시는 인터페이스만 알고 있다. 인터페이스 만으로는 구현 클래스에 대한 정보를 알 수 없다.

 

이번엔 CGLIB를 사용해보자.

@Test
void cglibProxy() {
    MemberService target = new MemberServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(true);

    MemberService proxy = (MemberService) proxyFactory.getProxy();
    MemberServiceImpl castingProxy = (MemberServiceImpl) proxy;
}
  • CGLIB는 인터페이스로 캐스팅하든, 구현체로 캐스팅하든 모두 성공한다.

  • 사실 이것도 너무 당연한게 CGLIB는 구현체를 상속받아서 프록시를 만든다. 구현체는 MemberServiceImpl이다. 그리고 이 MemberServiceImpl은 자기의 부모인 인터페이스(MemberService)를 알고 있다. 
  • 그럼 프록시 입장에서는 둘 다 알고 있으니 둘 다 캐스팅이 가능한 것이다.

 

정리를 하자면

JDK 동적 프록시는 구현체로 캐스팅 할 수 없다.

CGLIB 프록시는 구현체로 캐스팅 할 수 있다. 

 

근데, 이 문제가 뭐 어떻다는 건가? 진짜 문제는 의존관계 주입시에 발생한다.

 

프록시 기술과 한계 - 캐스팅 문제로 인한 의존관계 주입

JDK 동적 프록시를 사용하면서 의존관계 주입을 할 때 어떤 문제가 발생하는지 코드로 알아보자.

ProxyDIAspect

package cwchoiit.springadvanced.aop.proxyvs.code;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;

@Slf4j
@Aspect
public class ProxyDIAspect {

    @Before("execution(* cwchoiit.springadvanced.aop..*.*(..))")
    public void beforeAdvice(JoinPoint joinPoint) {
        log.info("[before] {}", joinPoint.getSignature());
    }
}
  • 우선 AOP 프록시 생성을 위해 간단한 @Aspect를 만들자.

ProxyDITest

package cwchoiit.springadvanced.aop.proxyvs;

import cwchoiit.springadvanced.aop.member.MemberService;
import cwchoiit.springadvanced.aop.member.MemberServiceImpl;
import cwchoiit.springadvanced.aop.proxyvs.code.ProxyDIAspect;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;

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

        memberServiceImpl.hello("hello");
    }
}
  • @Import(ProxyDIAspect.class)@Aspect를 스프링 빈으로 등록한다.
  • 그리고 인터페이스와 구체클래스 둘 다 주입받아보자. (이럴 일은 거의 없고 인터페이스를 주입받아야 좋은 설계가 맞지만 일단 테스트를 위해 이렇게 해보자)

application.yaml

spring:
  aop:
    proxy-target-class: false
  • JDK 동적 프록시로 프록시를 생성하도록 옵션을 적용하자.

실행 결과

org.springframework.beans.factory.BeanNotOfRequiredTypeException: 
Bean named 'memberServiceImpl' is expected to be of type 'cwchoiit.springadvanced.aop.member.MemberServiceImpl' 
but was actually of type 'jdk.proxy3.$Proxy65'
  • 이러한 타입 관련 에러가 발생한다. 왜냐? 위에서 설명한대로 JDK 동적 프록시는 구체 클래스에 대해 전혀 알 길이 없기 때문에 구체 클래스로 캐스팅이 불가능하기 때문이다.

 

반면, CGLIB로 프록시를 만들도록 옵션을 수정하고 실행해보자.

application.yaml

spring:
  aop:
    proxy-target-class: true
  • 실행해보면 정상 동작하는 것을 알 수 있다. 마찬가지 이유로 CGLIB는 구체 클래스를 기반으로 프록시를 만들고 구체 클래스는 당연히 구체 클래스도 알고 있고 그 상위인 인터페이스도 알고 있기 때문에 어떤 것으로도 캐스팅이 가능하기 때문이다.

 

정리를 하자면

지금까지 JDK 동적 프록시가 가지는 한계점을 알아보았다. 실제로 개발할 때는 인터페이스가 있으면 인터페이스를 기반으로 의존관계 주입을 하는 게 맞다. DI 장점이 무엇인가? DI를 받는 클라이언트 코드의 변경 없이 구현 클래스를 변경할 수 있다는 것이다. 이렇게 하려면 인터페이스를 기반으로 의존관계 주입을 받아야 한다. MemberServiceImpl 타입으로 의존관계 주입을 받는것처럼 구현 클래스에 의존관계를 주입하면 향후 구현 클래스를 변경할 때 의존관계 주입을 받는 클라이언트 코드도 함께 변경해야 한다. 따라서 올바르게 잘 설계된 애플리케이션이라면 이런 문제가 자주 발생하지는 않는다. 그럼에도 불구하고 테스트, 또는 여러가지 이유로 AOP 프록시가 적용된 구체 클래스를 직접 의존관계 주입 받아야 하는 경우가 있을 수 있다. 이때는 CGLIB를 통해 구체 클래스 기반으로 AOP 프록시를 적용하면 된다.

 

여기까지 듣고보면 CGLIB를 사용하는 것이 좋아보인다. CGLIB를 사용하면 이런 고민 자체를 하지 않아도 되니까 말이다. 

이번엔 CGLIB의 단점을 알아보자.

 

프록시 기술과 한계 - CGLIB

스프링에서 CGLIB는 구체 클래스를 상속 받아서 AOP 프록시를 생성한다. CGLIB는 구체 클래스를 상속받기 때문에 다음과 같은 문제가 있다.

 

CGLIB 구체 클래스 기반 프록시 문제점

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

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

CGLIB는 구체 클래스를 상속 받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 자식 클래스의 생성자에서 부모 클래스의 생성자도 호출해야 한다. (이 부분이 생략되어 있다면 자식 클래스의 생성자 첫 줄에 부모 클래스의 기본 생성자를 호출하는 super()가 자동으로 들어간다) 이 부분은 자바 문법 규약이다. CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 따라서 대상 클래스에 기본 생성자가 필수이다. 

 

생성자 2번 호출 문제

CGLIB는 구체 클래스를 상속받는다. 자바 언어에서 상속을 받으면 자식 클래스의 생성자를 호출할 때 부모 클래스의 생성자도 호출해야 한다. 그런데 왜 2번일까?

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

  • 그러니까, 프록시는 실제 객체가 반드시 있어야 한다. 그러러면 실제 객체를 만들어야 한다. 그때 생성자를 한번 호출한다.
  • 프록시 객체를 생성할 때 CGLIB는 실제 객체를 상속받는다. 상속을 받을 때 부모 클래스의 생성자를 호출하는데 부모 클래스가 곧 실제 객체이므로 또 생성자를 호출한다.

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

final 키워드가 클래스에 있으면 상속이 불가능하고, 메서드에 있으면 오버라이딩이 불가능하다. CGLIB는 상속을 기반으로 하기 때문에 두 경우 프록시가 생성되지 않거나 정상 동작하지 않는다. 그런데, 사실 이 부분은 크게 문제가 되지는 않는다. 거의 대부분 애플리케이션을 개발할 때 final 키워드를 붙인 클래스를 만들지 않기 때문에. 

 

정리를 하자면

JDK 동적 프록시는 대상 클래스 타입으로 주입할 때 문제가 있고, CGLIB는 대상 클래스에 기본 생성자 필수, 생성자 2번 호출 문제가 있다. 그렇다면 스프링은 어떤 방법을 권장할까?

 

프록시 기술과 한계 - 스프링의 해결책

스프링은 AOP 프록시 생성을 편리하게 제공하기 위해 오랜 시간 고민하고 문제들을 해결해왔다.

 

스프링의 기술 선택 변화, 스프링 3.2, CGLIB를 스프링 내부에 함께 패키징

 CGLIB를 사용하려면 CGLIB 라이브러리가 별도로 필요했다. 스프링은 CGLIB 라이브러리를 스프링 내부에 함께 패키징해서 별도의 라이브러리 추가 없이 CGLIB를 사용할 수 있게 되었다.

 

CGLIB 기본 생성자 필수 문제 해결

스프링 4.0부터 CGLIB의 기본 생성자가 필수인 문제가 해결되었다. objenesis 라는 특별한 라이브러리를 사용해서 기본 생성자 없이 객체 생성이 가능하다. 참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.

 

생성자 2번 호출 문제 해결

스프링 4.0부터 CGLIB의 생성자 2번 호출 문제가 해결되었다. 이것도 역시 objenesis 라는 특별한 라이브러리 덕분에 가능해졌다. 이제 생성자가 1번만 호출된다.

 

스프링 부트 2.0 - CGLIB 기본 사용

스프링 부트 2.0 버전부터 CGLIB를 기본으로 사용하도록 했다. 이렇게 해서 구체 클래스 타입으로 의존관계를 주입하는 문제를 해결했다. 스프링 부트는 별도의 설정이 없다면 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다. 따라서 인터페이스가 있어도 JDK 동적 프록시를 사용하는 것이 아니라 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다. 물론 스프링은 우리에게 선택권을 열어주기 때문에 다음과 같이 설정하면 JDK 동적 프록시도 사용할 수 있다.

spring:
  aop:
    proxy-target-class: false

 

728x90
반응형
LIST

+ Recent posts