소스 코드를 전혀 수정하지 않았는데, 어떻게 모든 메서드의 실행 시간이 자동으로 측정되는 걸까요?

Datadog, Elastic APM 같은 도구를 써본 적이 있다면, -javaagent 옵션 하나만 추가하면 모든 트레이싱이 시작되는 걸 경험했을 겁니다. 이것이 Java Agent와 바이트코드 조작 기술의 결과입니다.

Java Agent란

Java Agent 는 JVM이 클래스를 로드하는 시점에 바이트코드를 가로채서 변환할 수 있는 메커니즘입니다. java.lang.instrument 패키지를 통해 제공되며, 두 가지 진입점이 있습니다.

  • premain: JVM 시작 시 -javaagent 옵션으로 로드. 모든 클래스가 로드되기 전에 Transformer를 등록할 수 있음
  • agentmain: 실행 중인 JVM에 Attach API로 동적 연결. 이미 로드된 클래스도 retransform 가능

premain — JVM 시작 시 에이전트 로드

에이전트 클래스 작성

JAVA
package com.example.agent;

import java.lang.instrument.Instrumentation;

public class MyAgent {
    // JVM 시작 시 main() 이전에 호출됨
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("에이전트 로드됨, 인자: " + agentArgs);

        // ClassFileTransformer 등록
        inst.addTransformer(new TimingTransformer());
    }
}

MANIFEST.MF 설정

PLAINTEXT
Manifest-Version: 1.0
Premain-Class: com.example.agent.MyAgent
Can-Retransform-Classes: true
Can-Redefine-Classes: true

실행

BASH
# 에이전트 JAR을 지정하여 애플리케이션 실행
java -javaagent:my-agent.jar=option1=value1 -jar myapp.jar

ClassFileTransformer — 바이트코드 변환의 핵심

JAVA
public class TimingTransformer implements ClassFileTransformer {

