Hotpath 내부 구조 — 수집/분석/렌더링 전 과정

jfr을 가벼운 분석 리포트로 변환하는 도구 개발

#hotpath#jfr#jvm#latency

가벼운 마음으로 만든 프로젝트 hotpath의 내부 구조에 대한 설명 글이다.

개요

Hotpath는 JVM이 기록한 .jfr 파일을 읽어 CPU, GC, 메모리 할당, 스레드 경합 상태를 분석하고, 브라우저에서 바로 열 수 있는 단일 HTML 파일로 출력하는 CLI 도구다.

핵심 설계 원칙은 세 가지다.

  1. 단일 패스 — JFR 파일을 처음부터 끝까지 한 번만 읽는다. 이벤트를 전부 메모리에 올리지 않고, 각 핸들러가 집계 상태만 유지한다.
  2. 데이터·뷰 분리 — Java 코드는 JSON 직렬화까지만 담당한다. 차트 렌더링은 브라우저의 Plotly.js가 처리한다.
  3. 외부 의존성 최소화 — JFR 파싱에 JDK 내장 API(jdk.jfr.consumer)를 사용하므로 별도 파서 라이브러리가 필요 없다.

개발 목적

  • .jfr을 분석하는 대표적 도구인 JMC는 아주 훌륭한 도구이지만 리포트의 형태로서 제출 할 만한 형태는 아님.
  • 벤치마크 도구 리포트들은 내용이 자세한 만큼 방대해서 한눈에 확인하기에는 무리가 있다.
  • 깃에 올려서 확인하기에도 너무 내용이 크고 복잡함.
  • 가벼운 만큼 내용이 적지만 대표적 수치만 빠르게 훑고 전후 차이를 쉽게 확인할 수 있도록

추후에 프로젝트를 또 만든다면 그때 분석 리포트로 사용해볼 생각임

다만 현재 글의 목적은 추후에 프로젝트를 확장할 경우 더 쉽게 파악하기 위한 구조 문서화.


전체 파이프라인

.jfr 파일
    │
    ▼  [1단계] JfrReader
    RecordingFile.readEvent() 루프
    EventRouter → 타입별 핸들러 분기
    │
    ├── MetaHandler    (jdk.JVMInformation)
    ├── CpuHandler     (jdk.CPULoad, jdk.ExecutionSample)
    ├── GcHandler      (jdk.GarbageCollection, jdk.GCHeapSummary)
    ├── MemoryHandler  (jdk.GCHeapSummary, jdk.ObjectAllocation*)
    └── ThreadHandler  (jdk.JavaMonitorEnter, jdk.JavaThreadStatistics)
    │
    ▼  [2단계] Analyzers
    CpuAnalyzer    → CpuSummary    + List<Finding>
    GcAnalyzer     → GcSummary     + List<Finding>
    MemoryAnalyzer → MemorySummary + List<Finding>
    ThreadAnalyzer → ThreadSummary + List<Finding>
    │
    ▼  [3단계] TimelineBuilder
    raw 샘플 → 1초 단위 TimeBucket 목록
    │
    ▼  [4단계] HtmlRenderer
    AnalysisResult → Jackson → JSON
    template.replace("/*__HOTPATH_DATA__*/", json)
    │
    ▼
report.html

1단계 — JFR 파싱

RecordingFile 스트리밍

JFR 파일은 JDK 내장 jdk.jfr.consumer.RecordingFile로 파싱한다.

try (RecordingFile rf = new RecordingFile(jfrPath)) { while (rf.hasMoreEvents()) { RecordedEvent event = rf.readEvent(); router.dispatch(event); } }

hasMoreEvents() / readEvent() 루프는 이벤트를 하나씩 스트리밍한다. 파일 전체를 메모리에 올리지 않아 수백 MB 파일도 안정적으로 처리된다. 루프를 돌면서 첫 번째와 마지막 이벤트의 타임스탬프를 기록해 녹화 구간(recordingStart, recordingEnd)을 결정한다.

EventRouter — 이벤트 분기

// 등록: 이벤트 타입 이름 → 핸들러 목록 Map<String, List<EventHandler>> routes = new HashMap<>(); public void dispatch(RecordedEvent event) { List<EventHandler> handlers = routes.get(event.getEventType().getName()); if (handlers != null) { for (EventHandler h : handlers) h.handle(event); } }

