Skip to content

Latest commit

 

History

History
675 lines (519 loc) · 24.3 KB

File metadata and controls

675 lines (519 loc) · 24.3 KB

Java Agent 메커니즘 — premain()과 Instrumentation API


🎯 핵심 질문

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

  • -javaagent 옵션을 붙이면 JVM 내부에서 정확히 어떤 순서로 무슨 일이 일어나는가?
  • premain()main()의 실행 순서는 어떻게 되고, 왜 이 순서가 중요한가?
  • Instrumentation.addTransformer()는 어떻게 동작하고, 클래스 로딩의 어느 시점에 개입하는가?
  • agentmain()premain()은 무엇이 다르고, 실행 중인 JVM에 동적으로 Attach하는 것은 어떻게 가능한가?
  • Java Agent JAR 파일은 일반 JAR과 어떻게 다른가?

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

-javaagent:opentelemetry-javaagent.jar를 JVM 옵션에 추가하면 Spring MVC, JDBC, Redis, Kafka가 자동으로 계측된다. 코드 한 줄 안 바꿨는데 Trace가 생긴다. 어떻게?

이 원리를 모르면 Agent가 왜 특정 클래스는 계측하고 특정 클래스는 안 하는지, Agent 적용 후 왜 시작이 느려지는지, 왜 특정 환경에서 Agent가 충돌을 일으키는지 이해할 수 없다.

Java Agent는 JVM의 클래스 로딩 메커니즘에 훅을 거는 방식으로 동작한다. 이 훅의 원리를 이해하면 "코드 변경 없는 계측"의 한계와 가능성이 모두 보인다.


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

흔한 실수 1: Agent 충돌 시 원인을 모름

  상황: 두 개의 Java Agent 동시 적용
    -javaagent:opentelemetry-javaagent.jar
    -javaagent:newrelic.jar

  증상:
    특정 클래스에서 ClassCircularityError 또는 NoSuchMethodError
    일부 Trace가 생기고 일부는 안 생김
    간헐적으로 NullPointerException

  원인을 모를 때:
    "Agent 버전 문제인가?" → 버전 변경 시도
    "JVM 버전 문제인가?" → JVM 변경 시도
    결국 해결 못 하고 하나 제거

  원리를 알면:
    두 Agent가 같은 클래스(예: RestTemplate.execute)를 동시에 변환
    → 첫 번째 Agent가 바꾼 바이트코드를 두 번째 Agent가 다시 변환
    → 메서드 시그니처 불일치 또는 중복 계측
    → 해결: Agent 순서 조정 또는 OTel Collector로 통합

흔한 실수 2: Agent가 특정 클래스를 계측 못 하는 이유를 모름

  상황: 사내 공통 HTTP 클라이언트를 쓰는데 Trace에 안 잡힘

  원인을 모를 때:
    "설정이 잘못됐나?" → 환경변수 이것저것 변경
    "버전 문제인가?"

  원리를 알면:
    OTel Agent는 알려진 라이브러리 목록을 기반으로 계측
    사내 커스텀 클라이언트는 목록에 없음
    → 수동 계측(@Observed) 또는 커스텀 InstrumentationModule 작성 필요

흔한 실수 3: Bootstrap ClassLoader 문제

  Agent 클래스가 애플리케이션 클래스와 같은 ClassLoader를 공유한다고 가정
  → Agent 내부 클래스를 앱 코드에서 import하려 시도 → NoClassDefFoundError
  → 원리: Agent는 별도 ClassLoader에 격리됨

✨ 올바른 접근 (After — 원리를 알고 난 설계/운영)

Java Agent 동작 원리를 알면:

진단 체크리스트:
  1. Agent가 로딩됐는가?
     → JVM 옵션 확인: ps aux | grep javaagent
     → Agent 시작 로그: "[otel.javaagent] AgentInstaller loaded"

  2. 대상 클래스가 변환됐는가?
     → -Dotel.javaagent.debug=true 로그 확인
     → "Transforming class: org.springframework..."

  3. 변환이 실패했는가?
     → java.lang.instrument.IllegalClassFormatException 로그 확인

  4. 여러 Agent 충돌인가?
     → -verbose:class 로 클래스 로딩 순서 확인
     → Agent 하나씩 비활성화해서 범위 좁히기

