CGLib(Code Generation Library)

4 분 소요


RECOMMEND POSTS BEFORE THIS

0. 들어가면서

스프링 프레임워크(spring framework)는 동적 프록시(dynamic proxy)를 통해 개발자가 작성한 코드를 직접 수정하지 않고 기능을 확장합니다. Dynamic Proxy in Java 포스트에서 다뤘듯 JDK 리플렉션(reflection) 패키지에서 제공하는 다이나믹 프록시는 다음과 같은 한계점을 가집니다.

  • 리플렉션 기능을 사용하기 때문에 속도가 느립니다.
  • 인터페이스를 대상으로만 다이나믹 프록시를 적용할 수 있습니다.

스프링 프레임워크는 이런 한계를 극복하기 위해 동적 프록시를 지원하는 라이브러리를 사용합니다. 이번 포스트에선 스프링 프레임워크에서 기본적으로 채택하여 사용 중인 CGLib에 대해 살펴보겠습니다.

1. CGLib(Code Generation Library)

런타임에 클래스의 프록시 객체를 동적으로 생성합니다. JDK 동적 프록시처럼 인터페이스만 지원하는 것이 아니라 구현 클래스를 기반으로 프록시 객체를 생성합니다. 현재 다양한 프레임워크에서 사용되고 있습니다.

  • 하이버네이트(Hibername)
  • 모키토(Mokito)
  • 스프링 프레임워크 AOP(Aspect Oriented Programming)

1.1. What is difference between JDK and CGLib?

CGLib과 JDK에서 제공하는 동적 프록시 기능의 차이점은 프록시 구현 방법입니다.

  • JDK 동적 프록시는 인터페이스를 구현(implements)합니다.
    • 리플렉션을 사용합니다.
  • CGLib 동적 프록시는 클래스를 직접 상속(extends)합니다.
    • 클래스의 바이트 코드를 조작합니다.

https://www.baeldung.com/spring-aop-vs-aspectj

1.2. How to create proxy?

잠시 뒤 예제 코드를 살펴볼 에정이지만, 프록시 객체를 생성하는 방법을 간단하게 살펴보겠습니다. CGLib 라이브러리의 Enhance 클래스를 사용하여 프록시 객체를 생성합니다.

  • Enhancer 인스턴스를 생성합니다.
  • 부모 클래스를 지정합니다.
  • 서브젝트(subject) 객체의 메소드를 수행하기 전 기능을 확장할 수 있는 인터셉터를 지정합니다.
  • Enhancer 인스턴스를 통해 프록시 객체를 생성합니다.
    Enhancer enhancer = new Enhancer();
    enhancer.setSuperclass(DefaultPostService.class);
    enhancer.setCallback(new PostInterceptor());

    PostService proxy = (PostService) enhancer.create();
    proxy.getPosts();

1.3. How does it flow?

프록시 객체를 생성한 후 해당 객체의 메소드를 호출하면 다음과 같은 실행 흐름을 가집니다. 동적 프록시를 통해 getPosts 메소드를 가진 인터페이스 기능을 확장했다고 가정하였습니다.

  1. 클라이언트(client)가 프록시 객체의 getPosts 메소드를 호출합니다.
  2. MethodInterceptor 객체의 intercept 메소드가 실행됩니다.
  3. intercept 메소드 내부에서 필요한 기능들을 실행한 후 타겟(target) 객체에게 요청을 전달합니다.
  4. 타겟 객체는 전달받은 요청을 수행합니다.

1.4. Limitation

CGLib 동적 프록시 기능은 다음과 같은 한계점을 가집니다.

  • 클래스를 직접 상속하는 방식이기 때문에 메소드가 final인 경우 확장할 수 없습니다.
    • 접근 제어자가 private인 경우 프록시를 통해 호출할 수 없기 때문에 마찬가지로 기능 확장은 불가능합니다.
  • JDK 17 버전 이상부터 정상적으로 동작하지 않을 수 있습니다.
    • 공식 Github 레포지토리를 보면 아래와 같은 안내문을 확인할 수 있습니다.
    • 스프링 부트(spring boot) 3.X 버전이 등장하면서 JDK 17 버전을 강제적으로 사용하게 되었지만, 아직까진 CGLib 기반의 동적 프록시를 사용하고 있는 것으로 확인됩니다.

https://github.com/cglib/cglib

1.5. CGLib in Spring Framework

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 예외를 추적하는 것을 어렵게 만들기 때문이라고 합니다.

https://stackoverflow.com/questions/54980004/why-choose-cglib-proxying-as-the-default-after-springboot-2-0

2. Practice

간단한 예제 코드를 통해 프록시 기능을 살펴보겠습니다. CGLib 의존성을 별도로 추가하지 않고 스프링 부트 내부 패키지에서 제공하는 기능을 사용하였습니다.

2.1. PostService Interface

간단한 기능을 제공하는 인터페이스를 정의합니다.

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

2.2. DefaultPostService Class

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

2.3. PostInterceptor Class

MethodInterceptor 인터페이스를 상속받아 interceptor 메소드를 구현합니다.

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

2.4. Test

간단한 테스트 코드를 통해 프록시 객체를 생성하고 동작하는 모습을 확인합니다.

  • 상속 대상은 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."));
    }
}

Result - invoke_getPosts method

  • 정상적으로 결과를 얻으며 소요 시간을 측정하는 로그가 함께 출력됩니다.
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

Result - 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

댓글남기기