Java Flight Recorder 내부 구조 — Thread-Local Buffer부터 Disk Repository까지
JFR을 켜면 1% 미만 오버헤드로 JVM 내부가 그대로 기록돼요. 어떻게 이렇게 가벼울 수 있는지, 그리고 그 데이터가 어떤 경로를 거쳐 디스크에 쌓이는지 한 번 따라가 봐요. 이 글은 JFR을 "그냥 잘 쓰는 도구"에서 "내부 동작을 아는 도구"로 끌어올리고 싶은 분을 위한 글이에요.
운영 중인 서버에서 갑자기 응답 시간이 튀어요. 메트릭 그래프는 분명히 뭔가 이상하다고 말하는데, 정작 그 순간 무엇이 일어났는지는 알 수 없어요. 로그는 너무 거칠고, 프로파일러는 평소에 켜두기엔 너무 무거워요.
JDK Flight Recorder(이하 JFR)는 이런 간극을 메우려고 만들어진 도구예요. 항상 켜두어도 될 만큼 가볍고, 그러면서도 JVM 내부에서 일어나는 거의 모든 일을 시간순으로 기록해요. JEP 328이 OpenJDK 11에 머지되면서 정식 OpenJDK 기능이 됐고, 그 이후로는 누구나 별도 라이선스 없이 쓸 수 있어요.
이 글에서는 JFR이 어떻게 그렇게 가벼울 수 있는지, 데이터가 어디에서 어디로 흐르는지, 그리고 우리가 직접 이벤트를 정의할 때 JVM이 무엇을 해주는지 차근차근 살펴봐요.
JFR이 채우려고 했던 빈칸
기존에 JVM을 들여다보는 방법은 크게 셋이었어요.
- JMX: 메트릭을 폴링으로 가져와요. 순간순간의 스냅샷은 잘 보여주지만, 두 스냅샷 사이에 무슨 일이 있었는지는 알기 어려워요.
- 에이전트 기반 프로파일러: 자세한 정보를 주지만 오버헤드가 커요. 프로덕션에 상시 붙여두기엔 부담스럽죠.
- 로그: 무엇을 찍을지 미리 정해두어야 해요. 예상하지 못한 사건은 흔적이 남지 않아요.
JFR은 다른 접근을 택해요. JVM 안쪽에 항상 떠 있는 작은 기록기를 두고, 미리 정의된 수백 가지 "이벤트"가 발생할 때마다 그것을 거의 공짜에 가까운 비용으로 기록해요. 그래서 평소엔 가만히 있고, 문제가 생긴 순간 직전 N분의 기록을 잘라 분석할 수 있어요.
Oracle이 JFR을 OpenJDK로 옮기면서 내건 목표는 명확했어요. default 설정에서 1% 미만의 오버헤드예요. 이 한 줄을 지키기 위해 JFR의 거의 모든 설계 결정이 이루어졌다고 봐도 될 정도예요.
이벤트 모델 — 시간을 기록하는 세 가지 방법
JFR이 다루는 단위는 "이벤트"예요. 모든 이벤트는 발생 시간, 발생 스레드, 그리고 이벤트별로 정의된 필드들을 담아요. 종류는 크게 셋이에요.
| 종류 | 설명 | 예시 |
|---|---|---|
| Instant | 어떤 순간에 발생한 사건. 시작·끝 개념이 없어요. | 클래스 로딩, 예외 발생 |
| Duration | 시작과 끝이 있는 구간. threshold 이상 걸린 것만 기록해요. | 메서드 실행, GC pause |
| Sample | 일정 주기마다 시스템 상태를 표본 추출. | CPU profiling |
이 분류는 단순히 개념 정리가 아니라 실제 비용 차이로 직결돼요. Duration 이벤트에는 threshold라는 임계값을 줄 수 있어서, 예컨대 "10ms 이상 걸린 파일 읽기만 기록"처럼 잡으면 짧은 호출의 흔적은 아예 만들지 않아요. Sample은 stack-walk만 떠서 적어두니, 호출이 아무리 많이 일어나도 비용이 호출 횟수에 비례하지 않아요.
JFR이 기본으로 제공하는 이벤트 종류만 해도 500개가 넘어요. 대표적인 몇 가지를 추려보면 이래요.
jdk.ExecutionSample— CPU profiler. 기본 20ms 간격으로 Java 스레드의 스택을 떠요.jdk.ObjectAllocationInNewTLAB— TLAB가 새로 할당될 때마다 기록. 어떤 클래스가 어디서 메모리를 많이 쓰는지 보여줘요.jdk.ObjectAllocationOutsideTLAB— TLAB 밖에서 일어난 큰 객체 할당.jdk.GarbageCollection— GC 한 사이클의 시작과 끝, 종류, pause 시간.jdk.SocketRead/jdk.SocketWrite— 네트워크 I/O.jdk.FileRead/jdk.FileWrite— 파일 I/O.jdk.JavaMonitorEnter— 모니터 락 진입(컨텐션 분석).jdk.ThreadPark—LockSupport.park()호출.
이 중 어떤 것을, 어느 threshold로, stack trace까지 떠서 기록할지를 결정하는 게 바로 뒤에서 다룰 JFC 설정이에요.
3계층 버퍼 — 어떻게 거의 공짜가 됐을까
JFR의 모든 성능 비밀은 여기 모여 있어요. 이벤트 하나를 기록하는 일이 락 없이, 시스템 콜 없이, 그리고 거의 메모리 쓰기 한 번으로 끝나도록 설계돼 있어요.
flowchart LR
T1[Thread 1] --> TLB1[Thread-Local Buffer]
T2[Thread 2] --> TLB2[Thread-Local Buffer]
T3[Thread 3] --> TLB3[Thread-Local Buffer]
TLB1 --> GB[Global Ring Buffer]
TLB2 --> GB
TLB3 --> GB
GB --> DISK[Disk Repository .jfr chunks]
DISK --> FINAL[Final .jfr file]
흐름을 단계별로 보면 이래요.
1단계 — Thread-Local Buffer
각 Java 스레드는 자기만의 작은 native off-heap 버퍼를 가져요. 이벤트를 기록한다는 건 이 버퍼에 바이트 몇 개를 쓰는 일이에요. 다른 스레드와 공유하지 않으니 락이 필요 없고, off-heap이라 GC가 신경 쓰지도 않아요.
데이터는 변수 길이 정수 인코딩(LEB128)으로 압축돼서 들어가요. 짧은 숫자는 1바이트, 큰 숫자만 더 많은 바이트를 써요. 문자열·클래스명 같은 반복적인 값은 곧장 쓰지 않고 constant pool 인덱스만 적어요. 그래서 한 이벤트의 디스크 비용은 보통 십몇 바이트 수준이에요.
2단계 — Global Buffer
Thread-Local Buffer가 가득 차면 그 내용이 Global Ring Buffer로 "promote"돼요. 이때만 잠깐 동기화가 일어나요. 하지만 스레드마다 버퍼가 차는 속도가 다르기 때문에 동시에 promote가 몰리는 일은 드물어요. 이 점이 락 컨텐션을 거의 없게 만드는 핵심이에요.
Global Buffer는 이름 그대로 ring 구조예요. 가득 차면 가장 오래된 데이터를 덮어쓰거나, 디스크로 flush해요. 이 정책은 disk=true/disk=false 옵션으로 바뀌어요.
disk=true(기본값): Global Buffer가 가득 차면 디스크로 flush. 긴 기간 기록 가능.disk=false: 디스크 없이 메모리만 사용. 가장 오래된 데이터는 덮어써져요. 컨테이너 환경에서 디스크 I/O를 피하고 싶을 때 유용해요.
3단계 — Disk Repository
디스크에는 단일 파일이 아니라 여러 개의 chunk 파일로 분할돼서 저장돼요. 한 chunk의 기본 크기는 12MB(maxchunksize=12m)이고, 이 크기를 넘으면 새 chunk로 회전(rotation)해요.
여기서 chunk라는 단어가 중요해요. JFR의 chunk는 자체 완결적(self-contained) 이에요. 즉 chunk 하나만 떼어내도 그 안에 포함된 이벤트를 완전히 해석할 수 있어요. 이게 어떻게 가능한지는 다음 섹션에서 다뤄요.
기본 저장 위치는 OS의 임시 디렉토리 아래 타임스탬프 폴더예요. repository=/path/to/dir 옵션으로 바꿀 수 있어요.
이 회전 구조 덕분에 디스크 보존 정책도 단순해져요. maxage=10m을 주면 10분 넘은 chunk부터 차례로 삭제돼요. maxsize=200m을 주면 누적 200MB가 넘는 시점부터 오래된 chunk를 정리해요. 두 값을 동시에 줘도 되고, 둘 중 먼저 도달한 조건이 적용돼요. 별도의 reaper 스레드가 회전 시점마다 보존 정책을 검토해서 chunk를 지우니, JVM이 종료될 때 디스크에 작은 chunk 조각만 남아 있어도 정상이에요.
한 가지 알아두면 좋은 디테일이 있어요. 기본값에서는 JVM이 종료되면 repository 디렉토리도 비워져요. 만약 비정상 종료 후에도 chunk를 그대로 두고 싶다면 -XX:FlightRecorderOptions=preserve-repository=true를 줘야 해요. 컨테이너 환경에서 OOMKill로 죽는 JVM의 마지막 흔적을 보고 싶을 때 자주 쓰는 옵션이에요.
Chunk와 파일 포맷 — .jfr이 자기 자신을 설명한다
.jfr 파일은 chunk들의 연결로 이뤄져요. 한 chunk의 구조는 대체로 이래요.
flowchart TB
A[Chunk Header magic version offsets] --> B[Events Section LEB128 encoded]
B --> C[Checkpoint Events]
C --> D[Constant Pool strings classes stacktraces]
D --> E[Metadata Section type descriptors]
E --> F[Next Chunk]
- Chunk Header: 매직 넘버, 버전, 그리고 다른 섹션이 어디에 있는지 가리키는 오프셋들이에요.
- Events Section: 실제 이벤트들이 LEB128로 인코딩돼서 줄줄이 쌓여요. 각 이벤트는 자신의 타입 ID와 시간, 그리고 필드 값들을 담아요.
- Constant Pool: 문자열, 스택 트레이스, 클래스, 메서드 같은 자주 반복되는 값들이 모여 있어요. 이벤트 데이터는 이 풀의 인덱스만 가리켜요.
- Metadata Section: chunk 안에 등장하는 모든 이벤트 타입의 스키마예요. "타입 ID 47번은 jdk.ExecutionSample이고 필드는 이러이러하다"라고 자기 자신을 설명해요.
이 구조의 묘미는 metadata가 chunk 안에 들어 있다는 점이에요. 그래서 chunk 파일 하나만 받아도 그 안에 어떤 이벤트가 들어 있고 각 필드가 무엇을 의미하는지 알 수 있어요. JVM 버전이 달라도, 커스텀 이벤트가 섞여 있어도, 파서가 metadata만 읽으면 해석할 수 있죠.
쓰기 시점에 metadata와 constant pool은 chunk 끝에 붙어요. chunk가 회전될 때마다 새 chunk가 자기 metadata를 다시 갖고 시작하니, 도중에 잘려도 그 chunk까지는 온전히 해석돼요.
jfr CLI 도구를 쓰면 이 파일을 사람이 읽을 수 있는 형태로 풀어볼 수 있어요.
jfr print --events jdk.GarbageCollection my-recording.jfr
jfr summary my-recording.jfr
jfr metadata my-recording.jfr
jfr metadata를 실행하면 chunk 안에 들어 있는 모든 이벤트 타입과 필드 정의가 출력돼요. 파일 자체가 자신의 스키마를 들고 다닌다는 게 어떤 의미인지 직접 확인할 수 있어요.
설정 — default.jfc와 profile.jfc
JFR이 "어떤 이벤트를 켤지", "threshold는 얼마로 둘지", "stack trace를 떠야 할지"를 결정하는 건 JFC(Java Flight Recorder Configuration) 파일이에요. XML 형식이고, JDK가 두 가지 표준 프로파일을 함께 배포해요.
| 파일 | 위치 | 용도 |
|---|---|---|
default.jfc |
$JAVA_HOME/lib/jfr/default.jfc |
상시 기록용. 1% 미만 오버헤드. |
profile.jfc |
$JAVA_HOME/lib/jfr/profile.jfc |
짧은 진단용. 더 자세한 데이터, 더 큰 비용. |
두 파일의 차이는 주로 threshold와 sample 주기예요. 예컨대 default는 메서드 sampling을 20ms마다 하지만, profile은 10ms마다 해요. Allocation 이벤트도 default에서는 TLAB가 채워질 때만 기록하지만, profile에서는 더 잘게 추적해요.
JFC의 한 항목을 살짝 들여다보면 이래요.
<event name="jdk.FileRead">
<setting name="enabled">true</setting>
<setting name="stackTrace">true</setting>
<setting name="threshold">10 ms</setting>
</event>
이 세 개의 setting이 핵심이에요. enabled로 켜고 끄고, stackTrace로 스택을 떠서 비용을 더할지 정하고, threshold로 짧은 호출을 걸러내요. Sample 이벤트는 threshold 대신 period를 써요.
JFC는 그냥 XML이라 손으로 편집하거나, jfr configure 명령으로 대화식 생성도 할 수 있어요. 운영 환경에서는 보통 default.jfc를 복사해서 특정 이벤트만 켜둔 가벼운 커스텀 프로파일을 만들어 두고 써요.
jcmd로 조작하기
기록 자체는 JVM 옵션이나 jcmd로 시작·정지·덤프할 수 있어요. 운영 중인 JVM에 붙어서 명령을 던지는 게 가능한 게 큰 장점이에요.
JVM 기동 시 자동 시작.
java -XX:StartFlightRecording=duration=60s,filename=startup.jfr,settings=profile MyApp
실행 중인 JVM에 기록 시작.
jcmd <pid> JFR.start name=continuous settings=default maxage=10m
지금까지 모인 데이터만 떠서 파일로 내리기 (기록은 계속 진행).
jcmd <pid> JFR.dump name=continuous filename=snapshot.jfr
기록 중지.
jcmd <pid> JFR.stop name=continuous filename=final.jfr
운영 환경에서 자주 쓰는 패턴은 maxage=10m으로 "최근 10분만 메모리에 보관"하는 연속 기록을 띄워두는 거예요. 평소엔 그대로 두고, 문제 신호가 잡히면 그 순간 JFR.dump로 직전 10분의 활동을 떠서 분석하면 돼요. 이런 사후 분석 패턴이 JFR이 진가를 발휘하는 순간이에요.
Java 21부터는 jfr view라는 명령도 추가돼서, JMC 같은 GUI 도구 없이도 70여 가지 사전 정의된 뷰로 결과를 터미널에서 바로 볼 수 있어요.
jfr view hot-methods my-recording.jfr
jfr view gc my-recording.jfr
jfr view allocation-by-class my-recording.jfr
jfr view contention-by-class my-recording.jfr
뷰는 단순히 이벤트를 그대로 보여주는 게 아니라, 여러 이벤트를 조합해서 의미 있는 형태로 가공해서 출력해요. 예컨대 hot-methods 뷰는 jdk.ExecutionSample을 메서드별로 집계해서 점유율로 정렬하고, gc 뷰는 jdk.GarbageCollection 시리즈의 이벤트를 묶어 cycle별로 pause 시간과 발생 횟수를 표로 보여줘요. 운영 환경에서 빠르게 1차 진단을 할 때 JMC를 띄울 필요가 없어졌어요.
자주 쓰는 패턴 한 가지 더
운영에서 종종 보는 패턴이 하나 더 있어요. 두 개의 기록을 동시에 띄우는 거예요.
jcmd <pid> JFR.start name=continuous settings=default maxage=10m disk=false
jcmd <pid> JFR.start name=long-term settings=default maxage=6h disk=true
continuous는 메모리만 사용해서 최근 10분만 따라가요. 알람이 울리면 즉시 떠서 자세히 보는 용도예요. long-term은 디스크를 사용해서 6시간을 보존해요. 새벽에 발생한 일을 다음 날 출근해서 분석할 때 쓰는 거죠. JFR은 여러 기록을 동시에 띄우는 것을 허용하니, 한쪽이 다른 쪽을 가리지 않아요. 단, 두 기록이 같은 이벤트를 켤 경우 비용은 1회만 발생해요. JFR이 내부적으로 이벤트 publication과 consumer를 분리해두었기 때문이에요.
커스텀 이벤트 — 우리 코드도 JFR에 합류시키기
JFR이 정말 강력한 이유는 JVM이 만든 이벤트 옆에 우리의 비즈니스 이벤트를 똑같은 자격으로 끼워 넣을 수 있다는 점이에요. jdk.jfr.Event를 상속하는 클래스 하나면 충분해요.
import jdk.jfr.Category;
import jdk.jfr.Description;
import jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;
import jdk.jfr.StackTrace;
@Name("com.example.OrderProcessed")
@Label("Order Processed")
@Category("Application")
@Description("주문 처리 한 건의 시작과 끝을 측정해요")
@StackTrace(false)
public class OrderProcessedEvent extends Event {
@Label("Order ID")
public String orderId;
@Label("Item Count")
public int itemCount;
@Label("Total Amount")
public long totalAmount;
}
사용은 begin/end/commit 흐름이에요.
public OrderResult process(Order order) {
var event = new OrderProcessedEvent();
event.orderId = order.id();
event.begin();
try {
return doProcess(order);
} finally {
event.itemCount = order.items().size();
event.totalAmount = order.total();
event.end();
event.commit();
}
}
여기서 흥미로운 점은 commit()이 항상 디스크에 쓰는 게 아니에요. 이벤트 메타데이터의 threshold를 보고, 그보다 짧게 끝났다면 조용히 버려져요. 우리가 코드에서 따로 분기하지 않아도 "1초 이상 걸린 주문 처리만 기록"같은 정책이 JFC 한 줄로 결정돼요. 이 분리가 굉장히 깔끔하죠.
지원되는 필드 타입은 Java 기본형(boolean, char, byte, short, int, long, float, double)과 String, Thread, Class예요. 배열이나 다른 reference 타입은 조용히 무시되니, 복잡한 객체는 미리 직렬화해서 String으로 넣어야 해요.
커스텀 이벤트를 정의하면 JFC에도 자동으로 등록할 수 있고, jfr print --events com.example.OrderProcessed로 바로 조회할 수 있어요. JVM 내부 이벤트와 우리 이벤트가 같은 타임라인 위에서 보인다면 디버깅에서 굉장한 무기예요. "이 주문 처리가 12초 걸렸을 때 GC pause가 있었나?"라는 질문에 한 파일 안에서 답할 수 있게 되니까요.
이런 사용 사례가 가능한 이유는 JFR이 처음부터 외부 코드의 이벤트도 1급 시민으로 다루도록 설계됐기 때문이에요. JVM이 만든 jdk.GarbageCollection이든, 우리가 만든 com.example.OrderProcessed든 같은 thread-local 버퍼에 같은 형식으로 쓰여지고, 같은 chunk로 회전되고, 같은 metadata로 기술돼요. "내부용 도구"와 "사용자용 도구"의 경계가 없어요. 그래서 라이브러리 저자도 JFR 이벤트를 추가해 두면 그 라이브러리를 쓰는 모든 애플리케이션이 추가 비용 없이 관측 가능성을 확보할 수 있어요. 실제로 Hibernate, Quarkus, Netty, Spring 같은 프레임워크들이 자체 JFR 이벤트를 발행하기 시작했어요.
JEP 349 — 이벤트 스트리밍
JEP 328이 디스크에 기록하는 단계까지였다면, JEP 349(Java 14)는 그 기록을 실시간으로 소비하는 길을 열었어요.
기존에는 기록 → 덤프 → 파싱이라는 3단계를 거쳐야 했어요. 그래서 사후 분석에는 강하지만, "실시간 모니터링 시스템에 이벤트를 흘려보내기"에는 어울리지 않았죠. JEP 349는 jdk.jfr.consumer.RecordingStream이라는 API를 추가해서 이 간격을 메웠어요.
import jdk.jfr.consumer.RecordingStream;
import java.time.Duration;
try (var stream = new RecordingStream()) {
stream.enable("jdk.CPULoad").withPeriod(Duration.ofSeconds(1));
stream.enable("jdk.GarbageCollection");
stream.onEvent("jdk.CPULoad", event -> {
double total = event.getDouble("machineTotal");
System.out.printf("CPU: %.2f%n", total);
});
stream.onEvent("jdk.GarbageCollection", event -> {
long pauseNanos = event.getDuration("sumOfPauses").toNanos();
System.out.println("GC pause: " + pauseNanos + " ns");
});
stream.setMaxAge(Duration.ofMinutes(5));
stream.start();
}
내부적으로 RecordingStream은 disk repository의 chunk가 회전될 때마다 그 chunk를 파싱해서 등록된 핸들러를 호출해요. 즉, 본질적으로는 같은 디스크 기반 파이프라인을 그대로 쓰면서, 추가로 in-process 컨슈머를 붙인 거예요.
flush 주기는 setFlushInterval(Duration)로 조정할 수 있어요. 기본값은 1초고, JVM은 그 주기마다 thread-local 버퍼의 내용을 강제로 global로 promote시켜서 스트림 소비자가 거의 실시간으로 데이터를 볼 수 있게 해줘요. 모든 이벤트를 모아 한 번에 처리하고 싶다면 onFlush(Runnable) 콜백을 쓸 수 있어요.
이 API 덕분에 JFR이 Prometheus exporter, OpenTelemetry exporter, custom dashboard 등과 자연스럽게 연결돼요. 별도 에이전트 없이 JVM 안에서 자신의 활동을 외부로 흘려보내는 길이 열린 거예요.
스트리밍 API의 또 다른 흥미로운 점은 원격 소비도 지원한다는 거예요. RecordingStream의 생성자에 다른 JVM의 repository 경로를 넘기면, 그 JVM이 회전시키는 chunk를 다른 프로세스에서 읽을 수 있어요. 사이드카 패턴으로 JFR 데이터를 처리하는 컨테이너를 함께 띄우는 구성을 만들 때 유용해요. JVM 본체에는 무거운 의존성을 더하지 않고, 사이드카에서 자유롭게 변환·전송 로직을 둘 수 있죠.
다만 스트리밍에는 두 가지 트레이드오프가 있어요. 첫째, flush 주기를 짧게 잡을수록 promote 빈도가 늘어나서 thread-local 버퍼의 캐시 효율이 약간 떨어져요. 둘째, in-process 컨슈머가 처리하지 못한 이벤트는 그냥 chunk 안에 머물러요. 즉 컨슈머가 느리다고 해서 기록 자체가 막히지는 않아요. 이건 좋은 점이기도 하고 (기록은 안전), 동시에 주의해야 할 점이기도 해요 (느린 컨슈머는 데이터를 놓칠 수 있음).
Safepoint 없이 기록한다는 것
JFR의 설계에서 자주 간과되는 또 하나의 디테일이 있어요. 이벤트 기록은 safepoint를 거치지 않아요. 즉 모든 Java 스레드를 동시에 멈추는 일이 일어나지 않아요.
기존의 많은 진단 도구는 stack trace를 뜨거나 heap dump를 만들 때 JVM safepoint를 요구해요. 그 사이 모든 스레드는 일을 멈춰야 하죠. 트래픽이 많은 서비스에서는 이게 그 자체로 응답 시간 튐의 원인이 돼요.
JFR은 다르게 접근해요. CPU sampling을 위한 stack-walk조차 safepoint를 강제하지 않고, sample 대상 스레드에서 signal-handler-like 메커니즘으로 stack을 떠요. 이벤트 자체는 그 스레드가 어차피 자기 thread-local 버퍼에 직접 쓰기 때문에 다른 스레드를 멈출 이유가 없어요. global buffer로 promote할 때도 lock-free에 가까운 자료구조를 사용해서, 모든 스레드를 동시에 멈출 일이 없어요.
이게 운영에서 의미하는 바는 분명해요. JFR을 켠 채로 부하 테스트를 돌려도 latency p99 그래프가 거의 그대로 유지돼요. "프로파일러를 켜는 순간 다른 시스템이 된다"는 옛 격언이 JFR에서는 적용되지 않아요.
다시, 1%의 비밀
여기까지 보고 나면 처음 던졌던 질문에 답할 수 있어요. JFR은 왜 그렇게 가벼울까요.
- Thread-Local Buffer: 락이 거의 필요 없어요. 이벤트 기록 비용이 메모리 쓰기 수준이에요.
- Off-heap: GC가 신경 쓸 데이터가 아니에요.
- LEB128 + Constant Pool: 한 이벤트가 디스크에 남기는 발자국이 십몇 바이트예요.
- Threshold/Period: 짧은 호출이나 너무 잦은 sample은 애초에 만들지도 않아요.
- Chunk Rotation: 디스크 I/O는 백그라운드 스레드가 일괄로 처리해요. 핫 패스에 끼어들지 않아요.
- Self-describing chunk: 별도 스키마 파일이 필요 없어서 운영이 단순해요.
- Safepoint 회피: 모든 스레드를 동시에 멈추는 일이 일어나지 않아 latency 튐이 없어요.
- 이벤트 발행과 소비의 분리: 동일 이벤트를 여러 기록이 구독해도 비용은 1회만 발생해요.
이 모든 게 합쳐져서 default 설정 기준 1% 미만 오버헤드라는 목표를 가능하게 만들었어요. 그래서 JFR은 "문제가 생긴 다음에 켜는 도구"가 아니라 "항상 켜둔 채로 잊고 살다가 필요할 때 꺼내 쓰는 도구"가 될 수 있었던 거예요.
운영 중인 JVM에 가벼운 JFR 기록을 띄워두는 건 비용 대비 가장 후한 보험 중 하나예요. 다음 번 응답 시간이 튀는 순간, 이 글에서 본 흐름이 머릿속에 그려진다면 그 순간 jcmd ... JFR.dump를 자신 있게 던질 수 있을 거예요.
한눈에 정리
마지막으로 이 글의 핵심을 한 번 더 정리해요.
- JFR은 JVM 안에 항상 떠 있는 이벤트 기록기예요. JEP 328로 OpenJDK 11에 합류했어요.
- 이벤트는 Instant / Duration / Sample 세 종류이고, 각각 threshold·period로 비용을 조절해요.
- 데이터는 Thread-Local → Global → Disk 3계층을 흐르며, 핫 패스에 락이 거의 없어요.
- 디스크에는 자체 완결적 chunk로 나뉘어 저장돼요. chunk 안에 metadata와 constant pool이 함께 있어 단독 파싱이 가능해요.
default.jfc는 1% 미만 오버헤드의 상시용,profile.jfc는 더 자세한 진단용이에요.jcmd로 시작·정지·덤프할 수 있고,maxage=10m을 이용한 사후 분석 패턴이 가장 강력해요.jdk.jfr.Event를 상속해서 커스텀 이벤트를 추가할 수 있고, 그것이 JVM 이벤트와 동일한 자격으로 같은 타임라인에 올라가요.- JEP 349가 추가한 **
RecordingStream**으로 실시간 소비도 가능해서 메트릭 백엔드와 자연스럽게 연결돼요.
JFR은 한번 익혀두면 운영 도구함에서 빠질 일이 없는 도구예요. 이 글이 그 첫 걸음의 안내가 됐길 바라요.
참고자료
- JEP 328: Flight Recorder — JFR을 OpenJDK로 옮긴 원안.
- JEP 349: JFR Event Streaming —
RecordingStreamAPI를 추가한 제안. - JDK Flight Recorder — Wikipedia — 버퍼 구조와 chunk 포맷의 한눈에 보는 개요.
- Configuring the JDK Flight Recorder — Dev.java —
default.jfc/profile.jfc와jcmd사용법 공식 가이드. - Jfr — Parse and Print Flight Recorder Files — Dev.java —
jfrCLI 사용법. - Flight Recorder API Programmer's Guide — Oracle Docs — 커스텀 이벤트와 API 문법.
- The JDK Flight Recorder File Format — Gunnar Morling — chunk, constant pool, LEB128 인코딩 해설.
- Custom JFR Events: A Short Introduction — foojay — 커스텀 이벤트 작성 실전 예시.
- Improved JFR Allocation Profiling in JDK 16 — foojay —
ObjectAllocationInNewTLAB과 할당 프로파일링.