올바른 Agent 설정:
  # 단일 Agent (OTel)로 통합 — 벤더별 Agent 대신
  java -javaagent:opentelemetry-javaagent.jar \
       -Dotel.service.name=order-service \
       -Dotel.exporter.otlp.endpoint=http://collector:4317 \
       -jar app.jar

  # 불가피하게 두 Agent 필요 시: OTel Agent를 먼저
  java -javaagent:opentelemetry-javaagent.jar \
       -javaagent:other-agent.jar \
       -jar app.jar

🔬 내부 동작 원리

1. JVM 시작과 Agent 로딩 순서

java -javaagent:myagent.jar -jar app.jar 실행 시:

JVM 시작
  │
  ▼ 1. JVM 초기화 (Hotspot VM 구동)
  │    Bootstrap ClassLoader 준비
  │    System ClassLoader 준비
  │
  ▼ 2. Agent JAR 처리 (main() 이전)
  │    MANIFEST.MF 읽기
  │      Premain-Class: com.example.MyAgent
  │      Agent-Class: com.example.MyAgent    (agentmain용)
  │      Can-Redefine-Classes: true
  │      Can-Retransform-Classes: true
  │
  ▼ 3. premain() 호출
  │    MyAgent.premain(String agentArgs, Instrumentation inst)
  │      → inst.addTransformer(new MyTransformer())
  │         ClassFileTransformer 등록 완료
  │
  ▼ 4. main() 호출 (앱 시작)
  │    클래스 로딩 시작
  │
  ▼ 5. 클래스 로딩마다 Transformer 실행
       JVM이 클래스 로드 요청 수신
         → 등록된 모든 ClassFileTransformer.transform() 순서대로 호출
         → 반환된 byte[] 를 새 클래스 정의로 사용
         → null 반환 시 원본 바이트코드 그대로 사용

핵심:
  premain()이 main() 이전에 실행 → ClassFileTransformer가 등록됨
  이후 모든 클래스 로딩 시 Transformer가 개입할 기회를 얻음
  → "코드 변경 없이" 바이트코드 수준에서 변환 가능

2. Instrumentation API — 핵심 메서드

public interface Instrumentation {

    // 클래스 로딩 시 변환 훅 등록
    void addTransformer(ClassFileTransformer transformer);

    // 재변환 가능하게 등록 (이미 로딩된 클래스도 재변환 가능)
    void addTransformer(ClassFileTransformer transformer,
                        boolean canRetransform);

    // 이미 로딩된 클래스를 재변환 (agentmain에서 사용)
    void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

    // 이미 로딩된 클래스를 완전히 재정의 (시그니처 변경 불가)
    void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException,
                                                                UnmodifiableClassException;

    // 현재 JVM에 로딩된 모든 클래스 목록
    Class[] getAllLoadedClasses();

    // 객체의 실제 메모리 크기 (JVM 구현에 따라 다름)
    long getObjectSize(Object objectToSize);
}
ClassFileTransformer의 역할:

public interface ClassFileTransformer {
    byte[] transform(
        ClassLoader         loader,          // 로딩하는 ClassLoader
        String              className,       // "com/example/OrderService" (슬래시 구분)
        Class<?>            classBeingRedefined,  // 재정의 시에만 값 있음
        ProtectionDomain    protectionDomain,
        byte[]              classfileBuffer  // 원본 바이트코드
    ) throws IllegalClassFormatException;
}

동작:
  ① 반환값 null → 원본 바이트코드 사용 (변환 안 함)
  ② 반환값 byte[] → 반환된 바이트코드로 클래스 정의
  ③ 여러 Transformer가 등록된 경우:
     Transformer1.transform() → byte[]₁
     Transformer2.transform(byte[]₁) → byte[]₂  ← 이전 결과를 받아서 처리
     → 체인 방식으로 순서대로 적용

3. premain() vs agentmain() — 두 가지 Attach 방식

premain(): 정적 Attach (Static Attach)
─────────────────────────────────────
  JVM 시작 전에 Agent를 알고 있을 때 사용
  -javaagent 옵션으로 지정

  실행 순서:
    JVM 구동 → premain() → main()
  
  가능한 것:
    모든 클래스 로딩 전에 Transformer 등록
    → Bootstrap ClassLoader의 클래스도 계측 가능
    → java.net.HttpURLConnection 등 JDK 내부 클래스 계측

