1. Java Instrumentation API 소개
- Java Instrumentation API는 JVM에서 실행 중인 Java 코드를 감시하고 조작할 수 있는 메커니즘을 제공합니다.
- JDK 1.5에서 처음 도입되었으며, 이후 버전에서 지속적으로 개선되었습니다.
- 이 API의 핵심 가치는 원본 소스 코드를 수정하지 않고도 프로그램의 동작을 관찰, 수정, 확장할 수 있다는 점입니다.
- 애플리케이션 성능 모니터링(APM), 프로파일링, 커버리지 분석, 보안 검사 등 다양한 툴이 이 API를 기반으로 구축됩니다.
1.1 Instrumentation의 원리
- Instrumentation은 바이트코드 수준에서 작동하며, 클래스가 로드될 때 또는 이미 로드된 클래스를 재정의하여 동작을 변경합니다.
- 일반적으로 특정 메서드의 시작과 끝에 추가 코드를 삽입하여 실행 시간, 메모리 사용량, 예외 발생 등을 측정합니다.
- 이 과정은 애플리케이션의 실행 코드 흐름에 영향을 주지만, 원본 소스 코드는 변경되지 않습니다.
- 런타임에 이루어지는 이러한 "코드 주입" 방식이 "코드 침투 없는 계측"을 가능하게 합니다.
2. Java Agent의 개념과 유형
- Java Agent는 Instrumentation API를 사용하여 클래스를 변환하는 특수한 Java 프로그램입니다.
- Java Agent는 애플리케이션의 메인 메서드가 실행되기 전(premain) 또는 실행 중(agentmain)에 로드될 수 있습니다.
- JAR 파일 형태로 패키징되며, 특수한 매니페스트 속성을 포함합니다.
2.1 정적 에이전트(Static Agent)
- JVM 시작 시
-javaagent옵션을 통해 로드됩니다. premain메서드를 구현해야 합니다.premain메서드에서 ClassFileTransformer를 등록하여, 이후 각 클래스가 처음 로드될 때 바이트코드 변환이 이루어지도록 합니다.- 애플리케이션 전체 생명주기에 걸쳐 모니터링하는 데 적합합니다.
- 예시: APM 툴(New Relic, AppDynamics), 코드 커버리지 도구(JaCoCo), 프로파일러(YourKit)
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new MyClassTransformer());
}
2.2 동적 에이전트(Dynamic Agent)
- 이미 실행 중인 JVM에 Attach API를 사용하여 동적으로 로드됩니다.
agentmain메서드를 구현해야 합니다.- 실행 중인 JVM에 연결하여 에이전트 JAR를 로드하면 agentmain 메서드가 호출됩니다.
agentmain메서드 안에서 이미 로드된 클래스의 재정의(retransformation)를 수행할 수 있습니다.- 실행 중인 애플리케이션의 문제 진단이나 특정 조건에서만 모니터링이 필요한 경우 유용합니다.
- 예시: JVM 트러블슈팅 도구(Arthas, BTrace)
public static void agentmain(String agentArgs, Instrumentation inst) {
// ClassFileTransformer를 등록합니다. true 파라미터는 retransform 가능하게 설정
inst.addTransformer(new MyClassTransformer(), true);
// 이미 로드된 특정 클래스를 재변환합니다.
// 이 시점에 등록된 transformer가 호출되어 실제 바이트코드 변환이 수행됩니다.
inst.retransformClasses(TargetClass.class);
}
2.3 에이전트 JAR 매니페스트 설정
- Java Agent JAR 파일은 특수한 매니페스트 속성이 필요합니다.
- 정적 에이전트와 동적 에이전트의 매니페스트 설정이 다릅니다.
정적 에이전트 매니페스트(MANIFEST.MF) 예시:
Manifest-Version: 1.0
Premain-Class: com.example.MyJavaAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
동적 에이전트 매니페스트(MANIFEST.MF) 예시:
Manifest-Version: 1.0
Agent-Class: com.example.MyJavaAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
3. 바이트코드 변환 메커니즘
- Java 에이전트는
ClassFileTransformer인터페이스를 구현하여 바이트코드를 변환합니다. - 변환 과정에서 바이트코드 조작 라이브러리(ASM, Javassist, ByteBuddy 등)를 사용합니다.
- 이 프로세스는 원본 코드에는 영향을 주지 않으면서, 실행되는 코드를 변경합니다.
3.1 클래스 로드 및 변환 시점
- 클래스 로드 시점: JVM이 클래스를 처음 로드할 때 발생합니다.
- 클래스 로더가 클래스 파일을 찾고 바이트코드를 로드합니다.
- 클래스가 로드되기 전에 등록된
ClassFileTransformer가 바이트코드를 변환합니다. - 변환된 바이트코드는 JVM의 클래스 정의에 사용됩니다.
- 클래스 재정의(Retransformation) 시점: 이미 로드된 클래스의 바이트코드를 변경합니다.
Instrumentation.retransformClasses()메서드를 통해 트리거됩니다.- 이미 생성된 객체의 동작도 변경할 수 있습니다.
- 클래스 구조(필드 추가/삭제, 메서드 시그니처 변경 등)는 변경할 수 없습니다.
- 클래스 재정의(Redefinition) 시점: 클래스의 완전한 교체가 필요할 때 사용합니다.
Instrumentation.redefineClasses()메서드를 통해 트리거됩니다.- 클래스 변환기를 거치지 않고 직접 새 바이트코드를 제공합니다.
- 마찬가지로 클래스 구조는 변경할 수 없습니다.
3.2 ClassFileTransformer 인터페이스
- 바이트코드 변환의 핵심은
ClassFileTransformer인터페이스 구현체입니다. transform메서드는 클래스 로드 시 호출되어 바이트코드를 변환합니다.
public class MyClassTransformer implements ClassFileTransformer {
@Override
public byte[] transform(
ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// 변환하려는 클래스인지 확인
if (className.equals("com/example/TargetClass")) {
// 바이트코드 조작 라이브러리(ASM, Javassist 등)를 사용하여 변환
return transformBytes(classfileBuffer);
}
// 변환하지 않을 경우 원본 바이트코드 반환
return null;
}
}
3.3 주요 바이트코드 조작 라이브러리
- ASM: 가장 낮은 수준의 바이트코드 조작 라이브러리로, 높은 성능과 유연성을 제공합니다.
- Javassist: 더 높은 수준의 API를 제공하여 Java 코드와 유사한 문자열로 바이트코드를 조작할 수 있습니다.
- ByteBuddy: 현대적이고 타입 안전한 API를 제공하며, 복잡한 바이트코드 조작을 단순화합니다.