들어가며
Java/Kotlin 애플리케이션을 운영할 때 JVM 모니터링은 필수입니다. “왜 갑자기 응답이 느려졌지?”, “왜 메모리가 계속 올라가지?”라는 질문에 답하려면 JVM 내부를 들여다볼 수 있어야 합니다. 이 글에서는 Spring Boot + Micrometer 기반의 JVM 모니터링 지표를 정리합니다.
모니터링 아키텍처
Spring Boot App (Micrometer + Actuator) → Prometheus → GrafanaSpring Boot 설정
# application.yml
management:
endpoints:
web:
exposure:
include: health, info, prometheus, metrics
metrics:
tags:
application: ${spring.application.name}// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("io.micrometer:micrometer-registry-prometheus")
}핵심 모니터링 지표
1. 힙 메모리 (Heap Memory)
JVM에서 객체가 할당되는 영역입니다. new 키워드로 생성하는 모든 객체는 힙에 저장되며, GC(Garbage Collector)가 이 영역을 관리합니다.
힙 메모리 모니터링이 중요한 이유는 다음과 같습니다:
- OOM(OutOfMemoryError) 예방: 힙이 가득 차면 애플리케이션이 강제 종료됩니다
- GC 최적화: 힙 사용 패턴을 보면 GC 튜닝 방향을 알 수 있습니다
- 메모리 누수 탐지: 힙 사용량이 지속적으로 증가하면 누수를 의심해야 합니다
힙은 크게 Young Generation(Eden, Survivor)과 Old Generation으로 나뉩니다. 새로 생성된 객체는 Eden에 할당되고, GC를 거쳐 살아남으면 Old로 이동합니다. Old 영역이 꽉 차면 Full GC가 발생하는데, 이 과정에서 애플리케이션이 멈추는 STW(Stop-The-World)가 발생합니다.
주요 지표:
jvm_memory_used_bytes{area="heap"}: 현재 사용 중인 힙 메모리jvm_memory_max_bytes{area="heap"}: 최대 힙 메모리 (-Xmx)jvm_memory_committed_bytes{area="heap"}: OS에서 확보한 힙 메모리
알람 기준:
- 힙 사용률 80% 이상 지속: Warning
- 힙 사용률 90% 이상: Critical (OOM 위험)
# PromQL - 힙 사용률
jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100
# Eden, Survivor, Old 영역별 사용량
jvm_memory_used_bytes{area="heap", id="G1 Eden Space"}
jvm_memory_used_bytes{area="heap", id="G1 Survivor Space"}
jvm_memory_used_bytes{area="heap", id="G1 Old Gen"}2. 메타스페이스 (Metaspace)
클래스 메타데이터가 저장되는 영역입니다. Java 8 이전에는 PermGen(Permanent Generation)이라 불렸으며, 힙 내부에 위치했습니다. Java 8부터는 네이티브 메모리를 사용하는 Metaspace로 대체되었습니다.
메타스페이스에는 다음 정보가 저장됩니다:
- 클래스 구조 정보 (필드, 메서드 시그니처)
- 상수 풀 (Constant Pool)
- 어노테이션 정보
- 메서드 바이트코드
일반적인 애플리케이션에서 메타스페이스 문제는 드뭅니다. 하지만 동적으로 클래스를 생성하거나(예: 리플렉션, CGLIB 프록시), 핫 리로드 기능을 사용하는 경우 메타스페이스가 지속적으로 증가할 수 있습니다. 특히 Spring과 같은 프레임워크에서 프록시 클래스가 누수되면 결국 메타스페이스 OOM이 발생합니다.
주요 지표:
jvm_memory_used_bytes{area="nonheap", id="Metaspace"}: 메타스페이스 사용량jvm_memory_max_bytes{area="nonheap", id="Metaspace"}: 최대 메타스페이스
알람 기준:
- 메타스페이스 사용량 지속 증가: 클래스 로더 누수 의심
# PromQL - 메타스페이스 사용률
jvm_memory_used_bytes{area="nonheap", id="Metaspace"} /
jvm_memory_max_bytes{area="nonheap", id="Metaspace"} * 1003. GC (Garbage Collection)
GC는 JVM 성능의 핵심입니다. 애플리케이션이 더 이상 참조하지 않는 객체를 자동으로 정리하여 메모리를 회수합니다. 하지만 이 과정에서 애플리케이션의 모든 스레드가 일시 정지하는 STW(Stop-The-World)가 발생합니다.
GC 모니터링에서 두 가지를 함께 봐야 합니다:
- GC 빈도: 너무 자주 발생하면 메모리 할당 패턴에 문제가 있거나 힙이 부족한 것입니다
- GC 시간: 한 번에 오래 걸리면 애플리케이션 응답 지연으로 이어집니다
Minor GC(Young GC)는 Eden 영역을 정리하며 보통 수 밀리초 내에 끝납니다. 반면 Major GC(Full GC)는 전체 힙을 정리하므로 수백 밀리초에서 수 초가 걸릴 수 있습니다. Full GC가 자주 발생한다면 힙 크기 증가 또는 메모리 누수 점검이 필요합니다.
주요 지표:
jvm_gc_pause_seconds_count: GC 발생 횟수jvm_gc_pause_seconds_sum: GC 총 소요 시간jvm_gc_pause_seconds_max: 최대 GC 시간
알람 기준:
- GC 빈도 분당 10회 이상: 조사 필요
- 단일 GC 시간 1초 이상: Warning
- GC로 인한 STW 시간 비율 5% 이상: Critical
# PromQL - GC 발생률 (횟수/분)
rate(jvm_gc_pause_seconds_count[5m]) * 60
# GC 평균 소요 시간
rate(jvm_gc_pause_seconds_sum[5m]) / rate(jvm_gc_pause_seconds_count[5m])
# GC 타입별 확인
rate(jvm_gc_pause_seconds_count{action="end of minor GC"}[5m]) # Young GC
rate(jvm_gc_pause_seconds_count{action="end of major GC"}[5m]) # Full GC4. 스레드 (Threads)
스레드는 JVM 내에서 실제로 작업을 수행하는 실행 단위입니다. 웹 애플리케이션에서는 각 HTTP 요청이 스레드 풀의 스레드에 할당되어 처리됩니다.
스레드 모니터링에서 가장 중요한 것은 상태(state) 입니다:
- RUNNABLE: 실행 중이거나 실행 준비가 된 상태. CPU를 적극적으로 사용 중입니다
- BLOCKED: 다른 스레드가 잡고 있는 락(synchronized)을 기다리는 상태
- WAITING/TIMED_WAITING: I/O나 조건을 기다리는 상태
BLOCKED 상태의 스레드가 많다면 락 경합(lock contention)이 발생하고 있는 것입니다. 이는 동기화된 코드 영역에서 병목이 발생했음을 의미하며, 심한 경우 데드락으로 이어질 수 있습니다. 스레드 수가 급증하는 것도 문제인데, 대부분 스레드 풀 설정 미흡이나 요청 처리 지연으로 인해 스레드가 쌓이는 경우입니다.
# PromQL - 현재 스레드 수
jvm_threads_live_threads
# 스레드 상태별
jvm_threads_states_threads{state="runnable"}
jvm_threads_states_threads{state="blocked"}
jvm_threads_states_threads{state="waiting"}
jvm_threads_states_threads{state="timed-waiting"}알람 기준:
- BLOCKED 상태 스레드 지속: 데드락 또는 락 경합
- 스레드 수 급증: 스레드 풀 설정 검토
5. 클래스 로딩 (Class Loading)
JVM은 필요할 때 클래스를 동적으로 로딩합니다. 애플리케이션 시작 시 많은 클래스가 로딩되고, 이후에는 비교적 안정적으로 유지됩니다.
클래스 로딩 지표가 중요한 상황은 다음과 같습니다:
- 핫 리로드 환경: Spring DevTools, JRebel 등 사용 시 클래스가 반복적으로 로딩/언로딩됩니다
- 동적 프록시 남용: CGLIB, ByteBuddy 등으로 런타임에 클래스를 생성하는 경우
- 커스텀 클래스 로더: OSGi, 웹 컨테이너의 다중 앱 배포 환경
정상적인 애플리케이션에서 클래스 수는 시작 후 안정적으로 유지되어야 합니다. 시간이 지남에 따라 클래스 수가 계속 증가한다면 클래스 로더 누수를 의심해야 합니다. 이는 결국 메타스페이스 OOM으로 이어집니다.
# PromQL - 로딩된 클래스 수
jvm_classes_loaded_classes
# 클래스 로딩/언로딩률
rate(jvm_classes_loaded_classes[5m])
rate(jvm_classes_unloaded_classes_total[5m])알람 기준:
- 클래스 수 지속 증가: 클래스 로더 누수
6. CPU 사용률
CPU 사용률은 애플리케이션의 계산 부하를 나타냅니다. process_cpu_usage는 JVM 프로세스만의 사용률이고, system_cpu_usage는 호스트 전체의 사용률입니다.
CPU 사용률이 높은 원인은 크게 세 가지입니다:
- 애플리케이션 로직: 복잡한 계산, 무한 루프, 비효율적인 알고리즘
- GC 활동: 잦은 GC는 상당한 CPU를 소비합니다
- JIT 컴파일: 애플리케이션 시작 직후 바이트코드를 네이티브 코드로 컴파일하는 과정
system_cpu_usage가 높은데 process_cpu_usage가 낮다면, 같은 호스트의 다른 프로세스가 CPU를 많이 사용하고 있는 것입니다. 반대로 process_cpu_usage가 높다면 애플리케이션 코드나 GC를 점검해야 합니다.
# PromQL - JVM 프로세스 CPU 사용률
process_cpu_usage
# 시스템 전체 CPU 사용률
system_cpu_usage7. HTTP 요청 메트릭 (Spring Boot)
Spring Boot의 HTTP 요청 메트릭은 애플리케이션의 외부 트래픽 처리 상황을 보여줍니다. 이 지표들은 사용자 경험과 직결되므로 SLO/SLA 설정의 기반이 됩니다.
주요 관찰 포인트:
- RPS(Requests Per Second): 초당 처리하는 요청 수. 트래픽 패턴 파악에 필수
- 응답 시간: 평균보다는 P95, P99 같은 백분위수가 중요합니다. 평균은 극단값을 숨기기 때문입니다
- 에러율: 5xx 응답 비율. 시스템 장애 지표로 가장 민감하게 반응해야 합니다
응답 시간이 급증했을 때는 GC, DB 커넥션, 외부 API 응답 등 여러 요인을 함께 확인해야 합니다. 특정 엔드포인트만 느리다면 해당 로직의 문제이고, 전체적으로 느리다면 리소스 부족이나 GC 문제일 가능성이 높습니다.
# PromQL - 요청 처리량 (RPS)
rate(http_server_requests_seconds_count[5m])
# 평균 응답 시간
rate(http_server_requests_seconds_sum[5m]) / rate(http_server_requests_seconds_count[5m])
# 95 퍼센타일 응답 시간
histogram_quantile(0.95, rate(http_server_requests_seconds_bucket[5m]))
# 에러율
rate(http_server_requests_seconds_count{status=~"5.."}[5m]) /
rate(http_server_requests_seconds_count[5m]) * 100부하 상황에서의 모니터링
실제 장애 상황에서는 여러 지표가 동시에 변화합니다. 어떤 지표가 원인이고 어떤 것이 결과인지 파악하는 것이 중요합니다. 아래는 흔히 발생하는 부하 상황별 진단 방법입니다.
CPU 부하 상황
CPU 부하가 높을 때는 먼저 그 원인이 애플리케이션 로직인지 GC인지 구분해야 합니다. GC가 원인이라면 메모리 관련 지표도 함께 확인합니다.
관찰 포인트:
process_cpu_usage급증- GC 빈도 증가 (GC도 CPU 사용)
- 스레드 RUNNABLE 상태 증가
진단 방법:
스레드 덤프를 분석하면 어떤 코드가 CPU를 많이 사용하는지 알 수 있습니다. top -H로 CPU를 많이 사용하는 스레드의 native ID를 확인한 후, 스레드 덤프에서 해당 스레드의 스택 트레이스를 찾습니다.
# 스레드 덤프 생성
jstack <pid> > thread_dump.txt
# CPU 사용량 높은 스레드 확인
top -H -p <pid>메모리 부하 상황
메모리 부하는 보통 점진적으로 발생합니다. 힙 사용량이 서서히 올라가다가 Full GC가 빈번해지고, 결국 OOM으로 이어집니다. 이 과정에서 응답 시간이 불규칙하게 튀는 현상이 나타납니다.
관찰 포인트:
- 힙 사용률 지속 상승
- Full GC 빈도 증가
- GC 후에도 메모리 회복 안 됨
진단 방법:
힙 덤프를 생성하여 어떤 객체가 메모리를 많이 차지하는지 분석합니다. Eclipse MAT이나 VisualVM 같은 도구로 분석할 수 있습니다. OOM이 발생할 것 같으면 미리 JVM 옵션으로 자동 덤프를 설정해 두는 것이 좋습니다.
# 힙 덤프 생성
jmap -dump:format=b,file=heap_dump.hprof <pid>
# 또는 OOM 시 자동 덤프
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/스레드 병목 상황
스레드 병목은 동기화된 코드 영역에서 주로 발생합니다. 하나의 스레드가 락을 잡고 있으면 다른 스레드들은 BLOCKED 상태로 대기합니다. 이 상황이 지속되면 요청 처리량이 급격히 감소하고 응답 시간이 늘어납니다.
관찰 포인트:
- BLOCKED 스레드 증가
- 응답 시간 급증
- 처리량 감소
진단 방법:
스레드 덤프에서 BLOCKED 상태인 스레드를 찾아 어떤 락을 기다리고 있는지 확인합니다. “waiting to lock”과 “locked” 키워드로 락의 소유자와 대기자를 파악할 수 있습니다. 데드락이 발생한 경우 JVM이 자동으로 덤프에 “Found one Java-level deadlock”을 출력합니다.
# 스레드 덤프 분석
jstack <pid> | grep -A 30 "BLOCKED"
# 락 경합 확인
jstack <pid> | grep -E "waiting to lock|locked"GC 튜닝 관련 모니터링
GC 알고리즘 선택은 애플리케이션의 특성에 따라 달라집니다. 지연 시간이 중요한 서비스라면 저지연 GC를, 처리량이 중요한 배치 작업이라면 Parallel GC를 고려합니다.
GC 알고리즘별 특성
| GC | 특징 | 적합한 상황 |
|---|---|---|
| G1 GC | 균형잡힌 성능 | 대부분의 경우 (기본값) |
| ZGC | 초저지연 (10ms 미만) | 지연에 민감한 서비스 |
| Shenandoah | 저지연 | Red Hat 기반 |
| Parallel GC | 높은 처리량 | 배치 작업 |
G1 GC 모니터링
G1 GC는 Java 9부터 기본값이며, 대부분의 경우 좋은 성능을 보입니다. 힙을 여러 Region으로 나누고, 가비지가 많은 Region부터 수집합니다(Garbage-First). Mixed GC가 자주 발생하면 Old 영역에 가비지가 많이 쌓이고 있다는 신호입니다.
# G1 GC 영역별 사용량
jvm_memory_used_bytes{id=~"G1.*"}
# Mixed GC 빈도 (Old 영역 정리)
rate(jvm_gc_pause_seconds_count{action="end of minor GC", cause="G1 Evacuation Pause"}[5m])ZGC 모니터링 (Java 15+)
ZGC는 힙 크기와 상관없이 STW 시간을 10ms 미만으로 유지하는 저지연 GC입니다. 대부분의 작업을 애플리케이션 스레드와 동시에(concurrent) 수행합니다. 대용량 힙(수십~수백 GB)에서도 일관된 지연 시간을 보장하므로, 지연에 민감한 서비스에 적합합니다.
다만 ZGC는 처리량 면에서 G1보다 약간 낮을 수 있고, CPU를 더 많이 사용합니다. 따라서 배치 작업처럼 처리량이 중요한 경우에는 G1이나 Parallel GC가 더 나을 수 있습니다.
# ZGC 사이클 시간
rate(jvm_gc_pause_seconds_sum{gc="ZGC"}[5m])커넥션 풀 모니터링
대부분의 Java 애플리케이션은 외부 시스템(DB, Redis 등)과 커넥션을 유지합니다. 커넥션 풀이 부족하면 요청 처리가 지연되고, 심하면 타임아웃이 발생합니다.
HikariCP (Spring Boot 기본)
HikariCP는 Spring Boot의 기본 DB 커넥션 풀입니다. 가볍고 빠르며, 실용적인 기본 설정을 제공합니다.
커넥션 풀 모니터링에서 가장 중요한 지표는 pending(대기 중인 스레드)입니다. pending이 0보다 크면 커넥션을 얻기 위해 대기하는 스레드가 있다는 뜻입니다. 이 상태가 지속되면 풀 크기를 늘리거나 쿼리 성능을 개선해야 합니다.
active 커넥션 수가 max에 가깝게 유지되면 풀이 거의 소진된 상태입니다. 커넥션 획득 시간이 증가하면 DB 서버 부하나 네트워크 지연을 의심해 봐야 합니다.
# 활성 커넥션 수
hikaricp_connections_active
# 대기 중인 스레드 수
hikaricp_connections_pending
# 커넥션 획득 시간
rate(hikaricp_connections_acquire_seconds_sum[5m]) /
rate(hikaricp_connections_acquire_seconds_count[5m])
# 커넥션 사용률
hikaricp_connections_active / hikaricp_connections_max * 100알람 기준:
- pending > 0 지속: 커넥션 풀 부족
- 커넥션 획득 시간 증가: DB 부하 또는 풀 부족
커넥션 타임아웃 설정
HikariCP의 주요 설정값과 그 의미를 이해하고 적절히 조정해야 합니다:
- maximum-pool-size: 최대 커넥션 수. 무작정 늘리면 DB 서버에 부담이 됩니다. 대략
((core_count * 2) + effective_spindle_count)공식을 참고하세요 - minimum-idle: 유휴 커넥션 최소 개수. maximum-pool-size와 동일하게 설정하면 커넥션 생성/삭제 오버헤드가 없습니다
- connection-timeout: 커넥션 획득 대기 시간. 이 시간을 넘기면 SQLException 발생
- max-lifetime: 커넥션 최대 수명. DB나 네트워크 장비의 타임아웃보다 짧게 설정해야 합니다
# application.yml
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000Grafana 대시보드 구성
JVM 모니터링 대시보드는 한눈에 시스템 상태를 파악할 수 있도록 구성해야 합니다. 가장 중요한 지표를 상단에, 세부 분석용 지표를 하단에 배치합니다.
추천 패널 레이아웃
┌─────────────────────────────────────────────────────────┐
│ Overview: CPU, 힙 메모리, GC 시간, 스레드 │
├─────────────────────────────────────────────────────────┤
│ Heap Memory │ GC Pause Time │
├───────────────────────┼────────────────────────────────┤
│ GC Count by Type │ Thread States │
├───────────────────────┼────────────────────────────────┤
│ HTTP Requests │ Response Time (P95) │
├─────────────────────────────────────────────────────────┤
│ HikariCP Connections │ Class Loading │
└─────────────────────────────────────────────────────────┘커뮤니티 대시보드
- JVM Micrometer (ID: 4701)
- Spring Boot Statistics (ID: 6756)
알람 규칙 예시
# prometheus-rules.yml
groups:
- name: jvm
rules:
- alert: JVMHighHeapUsage
expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "JVM 힙 메모리 사용률 80% 초과"
- alert: JVMHeapCritical
expr: jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} > 0.9
for: 2m
labels:
severity: critical
annotations:
summary: "JVM 힙 메모리 사용률 90% 초과 - OOM 위험"
- alert: JVMHighGCPause
expr: jvm_gc_pause_seconds_max > 1
for: 1m
labels:
severity: warning
annotations:
summary: "GC 일시 정지 시간 1초 초과"
- alert: JVMFrequentGC
expr: rate(jvm_gc_pause_seconds_count[5m]) * 60 > 10
for: 5m
labels:
severity: warning
annotations:
summary: "GC 발생 빈도 분당 10회 초과"
- alert: JVMThreadsBlocked
expr: jvm_threads_states_threads{state="blocked"} > 5
for: 5m
labels:
severity: warning
annotations:
summary: "BLOCKED 상태 스레드 5개 초과"
- alert: HikariCPConnectionExhausted
expr: hikaricp_connections_pending > 0
for: 5m
labels:
severity: warning
annotations:
summary: "HikariCP 커넥션 대기 발생"
- alert: HighErrorRate
expr: |
rate(http_server_requests_seconds_count{status=~"5.."}[5m]) /
rate(http_server_requests_seconds_count[5m]) > 0.05
for: 5m
labels:
severity: critical
annotations:
summary: "HTTP 5xx 에러율 5% 초과"JVM 옵션 권장 설정
프로덕션 환경에서는 명시적인 JVM 옵션 설정이 중요합니다. 특히 메모리 관련 설정은 애플리케이션 특성에 맞게 조정해야 합니다.
기본 설정
힙 크기는 -Xms와 -Xmx를 동일하게 설정하는 것이 좋습니다. 서로 다르면 힙이 동적으로 리사이징되면서 성능에 영향을 줄 수 있습니다. 컨테이너 환경에서는 컨테이너 메모리의 약 70-80%를 힙에 할당하고, 나머지는 메타스페이스와 네이티브 메모리용으로 남겨둡니다.
java \
-Xms2g \ # 초기 힙 크기
-Xmx2g \ # 최대 힙 크기 (Xms와 동일 권장)
-XX:+UseG1GC \ # G1 GC 사용 (Java 9+ 기본값)
-XX:MaxGCPauseMillis=200 \ # 목표 GC 일시정지 시간
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/ \
-jar app.jar모니터링을 위한 추가 설정
GC 로그는 문제 발생 시 원인 분석에 필수적입니다. 프로덕션에서는 항상 GC 로그를 활성화하고 적절히 로테이션해야 합니다. GC 로그 자체의 오버헤드는 미미하므로 성능 걱정 없이 활성화해도 됩니다.
java \
-XX:+PrintGCDetails \ # GC 상세 로그
-XX:+PrintGCDateStamps \ # GC 시간 기록
-Xloggc:/var/log/gc.log \ # GC 로그 파일
-XX:+UseGCLogFileRotation \ # GC 로그 로테이션
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=10M \
-jar app.jarJava 17+ 통합 로깅
Java 9부터 통합 로깅(Unified Logging)이 도입되어 -Xlog 옵션으로 일관된 로깅 설정이 가능합니다. 기존의 -XX:+PrintGC* 옵션들은 deprecated되었습니다.
java \
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=5,filesize=10M \
-jar app.jar정리
JVM 모니터링의 핵심 포인트:
- 힙 메모리: 사용률 80% 이상 지속 시 주의
- GC: 빈도와 STW 시간 모두 관찰
- 스레드: BLOCKED 상태 지속 시 락 경합 의심
- 커넥션 풀: pending > 0이면 풀 부족
- 응답 시간: P95/P99로 꼬리 지연 확인
다음 글에서는 OpenSearch 모니터링에 대해 다룹니다.