이 문서를 읽고 나면 다음 질문에 답할 수 있습니다.
- Java 바이트코드란 무엇이고, JVM은
.class파일을 어떻게 실행하는가? - ASM, Javassist, ByteBuddy는 각각 어떤 추상화 수준에서 동작하는가?
- 세 도구 중 어느 것을 어떤 상황에서 선택해야 하는가?
- OTel Java Agent는 왜 ByteBuddy를 선택했는가?
- 바이트코드 조작이 잘못됐을 때 어떤 에러가 발생하고 어떻게 디버깅하는가?
Java Agent가 클래스를 "변환한다"고 했는데, 그 변환은 구체적으로 어떻게 이루어지는가. .class 파일 안에는 Java 소스코드가 없다. 컴파일된 바이트코드(bytecode) — JVM 명령어 집합으로 이루어진 이진 데이터가 있다.
이 바이트코드를 직접 수정하는 것이 바이트코드 조작이다. ASM은 이 명령어를 직접 다루고, ByteBuddy는 "이 메서드가 시작될 때 이 코드를 실행해"라고 선언적으로 표현한다. 두 도구 사이에는 복잡도와 안전성의 트레이드오프가 있다.
OTel Agent, Spring AOP, Mockito, JaCoCo — 이 도구들이 모두 바이트코드 조작을 사용한다. 이 기반 기술을 이해하면 이 도구들의 동작 방식과 한계가 명확해진다.
흔한 실수 1: 바이트코드 조작과 리플렉션을 혼동
리플렉션:
Class<?> c = Class.forName("com.example.OrderService");
Method m = c.getDeclaredMethod("processOrder", Order.class);
m.invoke(instance, order);
→ 런타임에 메서드 "호출" (코드 자체는 그대로)
바이트코드 조작:
OrderService.class 파일의 바이트코드를 수정
→ processOrder() 메서드 안에 새 명령어 삽입
→ 이후 모든 호출 시 수정된 코드가 실행
→ 리플렉션보다 훨씬 강력하고 낮은 오버헤드
흔한 실수 2: ASM으로 직접 작성 시 스택 계산 오류
JVM 바이트코드는 스택 기반 머신
각 명령어가 스택에서 값을 꺼내고 넣음
수동으로 MAXSTACK 계산 잘못 → VerifyError:
java.lang.VerifyError: Inconsistent stackmap frames at branch target
또는 지역 변수 인덱스 충돌 → NullPointerException (런타임):
this: 0번 슬롯
args[0]: 1번 슬롯
→ 실수로 같은 슬롯 덮어쓰기
흔한 실수 3: Javassist로 생성한 클래스가 다른 ClassLoader와 충돌
Javassist가 새 클래스 생성 시 기본 ClassLoader 사용
→ 특정 환경(OSGi, Application Server)에서 ClassNotFoundException
→ 해결: ClassPool에 올바른 ClassLoader 명시적 지정 필요
도구 선택 기준:
상황 1: 기존 메서드에 진입/종료 코드 삽입 (Agent 계측)
→ ByteBuddy @Advice 사용
→ 가장 안전하고 선언적, OTel이 선택한 이유
상황 2: 런타임에 새 클래스를 동적으로 생성
→ ByteBuddy (subclassing) 또는 Javassist
→ Mockito가 Mock 객체 생성할 때 사용하는 방식
상황 3: 기존 클래스 파일 오프라인 변환 (빌드 시)
→ ASM 또는 ByteBuddy (CLI 모드)
→ JaCoCo 커버리지 계측, Spring의 CGLIB
상황 4: JVM 내부 동작을 완전히 제어해야 할 때
→ ASM (저수준)
→ 성능 프로파일러 벤더, JVM 구현체 도구
실무에서의 선택 요약:
APM / 계측 도구 → ByteBuddy (@Advice)
테스트 목(Mock) → ByteBuddy (subclassing)
빌드 시 변환 → ASM (JaCoCo) or ByteBuddy
커스텀 극한 최적화 → ASM
Java 컴파일 과정:
OrderService.java (소스)
↓ javac 컴파일
OrderService.class (바이트코드)
↓ JVM 로딩 및 해석/JIT 컴파일
기계어 실행
.class 파일 구조:
Magic Number: 0xCAFEBABE ← Java의 상징
Minor/Major Version: 클래스 파일 버전
Constant Pool: 문자열, 클래스명, 메서드명 등 상수
Access Flags: public, abstract, final 등
This Class / Super Class / Interfaces
Fields: 필드 목록
Methods: 메서드 목록 (바이트코드 포함)
Attributes: SourceFile, LineNumberTable 등
메서드 하나의 바이트코드 예시:
Java 소스:
public int add(int a, int b) {
return a + b;
}
바이트코드 (javap -c 출력):
public int add(int, int);
Code:
0: iload_1 // 지역변수 1(a)를 스택에 push
1: iload_2 // 지역변수 2(b)를 스택에 push
2: iadd // 스택 top 두 값을 꺼내 더하고 결과 push
3: ireturn // 스택 top 값을 반환
JVM 스택 머신 원리:
각 명령어는 operand stack에서 값을 꺼내고(pop) 결과를 push
→ 레지스터 없이 스택만으로 연산
→ 단순하지만 바이트코드 조작 시 스택 상태를 정확히 추적해야 함
ASM의 방식 — 방문자(Visitor) 패턴:
ClassReader → 기존 .class 파일 파싱
ClassVisitor → 클래스 구조를 방문하며 변환
MethodVisitor → 메서드 바이트코드를 방문하며 변환
ClassWriter → 변환 결과를 새 byte[] 로 출력
예시: 모든 메서드 시작에 로그 삽입
public class TimingClassVisitor extends ClassVisitor {
public TimingClassVisitor(ClassVisitor cv) {
super(Opcodes.ASM9, cv);
}
@Override
public MethodVisitor visitMethod(int access, String name,
String desc, String sig, String[] ex) {
MethodVisitor mv = super.visitMethod(access, name, desc, sig, ex);
// 생성자와 static initializer는 제외
if (name.equals("<init>") || name.equals("<clinit>")) {
return mv;
}
return new TimingMethodVisitor(mv, name);
}
}
public class TimingMethodVisitor extends MethodVisitor {
private final String methodName;
public TimingMethodVisitor(MethodVisitor mv, String methodName) {
super(Opcodes.ASM9, mv);
this.methodName = methodName;
}
@Override
public void visitCode() {
super.visitCode();
// System.nanoTime() 호출 바이트코드 직접 삽입
mv.visitMethodInsn(
Opcodes.INVOKESTATIC,
"java/lang/System", // 클래스명 (슬래시 구분)
"nanoTime", // 메서드명
"()J", // 디스크립터: 인자 없음, long 반환
false
);
// 반환값(long)을 지역 변수에 저장
mv.visitVarInsn(Opcodes.LSTORE, startTimeVarIndex);
}
@Override
public void visitInsn(int opcode) {
// return 명령어 직전에 경과 시간 계산 코드 삽입
if (isReturnOpcode(opcode)) {
// System.nanoTime() - startTime 계산
mv.visitMethodInsn(Opcodes.INVOKESTATIC, "java/lang/System",
"nanoTime", "()J", false);
mv.visitVarInsn(Opcodes.LLOAD, startTimeVarIndex);
mv.visitInsn(Opcodes.LSUB);
// System.out.println(methodName + " took " + elapsed + "ns") 삽입
// ... 수십 줄의 바이트코드 조작 코드 ...
}
super.visitInsn(opcode);
}
}
ASM의 특징:
장점:
최고 성능 (JVM 수준 최적화 가능)
완전한 제어 (어떤 바이트코드도 삽입 가능)
외부 의존성 없음 (ASM 단독 사용)
단점:
매우 낮은 추상화 → 모든 JVM 명령어 직접 관리
스택 상태 수동 추적 → 실수 시 VerifyError
예외 처리 코드 직접 삽입 필요 (try-finally 직접 구현)
유지보수 어려움 → 코드가 매우 장황
Javassist의 방식 — 문자열 소스코드로 조작:
ClassPool pool = ClassPool.getDefault();
CtClass ctClass = pool.get("com.example.OrderService");
CtMethod method = ctClass.getDeclaredMethod("processOrder");
// 메서드 시작에 코드 삽입 (Java 소스 코드 문자열!)
method.insertBefore("""
long $startTime = System.nanoTime();
""");
// 메서드 종료에 코드 삽입
method.insertAfter("""
long elapsed = System.nanoTime() - $startTime;
System.out.println("processOrder took: " + elapsed + "ns");
""");
byte[] modifiedBytes = ctClass.toBytecode();
Javassist 특수 변수:
$0 → this (인스턴스 메서드)
$1, $2, ... → 메서드 인자 (1부터 시작)
$_ → 반환값 (insertAfter에서 사용)
$r → 반환 타입
$args → 인자 배열 (Object[])
$type → 반환 타입 (Class)
예외 처리:
method.addCatch("""
System.err.println("에러 발생: " + $e.getMessage());
throw $e;
""", pool.get("java.lang.Exception"));
Javassist의 특징:
장점:
Java 소스코드 문자열로 작성 → 직관적
ASM보다 훨씬 쉬운 학습 곡선
동적 클래스 생성 편리 (ClassPool.makeClass())
단점:
런타임에 소스코드를 컴파일 → 초기화 지연
타입 안전성 없음 (문자열 오타 → 런타임 에러)
복잡한 제네릭/람다 처리 어려움
현재 활발한 개발 없음 (ByteBuddy에 밀림)
ByteBuddy의 방식 — 선언적, 타입 안전:
방식 1: Subclassing (새 클래스 생성)
// 기존 클래스를 상속해서 메서드 오버라이드
Class<? extends OrderService> dynamicClass = new ByteBuddy()
.subclass(OrderService.class)
.method(ElementMatchers.named("processOrder"))
.intercept(MethodDelegation.to(TimingInterceptor.class))
.make()
.load(OrderService.class.getClassLoader())
.getLoaded();
// → Mockito의 Mock 객체 생성 방식
// → Spring CGLIB 프록시 방식
방식 2: @Advice (기존 클래스 바이트코드 직접 변환)
// 기존 클래스 변환 (복사본 생성 아님)
new AgentBuilder.Default()
.type(ElementMatchers.named("com.example.OrderService"))
.transform((builder, type, classLoader, module, protectionDomain) ->
builder.method(ElementMatchers.named("processOrder"))
.intercept(Advice.to(TimingAdvice.class))
)
.installOn(instrumentation);
// Advice 클래스 — 삽입할 코드 정의
public class TimingAdvice {
@Advice.OnMethodEnter
static long onEnter() {
return System.nanoTime(); // 반환값이 자동으로 @Enter 파라미터로 전달
}
@Advice.OnMethodExit(onThrowable = Throwable.class)
static void onExit(
@Advice.Origin String methodName, // 메서드 이름
@Advice.Enter long startTime, // onEnter 반환값
@Advice.Return Object returnValue, // 반환값
@Advice.Thrown Throwable thrown) { // 예외 (없으면 null)
long elapsed = System.nanoTime() - startTime;
System.out.printf("[Timing] %s took %d ms%n",
methodName, elapsed / 1_000_000);
}
}
ByteBuddy @Advice의 핵심 원리:
@Advice 클래스는 실제로 "실행"되지 않음
→ ByteBuddy가 @Advice 클래스의 바이트코드를 읽어서
→ 대상 메서드의 바이트코드 안으로 "인라인(inline)" 삽입
→ 메서드 호출 오버헤드 없음 (인라인이므로)
→ 컴파일 타임에 타입 검사 → 런타임 에러 없음
ByteBuddy ElementMatcher:
named("processOrder") // 이름 일치
namedStartsWith("process") // 이름 접두사
isAnnotatedWith(Timed.class) // 어노테이션 포함
takesArguments(Order.class) // 특정 인자 타입
returns(void.class) // 반환 타입
isPublic().and(not(isStatic())) // 조합 가능
isAnnotatedWith(Timed.class)
.and(not(isConstructor())) // 생성자 제외
┌────────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ 항목 │ ASM │ Javassist │ ByteBuddy │
├────────────────────┼──────────────────┼──────────────────┼──────────────────┤
│ 추상화 수준 │ 바이트코드 명령어 │ Java 소스 문자열 │ 선언적 DSL │
│ 학습 곡선 │ 매우 높음 │ 중간 │ 낮음 │
│ 타입 안전성 │ 없음 │ 없음 │ 있음 │
│ 성능 (변환 속도) │ 가장 빠름 │ 느림(컴파일 필요) │ 빠름 (ASM 기반) │
│ 인라인 삽입 │ 수동 │ 불가 │ @Advice로 자동 │
│ 예외 처리 코드 │ 직접 삽입 │ addCatch() │ onThrowable=자동 │
│ 동적 클래스 생성 │ 복잡 │ 편리 │ 편리 │
│ JDK 버전 대응 │ 빠름 │ 느림 │ 빠름 │
│ 유지보수성 │ 낮음 │ 중간 │ 높음 │
│ 실사용 예 │ JaCoCo, ASM 직접 │ 일부 레거시 APM │ OTel, Mockito │
└────────────────────┴──────────────────┴──────────────────┴──────────────────┘
OTel이 ByteBuddy를 선택한 구체적 이유:
1. @Advice 인라인 삽입
→ 메서드 호출 오버헤드 없음
→ 계측 코드가 원본 메서드 안으로 병합
→ ASM으로 직접 하면 수백 줄이 될 코드를 선언적으로 표현
2. 타입 안전성
→ @Advice.Origin, @Advice.Enter, @Advice.Return 등 명확한 파라미터
→ 컴파일 시점에 타입 오류 검출
3. ElementMatcher 조합
→ 수십 개의 InstrumentationModule이 각자의 조건으로 대상 클래스 선정
→ 가독성 있는 DSL로 관리 가능
4. ASM 내부 사용
→ ByteBuddy는 내부적으로 ASM을 사용
→ 성능은 ASM과 동일, 추상화만 높음
5. 활발한 커뮤니티
→ Rafael Winterhalter(ByteBuddy 작성자)가 OTel 커미터
→ 밀접한 협업으로 OTel 요구사항에 맞는 기능 추가
# 간단한 클래스 컴파일
cat > OrderService.java << 'EOF'
public class OrderService {
public int calculateTotal(int price, int quantity) {
return price * quantity;
}
}
EOF
javac OrderService.java
# 바이트코드 확인
javap -c OrderService.class
# Output:
# public int calculateTotal(int, int);
# Code:
# 0: iload_1 // price 로드
# 1: iload_2 // quantity 로드
# 2: imul // 곱셈
# 3: ireturn // 반환
# 상세 정보 (지역변수 테이블, 라인 번호)
javap -c -verbose OrderService.class// ASM ClassReader로 기존 클래스 분석
ClassReader reader = new ClassReader("com.example.OrderService");
ClassVisitor printer = new TraceClassVisitor(new PrintWriter(System.out));
reader.accept(printer, 0);
// → 클래스 구조와 바이트코드 출력<!-- pom.xml -->
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm</artifactId>
<version>9.6</version>
</dependency>
<dependency>
<groupId>org.ow2.asm</groupId>
<artifactId>asm-util</artifactId>
<version>9.6</version>
</dependency>// 런타임에 클래스 생성 (Mockito가 하는 것과 유사)
Class<?> dynamicType = new ByteBuddy()
.subclass(Object.class)
.name("com.example.DynamicGreeter")
.method(ElementMatchers.named("toString"))
.intercept(FixedValue.value("Hello from ByteBuddy!"))
.make()
.load(getClass().getClassLoader())
.getLoaded();
Object instance = dynamicType.getDeclaredConstructor().newInstance();
System.out.println(instance.toString());
// → "Hello from ByteBuddy!"
// 생성된 클래스 바이트코드 파일로 저장 (디버깅용)
new ByteBuddy()
.subclass(Object.class)
.make()
.saveIn(new File("/tmp/bytebuddy-output/"));
// → /tmp/bytebuddy-output/net/bytebuddy/renamed/Object$ByteBuddy$xxx.class
// → javap로 확인 가능// ByteBuddy가 생성한 바이트코드 검사
new AgentBuilder.Default()
// 디버그: 변환된 클래스를 파일로 저장
.with(AgentBuilder.Listener.StreamWriting.toSystemError())
.with(new AgentBuilder.Listener.Adapter() {
@Override
public void onTransformation(TypeDescription td,
ClassLoader cl,
JavaModule module,
boolean loaded,
DynamicType dt) {
try {
dt.saveIn(new File("/tmp/transformed/"));
} catch (IOException e) { /* ignore */ }
}
})
.type(named("com.example.OrderService"))
.transform(/* ... */)
.installOn(instrumentation);
// 변환된 .class 파일을 javap로 확인
// javap -c /tmp/transformed/com/example/OrderService.class바이트코드 조작 도구 성능 비교 (클래스 1,000개 변환 기준):
도구 | 변환 속도 | 메모리 사용 | 생성 코드 품질
──────────────────────┼─────────────┼─────────────┼──────────────────
ASM (직접) | 가장 빠름 | 최소 | 최적 (직접 제어)
ByteBuddy (@Advice) | 빠름 | 낮음 | ASM과 동일 수준
ByteBuddy (subclass) | 중간 | 중간 | 좋음
Javassist | 느림 | 높음 | 양호
실제 OTel Agent 시작 시간 분석:
전체 premain() 시간: ~2.5초
├─ ByteBuddy AgentBuilder 초기화: ~0.5초
├─ InstrumentationModule 목록 로딩: ~0.8초
├─ ElementMatcher 등록: ~0.3초
└─ Bootstrap ClassLoader 경로 추가: ~0.9초
런타임 오버헤드 (클래스 변환 완료 후):
변환된 클래스는 캐싱 → 재변환 없음
@Advice 인라인 삽입 → 메서드 호출 없음 → 오버헤드 최소
실측 오버헤드: 요청당 ~3~5μs (Span 생성 포함)
도구 선택의 트레이드오프:
ASM 선택 시:
얻는 것: 최대 성능, 완전한 제어
잃는 것: 개발 생산성, 유지보수성
적합: JVM 도구 벤더, 성능이 최우선인 경우
Javassist 선택 시:
얻는 것: 쉬운 학습, 직관적인 문법
잃는 것: 성능, 타입 안전성, 최신 JDK 호환성
적합: 소규모 레거시 프로젝트, 빠른 PoC
ByteBuddy 선택 시:
얻는 것: 높은 추상화 + ASM 수준 성능, 타입 안전성
잃는 것: ASM 대비 약간의 학습 비용 (그래도 가장 낮음)
적합: Agent 개발, Mock 프레임워크, 대부분의 계측 사용 사례
바이트코드 조작 vs 리플렉션 vs AOP:
리플렉션:
런타임 동적 호출 → 오버헤드 있음 (~5x vs 직접 호출)
기존 코드 변경 없음, 간단한 사용 사례
Spring AOP (프록시 기반):
인터페이스 → JDK 동적 프록시
클래스 → CGLIB (ByteBuddy 기반) 서브클래스
메서드 호출 시 오버헤드 (~10μs)
코드 변경 필요 (Bean으로 관리되어야 함)
ByteBuddy @Advice (인라인):
원본 메서드 안으로 코드 병합
메서드 호출 오버헤드 없음
코드 변경 없음 (Agent 방식)
가장 낮은 오버헤드
바이트코드 조작 도구 요약:
Java 바이트코드:
.class 파일 = JVM 스택 머신 명령어 집합
javap -c 로 사람이 읽을 수 있게 역어셈블 가능
바이트코드 조작 = 이 명령어를 삽입/수정/삭제
세 도구:
ASM: 바이트코드 명령어 직접 조작, 최고 성능, 최저 추상화
Javassist: Java 소스 문자열로 조작, 쉽지만 성능 낮음
ByteBuddy: 선언적 DSL + @Advice 인라인, 성능과 추상화 균형
OTel이 ByteBuddy를 선택한 이유:
@Advice 인라인 삽입 → 오버헤드 최소
타입 안전한 파라미터 (@Advice.Origin, @Advice.Enter 등)
ElementMatcher DSL → 계측 대상 선정 가독성
내부적으로 ASM 사용 → ASM 수준 성능
다음 단계:
ByteBuddy로 실제 동작하는 TimingAgent 직접 구현 (Ch2-03)
Q1. Spring AOP의 CGLIB 프록시와 ByteBuddy @Advice의 차이는? @Transactional이 ByteBuddy @Advice 방식을 쓰지 않는 이유는?
해설 보기
CGLIB 프록시 방식 (Spring AOP):
OrderService 원본 클래스
↓ CGLIB (ByteBuddy subclassing)
OrderService$$CGLIB 프록시 클래스
→ processOrder() {
트랜잭션 시작(); // 앞 어드바이스
super.processOrder(); // 원본 메서드 호출
트랜잭션 커밋(); // 뒤 어드바이스
}
ByteBuddy @Advice 방식:
OrderService.processOrder() 원본 바이트코드 안에
트랜잭션 시작 코드를 직접 삽입
→ 별도 프록시 객체 없음
→ super 호출 없음
Spring이 @Advice를 안 쓰는 이유:
- Spring Bean은 ApplicationContext에서 관리되는 객체 단위 → 프록시 패턴이 자연스러움
- 프록시는 교체 가능 (테스트에서 Mock으로 치환) → @Advice 인라인은 교체 불가
@Transactional은 Bean의 메서드 경계를 의미 → 프록시가 더 직관적- Spring AOP는 이미 잘 작동하는 기존 방식 → 교체 이유 없음
반면 OTel Agent는:
- Bean 관리 밖의 라이브러리 클래스도 계측해야 함 (RestTemplate, JDBC Driver 등)
- 프록시 생성이 불가능한 경우도 있음 (final 클래스 등)
- 최소 오버헤드가 중요 → @Advice 인라인 선택
Q2. ByteBuddy @Advice 클래스의 onEnter() 메서드가 static이어야 하는 이유는?
해설 보기
@Advice 인라인의 동작 방식:
ByteBuddy @Advice는 TimingAdvice.onEnter()를 실제로 호출하지 않습니다. 대신 onEnter() 메서드의 바이트코드를 복사해서 대상 메서드 안에 붙여 넣습니다.
static이어야 하는 이유:
- 인라인 삽입된 코드는 대상 클래스(
OrderService)의 컨텍스트에서 실행됨 - 만약
onEnter()가 인스턴스 메서드라면 →TimingAdvice인스턴스(this)가 필요 - 하지만 삽입 후 코드에는
TimingAdvice인스턴스가 없음 static이면this없이 바이트코드만 복사하면 됨
실제로 ByteBuddy는 @Advice 클래스를 런타임에 인스턴스화하지 않습니다. TimingAdvice.class를 분석해 바이트코드를 추출하고 대상 메서드에 삽입하는 것이 전부입니다.
// ❌ 불가 — 인스턴스 메서드
public class TimingAdvice {
@Advice.OnMethodEnter
long onEnter() { return System.nanoTime(); } // this가 필요 → 불가
}
// ✅ 가능 — static 메서드
public class TimingAdvice {
@Advice.OnMethodEnter
static long onEnter() { return System.nanoTime(); } // this 없음 → 인라인 가능
}Q3. javap -c로 보이는 바이트코드에서 iload_0과 iload_1의 차이는? 인스턴스 메서드에서 _0은 무엇을 가리키는가?
해설 보기
JVM의 지역 변수 배열(Local Variable Array) 인덱스:
인스턴스 메서드의 경우:
- 인덱스 0 →
this(암시적 첫 번째 파라미터) - 인덱스 1 → 첫 번째 명시적 파라미터
- 인덱스 2 → 두 번째 명시적 파라미터
- ...
static 메서드의 경우:
- 인덱스 0 → 첫 번째 파라미터 (
this없음) - 인덱스 1 → 두 번째 파라미터
// Java 소스
public int add(int a, int b) { // 인스턴스 메서드
return a + b;
}
// 바이트코드
// 0번: this (OrderService 인스턴스)
// 1번: a (int)
// 2번: b (int)
iload_1 // a 로드 (1번 슬롯)
iload_2 // b 로드 (2번 슬롯)
iadd
ireturnByteBuddy @Advice에서 @Advice.Argument(0)은 첫 번째 명시적 파라미터 (= 지역변수 인덱스 1)를 의미합니다. ByteBuddy가 this 인덱스 오프셋을 자동으로 처리해줍니다.
ASM에서 직접 바이트코드를 삽입할 때 이 인덱스를 잘못 계산하면 this를 덮어쓰거나 잘못된 값을 읽어 NullPointerException이 발생합니다. 이것이 ByteBuddy가 안전한 이유 중 하나입니다.