CGLib(Code Generation Library) 개념
RECOMMEND POSTS BEFORE THIS
0. 들어가면서
스프링 프레임워크(spring framework)는 동적 프록시(dynamic proxy)를 통해 개발자가 작성한 코드를 직접 수정하지 않고 기능을 확장한다. 자바(Java) 다이나믹 프록시(Dynamic Proxy) 글에서 다뤘듯 JDK 리플렉션(reflection) 패키지에서 제공하는 다이나믹 프록시는 다음과 같은 한계점을 가진다.
- 리플렉션 기능을 사용하기 때문에 속도가 느리다.
- 인터페이스를 대상으로만 다이나믹 프록시를 적용할 수 있다.
스프링 프레임워크는 이런 한계를 극복하기 위해 동적 프록시를 지원하는 라이브러리를 사용한다. 이번 글에선 스프링 프레임워크에서 기본적으로 채택하여 사용 중인 CGLib에 대해 살펴본다.
1. CGLib(Code Generation Library)
런타임에 클래스의 프록시 객체를 동적으로 생성한다. JDK 동적 프록시처럼 인터페이스만 지원하는 것이 아니라 구현 클래스를 기반으로 프록시 객체를 생성한다. 현재 다양한 프레임워크에서 사용되고 있다.
- 하이버네이트(Hibernate)
- 모키토(Mockito)
- 스프링 프레임워크 AOP(Aspect Oriented Programming)
CGLib과 JDK에서 제공하는 동적 프록시 기능의 차이점은 프록시 구현 방법이다.
- JDK 동적 프록시는 인터페이스를 구현(implements)한다.
- 리플렉션을 사용한다.
- CGLib 동적 프록시는 클래스를 직접 상속(extends)한다.
- 클래스의 바이트 코드를 조작한다.
잠시 뒤 예제 코드를 살펴볼 예정이지만, 프록시 객체를 생성하는 방법을 간단하게 살펴보자. CGLib 라이브러리의 Enhancer 클래스를 사용하여 프록시 객체를 생성한다.
- Enhancer 인스턴스를 생성한다.
- 부모 클래스를 지정한다.
- 서브젝트(subject) 객체의 메서드를 수행하기 전 기능을 확장할 수 있는 인터셉터를 지정한다.
- Enhancer 인스턴스를 통해 프록시 객체를 생성한다.
Enhancer enhancer = new Enhancer(); // [1]
enhancer.setSuperclass(DefaultPostService.class); // [2]
enhancer.setCallback(new PostInterceptor()); // [3]
PostService proxy = (PostService) enhancer.create(); // [4]
proxy.getPosts();
프록시 객체를 생성한 후 해당 객체의 메서드를 호출하면 다음과 같은 실행 흐름을 가진다. 동적 프록시를 통해 getPosts 메서드를 가진 인터페이스 기능을 확장했다고 가정한다.
- 클라이언트(client)가 프록시 객체의 getPosts 메서드를 호출한다.
- MethodInterceptor 객체의 intercept 메서드가 실행된다.
- intercept 메서드 내부에서 필요한 기능들을 실행한 후 타겟(target) 객체에게 요청을 전달한다.
- 타겟 객체는 전달받은 요청을 수행한다.
CGLib 동적 프록시 기능은 다음과 같은 한계점을 가진다.
- 클래스를 직접 상속하는 방식이기 때문에 메서드가
final인 경우 확장할 수 없다.- 접근 제어자가
private인 경우 프록시를 통해 호출할 수 없기 때문에 마찬가지로 기능 확장은 불가능하다.
- 접근 제어자가
- JDK 17 버전 이상부터 정상적으로 동작하지 않을 수 있다.
- 공식 Github 레포지토리를 보면 아래와 같은 안내문을 확인할 수 있다.
- 스프링 부트(spring boot) 3.X 버전이 등장하면서 JDK 17 버전을 강제적으로 사용하게 되었지만, 아직까진 CGLib 기반의 동적 프록시를 사용하고 있는 것으로 확인된다.
CGLib 동적 프록시는 리플렉션 방식을 사용하는 JDK 동적 프록시보다 속도가 빠르지만, 예전엔 몇 가지 문제가 있었다고 한다.
- net.sf.cglib.proxy.Enhancer 의존성 추가 필요했다.
- 서브젝트 클래스의 디폴트 생성자 필수였다.
- 서브젝트 클래스의 생성자 2회 호출했다.
- 상속으로 인한 부모 클래스 생성자 호출
- 타겟 인스턴스를 만들기 위한 생성자 호출
위 문제들로 인해 스프링 프레임워크는 인터페이스 상속 여부에 따라 내부적으로 프록시를 만드는 방식을 구분한 것으로 보인다. 프레임워크가 발전해나감에 따라 위 문제들이 해결되면서 현재는 기본적으로 모든 프록시를 CGLib 기반으로 생성하고 있다.
- net.sf.cglib.proxy.Enhancer 의존성 추가가 필요했지만, 프레임워크 내부적으로 org.springframework.cglib 패키지를 만들어 추가 의존성이 없도록 구성했다.
- 서브젝트 클래스의 디폴트 생성자 필수였던 불편함을 objenesis 라이브러리를 통해 해결했다.
- 서브젝트 클래스의 생성자를 2회 호출했던 문제도 objenesis 라이브러리를 통해 해결했다.
Spring Boot 2 버전부터 CGLib 프록시를 기본적으로 사용하고 있다. application.yml 파일의 spring.aop.proxy-target-class 속성을 통해 프록시 생성 방식을 변경할 수 있으며 기본값은 true다.
{
"name": "spring.aop.proxy-target-class",
"type": "java.lang.Boolean",
"description": "Whether subclass-based (CGLIB) proxies are to be created (true), as opposed to standard Java interface-based proxies (false).",
"defaultValue": true
},
스프링 팀 Phil Webb이 CGLib 프록시 방식을 기본으로 채택한 이유를 스택 오버플로우에서 설명했는데, 인터페이스 기반 프록시는 ClassCastException 예외를 추적하는 것을 어렵게 만들기 때문이라고 한다.
2. Practice
지금부터 예제 코드를 통해 프록시 기능을 살펴보자. CGLib 의존성을 별도로 추가하지 않고 스프링 부트 내부 패키지에서 제공하는 기능을 사용했다. 간단한 기능을 제공하는 PostService 인터페이스를 정의한다.
package action.in.blog.service;
import action.in.blog.domain.Post;
import java.util.List;
public interface PostService {
List<Post> getPosts();
void createPost(Post post);
}
DefaultPostService 클래스를 다음과 같이 구현한다.
- getPosts 메서드
- 포스트 정보를 반환한다.
- createPost 메서드
- 포스트 정보를 생성한다.
- 추가 상속을 방지하기 위해 final 키워드를 추가한다.
package action.in.blog.service;
import action.in.blog.domain.Post;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class DefaultPostService implements PostService {
public DefaultPostService() {
log.info("create DefaultPostService");
}
@Override
public List<Post> getPosts() {
return List.of(
new Post(1L, "Hello World", "This is content."),
new Post(2L, "Junhyunny's Devlog", "This is blog.")
);
}
@Override
public final void createPost(Post post) {
log.info("create new post {}", post);
}
}
프록시 객체에서 getPosts 메소드의 실행을 가로채는 PostInterceptor 클래스를 살펴보자. MethodInterceptor 인터페이스를 구현하여 intercept 메서드를 정의한다.
- intercept 메서드가 호출됨을 확인하기 위해 로그를 출력한다.
- getPosts 메서드 수행 시 소요되는 시간을 측정한다.
package action.in.blog.interceptor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
@Slf4j
public class PostInterceptor implements MethodInterceptor {
@Override
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
log.info("CGLib interceptor works");
if (method.getName().equals("getPosts")) {
var start = System.nanoTime();
var result = proxy.invokeSuper(obj, args);
log.info("getPosts method takes {} ns", System.nanoTime() - start);
return result;
}
return proxy.invokeSuper(obj, args);
}
}
간단한 테스트 코드를 통해 프록시 객체를 생성하고 동작하는 모습을 확인한다. 상속 대상은 DefaultPostService 클래스이다.
package action.in.blog.service;
import action.in.blog.domain.Post;
import action.in.blog.interceptor.PostInterceptor;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.cglib.proxy.Enhancer;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PostServiceTest {
PostService sut;
@BeforeEach
void setUp() {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(DefaultPostService.class);
enhancer.setCallback(new PostInterceptor());
sut = (PostService) enhancer.create();
}
@Test
void invoke_getPosts() {
var result = sut.getPosts();
var firstPost = result.get(0);
var secondPost = result.get(1);
assertEquals(2, result.size());
assertEquals("Hello World", firstPost.title());
assertEquals("This is content.", firstPost.content());
assertEquals("Junhyunny's Devlog", secondPost.title());
assertEquals("This is blog.", secondPost.content());
}
@Test
void invoke_createPost() {
sut.createPost(new Post(1, "Hello World", "This is new content."));
}
}
invoke_getPosts 테스트를 실행하면 정상적으로 결과를 얻으며 소요 시간을 측정하는 로그가 함께 출력된다.
01:08:23.738 [Test worker] INFO action.in.blog.service.DefaultPostService -- create DefaultPostService
01:08:23.743 [Test worker] INFO action.in.blog.interceptor.PostInterceptor -- CGLib interceptor works
01:08:23.755 [Test worker] INFO action.in.blog.interceptor.PostInterceptor -- getPosts method takes 12135211 ns
invoke_createPost 테스트를 실행하면 인터셉터가 실행되지 않고 createPost 메서드만 실행된다. final 키워드로 인해 상속이 이뤄지지 않으므로 프록시 기능이 동작하지 않는다.
01:08:32.473 [Test worker] INFO action.in.blog.service.DefaultPostService -- create DefaultPostService
01:08:32.477 [Test worker] INFO action.in.blog.service.DefaultPostService -- create new post Post[id=1, title=Hello World, content=This is new content.]
TEST CODE REPOSITORY
REFERENCE
- https://github.com/cglib/cglib
- https://bytebuddy.net/#/
- https://www.baeldung.com/cglib
- https://www.baeldung.com/spring-aop-vs-aspectj
- https://www.youtube.com/watch?v=MFckVKrJLRQ
- https://stackoverflow.com/questions/54980004/why-choose-cglib-proxying-as-the-default-after-springboot-2-0
- https://gmoon92.github.io/spring/aop/2019/04/20/jdk-dynamic-proxy-and-cglib.html
댓글남기기