agentmain(): 동적 Attach (Dynamic Attach)
──────────────────────────────────────────
  이미 실행 중인 JVM에 Agent를 붙일 때 사용
  Java Attach API (tools.jar)로 프로세스 ID 기반 연결

  동작 방식:
    별도 프로세스에서 Attach API 호출:
      VirtualMachine vm = VirtualMachine.attach("12345");  // PID
      vm.loadAgent("/path/to/myagent.jar");

    → 대상 JVM에서 agentmain() 호출
    → retransformClasses()로 이미 로딩된 클래스 재변환

  실제 사용 사례:
    JVM 프로파일러 (VisualVM, JProfiler, YourKit)
    arthas (Alibaba 오픈소스 Java 진단 도구)
    Attach 후 특정 메서드 실시간 계측

  제한:
    이미 로딩된 클래스를 재변환 시 메서드 추가/제거 불가
    메서드 내부 바이트코드만 변경 가능
    → premain()과 달리 Bootstrap 클래스 계측 어려움

비교:
  ┌──────────────┬────────────────────┬────────────────────┐
  │              │ premain()          │ agentmain()        │
  ├──────────────┼────────────────────┼────────────────────┤
  │ 시점          │ JVM 시작 시          │ 실행 중 동적 연결      │
  │ 지정 방법      │ -javaagent 옵션     │ Attach API         │
  │ Bootstrap 계측│ 가능                │ 어려움               │
  │ 재변환         │ 불필요 (첫 로딩)      │ retransformClasses │
  │ 사용 사례      │ APM, OTel Agent    │ 프로파일러, 진단       │
  └──────────────┴────────────────────┴────────────────────┘

4. Agent JAR 구조 — MANIFEST.MF의 역할

일반 JAR과 Agent JAR의 차이:

일반 실행 JAR:
  META-INF/MANIFEST.MF:
    Main-Class: com.example.Application
    Class-Path: lib/spring-core.jar lib/...

Agent JAR:
  META-INF/MANIFEST.MF:
    Premain-Class: com.example.MyAgent        ← premain() 포함 클래스
    Agent-Class: com.example.MyAgent          ← agentmain() 포함 클래스
    Can-Redefine-Classes: true                ← redefineClasses() 사용 여부
    Can-Retransform-Classes: true             ← retransformClasses() 사용 여부
    Boot-Class-Path: myagent-bootstrap.jar    ← Bootstrap ClassLoader에 추가할 JAR

최소한의 Agent 구현:

  // MyAgent.java
  public class MyAgent {
      public static void premain(String agentArgs, Instrumentation inst) {
          System.out.println("[MyAgent] premain() called, args: " + agentArgs);
          inst.addTransformer(new MyTransformer());
      }

      // agentmain 시그니처 (동적 Attach)
      public static void agentmain(String agentArgs, Instrumentation inst) {
          premain(agentArgs, inst);  // 동일 로직 재사용
      }
  }

  // MyTransformer.java
  public class MyTransformer implements ClassFileTransformer {
      @Override
      public byte[] transform(ClassLoader loader, String className,
                              Class<?> classBeingRedefined,
                              ProtectionDomain protectionDomain,
                              byte[] classfileBuffer) {
          
          // "com/example/OrderService" 형식 (슬래시 구분자)
          if (className.equals("com/example/OrderService")) {
              System.out.println("[MyAgent] Transforming: " + className);
              // 바이트코드 조작 후 반환
              return modifiedBytes;
          }
          
          return null;  // 변환 안 함 → 원본 사용
      }
  }

Maven 빌드 설정:
  <!-- pom.xml -->
  <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
          <archive>
              <manifestEntries>
                  <Premain-Class>com.example.MyAgent</Premain-Class>
                  <Agent-Class>com.example.MyAgent</Agent-Class>
                  <Can-Retransform-Classes>true</Can-Retransform-Classes>
              </manifestEntries>
          </archive>
      </configuration>
  </plugin>

5. ClassLoader 계층과 Agent의 격리

