본문 바로가기
Spring/spring AOP

스프링 코어 2 - 프록시 기술과 한계

by 킹차니 2022. 1. 19.

프록시 기술과 한계

프록시 기술과 한계를 알아보자.

 

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

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

 

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

 

• proxyTargetClass=false : JDK 동적 프록시를 사용해서 인터페이스 기반 프록시 생성

• proxyTargetClass=true : CGLIB을 사용해서 구체 클래스 기반 프록시 생성

• 참고로 옵션과 무관하게 인터페이스가 없으면 JDK 동적 프록시를 적용할 수 없으므로 CGLIB을 사용한다.

 

JDK 동적 프록시의 한계

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

@Test
void jdkProxy() {
    MemberServiceImpl target = new MemberServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(false);//JDK 동적 프록시

    //프록시를 인터페이스로 캐스팅
    MemberService proxy = (MemberService) proxyFactory.getProxy();

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

위의 코드는 JDK 동적 프록시로 생성된 MemberService proxy를 MemberSerbiceImpl타입으로 캐스팅하려 하면 ClassCastException이 발생한다. 

위 그림에서 처럼 JDK 동적 프록시는 MemberService(인터페이스)를 보고 프록시를 생성한다. 하여 JDK 동적 프록시를 인터페이스로 캐스팅하는 것을 당연히 가능하다.

 

하지만 문제는 인터페이스를 보고 구현된 프록시이기 때문에 인터페이스의 구현체인 MemberServiceImpl과는 아무 상관이 없다. 즉 MemberServiceImpl로의 타입 캐스팅이 불가능하다.

 

 

 

 

 

 

CGLIB 프록시

하지만 CGLIB은 JDK 타입 프록시가 못하는 타입 캐스팅이 가능하다.

@Test
void cglibProxy() {
    MemberServiceImpl target = new MemberServiceImpl();
    ProxyFactory proxyFactory = new ProxyFactory(target);
    proxyFactory.setProxyTargetClass(true);//CGLIB 프록시

    //프록시를 인터페이스로 캐스팅 성공
    MemberService proxy = (MemberService) proxyFactory.getProxy();

    //프록시를 MemberServiceImpl로 캐스팅
    MemberServiceImpl castingMemberService = (MemberServiceImpl) proxy;// OK
    //-> CGLIB 프록시를 구현 클래스로 캐스팅 시도 성공.
}

CGLIB은 구체 클래스를 기반으로 프록시를 생성한다.

CGLIB 프록시는 MemberServiceImpl을 기반으로 만들어진 프록시이기 때문에 물론 MemberServiceImpl의 부모인 MemberService(인터페이스)로 타입 캐스팅이 가능한 것이다.

 

결론:

JDK 동적 프록시는 대상 객체인 MemberServiceImpl로 캐스팅할 수 없다.

CGLIB 프록시는 대상 객체인 MemberServiceImpl로 캐스팅할 수 있다.

이러한 JDK 동적 프록시의 타입 캐스팅 문제는 의존관계 주입시에 발생한다.

 


프록시 기술과 한계 - 2. 의존 관계 주입

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

 

@Slf4j
@SpringBootTest(properties = {"spring.aop.proxy-target-class=false"}) //JDK 동적 프록시
@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");
    }
}

출력결과:
memberService class = class hello.aop.member.MemberServiceImpl$$EnhancerBySpringCGLIB$$22974795
memberServiceImpl class = class hello.aop.member.MemberServiceImpl$$EnhancerBySpringCGLIB$$22974795
[proxyDIAdvice] String hello.aop.member.MemberServiceImpl.hello(String)
MemberServiceImpl.hello

위와 같은 테스트를 실행하면 의존성을 주입받는 과정에서 에러가 발생한다. 현재 JDK 동적 프록시를 사용하여 프록시를 생성한다.(@SpringBootTest 부분 properties 참고)

JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성한다고 하였고 그러므로 MemberServiceImpl로 타입 캐스팅이 불가하다고 하였다.

