JFR로 실행 기록 남기고 분석하기
Java Flight Recorder를 명령줄과 API로 켜서 기록 파일을 만들고, jfr 도구와 RecordingFile로 GC·CPU·사용자 정의 이벤트를 읽는 방법을 배웁니다.
앞 글에서는 GC 로그로 할당률을 어림했습니다. 그런데 GC 로그만으로는 어떤 코드가 그만큼 할당했는지, 그동안 CPU는 어디에 쓰였는지, 어떤 스레드가 락을 기다렸는지를 함께 볼 수 없습니다. Java Flight Recorder(JFR)는 이런 정보를 이벤트(event) 단위로 모아 하나의 기록 파일에 담는 JDK 내장 도구입니다. 비행기의 블랙박스처럼 프로그램이 실행되는 동안 계속 기록해 두었다가, 문제가 생긴 뒤에 그 구간을 꺼내 보는 용도로 만들어졌습니다. 비유와 다른 점은 모든 것을 기록하지는 않는다는 것입니다. 켜 둔 종류의 이벤트만, 정해 둔 주기와 임계값에 따라 남깁니다.
JFR 이벤트에는 GC, CPU 사용률, 실행 중인 메서드의 표본, 객체 할당 표본, 락 대기, 스레드, 파일과 소켓 입출력, 예외 등이 있습니다. 모두 같은 시간축에 찍히므로 "이 GC가 일어난 순간 CPU는 어디에 쓰이고 있었나" 같은 질문에 답할 수 있습니다. 운영 중인 서비스에서 켜 둘 수 있을 만큼 부하가 적게 설계됐지만, 어떤 이벤트를 얼마나 자주 남기느냐에 따라 부하와 파일 크기가 달라집니다. 그래서 기록을 켜기 전에 무엇을 확인하려는지부터 정하는 것이 좋습니다.
JFR은 JDK 11부터 OpenJDK에 들어 있습니다. 이 글에서는 명령줄에서 기록을 켜고 jfr 도구로 읽는 방법, 자바 코드에서 기록을 다루는 Recording API, 내 코드의 동작을 이벤트로 남기는 사용자 정의 이벤트를 차례로 다룹니다. 출력은 앞 글과 같은 환경(2코어 Linux, JDK 21)에서 얻은 것입니다.
명령줄에서 기록 켜기
기록할 대상이 필요하므로, 3초 동안 게시글 제목 1만 개를 만들고 정렬하기를 되풀이하는 프로그램을 준비합니다. 문자열을 많이 만들고 CPU도 꾸준히 쓰므로 GC와 CPU 이벤트가 고르게 남습니다.
import java.util.ArrayList;
import java.util.List;
public final class BoardWorkload {
public static void main(String[] args) {
long end = System.nanoTime() + 3_000_000_000L; // 3초 동안 반복한다
int rounds = 0;
while (System.nanoTime() < end) {
List<String> titles = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
titles.add("post-" + (i * 7_919 % 10_000));
}
titles.sort(null);
rounds++;
}
System.out.println("rounds=" + rounds);
}
}JVM을 시작할 때 -XX:StartFlightRecording 옵션을 주면 프로그램이 시작하는 순간부터 기록합니다. 코드는 그대로 두고 실행 명령만 바꿔 기록을 켤 수 있다는 점이 이 방법의 장점입니다.
javac BoardWorkload.java
java -XX:StartFlightRecording=filename=board.jfr,settings=profile,duration=30s,dumponexit=true BoardWorkload[0.530s][info][jfr,startup] Started recording 1. The result will be written to:
[0.530s][info][jfr,startup]
[0.530s][info][jfr,startup] .../board.jfr
rounds=1036옵션은 쉼표로 이어 붙입니다. filename=board.jfr은 기록을 쓸 파일이고, duration=30s는 30초 뒤에 기록을 멈추라는 뜻입니다. dumponexit=true는 JVM이 끝날 때 그때까지의 기록을 파일에 쓰게 하므로, 이 프로그램처럼 30초가 되기 전에 끝나도 3초 동안의 기록이 남습니다. settings=profile은 어떤 이벤트를 어떤 주기로 남길지 정한 설정 파일을 고릅니다. JDK에는 상시 기록용으로 부하를 낮춘 default와, 더 자세한 대신 부하가 커서 짧은 기간에만 쓰는 profile 두 가지가 들어 있습니다. 예를 들어 실행 중인 메서드의 표본은 default에서 20밀리초마다, profile에서 10밀리초마다 찍습니다.
첫 줄의 시각을 보면 JFR이 기록을 시작하기까지 0.5초가 걸렸습니다. JFR을 켜면 JVM의 시작이 그만큼 늦어지므로, 짧게 끝나는 프로그램의 시작 시간을 잴 때는 이 점을 고려해야 합니다. 이제 기록 파일을 JDK에 함께 들어 있는 jfr 명령으로 읽어 보겠습니다. jfr summary는 이벤트 종류별 개수와 크기를 요약합니다.
jfr summary board.jfr Version: 2.1
Chunks: 1
Start: 2026-10-27 00:38:30 (UTC)
Duration: 3 s
Event Type Count Size (bytes)
=============================================================
jdk.ObjectAllocationSample 910 12760
jdk.InitialEnvironmentVariable 231 29684
jdk.ExecutionSample 131 1369
jdk.GarbageCollection 11 253
jdk.CPULoad 3 57
jdk.JavaMonitorEnter 0 0jdk.으로 시작하는 이름이 JDK가 정의한 이벤트입니다. 3초 동안 GC가 11번 일어났고(jdk.GarbageCollection), CPU 사용률은 1초마다 한 번씩 세 번 기록됐습니다(jdk.CPULoad). jdk.ExecutionSample은 정해진 주기마다 실행 중인 스레드의 스택을 찍은 실행 표본이고, jdk.ObjectAllocationSample은 객체 할당을 골라서 기록한 할당 표본입니다. 둘 다 모든 순간을 기록하는 것이 아니라 표본을 뽑는 것이므로, 개수 자체보다 어떤 메서드와 어떤 타입에 표본이 몰렸는지가 중요합니다. 둘째 줄의 jdk.InitialEnvironmentVariable 231개는 기록을 시작할 때의 환경 변수입니다. 기록 파일에는 이처럼 환경 변수, 시스템 속성, 명령줄 인수가 함께 들어가므로, 비밀 값이 섞여 있을 수 있는 자료로 다루고 접근 권한과 보관 기간을 정해야 합니다.
jfr view로 표 보기
이벤트를 사람이 읽기 좋게 모아 보려면 JDK 21에 추가된 jfr view를 씁니다. hot-methods 보기는 실행 표본이 가장 많이 찍힌 메서드를 보여 줍니다.
jfr view hot-methods board.jfr Java Methods that Executes the Most
Method Samples Percent
----------------------------------------------------------------------------------- ------- -------
java.util.ComparableTimSort.mergeHi(int, int, int, int) 48 36.64%
java.util.ComparableTimSort.mergeLo(int, int, int, int) 40 30.53%
java.util.ComparableTimSort.binarySort(Object[], int, int, int) 21 16.03%
java.lang.Integer.getChars(int, int, byte[]) 9 6.87%
BoardWorkload.main(String[]) 5 3.82%표본의 80% 이상이 ComparableTimSort의 메서드에 찍혔습니다. titles.sort(null)이 쓰는 정렬 구현이므로, 이 프로그램의 CPU 시간은 대부분 정렬에 쓰였다는 뜻입니다. 그다음의 Integer.getChars는 "post-" + 숫자에서 숫자를 문자열로 바꾸는 부분입니다. 코드만 보고 '문자열을 만드는 쪽이 비쌀 것'이라고 짐작했다면 틀렸을 것이고, 이렇게 표본으로 확인하는 것이 프로파일링의 기본입니다.
같은 파일에서 jfr view gc board.jfr를 실행하면 GC마다 한 줄씩 나옵니다.
Garbage Collections
Start GC ID Type Heap Before GC Heap After GC Longest Pause
-------- ----- --------------------------------------------------- -------------- ------------- -------------
09:38:31 2 Young Garbage Collection 19.4 MB 4.6 MB 5.92 ms
09:38:31 3 Young Garbage Collection 57.6 MB 4.4 MB 5.93 ms
09:38:31 4 Young Garbage Collection 58.4 MB 3.9 MB 2.80 ms
09:38:31 5 Young Garbage Collection 70.9 MB 4.1 MB 3.46 ms
09:38:32 6 Young Garbage Collection 71.1 MB 4.0 MB 3.69 msGC 직전에는 수십 MB였던 힙이 GC 직후에는 4MB 안팎으로 돌아옵니다. 앞 글에서 GC 로그로 본, 할당률은 높고 생존 객체 집합은 작은 모습 그대로입니다. GC 번호가 2부터 시작하는 것은 JFR이 기록을 시작하기 전에 GC가 두 번 있었기 때문입니다. -Xlog:gc를 함께 켜 보면 GC(0)과 GC(1)이 기록 시작 로그보다 먼저 찍힙니다. jfr view allocation-by-class board.jfr를 실행하면 할당 표본이 byte[], String, Object[] 순으로 몰려 있는 것도 볼 수 있습니다. 쓸 수 있는 보기의 목록은 인수 없이 jfr view를 실행하면 나옵니다.
표로 요약하기 전의 이벤트 하나하나는 jfr print로 봅니다. --events에 이벤트 이름을 쉼표로 이어 여러 개를 고를 수도 있습니다.
jfr print --events jdk.GarbageCollection board.jfrjdk.GarbageCollection {
startTime = 09:38:31.079 (2026-10-27)
duration = 5.92 ms
gcId = 2
name = "G1New"
cause = "G1 Evacuation Pause"
sumOfPauses = 5.92 ms
longestPause = 5.92 ms
eventThread = "VM Thread" (osThreadId = 20993)
}jfr print의 시각은 이 컴퓨터의 시간대(여기서는 한국 시간)로 나오지만, jfr summary의 Start는 UTC로 나옵니다. 기록을 애플리케이션 로그나 배포 시각과 맞춰 볼 때는 시간대를 꼭 확인해야 합니다.
프로그램 안에서 기록하기: Recording API
jdk.jfr 모듈의 Recording 클래스를 쓰면 자바 코드에서 기록을 시작하고 멈출 수 있습니다. 서비스 전체를 상시 기록할 때는 앞의 명령줄 옵션이나 뒤에서 볼 jcmd를 쓰고, 이 API는 테스트나 진단 도구에서 특정 작업의 앞뒤만 감싸 기록할 때 씁니다. 다음 프로그램은 CPU 사용률과 실행 표본 두 이벤트를 켜고, 작은 계산을 기록한 뒤 임시 파일에 저장합니다.
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import jdk.jfr.FlightRecorder;
import jdk.jfr.Recording;
public final class ProgrammaticJfrRecording {
private static long workload() {
long checksum = 0;
for (int value = 0; value < 200_000; value++) {
checksum += (long) value * value;
}
return checksum;
}
public static void main(String[] args) throws Exception {
System.out.println("available=" + FlightRecorder.isAvailable());
Path file = Files.createTempFile("board-recording", ".jfr");
try (Recording recording = new Recording()) {
recording.enable("jdk.CPULoad").withPeriod(Duration.ofMillis(20));
recording.enable("jdk.ExecutionSample").withPeriod(Duration.ofMillis(10));
recording.start();
long checksum = workload();
recording.stop();
recording.dump(file);
System.out.println("checksum=" + checksum);
}
System.out.println("file=" + file);
System.out.println("bytes=" + Files.size(file));
}
}available=true
checksum=2666646666700000
file=/tmp/board-recording16647477040254527460.jfr
bytes=112334FlightRecorder.isAvailable()은 이 JVM에서 JFR을 쓸 수 있는지 알려 줍니다. enable은 이벤트를 켜고 설정 객체를 돌려주므로, withPeriod를 이어 붙여 표본 주기를 정할 수 있습니다. 그다음은 start()로 기록을 시작하고, 기록하려는 작업을 한 뒤, stop()으로 멈추고, dump(file)로 파일에 씁니다. try-with-resources가 블록 끝에서 close()를 불러 기록이 쓰던 자원을 놓아 줍니다. checksum은 0부터 199,999까지의 제곱을 더한 값입니다. 임시 파일은 운영체제의 임시 디렉터리에 생기고 이름은 실행할 때마다 다르며, 이 예제는 파일을 지우지 않으므로 다 쓴 뒤에는 직접 지워야 합니다.
stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다
Recording이 NEW→RUNNING→STOPPED→CLOSED로 전환하고 dump는 STOPPED를 유지한 채 파일을 남깁니다. 수집 중지·파일 작성·내부 자원 해제의 다른 상태를 보여 줄 수 있습니다.
ProgrammaticJfrRecording이 직접 시작하는 경로의 메서드 순서를 읽습니다.
JFR 직접 시작의 정상 경로 · 수집과 보관의 구분
stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다
stop은 수집을 멈추고 데이터를 보관합니다. 이 예제는 보관 데이터를 dump한 뒤 try 블록 종료에서 close하며, 저장한 파일은 close 뒤에도 남습니다.
new Recording()
- 그 뒤 상태
- NEW
- 데이터와 자원
- 기록 객체 생성
start()
- 그 뒤 상태
- RUNNING
- 데이터와 자원
- 활성 이벤트 수집
stop()
- 그 뒤 상태
- STOPPED
- 데이터와 자원
- 수집 중단 · 데이터 보관
dump(file)
- 그 뒤 상태
- STOPPED
- 데이터와 자원
- 보관 데이터를 파일에 기록
try 블록 종료의 close()
- 그 뒤 상태
- CLOSED
- 데이터와 자원
- 기록 자원과 내부 데이터 해제
원문의 직접 시작 경로입니다. 예약 시작의 DELAYED 상태도 있으며, API는 RUNNING 중 dump도 허용합니다.
setDestination을 설정하지 않은 원문 경로입니다. 설정한 경우 stop이 파일에 저장하고 자동으로 닫는 정책은 이 흐름과 다릅니다.
이 예의 조건
- dump는 기록 상태를 바꾸지 않고 파일을 작성한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | setDestination을 설정하지 않은 직접 시작 원 코드의 정상 흐름 호출 순서다. NEW→RUNNING→STOPPED로 수집을 멈춘다. dump 후에도 STOPPED이며 파일이 작성된다. close가 내부 기록 데이터를 해제해 CLOSED가 되지만 작성한 파일은 남는다. destination 설정/DELAYED/RUNNING 중 dump의 별도 정책은 원 지원 주석에 있다. |
| 구분 | 내용 |
|---|---|
| 설명 | setDestination을 설정하지 않은 직접 시작 원 코드의 정상 흐름 호출 순서다. NEW→RUNNING→STOPPED로 수집을 멈춘다. dump 후에도 STOPPED이며 파일이 작성된다. close가 내부 기록 데이터를 해제해 CLOSED가 되지만 작성한 파일은 남는다. destination 설정/DELAYED/RUNNING 중 dump의 별도 정책은 원 지원 주석에 있다. |
| 전체 읽기 정보 | stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다 stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다 ProgrammaticJfrRecording이 직접 시작하는 경로의 메서드 순서를 읽습니다. JFR 직접 시작의 정상 경로 · 수집과 보관의 구분 stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다 stop은 수집을 멈추고 데이터를 보관합니다. 이 예제는 보관 데이터를 dump한 뒤 try 블록 종료에서 close하며, 저장한 파일은 close 뒤에도 남습니다. new Recording() 그 뒤 상태 NEW 데이터와 자원 기록 객체 생성 start() 그 뒤 상태 RUNNING 데이터와 자원 활성 이벤트 수집 stop() 그 뒤 상태 STOPPED 데이터와 자원 수집 중단 · 데이터 보관 dump(file) 그 뒤 상태 STOPPED 데이터와 자원 보관 데이터를 파일에 기록 try 블록 종료의 close() 그 뒤 상태 CLOSED 데이터와 자원 기록 자원과 내부 데이터 해제 원문의 직접 시작 경로입니다. 예약 시작의 DELAYED 상태도 있으며, API는 RUNNING 중 dump도 허용합니다. setDestination을 설정하지 않은 원문 경로입니다. 설정한 경우 stop이 파일에 저장하고 자동으로 닫는 정책은 이 흐름과 다릅니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기록 생성 Recording을 NEW로 만듭니다. |
|
|
| 수집 시작 start 뒤 workload를 실행합니다. |
|
|
| 수집 정지 stop이 데이터를 보관합니다. |
|
|
| 파일 작성 dump(file)을 호출합니다. |
|
|
| 자원 종료 try 블록 종료에서 close합니다. |
|
|
stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다
Recording이 NEW→RUNNING→STOPPED→CLOSED로 전환하고 dump는 STOPPED를 유지한 채 파일을 남깁니다. 수집 중지·파일 작성·내부 자원 해제의 다른 상태를 보여 줄 수 있습니다.
ProgrammaticJfrRecording이 직접 시작하는 경로의 메서드 순서를 읽습니다.
JFR 직접 시작의 정상 경로 · 수집과 보관의 구분
stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다
stop은 수집을 멈추고 데이터를 보관합니다. 이 예제는 보관 데이터를 dump한 뒤 try 블록 종료에서 close하며, 저장한 파일은 close 뒤에도 남습니다.
new Recording()
- 그 뒤 상태
- NEW
- 데이터와 자원
- 기록 객체 생성
start()
- 그 뒤 상태
- RUNNING
- 데이터와 자원
- 활성 이벤트 수집
stop()
- 그 뒤 상태
- STOPPED
- 데이터와 자원
- 수집 중단 · 데이터 보관
dump(file)
- 그 뒤 상태
- STOPPED
- 데이터와 자원
- 보관 데이터를 파일에 기록
try 블록 종료의 close()
- 그 뒤 상태
- CLOSED
- 데이터와 자원
- 기록 자원과 내부 데이터 해제
원문의 직접 시작 경로입니다. 예약 시작의 DELAYED 상태도 있으며, API는 RUNNING 중 dump도 허용합니다.
setDestination을 설정하지 않은 원문 경로입니다. 설정한 경우 stop이 파일에 저장하고 자동으로 닫는 정책은 이 흐름과 다릅니다.
이 예의 조건
- dump는 기록 상태를 바꾸지 않고 파일을 작성한다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | setDestination을 설정하지 않은 직접 시작 원 코드의 정상 흐름 호출 순서다. NEW→RUNNING→STOPPED로 수집을 멈춘다. dump 후에도 STOPPED이며 파일이 작성된다. close가 내부 기록 데이터를 해제해 CLOSED가 되지만 작성한 파일은 남는다. destination 설정/DELAYED/RUNNING 중 dump의 별도 정책은 원 지원 주석에 있다. |
| 구분 | 내용 |
|---|---|
| 설명 | setDestination을 설정하지 않은 직접 시작 원 코드의 정상 흐름 호출 순서다. NEW→RUNNING→STOPPED로 수집을 멈춘다. dump 후에도 STOPPED이며 파일이 작성된다. close가 내부 기록 데이터를 해제해 CLOSED가 되지만 작성한 파일은 남는다. destination 설정/DELAYED/RUNNING 중 dump의 별도 정책은 원 지원 주석에 있다. |
| 전체 읽기 정보 | stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다 stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다 ProgrammaticJfrRecording이 직접 시작하는 경로의 메서드 순서를 읽습니다. JFR 직접 시작의 정상 경로 · 수집과 보관의 구분 stop 뒤에도 dump할 데이터는 남고 close가 자원을 해제한다 stop은 수집을 멈추고 데이터를 보관합니다. 이 예제는 보관 데이터를 dump한 뒤 try 블록 종료에서 close하며, 저장한 파일은 close 뒤에도 남습니다. new Recording() 그 뒤 상태 NEW 데이터와 자원 기록 객체 생성 start() 그 뒤 상태 RUNNING 데이터와 자원 활성 이벤트 수집 stop() 그 뒤 상태 STOPPED 데이터와 자원 수집 중단 · 데이터 보관 dump(file) 그 뒤 상태 STOPPED 데이터와 자원 보관 데이터를 파일에 기록 try 블록 종료의 close() 그 뒤 상태 CLOSED 데이터와 자원 기록 자원과 내부 데이터 해제 원문의 직접 시작 경로입니다. 예약 시작의 DELAYED 상태도 있으며, API는 RUNNING 중 dump도 허용합니다. setDestination을 설정하지 않은 원문 경로입니다. 설정한 경우 stop이 파일에 저장하고 자동으로 닫는 정책은 이 흐름과 다릅니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 기록 생성 Recording을 NEW로 만듭니다. |
|
|
| 수집 시작 start 뒤 workload를 실행합니다. |
|
|
| 수집 정지 stop이 데이터를 보관합니다. |
|
|
| 파일 작성 dump(file)을 호출합니다. |
|
|
| 자원 종료 try 블록 종료에서 close합니다. |
|
|
단계 그림처럼 Recording은 상태를 가지고 움직입니다. 막 만든 기록은 NEW이고, start() 뒤에는 이벤트를 모으는 RUNNING, stop() 뒤에는 수집을 멈추고 데이터를 보관하는 STOPPED가 됩니다. dump(file)은 상태를 바꾸지 않고 보관한 데이터를 파일로 내보낼 뿐이고, close()가 내부 데이터를 놓아 주어 CLOSED가 됩니다. 이미 파일로 쓴 내용은 close() 뒤에도 남습니다. 이 밖에 scheduleStart로 시작을 미룬 DELAYED 상태가 있고, dump는 RUNNING 중에도 부를 수 있습니다. setDestination으로 저장 경로를 미리 정해 두면 stop()이 그 경로에 파일을 쓰고 기록을 바로 닫는다는 점도 이 흐름과 다릅니다. 자세한 규칙은 Recording API 문서에 있습니다.
한 번 쓴 기록은 다시 시작할 수 없습니다. 다음 프로그램은 만들자마자 닫은 기록을 시작하려고 합니다.
import jdk.jfr.Recording;
public final class ClosedJfrRecordingFailure {
public static void main(String[] args) {
Recording recording = new Recording();
recording.close();
recording.start();
}
}Exception in thread "main" java.lang.IllegalStateException: Recording can only be started once.
at jdk.jfr/jdk.jfr.internal.PlatformRecording.start(PlatformRecording.java:116)
at jdk.jfr/jdk.jfr.Recording.start(Recording.java:177)
at ClosedJfrRecordingFailure.main(ClosedJfrRecordingFailure.java:7)이 기록은 한 번도 시작한 적이 없는데도 메시지는 "한 번만 시작할 수 있다"고 말합니다. start()는 NEW 상태에서만 허용되고, 이미 시작했거나 CLOSED인 기록에서는 IllegalStateException을 던지기 때문입니다. 다시 기록하려면 Recording 객체를 새로 만들어야 합니다. 시작, 멈춤, 저장, 닫기를 한 메서드 안에서 이 순서대로 하고 닫기는 try-with-resources에 맡기면, 예외가 나도 기록이 열린 채 남지 않습니다.
파일이 있어도 원하는 이벤트가 없을 수 있다
앞의 ProgrammaticJfrRecording은 jdk.CPULoad와 jdk.ExecutionSample 두 이벤트를 켜고 기록했으므로, 파일에 두 이벤트가 모두 들어 있으리라고 기대하기 쉽습니다. 그런데 그 파일을 jfr summary로 열어 보면 뜻밖의 결과가 나옵니다.
Event Type Count Size (bytes)
=============================================================
jdk.Checkpoint 5 9024
jdk.Metadata 1 103223
jdk.CPULoad 1 19실행 표본(jdk.ExecutionSample)은 하나도 없습니다. 계산이 1밀리초도 안 돼 끝나서, 10밀리초마다 찍는 표본이 한 번도 찍히지 않은 것입니다. 112KB짜리 파일의 대부분은 이벤트의 형식을 설명하는 jdk.Metadata입니다. 즉 파일이 만들어지고 크기가 0보다 크다는 것은 저장에 성공했다는 뜻일 뿐, 원하는 이벤트가 들어 있다는 뜻은 아닙니다. 마찬가지로 이벤트가 0개라고 해서 문제가 없다는 뜻도 아닙니다. 그 이벤트가 켜져 있었는지, 임계값이나 주기에 비해 작업이 충분히 길었는지를 먼저 확인해야 합니다.
내 코드의 동작을 이벤트로 남기기
JDK 이벤트만으로는 "게시글 저장이 언제, 얼마나 걸렸나"를 알 수 없습니다. jdk.jfr.Event를 상속해 사용자 정의 이벤트를 만들면 애플리케이션의 작업도 JVM 이벤트와 같은 시간축에 남길 수 있습니다. 다음 프로그램은 게시글 저장 작업을 이벤트로 감쌉니다.
import jdk.jfr.Category;
import jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;
import jdk.jfr.StackTrace;
public final class CustomBoardOperationEvent {
@Name("com.example.BoardOperation")
@Label("Board operation")
@Category({"Application", "Board"})
@StackTrace(false)
private static final class BoardOperation extends Event {
@Label("Operation")
String operation;
@Label("User id")
long userId;
@Label("Success")
boolean success;
}
static void save(long userId) {
BoardOperation event = new BoardOperation();
event.operation = "save-summary";
event.userId = userId;
event.begin();
try {
long checksum = 0;
for (int value = 0; value < 10_000; value++) {
checksum += value;
}
event.success = checksum > 0;
} finally {
event.commit();
}
}
public static void main(String[] args) {
save(7);
System.out.println("event-committed");
}
}애노테이션은 이벤트의 정보를 정합니다. @Name은 이벤트 종류의 이름으로, 다른 이벤트와 겹치지 않게 패키지처럼 도메인을 거꾸로 쓴 이름을 붙입니다. @Label은 도구에 보여 줄 사람이 읽는 이름이고, @Category는 이벤트를 묶어 보여 줄 분류입니다. @StackTrace(false)는 이 이벤트에 스택 트레이스를 남기지 않게 해 기록 비용을 줄입니다. 필드에는 기본형과 String, Thread, Class만 쓸 수 있고, 다른 타입의 필드는 기록되지 않습니다.
begin()과 commit() 사이가 이벤트의 지속 시간입니다. end()로 끝 시각을 따로 정할 수도 있지만, 이 예제처럼 end() 없이 commit()하면 그 순간에 측정이 끝납니다. commit()을 finally에 둔 것은 작업이 예외로 끝나도 이벤트를 남기기 위해서입니다.
이 프로그램을 그냥 실행하면 event-committed 한 줄만 나오고 아무것도 기록되지 않습니다. 실행 중인 기록이 없으면 commit()은 아무 일도 하지 않기 때문입니다. 출력된 줄은 commit() 호출을 지나왔다는 뜻일 뿐입니다. 기록을 켜고 실행한 뒤 jfr print로 이 이벤트만 골라 보겠습니다.
javac CustomBoardOperationEvent.java
java -XX:StartFlightRecording=filename=board-op.jfr CustomBoardOperationEvent
jfr print --events com.example.BoardOperation board-op.jfrcom.example.BoardOperation {
startTime = 09:50:25.042 (2026-10-27)
duration = 0.140 ms
operation = "save-summary"
userId = 7
success = true
eventThread = "main" (javaThreadId = 1)
}이번에는 settings를 주지 않았으므로 default 설정으로 기록했습니다. 우리가 채운 세 필드와 함께, JFR이 자동으로 채운 시작 시각, 지속 시간, 이벤트를 남긴 스레드가 나옵니다. @StackTrace(false)를 주었으므로 스택 트레이스는 없습니다.
필드 값을 계산하는 데 비용이 든다면, 계산하기 전에 event.isEnabled()로 이 이벤트를 기록 중인 곳이 있는지 확인할 수 있습니다. event.shouldCommit()은 여기에 더해 지속 시간이 설정된 임계값을 넘는지까지 확인하므로, 느린 작업만 남기고 싶을 때 씁니다. 이벤트 필드에는 비밀번호나 게시글 본문처럼 민감한 내용을 넣지 않습니다. 또 이벤트의 이름과 필드는 이 기록을 읽는 대시보드나 분석 스크립트가 기대는 형식이므로, 바꿀 때는 그쪽과 함께 관리해야 합니다.
기록 파일을 코드로 읽기: RecordingFile
jdk.jfr.consumer 패키지의 RecordingFile을 쓰면 기록 파일을 자바 코드로 읽을 수 있습니다. 다음 프로그램은 사용자 정의 이벤트 하나를 기록하고, 저장한 파일을 다시 열어 그 이벤트의 개수와 필드 값을 확인합니다. 이벤트가 제대로 남는지 자동으로 확인하는 테스트를 이런 식으로 만들 수 있습니다.
import java.nio.file.Files;
import java.nio.file.Path;
import jdk.jfr.Event;
import jdk.jfr.Name;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
public final class JfrCustomEventRoundTrip {
@Name("com.example.BoardScore")
private static final class BoardScore extends Event {
long userId;
int score;
}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("board-score", ".jfr");
try (Recording recording = new Recording()) {
recording.enable("com.example.BoardScore");
recording.start();
BoardScore event = new BoardScore();
event.userId = 7;
event.score = 95;
event.commit();
recording.stop();
recording.dump(file);
}
int count = 0;
try (RecordingFile events = new RecordingFile(file)) {
while (events.hasMoreEvents()) {
RecordedEvent event = events.readEvent();
if (event.getEventType().getName().equals("com.example.BoardScore")) {
count++;
System.out.println("user=" + event.getLong("userId"));
System.out.println("score=" + event.getInt("score"));
}
}
}
System.out.println("count=" + count);
}
}user=7
score=95
count=1읽는 쪽은 hasMoreEvents()와 readEvent()로 이벤트를 하나씩 꺼냅니다. 파일에는 이 이벤트 말고도 JVM 정보 같은 이벤트가 섞여 있으므로 getEventType().getName()으로 원하는 종류만 고르고, 필드는 선언한 이름으로 getLong, getInt처럼 읽습니다. RecordingFile도 파일을 열어 두는 자원이라 try-with-resources로 닫습니다. 사용자 정의 이벤트는 따로 끄지 않는 한 기본으로 켜져 있어서 recording.enable(...) 줄이 없어도 이 예제는 같은 결과를 냅니다. 그래도 어떤 이벤트를 모으는 기록인지 코드에 드러나도록 적어 둡니다.
운영 환경에서 JFR 쓰기
이미 실행 중인 서비스라면 앞 글의 jcmd로 기록을 켤 수 있습니다. 다음은 장애가 났을 때 60초 동안 profile 설정으로 기록하는 예입니다.
jcmd <pid> JFR.start name=incident settings=profile duration=60s filename=/tmp/incident.jfr
jcmd <pid> JFR.dump name=incident filename=/tmp/incident-now.jfr
jcmd <pid> JFR.stop name=incident첫 명령은 incident라는 이름으로 기록을 시작하고, 60초가 지나면 filename의 파일에 씁니다. 그 전에라도 JFR.dump로 지금까지의 내용을 다른 파일에 복사할 수 있고, JFR.stop으로 멈출 수 있습니다. jcmd <pid> help JFR.start로 보면 이 명령의 영향은 Medium으로, 설정에 따라 낮을 수도 높을 수도 있다고 적혀 있습니다. 서비스를 시작할 때부터 상시 기록하려면 -XX:StartFlightRecording=maxage=1h,maxsize=500m처럼 보관 기간과 크기의 상한을 정해 둡니다. 기간이나 크기의 상한을 하나도 정하지 않으면 JDK 21은 기본값 maxsize=250MB를 쓴다는 메시지를 남깁니다.
기록을 읽을 때는 확인하려는 가설에 맞는 이벤트부터 봅니다.
| 확인하려는 가설 | 볼 이벤트 | jfr view 보기 |
|---|---|---|
| CPU를 많이 쓰는 코드가 있다 | jdk.ExecutionSample, 네이티브 코드는 jdk.NativeMethodSample | hot-methods |
| 할당이 많다 | jdk.ObjectAllocationSample | allocation-by-class, allocation-by-site |
synchronized 락을 오래 기다린다 | jdk.JavaMonitorEnter | contention-by-site |
ReentrantLock 같은 도구에서 오래 멈춘다 | jdk.ThreadPark | |
| GC가 잦거나 오래 멈춘다 | jdk.GarbageCollection | gc, gc-pauses |
락 대기 이벤트는 임계값보다 오래 기다린 경우만 남습니다. profile에서는 10밀리초, default에서는 20밀리초입니다. 그래서 짧은 대기가 아주 많은 문제는 이 이벤트에 잡히지 않을 수 있고, 앞에서 본 것처럼 이벤트가 0개라는 결과를 문제가 없다는 뜻으로 읽으면 안 됩니다. 기록한 시간 범위는 애플리케이션 로그의 요청 ID나 배포 시각과 맞춰 보면서, 증상이 나타난 구간을 기록이 실제로 덮고 있는지 확인합니다.
JFR을 켜면 다른 프로파일러는 필요 없나요?
JFR은 JVM 안의 여러 사건을 적은 부하로 폭넓게 모으는 데 강합니다. 하지만 네이티브 코드 깊숙한 곳의 병목, 특정 객체를 누가 붙잡고 있는지 같은 참조 경로, 한 줄씩 따라가는 디버깅은 각각 네이티브 프로파일러, 힙 덤프 분석기, 디버거가 더 잘합니다.
그래서 보통은 JFR로 어느 쪽이 문제인지 가설을 좁힌 다음, 그 가설에 맞는 도구를 더합니다. 증상별로 어떤 도구를 고를지는 다음 글에서 다룹니다.
정리
- JFR은 GC, CPU, 할당, 락, 입출력 같은 이벤트를 같은 시간축에 기록합니다. 켜 둔 이벤트만 정해 둔 주기와 임계값에 따라 남습니다.
-XX:StartFlightRecording이나jcmd <pid> JFR.start로 기록하고,jfr summary,jfr view,jfr print로 읽습니다. 상시 기록에는default, 짧은 집중 기록에는profile설정을 씁니다.Recording은NEW에서RUNNING,STOPPED,CLOSED로만 움직이며 다시 시작할 수 없습니다.dump는 상태를 바꾸지 않고,close뒤에도 저장한 파일은 남습니다.- 사용자 정의 이벤트로 애플리케이션 작업을 JVM 이벤트와 같은 시간축에 남길 수 있습니다. 실행 중인 기록이 없으면
commit()은 아무것도 남기지 않습니다. - 파일이 있다고 원하는 이벤트가 있는 것은 아니고, 이벤트가 0개라고 문제가 없는 것도 아닙니다. 설정, 주기, 임계값, 작업 시간을 함께 확인합니다.
- 기록 파일에는 환경 변수와 명령줄 인수가 들어가므로 민감한 자료로 다룹니다.
연습 문제
게시글이 발행됐다는 사용자 정의 이벤트를 기록 파일에 저장한 뒤, 파일을 다시 읽어 그 이벤트가 정확히 한 건이고 필드 값도 입력과 같은지 확인하세요.
- 이벤트 이름은
com.example.PostPublished이고, 필드는 제목title과 조회 수viewCount입니다. - 제목은
JFR introduction, 조회 수는 45로 기록합니다. - 기록의 시작, 커밋, 멈춤, 저장, 닫기 순서를 지키고, 다 쓴 임시 파일은 지웁니다.
종류와 개수만 확인하면 어떤 경우를 놓치는지도 함께 생각해 보세요.
해설 보기
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import jdk.jfr.Event;
import jdk.jfr.Name;
import jdk.jfr.Recording;
import jdk.jfr.consumer.RecordedEvent;
import jdk.jfr.consumer.RecordingFile;
public final class JfrPostPublishedEventSolution {
@Name("com.example.PostPublished")
private static final class PostPublished extends Event {
String title;
int viewCount;
}
public static void main(String[] args) throws Exception {
Path file = Files.createTempFile("post-published", ".jfr");
try {
try (Recording recording = new Recording()) {
recording.enable("com.example.PostPublished");
recording.start();
PostPublished event = new PostPublished();
event.title = "JFR introduction";
event.viewCount = 45;
event.commit();
recording.stop();
recording.dump(file);
}
List<RecordedEvent> published = RecordingFile.readAllEvents(file).stream()
.filter(e -> e.getEventType().getName().equals("com.example.PostPublished"))
.toList();
System.out.println("count=" + published.size());
if (published.size() == 1) {
RecordedEvent recorded = published.get(0);
System.out.println("title-matches=" + "JFR introduction".equals(recorded.getString("title")));
System.out.println("viewCount-matches=" + (recorded.getInt("viewCount") == 45));
}
} finally {
Files.deleteIfExists(file);
}
}
}count=1
title-matches=true
viewCount-matches=true기록하는 쪽은 앞의 JfrCustomEventRoundTrip과 같습니다. 읽는 쪽에서는 RecordingFile.readAllEvents로 파일의 이벤트를 목록으로 한꺼번에 읽은 뒤, 스트림으로 원하는 종류만 남겼습니다. 이벤트가 적은 작은 파일이라 이렇게 해도 되지만, 큰 기록 파일이라면 앞 예제처럼 readEvent()로 하나씩 읽는 편이 메모리를 덜 씁니다.
개수만 확인하면 다른 내용의 이벤트가 한 건 저장된 경우를 놓칩니다. 예를 들어 실수로 제목을 다른 필드 값으로 채웠어도 이벤트 종류와 개수는 맞으므로 count=1만으로는 통과합니다. 그래서 한 건일 때 getString("title")과 getInt("viewCount")로 읽은 값을 입력과 비교했습니다. 바깥의 try와 finally는 확인 도중에 예외가 나도 임시 파일을 지우기 위한 것입니다. 운영 환경의 기록 파일은 이와 달리 정해 둔 보관 기간 동안 남겨 둡니다.
다음 글
스레드 덤프와 프로파일러로 장애 원인 찾기에서는 지금까지 본 GC 로그, jcmd, JFR에 디버거와 스레드 덤프를 더해, 증상에 맞는 도구를 골라 원인을 좁혀 가는 방법을 정리합니다.