JDK Flight Recorder + JDK Mission Control 실전 — 운영 중 JVM 프로파일링

"☕" 12 "min read"

GC 로그와 Heap Dump로도 원인을 못 찾을 때가 있습니다. "메모리는 충분한데 응답이 느리다", "특정 시간대에만 CPU가 튄다" 같은 상황이 그렇습니다. 이 글에서는 JDK 공식 프로파일링 도구인 JFR과 JMC를 사용해 운영 서버에서 메모리 할당 핫스팟, 스레드 병목, GC 원인을 실시간으로 찾는 방법을 정리합니다.

👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기


1. JFR이 뭔가요? — 기초부터

JDK Flight Recorder(JFR) 는 JVM 내부에 내장된 공식 프로파일링/진단 프레임워크입니다. Java 11부터 OpenJDK에 무료로 포함되었고, Java 8u262 이후 버전에서도 사용 가능합니다.

이름이 "Flight Recorder"인 이유가 있습니다. 비행기 블랙박스처럼 JVM이 동작하는 동안 항상 데이터를 기록해두고, 문제가 생겼을 때 꺼내서 분석하는 방식이기 때문입니다.

JFR vs 다른 프로파일링 도구 비교

도구오버헤드운영 사용특징
JFR1~2%권장JDK 내장, 별도 설치 불필요
VisualVM5~15%비권장개발/로컬 분석 용도
Async-Profiler2~5%주의정밀도 높음, 별도 설치 필요
Datadog Profiler2~5%가능APM 통합, 유료
Heap Dump 분석순간 중단주의특정 시점 스냅샷만 가능

JDK Mission Control(JMC) 은 JFR로 기록된 .jfr 파일을 열어서 분석하는 GUI 도구입니다. JFR이 블랙박스 녹화기라면, JMC는 그 녹화 파일을 재생하는 플레이어입니다.

JFR-JMC 아키텍처JVM 내부의 JFR 엔진이 링 버퍼에 이벤트를 기록하고, jcmd로 .jfr 파일을 저장한 뒤 JMC로 분석하는 흐름JVM 프로세스JFR 엔진오버헤드 1~2%GC 이벤트스레드 이벤트메모리 할당링 버퍼maxage=1hmaxsize=500m최신 1시간 데이터항상 보관jcmdJFR.dump / JFR.start문제 발생 or 조건 충족recording.jfr이진 파일 저장JMC GUIAutomatedAnalysisMemoryThreads

2. JMC 설치

JFR은 JDK에 내장되어 있어 별도 설치가 필요 없지만, JMC는 별도로 받아야 합니다.

# macOS (Homebrew)
brew install --cask jdk-mission-control

# Linux / Windows
# https://adoptium.net/jmc 에서 OS별 다운로드
# 또는 직접 다운로드
wget https://github.com/adoptium/jmc-build/releases/download/8.3.1/org.openjdk.jmc-8.3.1-linux.gtk.x86_64.tar.gz
tar -xzf org.openjdk.jmc-*.tar.gz
cd jmc && ./jmc   # 실행

# 버전 확인
# JDK 17: JMC 8.x 권장
# JDK 21: JMC 9.x 권장Code language: PHP (php)

3. JFR 녹화 시작하기 — 세 가지 방법

방법 1: JVM 시작 시 자동 녹화 설정 (운영 환경 권장)

java \
  -Xmx4g \
  -XX:+UseG1GC \
  # JFR 항상 켜두기: 지속 녹화 모드 (링 버퍼, 최신 1시간 데이터 유지)
  -XX:StartFlightRecording=\
    name=continuous,\
    maxage=1h,\
    maxsize=500m,\
    dumponexit=true,\
    filename=/var/log/jfr/exit_dump.jfr,\
    settings=profile \
  -jar app.jarCode language: PHP (php)
옵션설명
maxage=1h1시간치 데이터만 링 버퍼에 보관
maxsize=500m버퍼 최대 500MB
dumponexit=trueJVM 종료 시 자동으로 파일 저장
settings=profile상세 프로파일링 설정 (default보다 더 많은 이벤트 수집)

settings=default는 오버헤드 ~1%, settings=profile은 ~2%입니다. 운영 환경에서 처음엔 default로 시작하고, 문제 분석 시점에 profile로 전환하는 것을 권장합니다.

방법 2: jcmd로 필요한 순간에만 녹화 (가장 실용적)

# 실행 중인 JVM PID 확인
jps -l
# 출력: 12345 com.example.MySpringApplication