JVM ClassLoader 계층 구조:

  Bootstrap ClassLoader (C++ 구현, null로 표현)
    → java.lang.*, java.util.*, sun.*, com.sun.*
    → JDK 핵심 클래스

  Platform ClassLoader (Java 9+ / Extension ClassLoader)
    → javax.*, java.sql.*, java.naming.*
    → JDK 확장 클래스

  Application ClassLoader (System ClassLoader)
    → 앱의 classpath 클래스
    → 우리가 만드는 서비스 코드

  Agent ClassLoader (별도 격리)
    → Agent 자체 클래스
    → 앱 ClassLoader와 분리 → 버전 충돌 없음

Agent 격리의 의미:
  Agent가 내부적으로 Guava 30을 쓰더라도
  앱이 Guava 31을 쓴다면 → 충돌 없음 (다른 ClassLoader)

  단, Agent가 앱 클래스를 분석/변환해야 하므로
  → Transformer는 앱 ClassLoader에 접근 가능
  → 반대로 앱 코드에서 Agent 내부 클래스 import는 불가

Bootstrap ClassLoader에 클래스 추가 (Agent에서 필요 시):
  // premain() 내에서
  inst.appendToBootstrapClassLoaderSearch(
      new JarFile("myagent-bootstrap.jar")
  );
  // 이제 bootstrap 클래스도 Agent 클래스를 참조 가능
  // OTel Agent가 Context 전파를 위해 사용하는 방식

💻 실전 실험

실험 1: 가장 단순한 Agent 직접 만들기

// src/main/java/com/example/LoggingAgent.java
package com.example;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;

public class LoggingAgent {

    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("[LoggingAgent] 시작! main() 이전에 실행됨");
        System.out.println("[LoggingAgent] args: " + agentArgs);

        inst.addTransformer(new ClassFileTransformer() {
            @Override
            public byte[] transform(ClassLoader loader, String className,
                                    Class<?> classBeingRedefined,
                                    ProtectionDomain domain,
                                    byte[] classfileBuffer) {
                // com/example 패키지의 클래스만 로깅
                if (className != null && className.startsWith("com/example")) {
                    System.out.println("[LoggingAgent] 로딩: " + className);
                }
                return null;  // 변환 안 하고 원본 그대로
            }
        });
    }
}
# 빌드 (MANIFEST.MF 포함)
mvn package

# 테스트 앱에 적용
java -javaagent:target/logging-agent.jar=hello \
     -jar target/app.jar

# 출력:
# [LoggingAgent] 시작! main() 이전에 실행됨
# [LoggingAgent] args: hello
# [LoggingAgent] 로딩: com/example/OrderService
# [LoggingAgent] 로딩: com/example/ProductService
# ...
# (앱 main() 시작)

실험 2: OTel Agent 디버그 로그로 클래스 변환 확인

# OTel Agent 디버그 모드
java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.javaagent.debug=true \
     -Dotel.service.name=demo \
     -Dotel.exporter.otlp.endpoint=http://localhost:4317 \
     -jar app.jar 2>&1 | grep "Transforming\|Instrumented\|Skipping" | head -30

# 출력 예시:
# [otel.javaagent] Instrumented class org.springframework.web.servlet.FrameworkServlet
# [otel.javaagent] Instrumented class com.mysql.cj.jdbc.ConnectionImpl
# [otel.javaagent] Skipping class java.lang.String (not matching any instrumentation)

# 변환된 클래스 목록 파일로 저장
java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.javaagent.debug=true \
     -jar app.jar 2>&1 | grep "Instrumented class" > instrumented-classes.txt

wc -l instrumented-classes.txt
# OTel Agent가 변환한 클래스 수 확인

실험 3: 동적 Attach (agentmain) 실험

# 실행 중인 JVM PID 확인
jps -l
# 12345 com.example.Application

# arthas(Alibaba 진단 도구)로 동적 Attach 체험
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar 12345

# Arthas 셸에서 클래스 실시간 계측
arthas> trace com.example.OrderService processOrder
# 메서드 호출마다 실행 경로와 시간 출력 (agentmain 기반)

# 특정 메서드 실행 시간 모니터링
arthas> monitor com.example.OrderService processOrder -c 5
# 5초마다 호출 횟수, 성공/실패, 평균 시간 출력