그러므로 해당 테스트에서 의존관계를 주입받고 싶은 대상은 MemberServiceImpl 타입의 객체이지만 실제 빈으로 등록된 빈은 MemberService를 기반으로 구현된 com.sun.proxy.$Proxy54 객체이다.

하여 MemberServiceImpl을 의존성 주입을 받으려 할 때 에러가 발생하는 것이다.

 

이번에는 CGLIB을 사용하여 프록시를 생성하도록 해보자.

물론 CGLIB을 사용하여 프록시를 만들면 MemberServiceImpl타입의 의존성을 주입 받는 과정에서 에러가 발생하지 않는다.

왜냐하면 CGLIB은 MemberServiceImpl이라는 구체 클래스를 기반으로 프록시를 생성하기 때문이다.

출력된 결과를 보면 MemberService, MemberServiceImpl을 보면 모두 같은 프록시 객체인 것을 알 수 있다.

 

 

정리

JDK 동적 프록시는 대상 객체인 MemberServiceImpl 타입에 의존관계를 주입할 수 없다.

CGLIB 프록시는 대상 객체인 MemberServiceImpl 타입에 의존관계 주입을 할 수 있다.

 

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

 

 

이제 CGLIB의 한계점에 대해서 알아보자.

 


프록시 기술과 한계 - 2. CGLIB

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

 

CGLIB 구체 클래스 기반 문제점

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

• 생성자 2번 호출 문제

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

 

 

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

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

CGLIB를 사용할 때 CGLIB가 만드는 프록시의 생성자는 우리가 호출하는 것이 아니다. CGLIB 프록시는 대상 클래스를 상속 받고, 생성자에서 대상 클래스의 기본 생성자를 호출한다. 따라서 대상 클래스에 기본 생성자를 만들어야 한다. (생성자가 하나도 없으면 자동으로 만들어진다.)

 

 

 

생성자 2번 호출 문제


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

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

 

 

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

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

 

 

그렇다면 JDK 동적 프록시도 단점이 존재하고, CGLIB도 단점이 존재하는데, 스프링은 어떤 방식을 권장하는지 알아보자.

 


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

스프링의 기술 선택 변화 : 

스프링 3.2, CGLIB를 스프링 내부에 함께 패키징

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

core org.springframework

 

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

 

 

정리

스프링은 최종적으로 스프링 부트 2.0에서 CGLIB를 기본으로 사용하도록 결정했다. CGLIB를 사용하면 JDK 동적 프록시에서 동작하지 않는 구체 클래스 주입이 가능하다. 여기에 추가로 CGLIB의 단점들이 이제는 많이 해결되었다. CGLIB의 남은 문제라면 final 클래스나 final 메서드가 있는데, AOP를 적용할 대상에는 final 클래스나 final 메서드를 잘 사용하지는 않으므로 이 부분은 크게 문제가 되지는 않는다.

사실 어떤 프록시 기술을 사용하든 상관이 없다. JDK 동적 프록시든 CGLIB든 또는 어떤 새로운 프록시 기술을 사용해도 된다. 심지어 클라이언트 입장에서 어떤 프록시 기술을 사용하는지 모르고 잘 동작하는 것이 가장 좋다.

마지막으로 ProxyDITest 를 다음과 같이 변경해서 아무런 설정 없이 실행해보면 CGLIB가 기본으로 사용되는 것을 확인할 수 있다.

@Slf4j
@SpringBootTest // 기본으로 spring.aop.proxy-target-class=ture가 적용된다.
@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");
    }
}

출력결과:
memberService class = class hello.aop.member.MemberServiceImpl$$EnhancerBySpringCGLIB$$9245fb1
memberServiceImpl class = class hello.aop.member.MemberServiceImpl$$EnhancerBySpringCGLIB$$9245fb1
[proxyDIAdvice] String hello.aop.member.MemberServiceImpl.hello(String)
MemberServiceImpl.hello

 

 

 

 

김영한님의 인프런 강의와 PDF를 바탕으로 정리하였습니다.