# 녹화 시작 (60초간 수집 후 자동 저장)
jcmd 12345 JFR.start \
  duration=60s \
  filename=/tmp/recording_$(date +%Y%m%d_%H%M%S).jfr \
  settings=profile \
  name=my-recording

# 출력: Started recording 1. ...

# 현재 녹화 상태 확인
jcmd 12345 JFR.check

# 녹화 중 즉시 덤프 (duration 끝나기 전에 현재까지 저장)
jcmd 12345 JFR.dump \
  name=my-recording \
  filename=/tmp/snapshot.jfr

# 녹화 중지
jcmd 12345 JFR.stop name=my-recordingCode language: PHP (php)

방법 3: Spring Boot 애플리케이션에서 프로그래밍 방식으로 제어

JFR API를 직접 사용하면 특정 HTTP 요청이나 이벤트가 발생했을 때 자동으로 녹화를 트리거할 수 있습니다.

import jdk.jfr.Recording;
import jdk.jfr.Configuration;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.stereotype.Component;

import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * 커스텀 Actuator 엔드포인트: JFR 녹화를 HTTP로 제어합니다.
 * GET  /actuator/jfr/start  → 녹화 시작
 * GET  /actuator/jfr/stop   → 녹화 중지 및 파일 저장
 */
@Component
@Endpoint(id = "jfr")
public class JfrActuatorEndpoint {

    private static final String JFR_OUTPUT_DIR = "/var/log/jfr";
    private Recording currentRecording;

    @ReadOperation
    public String startRecording() throws Exception {
        if (currentRecording != null && currentRecording.getState().name().equals("RUNNING")) {
            return "이미 녹화 중입니다.";
        }

        // 'profile' 설정으로 상세 데이터 수집
        Configuration config = Configuration.getConfiguration("profile");
        currentRecording = new Recording(config);
        currentRecording.setName("actuator-triggered-" +
            LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));

        currentRecording.start();
        return "JFR 녹화 시작: " + currentRecording.getName();
    }

    @WriteOperation
    public String stopRecording() throws Exception {
        if (currentRecording == null) {
            return "진행 중인 녹화가 없습니다.";
        }

        // 파일명에 타임스탬프 포함
        String filename = JFR_OUTPUT_DIR + "/" +
            currentRecording.getName() + ".jfr";
        Path outputPath = Paths.get(filename);

        currentRecording.stop();
        currentRecording.dump(outputPath);   // 파일로 저장
        currentRecording.close();
        currentRecording = null;

        return "JFR 녹화 저장 완료: " + filename;
    }
}Code language: JavaScript (javascript)
/**
 * 커스텀 JFR 이벤트: 비즈니스 로직과 JFR을 연동합니다.
 * JMC에서 이 이벤트를 필터링해 특정 API 호출의 성능을 분석할 수 있습니다.
 */
import jdk.jfr.Category;
import jdk.jfr.Event;
import jdk.jfr.Label;
import jdk.jfr.Name;

@Name("com.example.OrderProcessing")
@Label("주문 처리")
@Category({"Business", "Order"})
public class OrderProcessingEvent extends Event {

    @Label("주문 ID")
    public String orderId;

    @Label("처리 단계")
    public String phase;

    @Label("처리된 항목 수")
    public int itemCount;
}

// 사용 예시
@Service
public class OrderService {

    public void processOrder(Order order) {
        // JFR 이벤트 생성 및 시작
        OrderProcessingEvent event = new OrderProcessingEvent();
        event.orderId = order.getId();
        event.phase = "validation";
        event.begin();   // 타이머 시작

        try {
            validateOrder(order);
            event.phase = "payment";
            processPayment(order);
            event.itemCount = order.getItems().size();
        } finally {
            event.commit();  // 이벤트 기록 (JFR 버퍼에 저장)
        }
    }
}Code language: JavaScript (javascript)

4. JMC로 분석하기 — 주요 뷰 완전 정리

JFR 파일을 JMC로 열면 여러 분석 탭이 나타납니다. 초급~중급 개발자에게 가장 유용한 뷰를 중심으로 정리합니다.