실험 4: 클래스 로딩 순서 직접 확인

# 클래스 로딩 전체 추적 (-verbose:class)
java -verbose:class \
     -javaagent:opentelemetry-javaagent.jar \
     -jar app.jar 2>&1 | grep "Loaded" | head -50

# 출력:
# [Loaded java.lang.Object from /path/to/rt.jar]  ← Bootstrap
# [Loaded com.example.LoggingAgent from file:/path/to/agent.jar]  ← Agent 먼저
# ...
# [Loaded com.example.OrderService from file:/path/to/app.jar]  ← 앱 클래스

# Agent가 Bootstrap ClassLoader에 추가한 클래스 확인
java -verbose:class -javaagent:opentelemetry-javaagent.jar -jar app.jar \
     2>&1 | grep "io.opentelemetry" | head -10

📊 성능 비교

Agent 적용 유무에 따른 JVM 시작 시간:

                    | Agent 없음  | OTel Agent | OTel Agent (10% 샘플링)
────────────────────┼────────────┼────────────┼─────────────────────────
JVM 시작 시간         | 3.2s       | 5.8s       | 5.8s (샘플링은 시작 무관)
premain() 소요       | -          | ~2.5s      | ~2.5s
메모리 추가 사용        | -          | 50~100MB   | 50~100MB
CPU 오버헤드 (운영)     | -          | 1~3%       | < 0.5%

시작 시간이 느린 이유:
  premain() 내에서 모든 InstrumentationModule 초기화
  ByteBuddy AgentBuilder 구성 (수백 개의 ElementMatcher 등록)
  Bootstrap ClassLoader에 JAR 추가

운영 중 오버헤드:
  클래스 변환: 최초 1회만 (이후 변환된 바이트코드 캐싱)
  Span 생성: 요청당 ~5μs (BatchSpanProcessor로 비동기 전송)
  Context 전파: ThreadLocal 접근 ~1μs

Kubernetes 환경에서의 고려:
  Pod 시작 시간 +2~3초
  → Readiness probe timeout 여유 있게 설정
  → 시작 시간이 중요한 서비스는 GraalVM native image 고려

⚖️ 트레이드오프

Java Agent 방식의 장단점:

장점:
  ① 코드 변경 없음
     → 레거시 서비스, 소스 접근 불가 서비스에 적용 가능
  ② 일관된 계측
     → 모든 서비스에 동일한 Agent → 계측 누락 없음
  ③ 라이브러리 자동 계측
     → Spring, JDBC, Kafka 등 주요 라이브러리 즉시 적용

단점:
  ① JVM 시작 시간 증가 (+2~3초)
     → Lambda, 컨테이너 빠른 재시작 환경에서 부담
  ② 메모리 추가 사용 (+50~100MB)
     → 메모리 제약이 있는 환경 (512MB 이하)에서 주의
  ③ ClassLoader 충돌 가능성
     → 다른 Agent와 동시 사용 시 버전/클래스 충돌
  ④ 비즈니스 로직 계측 불가
     → 커스텀 코드 경계는 별도 수동 계측 필요
  ⑤ 디버깅 어려움
     → 변환된 바이트코드는 소스 레벨 디버깅 어려움

Agent 없는 대안:
  OpenTelemetry SDK 직접 추가 (코드에 의존성 추가)
    → 제어 수준 높음, 시작 시간 영향 없음
    → 모든 계측 코드 직접 작성 필요
  
  -javaagent를 사용하되 범위 제한:
    OTEL_INSTRUMENTATION_[NAME]_ENABLED=false
    → 불필요한 계측 비활성화로 오버헤드 줄이기

📌 핵심 정리

Java Agent 메커니즘 핵심:

실행 순서:
  java -javaagent:agent.jar -jar app.jar
  → JVM 구동 → premain() → main()
  → premain()에서 ClassFileTransformer 등록
  → 이후 모든 클래스 로딩 시 Transformer.transform() 호출

두 가지 Attach:
  premain()  → JVM 시작 전 (-javaagent 옵션)
  agentmain() → 실행 중 동적 (Attach API + PID)

MANIFEST.MF:
  Premain-Class, Agent-Class 지정 필수
  Can-Retransform-Classes: true → 동적 재변환 가능

