GC 로그, Heap Dump, JFR로 문제를 사후에 분석하는 방법은 이제 알게 되었습니다. 이번 글에서는 한 발 앞서서, 문제가 생기기 전에 이상 징후를 포착하는 실시간 JVM 모니터링 대시보드를 직접 구축합니다. Spring Boot Actuator → Micrometer → Prometheus → Grafana로 이어지는 파이프라인 전체를 처음부터 끝까지 구성하고, 이 시리즈에서 다뤘던 Off-Heap, GC, Virtual Thread 지표를 하나의 화면에서 볼 수 있도록 만들어 봅니다.
👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기
👉 Java 21 Virtual Thread 완전 정리 — 메모리 모델, Pinning, Spring Boot 적용
Table of Contents
1. 전체 구조 이해하기
본격적으로 설정하기 전에 각 컴포넌트가 어떤 역할을 하는지 먼저 파악합니다.
각 컴포넌트 역할 한 줄 요약
컴포넌트 역할 Micrometer JVM 내부 수치를 측정해 다양한 모니터링 시스템 형식으로 변환하는 측정 파사드 Spring Boot Actuator 앱 내부 상태를 HTTP 엔드포인트로 노출 ( /actuator/prometheus등)Prometheus 15초 간격으로 /actuator/prometheus에서 메트릭을 당겨(pull) 시계열 DB에 저장Grafana Prometheus에 PromQL로 쿼리해 그래프로 시각화, 알람 규칙 설정
2. Spring Boot 의존성 및 설정
build.gradle / pom.xml 의존성 추가
// build.gradle
dependencies {
// Spring Boot Actuator: /actuator/* 엔드포인트 활성화
implementation 'org.springframework.boot:spring-boot-starter-actuator'
// Micrometer Prometheus 레지스트리: /actuator/prometheus 엔드포인트 활성화
implementation 'io.micrometer:micrometer-registry-prometheus'
// (선택) AOP 기반 메서드 타이밍 측정 시 필요
implementation 'org.springframework.boot:spring-boot-starter-aop'
}Code language: JavaScript (javascript)
<!-- pom.xml -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
</dependencies>Code language: HTML, XML (xml)
application.yml 설정
# application.yml
management:
endpoints:
web:
exposure:
# prometheus, health는 최소한으로 노출
# 운영 환경에서는 네트워크 레벨로 외부 접근 차단 권장
include: prometheus, health, info, metrics
endpoint:
prometheus:
enabled: true
health:
show-details: when-authorized
metrics:
# JVM 관련 메트릭 전체 활성화
enable:
jvm: true
process: true
system: true
tomcat: true
# 모든 메트릭에 공통 태그 추가 (Grafana에서 필터링에 사용)
tags:
application: ${spring.application.name}
environment: ${spring.profiles.active:local}
# Prometheus 히스토그램 활성화 (p99 레이턴시 계산에 필요)
observations:
http:
server:
requests:
enabled: true
spring:
application:
name: my-spring-app
# Virtual Thread 활성화 (Java 21 + Spring Boot 3.2)
threads:
virtual:
enabled: trueCode language: PHP (php)
설정 확인
# 앱 시작 후 메트릭 엔드포인트 확인
curl http://localhost:8080/actuator/prometheus | head -50
# 출력 예시:
# HELP jvm_memory_used_bytes The amount of used memory
# TYPE jvm_memory_used_bytes gauge
# jvm_memory_used_bytes{application="my-spring-app",area="heap",id="G1 Eden Space",} 1.23456789E8
# jvm_memory_used_bytes{application="my-spring-app",area="heap",id="G1 Old Gen",} 5.67890123E8
# jvm_memory_used_bytes{application="my-spring-app",area="nonheap",id="Metaspace",} 8.9012345E7
# ...Code language: PHP (php)
3. 커스텀 비즈니스 메트릭 추가
JVM 기본 메트릭 외에도 비즈니스 로직과 연결된 커스텀 메트릭을 추가하면 "주문 처리 속도가 느려질 때 GC가 같이 증가하는가?" 같은 상관관계 분석이 가능해집니다.
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.concurrent.atomic.AtomicInteger;
@Service
@Slf4j
public class OrderMetricsService {
// 주문 처리 타이머 (p50, p95, p99 자동 계산)
private final Timer orderProcessingTimer;
// 주문 성공/실패 카운터
private final Counter orderSuccessCounter;
private final Counter orderFailureCounter;
// 현재 처리 중인 주문 수 (실시간 Gauge)
private final AtomicInteger activeOrders = new AtomicInteger(0);
public OrderMetricsService(MeterRegistry registry) {
// Timer: 처리 시간 측정, 백분위 히스토그램 활성화
this.orderProcessingTimer = Timer.builder("order.processing.duration")
.description("주문 처리 소요 시간")
.tag("service", "order")
.publishPercentiles(0.5, 0.95, 0.99) // p50, p95, p99 계산
.publishPercentileHistogram(true) // Prometheus 히스토그램 형식
.register(registry);
// Counter: 단조 증가 카운터
this.orderSuccessCounter = Counter.builder("order.processed.total")
.description("처리 완료된 주문 총 수")
.tag("status", "success")
.register(registry);
this.orderFailureCounter = Counter.builder("order.processed.total")
.description("처리 실패한 주문 총 수")
.tag("status", "failure")
.register(registry);
// Gauge: 현재 값을 실시간으로 반영
Gauge.builder("order.active.count", activeOrders, AtomicInteger::get)
.description("현재 처리 중인 주문 수")
.register(registry);
}
public Order processOrder(OrderRequest request) {
activeOrders.incrementAndGet(); // 처리 시작: +1
return orderProcessingTimer.record(() -> { // 실행 시간 자동 측정
try {
Order result = doProcessOrder(request);
orderSuccessCounter.increment();
return result;
} catch (Exception e) {
orderFailureCounter.increment();
throw e;
} finally {
activeOrders.decrementAndGet(); // 처리 완료: -1
}
});
}
}Code language: PHP (php)
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.stereotype.Component;
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.util.List;
/**
* Off-Heap (DirectByteBuffer) 사용량을 Micrometer 메트릭으로 노출
* 이전 글에서 다뤘던 DirectByteBuffer 누수를 대시보드에서 모니터링
*/
@Component
public class OffHeapMetricsRegistrar {
public OffHeapMetricsRegistrar(MeterRegistry registry) {
List<BufferPoolMXBean> pools =
ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class);
for (BufferPoolMXBean pool : pools) {
String poolName = pool.getName(); // "direct" 또는 "mapped"
// Direct Buffer 사용량 (bytes)
io.micrometer.core.instrument.Gauge.builder(
"jvm.buffer.offheap.used",
pool,
p -> (double) p.getMemoryUsed()
)
.tag("pool", poolName)
.description("Off-Heap " + poolName + " 버퍼 사용량 (bytes)")
.register(registry);
// Direct Buffer 개수
io.micrometer.core.instrument.Gauge.builder(
"jvm.buffer.offheap.count",
pool,
p -> (double) p.getCount()
)
.tag("pool", poolName)
.description("Off-Heap " + poolName + " 버퍼 개수")
.register(registry);
}
}
}Code language: JavaScript (javascript)
4. Prometheus + Grafana Docker Compose 구성
개발/스테이징 환경에서 빠르게 구성하는 방법입니다.
# docker-compose.monitoring.yml
version: '3.8'
services:
# Prometheus: 메트릭 수집 및 저장
prometheus:
image: prom/prometheus:v2.50.0
container_name: prometheus
ports:
- "9090:9090"
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- ./monitoring/alert-rules.yml:/etc/prometheus/alert-rules.yml
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=15d' # 15일치 데이터 보관
- '--web.enable-lifecycle' # 설정 파일 핫 리로드 가능
# Grafana: 시각화 대시보드
grafana:
image: grafana/grafana:10.3.0
container_name: grafana
ports:
- "3000:3000"
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin123 # 실제 운영 시 변경 필수
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
prometheus_data:
grafana_data:Code language: PHP (php)
# monitoring/prometheus.yml
global:
scrape_interval: 15s # 15초마다 메트릭 수집
evaluation_interval: 15s # 알람 규칙 평가 주기
# 알람 규칙 파일 참조
rule_files:
- "alert-rules.yml"
# 알람을 받을 Alertmanager (선택)
# alerting:
# alertmanagers:
# - static_configs:
# - targets: ['alertmanager:9093']
scrape_configs:
# Spring Boot 앱 메트릭 수집
- job_name: 'spring-boot-app'
metrics_path: '/actuator/prometheus'
scrape_interval: 15s
static_configs:
- targets:
- 'host.docker.internal:8080' # 로컬 개발 시
# - 'app-server-1:8080' # 운영 서버 IP로 교체
# - 'app-server-2:8080'
# 기본 인증이 필요한 경우
# basic_auth:
# username: 'actuator-user'
# password: 'actuator-pass'Code language: PHP (php)
# monitoring/alert-rules.yml — 알람 규칙 정의
groups:
- name: jvm-alerts
rules:
# Heap Old Gen 사용률 85% 초과 10분 지속 → 경고
- alert: HeapOldGenHighUsage
expr: |
(
jvm_memory_used_bytes{area="heap", id=~".*[Oo]ld.*"}
/ jvm_memory_max_bytes{area="heap", id=~".*[Oo]ld.*"}
) > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "Heap Old Gen 사용률 85% 초과 ({{ $labels.application }})"
description: "OOM 발생 전 Heap Dump 수집 또는 GC 튜닝이 필요합니다."
# GC Pause 시간 평균 500ms 초과 5분 지속 → 경고
- alert: GCPauseTimeTooLong
expr: |
rate(jvm_gc_pause_seconds_sum[5m])
/ rate(jvm_gc_pause_seconds_count[5m]) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "GC Pause 평균 500ms 초과 ({{ $labels.application }})"
description: "GC 로그 분석 또는 GC 알고리즘 전환을 검토하세요."
# Direct Buffer 사용량 1GB 초과 → 경고 (Off-Heap 누수 감지)
- alert: DirectBufferHighUsage
expr: |
jvm_buffer_offheap_used{pool="direct"} > 1073741824
for: 5m
labels:
severity: warning
annotations:
summary: "Direct Buffer 사용량 1GB 초과 ({{ $labels.application }})"
description: "Off-Heap 메모리 누수를 의심하세요. DirectByteBuffer 분석이 필요합니다."
# 스레드 수 급증 → 경고 (스레드 누수 감지)
- alert: ThreadCountHighUsage
expr: jvm_threads_live_threads > 500
for: 5m
labels:
severity: warning
annotations:
summary: "JVM 스레드 수 500개 초과 ({{ $labels.application }})"
description: "스레드 누수 또는 스레드풀 설정을 점검하세요."Code language: PHP (php)
5. Grafana 데이터소스 및 대시보드 프로비저닝
# monitoring/grafana/datasources/prometheus.yml
# Grafana 시작 시 자동으로 Prometheus 데이터소스 등록
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
url: http://prometheus:9090
isDefault: true
editable: falseCode language: PHP (php)
# monitoring/grafana/dashboards/dashboard.yml
# 대시보드 JSON 파일 자동 로드 설정
apiVersion: 1
providers:
- name: JVM Dashboards
type: file
options:
path: /etc/grafana/provisioning/dashboardsCode language: PHP (php)
6. PromQL 핵심 쿼리 모음 — 패널 구성하기
Grafana 패널을 구성할 때 사용하는 PromQL 쿼리입니다. 각 쿼리를 패널에 붙여넣기만 하면 됩니다.
Heap 메모리 패널
# Heap 영역별 사용량 (MB 단위 변환)
jvm_memory_used_bytes{application="$app", area="heap"}
/ 1024 / 1024
# Heap 사용률 (%)
(
sum(jvm_memory_used_bytes{application="$app", area="heap"})
/
sum(jvm_memory_max_bytes{application="$app", area="heap"})
) * 100
# Old Gen 사용률 (OOM 예측에 핵심)
(
jvm_memory_used_bytes{application="$app", id=~".*[Oo]ld.*"}
/
jvm_memory_max_bytes{application="$app", id=~".*[Oo]ld.*"}
) * 100Code language: PHP (php)
GC 패널
# GC 발생 횟수 (분당)
rate(jvm_gc_pause_seconds_count{application="$app"}[1m]) * 60
# GC 평균 Pause 시간 (ms)
(
rate(jvm_gc_pause_seconds_sum{application="$app"}[5m])
/
rate(jvm_gc_pause_seconds_count{application="$app"}[5m])
) * 1000
# GC 종류별 Pause 시간 비교
rate(jvm_gc_pause_seconds_sum{application="$app"}[5m])
by (action, cause)Code language: PHP (php)
Off-Heap (DirectByteBuffer) 패널
# Direct Buffer 사용량 (MB)
jvm_buffer_offheap_used{application="$app", pool="direct"}
/ 1024 / 1024
# Direct Buffer 개수 (누수 시 지속 증가)
jvm_buffer_offheap_count{application="$app", pool="direct"}Code language: PHP (php)
스레드 패널
# 전체 라이브 스레드 수
jvm_threads_live_threads{application="$app"}
# 상태별 스레드 수 (RUNNABLE, BLOCKED, WAITING 등)
jvm_threads_states_threads{application="$app"}
# 데몬 스레드 비율
jvm_threads_daemon_threads{application="$app"}
/ jvm_threads_live_threads{application="$app"} * 100Code language: PHP (php)
HTTP 요청 성능 패널
# 초당 요청 수 (RPS)
rate(http_server_requests_seconds_count{application="$app"}[1m])
# p99 응답 시간 (ms)
histogram_quantile(0.99,
rate(http_server_requests_seconds_bucket{application="$app"}[5m])
) * 1000
# 엔드포인트별 p99 응답 시간
histogram_quantile(0.99,
rate(http_server_requests_seconds_bucket{
application="$app",
uri!~"/actuator.*" # Actuator 엔드포인트 제외
}[5m])
) by (uri, method) * 1000
# 에러율 (4xx + 5xx 비율)
sum(rate(http_server_requests_seconds_count{
application="$app", status=~"[45].."
}[5m]))
/
sum(rate(http_server_requests_seconds_count{
application="$app"
}[5m])) * 100Code language: PHP (php)
비즈니스 메트릭 패널
# 주문 처리 초당 성공/실패 건수
rate(order_processed_total{application="$app"}[1m])
# 주문 처리 p99 시간 (ms)
histogram_quantile(0.99,
rate(order_processing_duration_seconds_bucket{application="$app"}[5m])
) * 1000
# 현재 처리 중인 주문 수
order_active_count{application="$app"}Code language: PHP (php)
7. Grafana 대시보드 레이아웃 구성
8. Grafana 대시보드 JSON 핵심 패널 예시
직접 Grafana UI에서 패널을 만드는 대신, JSON으로 임포트할 수 있습니다. 아래는 Heap 사용률 패널과 GC Pause 패널의 JSON 예시입니다.
{
"title": "JVM Memory Dashboard",
"panels": [
{
"title": "Heap Old Gen 사용률 (%)",
"type": "timeseries",
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
"targets": [
{
"expr": "(jvm_memory_used_bytes{application=\"$app\", id=~\".*[Oo]ld.*\"} / jvm_memory_max_bytes{application=\"$app\", id=~\".*[Oo]ld.*\"}) * 100",
"legendFormat": "Old Gen 사용률"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": {
"steps": [
{ "color": "green", "value": 0 },
{ "color": "yellow", "value": 70 },
{ "color": "red", "value": 85 }
]
}
}
},
"options": {
"tooltip": { "mode": "multi" }
}
},
{
"title": "GC Pause 시간 (ms)",
"type": "timeseries",
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
"targets": [
{
"expr": "rate(jvm_gc_pause_seconds_sum{application=\"$app\"}[5m]) / rate(jvm_gc_pause_seconds_count{application=\"$app\"}[5m]) * 1000",
"legendFormat": "평균 GC Pause (ms)"
}
],
"fieldConfig": {
"defaults": {
"unit": "ms",
"thresholds": {
"steps": [
{ "color": "green", "value": 0 },
{ "color": "yellow", "value": 200 },
{ "color": "red", "value": 500 }
]
}
}
}
}
],
"templating": {
"list": [
{
"name": "app",
"type": "query",
"query": "label_values(jvm_memory_used_bytes, application)",
"label": "Application"
}
]
}
}Code language: JSON / JSON with Comments (json)
Grafana 커뮤니티 대시보드 ID 4701 (JVM Micrometer)를 임포트하면 기본 JVM 대시보드를 빠르게 시작점으로 활용할 수 있습니다.
Dashboards > Import > ID 4701입력 후 본 글의 패널을 추가하는 방식을 권장합니다.
9. 알람 → Slack 연동
# monitoring/alertmanager.yml (Alertmanager 사용 시)
global:
slack_api_url: 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
route:
group_by: ['alertname', 'application']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'slack-jvm-alerts'
receivers:
- name: 'slack-jvm-alerts'
slack_configs:
- channel: '#jvm-alerts'
title: 'JVM 알람: {{ .GroupLabels.alertname }}'
text: |
*애플리케이션*: {{ .GroupLabels.application }}
*심각도*: {{ .CommonLabels.severity }}
{{ range .Alerts }}
*요약*: {{ .Annotations.summary }}
*설명*: {{ .Annotations.description }}
*시각*: {{ .StartsAt.Format "2006-01-02 15:04:05" }}
{{ end }}
send_resolved: true # 알람 해소 시에도 알림Code language: PHP (php)
또는 Grafana 자체 알람 기능을 사용할 수도 있습니다 (Alertmanager 불필요).
# Grafana Contact Point 설정 (Grafana UI에서 설정 가능)
# Alerting > Contact points > Add contact point
# Type: Slack
# Webhook URL: https://hooks.slack.com/services/...Code language: PHP (php)
10. 이 시리즈에서 다룬 지표 전체 정리
지금까지 이 시리즈에서 다룬 모든 문제와 그것을 감지하는 메트릭을 정리합니다.
11. 추가 고려사항
Prometheus 메트릭 카디널리티 주의
태그(label)의 값 조합이 너무 많으면 Prometheus 메모리 사용량이 폭발합니다. 예를 들어 userId를 태그로 쓰면 사용자 수만큼 시계열이 생성됩니다.
// 카디널리티 폭발: userId를 태그로 사용
Counter.builder("order.processed.total")
.tag("userId", userId) // 사용자 수 = 시계열 수
.register(registry);
// 집계 가능한 태그만 사용
Counter.builder("order.processed.total")
.tag("userType", user.getType()) // "PREMIUM" / "STANDARD" 등 고정값
.register(registry);Code language: JavaScript (javascript)
Micrometer Tracing — 분산 추적 연동
Spring Boot 3.x와 Micrometer Tracing을 조합하면 Zipkin이나 Jaeger로 요청 흐름을 분산 추적할 수 있습니다. 특정 요청의 어느 구간에서 시간이 소요되는지 TraceId로 추적할 수 있어 JVM 메트릭과 함께 사용하면 강력합니다.
<!-- 분산 추적 의존성 추가 -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>Code language: HTML, XML (xml)
운영 환경 보안
/actuator/prometheus를 외부에 노출할 때는 반드시 보안 처리를 해야 합니다.
# 방법 1: 별도 포트에서만 Actuator 노출 (권장)
management:
server:
port: 9090 # 앱 포트(8080)와 분리, 방화벽으로 외부 차단
endpoints:
web:
exposure:
include: prometheus, healthCode language: PHP (php)
👉 Java Off-Heap 메모리와 DirectByteBuffer 누수 탐지 실전 가이드
👉 JVM GC 로그 분석 실전 — GCViewer와 GCEasy로 튜닝 시작하기
12. 마무리
이번 글을 세 줄로 정리합니다.
- Spring Boot Actuator + Micrometer + Prometheus + Grafana 파이프라인으로 JVM 내부 지표를 실시간으로 시각화하면, 문제가 발생하기 전에 이상 징후를 포착할 수 있습니다.
- Off-Heap 누수, GC Pause, 스레드 이상, HTTP 레이턴시를 한 대시보드에서 함께 보면 각 지표 간 상관관계를 발견할 수 있어 근본 원인 분석이 훨씬 빠릅니다.
- 알람 규칙(Old Gen 85%, GC Pause 500ms, Direct Buffer 1GB)을 설정해두면 야간이나 주말에 발생하는 장애도 조기에 대응할 수 있습니다.
시리즈 마무리
이 시리즈에서 다룬 내용을 한 줄씩 정리합니다.
| 편 | 주제 | 핵심 내용 |
|---|---|---|
| 1 | Off-Heap / DirectByteBuffer 누수 탐지 | jcmd, pmap, NativeMemoryTracking으로 GC 밖 메모리 추적 |
| 2 | ZGC vs G1GC Off-Heap 차이 | GC 선택에 따른 Direct Buffer 해제 메커니즘 차이 |
| 3 | Heap Dump 자동 수집과 분석 | -XX:+HeapDumpOnOutOfMemoryError, Eclipse MAT 파이프라인 |
| 4 | GC 로그 분석 실전 | -Xlog:gc*, GCViewer/GCEasy, 증상별 튜닝 |
| 5 | JFR + JMC 운영 프로파일링 | 1~2% 오버헤드로 핫스팟/병목 실시간 분석 |
| 6 | Java 21 Virtual Thread | 메모리 모델, Pinning, Spring Boot 3.2 적용 |
| 7 | Micrometer + Grafana 대시보드 | 모든 JVM 지표를 한 화면에서 모니터링 |
조회수: 1