JMC 분석 플로우.jfr 파일 열기 → Automated Analysis → 경고 항목 확인 → 이슈 유형별 뷰(Memory/Threads/Method Profiling)로 분기하는 분석 플로우.jfr 파일 열기JMC > File > Open FileAutomated Analysis자동 분석 리포트 — HIGH/MEDIUM/OK 색상 분류경고 항목 확인빨간색(HIGH) → 주황색(MEDIUM) 순서로 클릭Memory 이슈레이턴시 이슈CPU 이슈Memory 뷰Allocations 탭클래스별 할당량·스택 추적Threads / GC 뷰스레드 상태 타임라인BLOCKED/WAITING 원인 확인Method Profiling 뷰Flame GraphCPU 핫스팟 메서드 특정Automated Analysis가 여러 항목을 동시에 감지하면 HIGH 항목 중 본인 증상과 가장 가까운 뷰부터 진입

Automated Analysis — 자동 분석 리포트 읽기

JMC를 열면 가장 먼저 보이는 화면입니다. 색상으로 심각도를 표시합니다.

Automated Analysis 결과 예시:

HIGH     GC Pause Duration        평균 STW 890ms — 기준(200ms) 초과
MEDIUM   Memory Allocation Rate   초당 2.3GB 할당 — 이상치 감지
MEDIUM   Thread Blocking          "http-nio-exec-3" 스레드 18% 시간 블로킹
OK       Method Profiling         특이 사항 없음
OK       Exception Count          예외 발생 빈도 정상Code language: PHP (php)

→ 빨간색/주황색 항목부터 클릭해서 상세 뷰로 이동합니다.

Memory > Allocations 뷰 — 메모리 할당 핫스팟

"어떤 코드가 메모리를 가장 많이 만들어내고 있나"를 찾는 핵심 뷰입니다.

Allocations 뷰 예시:

Class                                   | Allocation (MB) | Count
----------------------------------------|-----------------|--------
byte[]                                  | 1,234 MB        | 8,234,123
java.lang.String                        | 891 MB          | 12,456,789
com.example.dto.ResponseDto             | 456 MB          | 3,234,567  <- 주목
char[]                                  | 234 MB          | 5,678,901

ResponseDto가 456MB를 할당하고 있다면 → Stack Trace 탭에서 어느 메서드에서 만들어지는지 확인:

Stack Trace for ResponseDto allocations:
  com.example.service.ReportService.generateReport(ReportService.java:145)  <- 원인
    com.example.controller.ReportController.getReport(ReportController.java:67)
      ...Code language: CSS (css)

Threads 뷰 — 스레드 병목 찾기

스레드가 실제로 "일하는 시간" vs "기다리는 시간"을 타임라인으로 보여줍니다.

Thread State 범례:
■ RUNNING     → 실제로 CPU에서 코드 실행 중 (녹색)
■ BLOCKED     → 모니터 락 대기 중 (빨간색) <- 주목
■ WAITINGObject.wait(), LockSupport.park() 대기 (주황색)
■ SLEEPINGThread.sleep() 중 (회색)Code language: CSS (css)
// BLOCKED 시간이 길게 나온다면 이런 코드를 의심
@Service
public class ProblemService {

    // synchronized 메서드: 한 번에 하나의 스레드만 진입 가능
    // 트래픽이 많을 때 나머지 스레드는 BLOCKED 상태로 대기
    public synchronized Report generateReport(String userId) {
        // 처리 시간이 긴 작업...
        return heavyCalculation(userId);
    }
}

// 개선: 필요한 부분만 최소한으로 동기화
@Service
public class ImprovedService {

    private final ReentrantLock lock = new ReentrantLock();

    public Report generateReport(String userId) {
        // 락 없이 처리 가능한 부분 먼저 실행
        Data data = fetchData(userId);      // IO 작업 — 락 불필요

        lock.lock();
        try {
            return heavyCalculation(data);  // 실제로 공유 자원 접근하는 부분만 락
        } finally {
            lock.unlock();
        }
    }
}Code language: PHP (php)

5. 실전 케이스 — JFR로 문제 찾고 해결하기

케이스 1: 특정 시간대에 CPU가 순간적으로 치솟는 문제

증상: 매일 오전 10시~11시 사이 CPU 사용률 90% 이상 급등
GC 로그: 이상 없음, Heap Dump: 특이 사항 없음
→ JFR으로 분석 시작
# 오전 9시 55분부터 1시간 녹화 시작
jcmd 12345 JFR.start \
  duration=3600s \
  filename=/tmp/cpu_spike_$(date +%Y%m%d).jfr \
  settings=profileCode language: PHP (php)

JMC Method Profiling(Flame Graph) 뷰에서 확인:

Flame Graph 분석 결과:
전체 CPU 시간의 67%가 아래 호출 스택에 집중:

com.example.batch.DailyReportBatch.run()
  └─ com.example.service.StatisticsService.calculateAllUsers()  <- 67% 점유
       └─ com.example.repository.UserRepository.findAll()       <- DB 전체 조회
            └─ (JDBC) SELECT * FROM users                        <- 인덱스 없는 풀스캔Code language: CSS (css)

해결:

// 문제 코드: 전체 유저를 메모리에 올린 후 Java에서 필터링
@Service
public class StatisticsService {

    public Statistics calculateAllUsers() {
        List<User> allUsers = userRepository.findAll();  // 100만 건 전체 로드
        return allUsers.stream()
            .filter(u -> u.isActive())
            .collect(...);
    }
}

// 개선: DB에서 필터링해서 가져오기 + 페이징
@Service
public class StatisticsService {

    public Statistics calculateAllUsers() {
        // DB 레벨에서 필터링, 청크 단위로 처리
        return userRepository.streamActiveUsers()
            .collect(statisticsCollector());
    }
}

// Repository
public interface UserRepository extends JpaRepository<User, Long> {

    // Spring Data JPA Stream 사용: 한 번에 메모리에 올리지 않음
    @Query("SELECT u FROM User u WHERE u.active = true")
    @QueryHints(value = @QueryHint(name = HINT_FETCH_SIZE, value = "1000"))
    Stream<User> streamActiveUsers();
}Code language: PHP (php)

케이스 2: 메모리 사용량이 서서히 증가하는 문제 (Heap Dump로 못 잡은 경우)

증상: Heap 사용량이 하루에 500MB씩 서서히 증가
Heap Dump 분석: 특정 누수 객체 특정 어려움
→ JFR Allocation 뷰로 분석

JMC Memory > Allocations 뷰 + TLAB Allocations 확인:

Object Allocation Outside TLAB (큰 객체 할당):

Class                        | Total Size | Count
-----------------------------|------------|------
byte[] (> 256KB)             | 45 GB      | 180,000회  <- 6시간 기준
  Top Allocator:
  com.example.export.ExcelExporter.generateSheet() — 45GB 중 92%Code language: JavaScript (javascript)

해결:

// 문제 코드: 엑셀 시트를 통째로 byte[]로 만들어 메모리에 보관
@Service
public class ExcelExporter {

    public byte[] generateReport(List<Data> data) {
        Workbook workbook = new XSSFWorkbook();  // 전체를 메모리에 생성
        Sheet sheet = workbook.createSheet();
        // 수만 행 작성...
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        workbook.write(out);
        return out.toByteArray();  // 수백MB byte[] 반환
    }
}

// 개선: SXSSFWorkbook(스트리밍 모드)로 메모리 사용량 최소화
@Service
public class ExcelExporter {

    public void generateReport(List<Data> data, OutputStream outputStream) throws IOException {
        // SXSSFWorkbook: 100행만 메모리에 유지, 나머지는 임시 파일로
        try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
            Sheet sheet = workbook.createSheet();
            for (int i = 0; i < data.size(); i++) {
                Row row = sheet.createRow(i);
                fillRow(row, data.get(i));
            }
            workbook.write(outputStream);  // 스트림으로 직접 출력
        }
        // try-with-resources로 임시 파일 자동 정리
    }
}Code language: PHP (php)

케이스 3: 특정 API만 응답이 느린 문제

증상: /api/v1/search 엔드포인트만 p99 3000ms
다른 API는 정상, DB 슬로우 쿼리도 없음
→ JFR Threads + Method Profiling 조합으로 분석Code language: JavaScript (javascript)
# /api/v1/search 부하를 발생시키면서 30초 녹화
jcmd 12345 JFR.start duration=30s filename=/tmp/search_slow.jfr settings=profile
# (부하 발생)
# 자동 저장 후 JMC로 분석Code language: PHP (php)

Threads 뷰에서 "http-nio-exec-*" 스레드들의 상태 확인:

Thread 상태 분포 (30초 중):
  RUNNING  : 12%   <- 실제 작업 시간이 적음
  WAITING  : 88%   <- 대부분 대기 중

WAITING 원인 (Stack Trace):
  java.util.concurrent.ForkJoinPool.awaitWork()
    com.example.search.SearchService.search()
      → CompletableFuture.allOf(futures).join()  <- 비동기 결과를 블로킹으로 대기
         → 외부 API 3개를 순차 호출 중 (실제로는 병렬 호출이지만 가장 느린 것에 종속)Code language: CSS (css)

해결:

// 문제 코드: 외부 API 3개를 각각 비동기로 호출하지만
//    타임아웃 없이 모든 결과를 기다림
@Service
public class SearchService {