ClassLoader 격리:
  Agent는 별도 ClassLoader → 앱과 버전 충돌 없음
  Bootstrap에는 appendToBootstrapClassLoaderSearch()로 추가

다음 단계:
  바이트코드를 실제로 어떻게 변환하는가?
  → ASM / Javassist / ByteBuddy 비교 (Ch2-02)
  → ByteBuddy로 직접 Timer 구현 (Ch2-03)

🤔 생각해볼 문제

Q1. ClassFileTransformer.transform()null을 반환하면 어떻게 되는가? 반환값이 원본과 동일한 byte[]를 반환하는 것과 null을 반환하는 것의 차이는?

해설 보기

null 반환:

  • JVM이 원본 classfileBuffer를 그대로 사용
  • 추가 byte 배열 복사 없음 → 메모리/성능 효율적
  • 권장: 변환이 필요 없는 클래스에 항상 null 반환

원본과 동일한 byte[] 반환:

  • JVM이 반환된 byte 배열을 새 클래스 정의로 사용
  • 내용이 같더라도 불필요한 메모리 복사 발생
  • 성능상 이유 없이 원본을 복사해서 반환하면 낭비

실제 코드에서의 패턴:

public byte[] transform(..., byte[] classfileBuffer) {
    // 대상 클래스가 아니면 즉시 null 반환 (빠른 경로)
    if (!className.startsWith("com/example/")) {
        return null;
    }
    // 변환 수행
    return modifyBytes(classfileBuffer);
}

OTel Agent는 수십만 개의 클래스 로딩마다 transform()이 호출되므로, 불필요한 클래스는 최대한 빨리 null을 반환하는 것이 시작 시간 최적화의 핵심입니다.


Q2. premain()에서 등록한 ClassFileTransformerpremain() 실행 중에 로딩되는 클래스에도 적용되는가?

해설 보기

적용되지 않습니다.

addTransformer() 호출 이후에 로딩되는 클래스부터 적용됩니다. premain() 내부에서 이미 로딩된 클래스 (예: premain() 코드 자체가 참조하는 클래스들)는 Transformer를 통과하지 않습니다.

이것이 Can-Retransform-Classes: trueretransformClasses()가 존재하는 이유입니다:

public static void premain(String args, Instrumentation inst) {
    inst.addTransformer(new MyTransformer(), true); // canRetransform=true

    // 이미 로딩된 클래스를 명시적으로 재변환
    for (Class<?> c : inst.getAllLoadedClasses()) {
        if (isTarget(c)) {
            inst.retransformClasses(c);
        }
    }
}

OTel Agent는 premain()에서 Transformer를 등록한 뒤, 아직 로딩되지 않은 클래스들은 첫 로딩 시 자동 변환되고, JDK 초기화 과정에서 이미 로딩된 일부 클래스는 retransformClasses()로 재변환합니다.


Q3. Lambda 환경에서 Java Agent를 사용하면 어떤 문제가 생기는가? 어떻게 대안을 찾는가?

해설 보기

Lambda에서의 문제:

  1. Cold Start 심화: Lambda cold start 자체가 13초인데 Agent premain() 23초 추가 → cold start 2배 증가
  2. 메모리 제약: Lambda 기본 512MB에서 Agent 100MB 추가 → 비율로 부담
  3. 실행 환경 제약: AWS Lambda Layers로 Agent JAR 배포 필요

대안:

  1. Warm-up 최적화: Provisioned Concurrency로 cold start 자체를 줄임
  2. SDK 직접 사용: Agent 없이 OpenTelemetry SDK + Lambda용 Exporter
    <dependency>
        <groupId>io.opentelemetry.instrumentation</groupId>
        <artifactId>opentelemetry-aws-lambda-core-1.0</artifactId>
    </dependency>
  3. GraalVM Native Image: 바이트코드 조작 불가하지만 AOT 컴파일로 극도로 빠른 시작
    • 단, OTel Agent와 Native Image는 호환 제한적
  4. AWS X-Ray: Lambda에 최적화된 벤더 솔루션 (lock-in 감수)

실무 선택:

  • 요청 빈도 높음 → Provisioned Concurrency + Agent 가능
  • 요청 빈도 낮음 → SDK 직접 사용 또는 X-Ray