bytecode-instrumentation

1 개의 포스트

datadog

접두사 트라이를 JVM 상수로 인코딩해 APM Java 시작 시간을 개선한 방법 (새 탭에서 열림)

애플리케이션 시작 시간은 사용자 경험, 개발 생산성, 클라우드 비용에 모두 영향을 주며, Java APM의 클래스 매칭 비용도 주요 요인이다. Datadog은 JVM 시작 단계의 제약을 고려해 클래스명 접두사 매칭을 최적화했고, 최근 4년간 관련 오버헤드를 30% 줄였다. 특히 여러 접두사 매칭 트라이(trie)를 하나의 JVM 문자열 상수로 미리 인코딩해, 시작 시 별도의 파일 읽기나 자료구조 구축 없이 빠르게 조회하도록 만든 것이 핵심이다. ## Java APM과 클래스 계측 - Java Instrumentation API는 클래스가 로드되기 전에 변환할 수 있어, 네이티브 코드가 필요한 JVMTI보다 Java 기반 APM 구현에 적합하다. - APM은 메서드 시작·종료 시점 기록, 호출 간 컨텍스트 전파 등을 위해 클래스에 코드를 삽입한다. - 개별 메서드 계측 비용은 작지만, 모든 클래스와 메서드를 계측하면 전체 성능 비용이 급격히 증가한다. - 따라서 관측 가치가 높은 클래스만 선별해야 하며, 이 선별 과정이 **클래스 매칭**이다. ## 클래스명 접두사로 탐색 범위 줄이기 - 일반적인 Java 애플리케이션은 수만 개의 클래스를 정의하고, 대규모 엔터프라이즈 애플리케이션은 10만 개 이상을 로드할 수 있다. - 클래스 파일을 분석하거나 상속 구조를 확인하는 방식은 비용이 크다. - 클래스 파일 파싱이 필요하다. - 관련 타입의 추가 클래스 파일을 읽어야 할 수 있다. - 이에 따라 Datadog APM은 먼저 클래스명과 패키지명 접두사를 curated ignore list와 비교해 계측 대상이 아닌 클래스를 빠르게 제외한다. - 이 초기 접두사 매칭은 모든 클래스에 적용되므로, 작은 알고리즘 개선도 전체 시작 시간에 측정 가능한 영향을 준다. ## JVM 시작 단계의 특별한 제약 - 에이전트는 애플리케이션의 `main`보다 먼저 실행되는 `premain` 단계에서 클래스 변환기를 등록한다. - 이 시점에는 다음과 같은 제약이 있다. - 로드된 클래스가 거의 없다. - JIT 컴파일러가 아직 준비되지 않았다. - Java 8에서는 `premain` 이후에야 JIT가 시작되므로 코드가 인터프리트되고 최적화되지 않는다. - 일부 JDK API 호출이 애플리케이션 동작에 영향을 줄 수 있다. - 예를 들어 `java.util.logging`을 사용하면 `LogManager`가 초기화된다. - 이후 애플리케이션의 `main`에서 사용자 지정 `java.util.logging.manager`를 설정하려 해도 이미 초기화가 끝났기 때문에 동작이 깨질 수 있다. - 따라서 `premain`에서는 외부 리소스, 무거운 라이브러리, 부작용이 있는 JDK API 사용을 피해야 한다. ## 기존 코드 기반 매칭의 한계 - 2020년 당시 Datadog APM Java는 복잡한 중첩 구조의 코드를 직접 생성해 접두사를 매칭했다. - 이 방식은 유연했지만 다음 문제가 있었다. - 매칭 규칙을 유지보수하기 어려웠다. - Java 8의 초기화 단계에서는 JIT 최적화를 기대할 수 없었다. - 시작 시 빠르게 실행되도록 여러 추가 최적화가 필요했다. - 분석 결과, 기존 매칭 규칙에는 트라이 자료구조가 가장 적합한 대안으로 판단됐다. - 하지만 일반적인 트라이는 리소스 파일을 찾고, 읽고, 파싱한 뒤 노드를 구성해야 하므로 `premain` 환경에 적합하지 않았다. ## 트라이를 JVM 문자열 상수로 인코딩 - Datadog은 `ClassNameTrie`라는 접두사 트라이를 만들고, 이를 하나의 JVM 문자열 상수로 표현했다. - 문자열 상수는 클래스 로딩 과정에서 JVM이 직접 로드하므로 다음 장점이 있다. - `ldc` 단일 바이트코드 명령으로 접근할 수 있다. - 파일 검색이나 I/O가 필요 없다. - 클래스 내부에 포함되므로 애플리케이션 패키징·재패키징 후에도 유지된다. - 데이터가 작고 연속적으로 저장되어 캐시 지역성이 좋다. - 시작 단계에 트라이 객체를 별도로 생성할 필요가 없다. ## 문자열 내부의 트라이 구조 - Java의 `char`는 2바이트이며 65,536개의 값을 표현할 수 있다. - 이 값을 일반적인 문자뿐 아니라 트라이의 제어 정보와 매칭 결과 저장에도 사용한다. - 각 트라이 노드는 다음 구조를 가진다. - 첫 번째 문자: 해당 노드의 브랜치 개수 - 이어지는 문자들: 각 브랜치의 문자 - 브랜치 문자 뒤의 값 문자들: 각 브랜치의 결과 정보 - 브랜치 문자는 정렬되어 저장되므로 이진 검색으로 빠르게 다음 경로를 찾을 수 있다. - 값은 상위 비트에 따라 세 가지 의미를 가진다. - **Leaf**: 확정된 결과이며 검색을 즉시 종료한다. - **Bud**: 잠정적인 결과를 제공하지만 더 긴 접두사를 확인하기 위해 검색을 계속한다. - **Inline segment 길이**: 해당 브랜치에 이어지는 문자열 구간의 길이를 나타낸다. - Bud와 leaf에는 glob 비트를 설정할 수 있다. - 기본적으로 결과는 입력 키가 해당 노드에서 정확히 끝날 때만 적용된다. - glob 비트가 있으면 뒤에 문자가 더 남아 있어도 결과를 적용할 수 있다. - 이 비트 구성 때문에 `ClassNameTrie`에 저장할 수 있는 최대 값은 8,191이다. ## 실용적인 결론 JVM의 가장 이른 시작 단계에서 실행되는 코드는 JIT 최적화나 일반적인 런타임 자료구조 생성에 의존하기 어렵다. 이런 환경에서는 작은 매칭 데이터라도 문자열 상수처럼 JVM이 직접 로드할 수 있는 형태로 미리 인코딩하면 초기화 비용과 I/O를 줄이고, 대규모 클래스 탐색의 누적 비용을 낮출 수 있다.