    public SearchResult search(String query) throws Exception {
        CompletableFuture<ResultA> futureA = CompletableFuture.supplyAsync(() -> apiA.search(query));
        CompletableFuture<ResultB> futureB = CompletableFuture.supplyAsync(() -> apiB.search(query));
        CompletableFuture<ResultC> futureC = CompletableFuture.supplyAsync(() -> apiC.search(query));

        // 타임아웃 없음 — 하나가 느리면 전체가 느려짐
        CompletableFuture.allOf(futureA, futureB, futureC).join();
        return merge(futureA.get(), futureB.get(), futureC.get());
    }
}

// 개선: 타임아웃 설정 + 부분 결과 허용
@Service
public class SearchService {

    public SearchResult search(String query) throws Exception {
        CompletableFuture<ResultA> futureA = CompletableFuture
            .supplyAsync(() -> apiA.search(query))
            .orTimeout(500, TimeUnit.MILLISECONDS)       // 500ms 타임아웃
            .exceptionally(ex -> ResultA.empty());       // 실패 시 빈 결과 반환

        CompletableFuture<ResultB> futureB = CompletableFuture
            .supplyAsync(() -> apiB.search(query))
            .orTimeout(500, TimeUnit.MILLISECONDS)
            .exceptionally(ex -> ResultB.empty());

        CompletableFuture<ResultC> futureC = CompletableFuture
            .supplyAsync(() -> apiC.search(query))
            .orTimeout(500, TimeUnit.MILLISECONDS)
            .exceptionally(ex -> ResultC.empty());

        // 전체 완료 대기 (각각 타임아웃 있으므로 최대 500ms 대기)
        CompletableFuture.allOf(futureA, futureB, futureC).join();
        return merge(futureA.get(), futureB.get(), futureC.get());
    }
}Code language: PHP (php)

6. 커스텀 JFR 이벤트 — 비즈니스 로직과 JVM 지표 연결

JFR에 직접 커스텀 이벤트를 심으면, JVM 레벨 데이터와 비즈니스 로직을 JMC 하나에서 연결해서 볼 수 있습니다.

import jdk.jfr.*;

/**
 * HTTP 요청 처리 시간을 JFR로 기록하는 커스텀 이벤트
 * JMC에서 GC 이벤트와 같은 타임라인에서 확인 가능
 */
@Name("com.example.HttpRequest")
@Label("HTTP 요청")
@Category("Application")
@StackTrace(false)   // 스택 트레이스 생략 (오버헤드 감소)
public class HttpRequestEvent extends Event {

    @Label("HTTP 메서드")
    public String method;

    @Label("요청 경로")
    public String path;

    @Label("HTTP 상태 코드")
    public int statusCode;

    @Label("처리 시간 (ms)")
    public long durationMs;
}

// Spring Interceptor에서 모든 요청 자동 기록
@Component
public class JfrHttpInterceptor implements HandlerInterceptor {

    private static final String EVENT_ATTR = "jfr-event";

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) {
        HttpRequestEvent event = new HttpRequestEvent();
        event.begin();   // 타이머 시작
        event.method = request.getMethod();
        event.path = request.getRequestURI();
        request.setAttribute(EVENT_ATTR, event);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response, Object handler, Exception ex) {
        HttpRequestEvent event = (HttpRequestEvent) request.getAttribute(EVENT_ATTR);
        if (event != null && event.shouldCommit()) {  // JFR 임계값 이상일 때만 기록
            event.statusCode = response.getStatus();
            event.durationMs = /* 경과 시간 계산 */;
            event.commit();  // JFR 버퍼에 기록
        }
    }
}Code language: PHP (php)

이렇게 하면 JMC에서 "GC Full GC 발생 → 그 직전에 어떤 HTTP 요청이 들어왔나" 같은 상관관계를 타임라인에서 직접 확인할 수 있습니다.


7. 운영 환경 JFR 파이프라인 자동화

JFR 자동 수집 파이프라인알람 트리거 → JFR 자동 덤프 → JMC 분석 또는 jfr CLI → Slack 알림으로 이어지는 파이프라인CPU > 90%p99 레이턴시 급등Heap 사용률 > 80%트리거 조건JFR 자동 덤프jcmd JFR.dumpauto_dump.jfr 저장JMC GUI 분석수동 딥다이브jfr CLI 리포트자동 요약 출력Slack알림 발송파일 경로
#!/bin/bash
# jfr-auto-dump.sh — 알람 발생 시 JFR 자동 덤프 + Slack 알림

