Bldev's Blog

programming · java · bytebuddy

[프로그래밍] 바이트 버디(Byte Buddy)를 사용한 자바 바이트코드 생성

2023. 5. 12.

바이트 버디

바이트 버디(Byte Buddy)는 자바 바이트코드를 런타임에 생성하고 조작할 수 있게 해주는 오픈소스 라이브러리다. ASM과 같은 저수준 바이트코드 API 위에 높은 수준 DSL을 제공해서, 복잡도가 높은 바이트코드 생성도 상대적으로 간단하게 만들 수 있다.

바이트버디의 사용 목적은 다음과 같다.

  • 런타임 코드 생성: 컴파일 타임에 미리 알 수 없는 프록시나 AOP용 코드도 런타임에 동적으로 생성할 수 있다. 예를 들어 인터페이스 구현체를 런타임에 만들거나 요청을 가로채는 로깅/모니터링 대리자를 자동 생성할 때 유용하다.
  • 자바 에이전트 기반 트레이싱/프로파일링: 자바 에이전트로 클래스 로딩 시점에 바이트코드를 변환해 메서드 진입/종료 시점에 이벤트를 삽입하거나 응답 시간을 기록하는 등 트레이싱/프로파일러 기능을 주입할 수 있다.
  • 기존 클래스 수정(재정의, 인스턴스 메서드 삽입): 이미 배포된 클래스의 메서드를 재정의하거나, 메서드 호출 전/후에 코드를 삽입해 동작을 변경할 수 있다. 리팩터링 없이 기능 플러그인을 추가할 때 사용된다.
  • 테스트나 모킹 프레임워크 구현: 테스트 환경에서 동적으로 동작을 대체하거나 모킹 객체를 생성하는 데 활용된다. 모키토(Mockito) 같은 라이브러리가 내부적으로 바이트버디를 사용해 런타임 프록시를 만들기도 한다.

주요 장점은 다음과 같다.

  1. DSL 기반 API: Byte Buddy의 API는 체이닝 DSL 형태로 new ByteBuddy().subclass(...).method(...).intercept(...) 같은 직관적인 코드로 바이트코드를 생성/변환한다.

  2. 리플렉션 대비 성능 우위: 런타임 프록시나 리플렉션 API 대신, 실제 네이티브 자바 클래스가 생성되어 실행되므로 호출 오버헤드가 적고 기본 JVM 최적화(HotSpot JIT) 적용이 가능하다.

  3. 자바 개발자 친화적 문법: ASM 저수준 바이트코드 대신 자바 코드와 어노테이션(@Advice, @SuperCall)으로 API 제공한다. IDE 자동 완성도 지원된다.

  4. 다양한 애플리케이션 서버/런타임에서 작동: JDK5 부터 최신 버전까지 지원하여 호환성이 높다. 자바 에이전트를 통해 애플리케이션 시작 또는 실행 중에 코드 삽입이 가능하며, 동적으로 만든 클래스를 JVM에 넣는 클래스 로딩 전략(WRAPPER, INJECTION, CHILD_FIRST)으로 다양한 컨테이너/서버 환경에서 충돌 없이 적용 가능하다.

기본 개념

  • AgentBuilder : 자바 에이전트를 통해 클래스 로드를 후킹하고 조작
  • DynamicType.Builder : 새로운 클래스 생성 및 기존 클래스 수정
  • MethodDelegation : 메서드 호출을 다른 객체로 위임
  • Advice : 메서드 진입/종료에 코드를 삽입

사용 방법

1) 클래스 생성

Class<?> dynamic = new ByteBuddy()
    .subclass(Object.class)
    .name("com.example.HelloWorld")
    .defineMethod("sayHello", String.class, Visibility.PUBLIC)
    .intercept(FixedValue.value("Hello Byte Buddy"))
    .make()
    .load(getClass().getClassLoader(), ClassLoadingStrategy.Default.INJECTION)
    .getLoaded();

Object instance = dynamic.getDeclaredConstructor().newInstance();
Method method = dynamic.getMethod("sayHello");
System.out.println(method.invoke(instance));
Hello Byte Buddy
  • subclass + defineMethod + intercept를 통해 런타임에 클래스와 메서드를 새로 만든다. ClassLoadingStrategy.Default.INJECTION으로 현재 클래스 로더에 직접 삽입해서 즉시 인스턴스를 생성하고 호출할 수 있다.

2) 기존 클래스 메서드 인터셉트