    @Override
    public byte[] transform(
            ClassLoader loader,
            String className,         // 예: com/example/service/UserService
            Class<?> classBeingRedefined,
            ProtectionDomain protectionDomain,
            byte[] classfileBuffer)    // 원본 바이트코드
    {
        // 관심 있는 클래스만 변환
        if (!className.startsWith("com/example/service/")) {
            return null; // null 반환 = 변환하지 않음
        }

        try {
            // 바이트코드 변환 로직 (ASM, ByteBuddy 등 사용)
            return transformClass(classfileBuffer);
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

transform 메서드는 JVM이 클래스를 로드할 때마다 호출됩니다. 원본 바이트코드(classfileBuffer)를 받아서 변환된 바이트코드를 반환하면, JVM은 변환된 버전을 사용합니다.

agentmain — 실행 중인 JVM에 연결

JAVA
public class MyAgent {
    // Attach API로 연결 시 호출
    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("동적으로 에이전트 연결됨");

        inst.addTransformer(new TimingTransformer(), true);

        // 이미 로드된 클래스도 다시 변환 가능
        try {
            for (Class<?> clazz : inst.getAllLoadedClasses()) {
                if (clazz.getName().startsWith("com.example.service.")) {
                    inst.retransformClasses(clazz);
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Attach API로 연결하기

JAVA
// 별도 프로세스에서 실행
import com.sun.tools.attach.VirtualMachine;

public class AgentAttacher {
    public static void main(String[] args) throws Exception {
        // 대상 JVM PID
        String pid = args[0];

        VirtualMachine vm = VirtualMachine.attach(pid);
        try {
            vm.loadAgent("/path/to/my-agent.jar", "optionalArgs");
        } finally {
            vm.detach();
        }
    }
}

ByteBuddy — 바이트코드 조작을 쉽게

ASM으로 직접 바이트코드를 조작하는 것은 JVM 명령어를 하나하나 다뤄야 해서 매우 복잡합니다. ByteBuddy는 이를 고수준 API로 추상화합니다.

기본 사용법 — 동적 클래스 생성

JAVA
Class<?> dynamicType = new ByteBuddy()
    .subclass(Object.class)
    .name("com.example.Generated")
    .method(named("toString"))
    .intercept(FixedValue.value("Hello from generated class!"))
    .make()
    .load(getClass().getClassLoader())
    .getLoaded();

Object instance = dynamicType.getDeclaredConstructor().newInstance();
System.out.println(instance.toString()); // "Hello from generated class!"

Agent와 함께 사용 — 메서드 실행 시간 측정

JAVA
public class TimingAgent {
    public static void premain(String args, Instrumentation inst) {
        new AgentBuilder.Default()
            // com.example.service 패키지의 모든 클래스 대상
            .type(nameStartsWith("com.example.service"))
            // 모든 public 메서드에 적용
            .transform((builder, typeDescription, classLoader, module, domain) ->
                builder.method(isPublic())
                    .intercept(MethodDelegation.to(TimingInterceptor.class))
            )
            .installOn(inst);
    }
}

Interceptor 구현

JAVA
public class TimingInterceptor {

    @RuntimeType
    public static Object intercept(
            @Origin Method method,
            @SuperCall Callable<?> callable) throws Exception {

        long start = System.nanoTime();
        try {
            return callable.call(); // 원래 메서드 실행
        } finally {
            long elapsed = System.nanoTime() - start;
            System.out.printf("[%s.%s] %d ms%n",
                method.getDeclaringClass().getSimpleName(),
                method.getName(),
                TimeUnit.NANOSECONDS.toMillis(elapsed));
        }
    }
}

Advice — 원본 메서드에 코드 주입

MethodDelegation과 달리 Advice는 원본 메서드의 바이트코드에 직접 코드를 삽입합니다. 성능 오버헤드가 더 적습니다.

JAVA
public class TimingAdvice {

    @Advice.OnMethodEnter
    static long enter() {
        return System.nanoTime();
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class)
    static void exit(
            @Advice.Enter long startTime,
            @Advice.Origin Method method,
            @Advice.Thrown Throwable thrown) {

        long elapsed = System.nanoTime() - startTime;
        if (thrown != null) {
            System.err.printf("[%s] 예외 발생: %s (%d ms)%n",
                method.getName(), thrown.getMessage(),
                TimeUnit.NANOSECONDS.toMillis(elapsed));
        } else {
            System.out.printf("[%s] %d ms%n",
                method.getName(),
                TimeUnit.NANOSECONDS.toMillis(elapsed));
        }
    }
}
JAVA
// Agent에서 Advice 적용
new AgentBuilder.Default()
    .type(nameStartsWith("com.example.service"))
    .transform((builder, type, cl, module, domain) ->
        builder.visit(Advice.to(TimingAdvice.class).on(isPublic()))
    )
    .installOn(inst);

APM 에이전트의 동작 원리

Datadog, Elastic APM, New Relic 같은 APM 도구의 내부 동작 과정은 다음과 같습니다.

PLAINTEXT
1. -javaagent:dd-java-agent.jar로 JVM 시작
2. premain()에서 Instrumentation 확보
3. 알려진 프레임워크/라이브러리의 클래스에 Transformer 등록
   - Spring MVC: DispatcherServlet
   - JDBC: PreparedStatement
   - HTTP Client: HttpURLConnection, OkHttp 등
4. 각 Transformer가 메서드 진입/종료 시 계측 코드 삽입
   - 트레이스 ID 생성/전파
   - 실행 시간 기록
   - 에러 캡처
5. 수집된 데이터를 비동기로 APM 서버에 전송

이 과정이 ** 소스 코드 수정 없이** 바이트코드 수준에서 일어나므로, 개발자는 에이전트 JAR만 추가하면 됩니다.

빌드 설정 (Gradle)

GROOVY
plugins {
    id 'java'
}

dependencies {
    implementation 'net.bytebuddy:byte-buddy:1.14.12'
    implementation 'net.bytebuddy:byte-buddy-agent:1.14.12'
}

jar {
    manifest {
        attributes(
            'Premain-Class': 'com.example.agent.MyAgent',
            'Agent-Class': 'com.example.agent.MyAgent',
            'Can-Retransform-Classes': 'true',
            'Can-Redefine-Classes': 'true'
        )
    }
}

주의할 점

필터링 없이 모든 클래스에 Transformer를 적용하면 시작 시간이 수십 초 늘어난다

ClassFileTransformer.transform()JVM이 로드하는 모든 클래스 에 대해 호출됩니다. 필터 조건 없이 모든 클래스를 변환하면 수천 개의 JDK 내부 클래스까지 처리하게 되어 애플리케이션 시작 시간이 급격히 증가합니다. className.startsWith("com/myapp/")처럼 대상을 좁히는 것이 필수입니다.

바이트코드 변환 오류는 VerifyError로 나타난다

잘못된 바이트코드를 생성하면 JVM 바이트코드 검증기가 VerifyError를 던집니다. 이 에러는 스택 트레이스만으로는 원인을 파악하기 어려워 디버깅이 매우 힘듭니다. ASM으로 직접 바이트코드를 조작하기보다 ByteBuddy 같은 고수준 라이브러리를 사용하면 검증된 바이트코드를 생성해줍니다.

에이전트끼리 충돌하는 경우

여러 Java Agent를 동시에 사용하면 같은 클래스에 대해 여러 Transformer가 순차적으로 실행됩니다. 한 에이전트가 변환한 바이트코드를 다른 에이전트가 예상치 못한 형태로 받을 수 있어 충돌이 발생합니다. APM 에이전트와 코드 커버리지 에이전트를 함께 쓸 때 이런 문제가 자주 보고됩니다.

정리

항목설명
premainJVM 시작 시 로드. -javaagent 옵션으로 지정
agentmain실행 중인 JVM에 Attach API로 동적 연결
ClassFileTransformer클래스 로딩 시점에 바이트코드를 변환하는 핵심 인터페이스
ByteBuddy고수준 Java API로 바이트코드 조작. AgentBuilder + Advice 패턴
APM 원리프레임워크 클래스에 Transformer를 등록하여 소스 수정 없이 계측
성능 주의대상 클래스 필터링 필수. Advice가 MethodDelegation보다 오버헤드 적음
댓글 로딩 중...