PID=$(pgrep -f "app.jar")
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
JFR_FILE="/var/log/jfr/auto_dump_${TIMESTAMP}.jfr"
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK"

# 현재까지 링 버퍼에 쌓인 데이터 즉시 덤프
jcmd $PID JFR.dump filename="$JFR_FILE"

if [ $? -eq 0 ]; then
    FILE_SIZE=$(du -sh "$JFR_FILE" | cut -f1)

    curl -s -X POST "$SLACK_WEBHOOK" \
         -H "Content-Type: application/json" \
         -d "{
           \"text\": \"*JFR 자동 덤프 완료*\n서버: $(hostname)\n시각: ${TIMESTAMP}\n파일: ${JFR_FILE} (${FILE_SIZE})\n\n분석 방법: JMC > File > Open File\"
         }"
else
    echo "JFR 덤프 실패 (JFR 활성화 여부 확인 필요)"
fiCode language: PHP (php)

8. 추가 고려사항

추가로 알아두면 좋은 내용들입니다.

jfr CLI 도구 — JMC 없이 터미널에서 분석

Java 14부터 jfr 커맨드라인 도구가 추가되었습니다. 서버에서 바로 요약 정보를 볼 수 있습니다.

# .jfr 파일 요약 정보 출력
jfr summary /tmp/recording.jfr

# 특정 이벤트만 필터링해서 출력
jfr print --events jdk.GCPhasePause /tmp/recording.jfr

# STW 시간만 추출
jfr print --events jdk.GCPhasePause --json /tmp/recording.jfr \
  | jq '.recordings[].events[] | {start: .startTime, duration: .duration}'Code language: PHP (php)

Continuous JFR + 이상 감지 자동화

Java 14에 추가된 RecordingStream API를 사용하면 JFR 이벤트를 실시간 스트림으로 처리할 수 있습니다. 특정 조건(예: GC Pause > 500ms)이 감지되면 즉시 덤프를 트리거하는 등 정밀한 자동화가 가능합니다.

@PostConstruct
public void startJfrMonitoring() {
    Thread.ofVirtual().start(() -> {  // Java 21 Virtual Thread 활용
        try (RecordingStream rs = new RecordingStream()) {
            rs.enable("jdk.GCPhasePause").withThreshold(Duration.ofMillis(200));

            rs.onEvent("jdk.GCPhasePause", event -> {
                double pauseMs = event.getDuration("duration").toMillis();
                if (pauseMs > 500) {
                    log.warn("[JFR] GC Pause {}ms 감지 — 자동 덤프 트리거", pauseMs);
                    triggerJfrDump();  // 조건 충족 시 즉시 덤프
                }
            });

            rs.start();  // 블로킹 — Virtual Thread이므로 플랫폼 스레드 점유 없음
        }
    });
}Code language: JavaScript (javascript)

👉 Java 21 Virtual Thread 완전 정리 — 메모리 모델, Pinning, Spring Boot 적용

👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기


9. 마무리

이번 글을 세 줄로 정리합니다.

  1. JFR은 오버헤드 1~2%로 운영 서버에서 상시 활성화 가능한 JDK 공식 프로파일러로, GC 로그만으로는 찾기 어려운 메모리 할당 핫스팟과 스레드 병목을 찾는 데 강력합니다.
  2. JMC의 Automated Analysis → 문제 뷰(Memory/Threads/Method Profiling) 순으로 좁혀가는 분석 플로우가 가장 효율적이며, 커스텀 JFR 이벤트로 비즈니스 로직과 JVM 이벤트를 같은 타임라인에서 볼 수 있습니다.
  3. dumponexit=true로 항상 링 버퍼를 유지해두고, 알람 발생 시 jcmd JFR.dump로 즉시 덤프하는 파이프라인을 구축해두면 "재현 불가" 문제도 사후 분석이 가능합니다.

다음 글에서는 Spring Boot + Micrometer로 JVM 메모리 대시보드를 직접 구축하는 방법을 다룹니다. 지금까지 다룬 GC 로그, Heap Dump, JFR 분석을 하나의 Grafana 대시보드에서 모니터링하는 파이프라인을 완성해 보겠습니다.

Y

yshyuk

Java 백엔드 개발자 | Spring, AWS, DevOps
2020년부터 Java/Spring boot 서버 개발자로 일하면서, 인프라(AWS/NCP), DevOps 업무를 병행하였고, 현재는 OpenSource 기여에도 관심을 가지고 있습니다.

조회수: 0