Skip to content

Latest commit

 

History

History
704 lines (554 loc) · 25.7 KB

File metadata and controls

704 lines (554 loc) · 25.7 KB

바이트코드 조작 도구 비교 — ASM / Javassist / ByteBuddy


🎯 핵심 질문

이 문서를 읽고 나면 다음 질문에 답할 수 있습니다.

  • Java 바이트코드란 무엇이고, JVM은 .class 파일을 어떻게 실행하는가?
  • ASM, Javassist, ByteBuddy는 각각 어떤 추상화 수준에서 동작하는가?
  • 세 도구 중 어느 것을 어떤 상황에서 선택해야 하는가?
  • OTel Java Agent는 왜 ByteBuddy를 선택했는가?
  • 바이트코드 조작이 잘못됐을 때 어떤 에러가 발생하고 어떻게 디버깅하는가?

🔍 왜 이 개념이 실무에서 중요한가

Java Agent가 클래스를 "변환한다"고 했는데, 그 변환은 구체적으로 어떻게 이루어지는가. .class 파일 안에는 Java 소스코드가 없다. 컴파일된 바이트코드(bytecode) — JVM 명령어 집합으로 이루어진 이진 데이터가 있다.

이 바이트코드를 직접 수정하는 것이 바이트코드 조작이다. ASM은 이 명령어를 직접 다루고, ByteBuddy는 "이 메서드가 시작될 때 이 코드를 실행해"라고 선언적으로 표현한다. 두 도구 사이에는 복잡도와 안전성의 트레이드오프가 있다.

OTel Agent, Spring AOP, Mockito, JaCoCo — 이 도구들이 모두 바이트코드 조작을 사용한다. 이 기반 기술을 이해하면 이 도구들의 동작 방식과 한계가 명확해진다.


😱 흔한 실수 (Before — 원리를 모를 때의 접근)

흔한 실수 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 명시적 지정 필요

✨ 올바른 접근 (After — 도구를 목적에 맞게 선택)

도구 선택 기준:

상황 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

🔬 내부 동작 원리

1. Java 바이트코드 기초

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
  → 레지스터 없이 스택만으로 연산
  → 단순하지만 바이트코드 조작 시 스택 상태를 정확히 추적해야 함

2. ASM — 바이트코드 수준 직접 조작

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 직접 구현)
    유지보수 어려움 → 코드가 매우 장황

3. Javassist — 소스 코드 수준 조작

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에 밀림)

4. ByteBuddy — 선언적 DSL 방식

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()))            // 생성자 제외

5. 세 도구 비교 — OTel이 ByteBuddy를 선택한 이유

┌────────────────────┬──────────────────┬──────────────────┬──────────────────┐
│ 항목                │ 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 요구사항에 맞는 기능 추가

💻 실전 실험

실험 1: javap로 바이트코드 직접 보기

# 간단한 클래스 컴파일
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

실험 2: ASM으로 바이트코드 직접 읽기

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

실험 3: ByteBuddy로 동적 클래스 생성

// 런타임에 클래스 생성 (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로 확인 가능

실험 4: ByteBuddy 바이트코드 덤프로 변환 결과 확인

// 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를 안 쓰는 이유:

  1. Spring Bean은 ApplicationContext에서 관리되는 객체 단위 → 프록시 패턴이 자연스러움
  2. 프록시는 교체 가능 (테스트에서 Mock으로 치환) → @Advice 인라인은 교체 불가
  3. @Transactional은 Bean의 메서드 경계를 의미 → 프록시가 더 직관적
  4. 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_0iload_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
ireturn

ByteBuddy @Advice에서 @Advice.Argument(0)은 첫 번째 명시적 파라미터 (= 지역변수 인덱스 1)를 의미합니다. ByteBuddy가 this 인덱스 오프셋을 자동으로 처리해줍니다.

ASM에서 직접 바이트코드를 삽입할 때 이 인덱스를 잘못 계산하면 this를 덮어쓰거나 잘못된 값을 읽어 NullPointerException이 발생합니다. 이것이 ByteBuddy가 안전한 이유 중 하나입니다.