GC 로그와 Heap Dump로도 원인을 못 찾을 때가 있습니다. "메모리는 충분한데 응답이 느리다", "특정 시간대에만 CPU가 튄다" 같은 상황이 그렇습니다. 이 글에서는 JDK 공식 프로파일링 도구인 JFR과 JMC를 사용해 운영 서버에서 메모리 할당 핫스팟, 스레드 병목, GC 원인을 실시간으로 찾는 방법을 정리합니다.
👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기
Table of Contents
1. JFR이 뭔가요? — 기초부터
JDK Flight Recorder(JFR) 는 JVM 내부에 내장된 공식 프로파일링/진단 프레임워크입니다. Java 11부터 OpenJDK에 무료로 포함되었고, Java 8u262 이후 버전에서도 사용 가능합니다.
이름이 "Flight Recorder"인 이유가 있습니다. 비행기 블랙박스처럼 JVM이 동작하는 동안 항상 데이터를 기록해두고, 문제가 생겼을 때 꺼내서 분석하는 방식이기 때문입니다.
JFR vs 다른 프로파일링 도구 비교
도구 오버헤드 운영 사용 특징 JFR 1~2% 권장 JDK 내장, 별도 설치 불필요 VisualVM 5~15% 비권장 개발/로컬 분석 용도 Async-Profiler 2~5% 주의 정밀도 높음, 별도 설치 필요 Datadog Profiler 2~5% 가능 APM 통합, 유료 Heap Dump 분석 순간 중단 주의 특정 시점 스냅샷만 가능
JDK Mission Control(JMC) 은 JFR로 기록된 .jfr 파일을 열어서 분석하는 GUI 도구입니다. JFR이 블랙박스 녹화기라면, JMC는 그 녹화 파일을 재생하는 플레이어입니다.
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=1h | 1시간치 데이터만 링 버퍼에 보관 |
maxsize=500m | 버퍼 최대 500MB |
dumponexit=true | JVM 종료 시 자동으로 파일 저장 |
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로 열면 여러 분석 탭이 나타납니다. 초급~중급 개발자에게 가장 유용한 뷰를 중심으로 정리합니다.
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 → 모니터 락 대기 중 (빨간색) <- 주목
■ WAITING → Object.wait(), LockSupport.park() 대기 (주황색)
■ SLEEPING → Thread.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 파이프라인 자동화
#!/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. 마무리
이번 글을 세 줄로 정리합니다.
- JFR은 오버헤드 1~2%로 운영 서버에서 상시 활성화 가능한 JDK 공식 프로파일러로, GC 로그만으로는 찾기 어려운 메모리 할당 핫스팟과 스레드 병목을 찾는 데 강력합니다.
- JMC의 Automated Analysis → 문제 뷰(Memory/Threads/Method Profiling) 순으로 좁혀가는 분석 플로우가 가장 효율적이며, 커스텀 JFR 이벤트로 비즈니스 로직과 JVM 이벤트를 같은 타임라인에서 볼 수 있습니다.
dumponexit=true로 항상 링 버퍼를 유지해두고, 알람 발생 시jcmd JFR.dump로 즉시 덤프하는 파이프라인을 구축해두면 "재현 불가" 문제도 사후 분석이 가능합니다.
다음 글에서는 Spring Boot + Micrometer로 JVM 메모리 대시보드를 직접 구축하는 방법을 다룹니다. 지금까지 다룬 GC 로그, Heap Dump, JFR 분석을 하나의 Grafana 대시보드에서 모니터링하는 파이프라인을 완성해 보겠습니다.
조회수: 0