EventRouter는 이벤트 타입 이름을 키로 핸들러 목록을 관리한다. 하나의 이벤트 타입에 여러 핸들러를 등록할 수 있다. jdk.GCHeapSummary는 GcHandler와 MemoryHandler 둘 다 구독하는데, GcHandler는 gcId별 힙 전후를 추적하고 MemoryHandler는 힙 사용 타임라인을 기록하기 때문이다.

핸들러별 수집 로직

MetaHandler

jdk.JVMInformation 이벤트를 받아 JVM 버전, JVM 인수, 메인 클래스, PID를 추출한다. JFR 필드 접근 시 event.getValue()가 내부적으로 char 배열 캐스팅에 실패하는 경우가 있어 타입 지정 accessor인 event.getString(field)를 사용하고 예외 시 빈 문자열로 폴백한다.

CpuHandler

두 종류의 이벤트를 처리한다.

jdk.CPULoad — JFR이 주기적으로(기본 1초) 기록하는 CPU 사용률 스냅샷이다. jvmUserjvmSystem 필드를 getFloat()로 읽어 타임스탬프와 함께 CpuSample 목록에 추가한다.

jdk.CPULoad
  └── jvmUser   (float, 0.0~1.0)  JVM 프로세스의 유저 스페이스 CPU 사용률
  └── jvmSystem (float, 0.0~1.0)  JVM 프로세스의 커널 스페이스 CPU 사용률

