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


관련 링크


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