new ByteBuddy()
  .redefine(ExistingClass.class)
  .method(named("targetMethod"))
  .intercept(FixedValue.value("Intercepted"))
  .make()
  .load(ExistingClass.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER);
  • 기존 클래스의 targetMethodredefine으로 재정의하며 실행 결과를 Intercepted로 고정한다. 단, 이미 로드된 클래스에 대해 WRAPPER 전략은 Class already loaded 예외가 날 수 있으므로, 실제 환경에서는 에이전트/재트랜스폼 환경에서 사용하는 것이 안전하다.

3) 자바 에이전트에서 어드바이스 사용

public class TraceAgent {
  public static void premain(String agentArgs, Instrumentation inst) {
    new AgentBuilder.Default()
      .type(any())
      .transform((builder, typeDescription, classLoader, module) ->
        builder.visit(Advice.to(TraceAdvice.class).on(named("execute"))))
      .installOn(inst);
  }
}

public class TraceAdvice {
  @Advice.OnMethodEnter
  static long enter(@Advice.Origin String method) {
    System.out.println("enter: " + method);
    return System.nanoTime();
  }

  @Advice.OnMethodExit
  static void exit(@Advice.Enter long start, @Advice.Origin String method) {
    long elapsed = System.nanoTime() - start;
    System.out.println("exit: " + method + " " + elapsed + "ns");
  }
}
  • AgentBuilderAdvice를 조합해서 JVM 에이전트로 런타임 클래스 로딩 시점에 메서드를 후킹한다. 이 방식은 애플리케이션 코드 변경 없이도 트레이싱/모니터링/로깅을 삽입할 수 있고, premain에서 한번만 설치되면 애플리케이션 전체에 자동 적용된다.

주의할 점

  • 바이트코드 변환은 버전 호환성이 중요하다.
  • 잘못된 변환은 LinkageError, VerifyError를 발생시킬 수 있다.
  • 아키텍처에 따라 성능 영향과 메모리 사용 증가가 있을 수 있다.

실제 활용 사례

  • 바이트코드 프로시 : 스프링 AOP, 모키토(Mockito), 하이버네이트(Hibernate) 등
  • 로깅/모니터링 에이전트: 뉴 렐릭(New Relic), 데이터독(Datadog) 등
  • 라이브 패치/옵티마이저 툴 구현
  • 오픈텔레메트리 커스텀 자바 에이전트 구현

예제

  • 환경: JDK 17, Byte Buddy 1.18.7
  • 의존성: byte-buddy-1.18.7.jar
import net.bytebuddy.ByteBuddy;
import net.bytebuddy.dynamic.loading.ClassLoadingStrategy;
import net.bytebuddy.implementation.FixedValue;
import net.bytebuddy.matcher.ElementMatchers;

public class ByteBuddyExample {
    public static void main(String[] args) throws Exception {
        // 1) 클래스 생성
        Class<?> dynamic = new ByteBuddy()
            .subclass(Object.class)
            .name("com.example.HelloWorld")
            .defineMethod("sayHello", String.class, net.bytebuddy.description.modifier.Visibility.PUBLIC)
            .intercept(FixedValue.value("Hello Byte Buddy"))
            .make()
            .load(ByteBuddyExample.class.getClassLoader(), ClassLoadingStrategy.Default.INJECTION)
            .getLoaded();

        Object instance = dynamic.getDeclaredConstructor().newInstance();
        java.lang.reflect.Method method = dynamic.getMethod("sayHello");
        System.out.println("sayHello output: " + method.invoke(instance));

        // 2) 기존 클래스 재정의
        Class<?> redefined = new ByteBuddy()
            .redefine(ExistingClass.class)
            .method(ElementMatchers.named("targetMethod"))
            .intercept(FixedValue.value("Intercepted"))
            .make()
            .load(ExistingClass.class.getClassLoader(), ClassLoadingStrategy.Default.WRAPPER)
            .getLoaded();

        Object existingInstance = redefined.getDeclaredConstructor().newInstance();
        java.lang.reflect.Method target = redefined.getMethod("targetMethod");
        System.out.println("redefined targetMethod: " + target.invoke(existingInstance));
    }
}

class ExistingClass {
    public String targetMethod() { return "original"; }
}
sayHello output: Hello Byte Buddy
redefined targetMethod: Intercepted

트러블슈팅

  • 오류: ExistingClassmain 내부 로컬 클래스(ByteBuddyExample$1ExistingClass)로 정의한 후 WRAPPER로 재정의 시도 시 IllegalStateException: Class already loaded
  • 원인: 로컬 내부 클래스는 이미 로딩된 이름을 제어하기 어렵고, 바이트 버디의 WRAPPER 전략으로 동적 재정의 중 중복 로드 발생
  • 해결: ExistingClass를 탑 레벨 클래스로 분리해서 정상 동작 확인

참고 문서