jdk.ExecutionSample — CPU 프로파일링 샘플이다. JFR이 지정된 간격(기본 10~20ms)으로 실행 중인 스레드의 스택 트레이스를 기록한다. 스택 최상단 프레임의 메서드(className#methodName)를 키로 카운트를 누적한다.

RecordedStackTrace stack = event.getStackTrace(); RecordedMethod method = stack.getFrames().getFirst().getMethod(); String key = method.getType().getName() + "#" + method.getName(); executionSamples.merge(key, 1, Integer::sum);

이 카운트 맵이 Hot Methods 분석의 원천.

GcHandler

jdk.GarbageCollectionjdk.GCHeapSummary 두 이벤트를 조합해 GC 이벤트 하나에 힙 전후 크기를 함께 기록한다.

jdk.GCHeapSummary (when="Before GC") → gcId별 heapBefore 임시 저장
jdk.GCHeapSummary (when="After GC")  → heapBefore + heapAfter 쌍으로 heapAfterMap에 저장
jdk.GarbageCollection                → duration, cause, name + heapAfterMap 조회 → RawGcEvent 생성

gcId 필드가 세 이벤트를 연결하는 키다. GCHeapSummary가 GarbageCollection보다 먼저 도착하므로 중간 저장소(heapBefore, heapAfterMap)로 이벤트를 조합한다.

RawGcEvent
  ├── startEpochMs    GC 시작 시각
  ├── pauseMs         Stop-The-World 시간 (GarbageCollection.duration)
  ├── cause           GC 발생 원인 (예: "G1 Evacuation Pause")
  ├── name            GC 이름 (예: "G1New")
  ├── heapBeforeBytes GC 직전 힙 사용량
  └── heapAfterBytes  GC 직후 힙 사용량

MemoryHandler

jdk.GCHeapSummary로 힙 사용량 타임라인을 구성하고, jdk.ObjectAllocationInNewTLAB / jdk.ObjectAllocationOutsideTLAB으로 클래스별 할당량을 누적한다.

할당 이벤트에서 클래스 이름은 event.getClass("objectClass").getName()으로 읽는다. getValue()가 아닌 타입 지정 accessor를 써야 RecordedClass 타입을 올바르게 받을 수 있다.

jdk.ObjectAllocationInNewTLAB
  └── objectClass (RecordedClass)  할당된 객체의 클래스
  └── tlabSize    (long)           TLAB 크기 (bytes)

jdk.ObjectAllocationOutsideTLAB
  └── objectClass (RecordedClass)
  └── allocationSize (long)

ThreadHandler

jdk.JavaMonitorEnter로 모니터 락 경합 이벤트를 수집한다. 이벤트 자체의 duration이 락 대기 시간이다. monitorClassevent.getClass(), eventThreadevent.getThread()로 각각 타입 지정 접근한다.

jdk.JavaThreadStatistics는 JFR이 주기적으로 기록하는 스레드 수 통계로, activeCount 필드를 읽어 시계열 데이터로 저장한다.


2단계 — Analyzer

각 Analyzer는 핸들러가 수집한 raw 데이터를 받아 두 가지 작업을 수행한다.

  1. buildSummary() — 통계 집계 (평균, 최대, 상위 N개 등)
  2. analyze() — 임계값 기반 이상 탐지 → List<Finding> 반환

Finding 구조

record Finding( Severity severity, // CRITICAL / WARNING / INFO String category, // "CPU" / "GC" / "Memory" / "Thread" String title, String description, String recommendation ) {}

CpuAnalyzer 임계값

조건심각도
maxUser > 80% AND avgUser > 80%CRITICAL
maxUser > 80% AND avgUser ≤ 80%WARNING
상위 1위 메서드 점유율 > 20%WARNING

Hot Methods는 executionSamples 맵을 카운트 내림차순 정렬 후 상위 10개를 뽑는다. 각 메서드의 점유율은 (해당 메서드 샘플 수 / 전체 샘플 수) × 100으로 계산한다.

GcAnalyzer 임계값

조건심각도
maxPause ≥ 500msCRITICAL
maxPause ≥ 200msWARNING
totalSTW / recordingDuration > 5%WARNING

STW 비율은 녹화 전체 시간 대비 GC로 소비된 시간의 비중이다. 5%를 넘으면 애플리케이션이 실질적으로 95% 미만의 시간만 실제 작업에 쓰고 있다는 의미다.

MemoryAnalyzer 임계값

조건심각도
maxHeap / committedHeap > 85%WARNING
최다 할당 클래스 총량 > 100MBINFO

Top Allocators는 클래스별 누적 할당량을 Map<String, Long>으로 집계한 뒤 내림차순 상위 10개를 추출한다.

Map<String, Long> byClass = new HashMap<>(); for (var s : allocSamples) { byClass.merge(s.className(), s.bytes(), Long::sum); }

ThreadAnalyzer 임계값

조건심각도
totalContentionMs > 5,000msCRITICAL
totalContentionMs > 1,000msWARNING
100ms 이상 대기 이벤트 존재 시 최장 항목INFO

Top Contentions는 100ms 이상 대기한 이벤트만 필터링해 대기 시간 내림차순 상위 10개를 반환한다.


3단계 — TimelineBuilder

각 핸들러의 raw 샘플을 1초 단위 TimeBucket으로 집계한다. 차트의 x축 데이터로 사용된다.

버킷 인덱스 계산

private static int idx(Instant t, Instant start, int max) { long s = ChronoUnit.SECONDS.between(start, t); return (int) Math.max(0, Math.min(s, max - 1)); }

시작 시각 기준으로 몇 초 경과했는지를 인덱스로 변환한다. 최대 버킷 수는 3,600개(1시간)로 제한한다.

집계 방식

데이터집계 방식
CPU user/system같은 버킷 내 샘플 평균
힙 사용량같은 버킷 내 샘플 평균
GC 횟수버킷 내 발생 횟수 합산
GC pause버킷 내 pause 합산 (ms)
스레드 수같은 버킷 내 샘플 평균
Lock contention버킷 내 발생 횟수 합산

4단계 — HtmlRenderer

String template = loadTemplate(); // /templates/report.html 로드 String json = mapper.writeValueAsString(result); // AnalysisResult → JSON String html = template.replace("/*__HOTPATH_DATA__*/", json); Files.writeString(outputPath, html, StandardCharsets.UTF_8);

AnalysisResult 전체를 Jackson으로 JSON 직렬화해 HTML 템플릿의 플레이스홀더에 치환한다. JavaTimeModule을 등록하고 WRITE_DATES_AS_TIMESTAMPS를 비활성화해 Instant가 ISO-8601 문자열로 직렬화된다.

템플릿 구조

<script> const DATA = /*__HOTPATH_DATA__*/;JSON 삽입 지점 </script> <!-- 이후 렌더링 스크립트 --> <script> (function () { // DATA를 읽어 Plotly.js로 차트 그리기 Plotly.newPlot('chart-cpu-load', [...]); Plotly.newPlot('chart-gc-timeline', [...]); ... })(); </script>

Java가 JSON을 주입하는 부분은 한 줄이다. 나머지 HTML/CSS/JS는 전부 정적이다. 이 방식 덕분에 렌더링 로직을 Java에서 관리할 필요가 없고, 차트 UI를 수정할 때 Java 코드를 건드리지 않아도 된다.


모듈 구조

hotpath/
├── hotpath-core/
│   └── src/main/java/org/yyubin/hotpath/
│       ├── model/
│       │   ├── AnalysisResult.java   전체 결과 집합 (렌더러에 전달)
│       │   ├── TimeBucket.java       1초 단위 집계 슬롯
│       │   ├── CpuSummary.java       CPU 통계 + Hot Methods
│       │   ├── GcSummary.java        GC 통계 + 이벤트 목록
│       │   ├── MemorySummary.java    힙 통계 + Top Allocators
│       │   ├── ThreadSummary.java    스레드 통계 + Lock Contention
│       │   ├── RecordingMeta.java    JVM 정보, 녹화 시간
│       │   └── Finding.java          이상 탐지 결과
│       ├── reader/
│       │   ├── JfrReader.java        파일 읽기 + 라우터 조립
│       │   ├── EventRouter.java      타입명 → 핸들러 목록 디스패치
│       │   ├── ReadResult.java       핸들러 묶음 전달 객체
│       │   ├── TimelineBuilder.java  raw 샘플 → TimeBucket 집계
│       │   └── handler/
│       │       ├── EventHandler.java (interface)
│       │       ├── MetaHandler.java
│       │       ├── CpuHandler.java
│       │       ├── GcHandler.java
│       │       ├── MemoryHandler.java
│       │       └── ThreadHandler.java
│       ├── analyzer/
│       │   ├── CpuAnalyzer.java
│       │   ├── GcAnalyzer.java
│       │   ├── MemoryAnalyzer.java
│       │   └── ThreadAnalyzer.java
│       └── renderer/
│           └── HtmlRenderer.java
│
├── hotpath-cli/
│   └── src/main/java/org/yyubin/hotpath/
│       ├── HotpathCommand.java   파이프라인 조립 및 실행
│       └── Main.java             Picocli 진입점
│
└── hotpath-core/src/main/resources/
    └── templates/report.html     정적 HTML 템플릿 + Plotly.js

데이터 흐름 요약

JFR 이벤트                    핸들러 상태               Analyzer 출력
─────────────────────────────────────────────────────────────────────
jdk.CPULoad          ──►  List<CpuSample>      ──►  CpuSummary
jdk.ExecutionSample  ──►  Map<method, count>   ──►  Hot Methods

jdk.GCHeapSummary    ──►  Map<gcId, heap[]>    ─┐
jdk.GarbageCollection──►  List<RawGcEvent>     ─┴►  GcSummary

jdk.GCHeapSummary    ──►  List<HeapSample>     ──►  MemorySummary
jdk.ObjectAlloc*     ──►  List<AllocSample>    ──►  Top Allocators

jdk.JavaMonitorEnter ──►  List<ContentionEvent>──►  ThreadSummary
jdk.JavaThreadStats  ──►  List<ThreadCountSample>

모든 핸들러 상태     ──►  TimelineBuilder      ──►  List<TimeBucket>

CpuSummary + GcSummary + MemorySummary + ThreadSummary + TimeBucket
                                         ──►  AnalysisResult
                                         ──►  JSON
                                         ──►  report.html

수집 조건 — JFR 설정에 따른 가용성

Hotpath가 읽는 이벤트 중 일부는 JFR 설정에 따라 기록되지 않을 수 있다. 이벤트가 없으면 해당 항목은 빈 상태(0, 빈 목록)로 처리되며 리포트에 "데이터 없음"으로 표시된다.

이벤트default.jfcprofile.jfc
jdk.CPULoad
jdk.ExecutionSample
jdk.GarbageCollection
jdk.GCHeapSummary
jdk.ObjectAllocation*
jdk.JavaMonitorEnter부분
jdk.JavaThreadStatistics

전체 측정치를 수집하려면 settings=profile 또는 커스텀 hotpath.jfc로 녹화해야 한다.


UI


관련 링크


기여, 이슈, 제안 및 제언 언제나 환영🤗