실제 운영 환경에서 자주 발생하는 장애 시나리오입니다.
업무에서 시스템 응답 속도가 눈에 띄게 느려졌다는 피드백이 들어왔습니다. 사용자들은 "인터페이스가 멈춘다", "화면이 계속 로딩된다"고 불평합니다.
모니터링 시스템을 확인해보면 다음과 같습니다:
CPU 사용률: 30%
메모리 사용률: 55%
로드 애버리지: 0.8
디스크 IO: 정상
Grafana 지표는 모두 정상입니다.
자원 관점에서 보면 시스템에 부하가 거의 없는 것으로 보입니다.
하지만 실제로는 요청이 너무 느려 사용자가 받아들일 수 없는 수준입니다.
CPU 낮음 ≠ 시스템 정상 작동
많은 팀이 모니터링 체계에서 처음 빠지는 인식의 함정입니다.
CPU는 단 하나의 사실만 나타냅니다:
CPU가 현재 얼마나 많은 계산 자원을 소비하고 있는가
다음 내용은 전혀 반영하지 않습니다:
- 요청이 차단되었는지 여부
- 스레드가 대기 중인지 여부
- I/O가 병목이 되고 있는지 여부
- 비즈니스 흐름이 부분적으로 마비되었는지 여부
한마디로 요약하면:
CPU가 낮다는 것은 시스템이 계산을 하지 않고 있다는 뜻일 뿐, 기다리지 않는다는 뜻이 아닙니다.
CPU 낮은데 시스템이 느린 이유
1. I/O에 차단된 스레드 풀이 조용히 꽉 참
일반적인 Java 서비스 구조를 보겠습니다:
@RestController
public Result getOrderInfo() {
OrderData data = orderService.fetchOrder(orderId);
return Result.success(data);
}
코드 수준에서는 동기적이고 순차적이며 제어 가능해 보입니다.
하지만 실행 시 실제 경로는 다음과 같습니다:
HTTP 요청 → Tomcat 작업자 스레드 → DB 연결 풀 획득(차단) → SQL 실행(느린 쿼리) → 하위 RPC 응답 대기
이 중 어느 단계라도 느려지면:
- Tomcat 작업 스레드가 점유됨
- 새로운 요청이 대기열에 쌓임
- 응답 시간이 기하급수적으로 증가
그런데 CPU 사용률은?
거의 변하지 않습니다.
왜냐하면 스레드 대부분이 WAITING 또는 TIMED_WAITING 상태이기 때문입니다.
2. 연결 풀 소진, CPU 100%보다 더 치명적
장애 상황에서 자주 보이는 모니터링 조합입니다:
DB CPU: 40%
DB QPS: 정상
애플리케이션 TPS: 하락
문제는 여기에 있습니다:
HikariCP 활성 연결: 50 / 50
대기 스레드: 계속 증가
이는 다음을 의미합니다:
- 데이터베이스는 견딜 수 있음
- 하지만 애플리케이션이 연결을 가져올 수 없음
- 요청이
getConnection()단계에서 차단됨
JVM 관점에서:
"HTTP-8080-exec-123" waiting on condition
시스템이 다운되지는 않았지만, 효과적인 서비스를 제공할 수 없습니다.
3. 하나의 느린 인터페이스가 전체 시스템 처리량을 무너뜨림
많은 팀이 다음 사실을 간과합니다:
시스템 처리량 ≈ 가장 느린 경로의 성능
인터페이스 응답 시간이 50ms에서 300ms로 늘어난다면:
예상 QPS ≈ 1000
실제 QPS ≈ 160
CPU는 여전히 낮지만, 스레드 풀이 대기열을 형성하고 지연이 누적됩니다.
이러한 문제의 전형적인 특징은:
- CPU 낮음
- 메모리 여유
- 하지만 P95/P99 지연 시간이 지속적으로 상승
평균값과 CPU만 바라보면 전혀 인지할 수 없습니다.
Java 애플리케이션 진단 방법
다음을 확인했다면:
- CPU 낮음
- 메모리 정상
- 하지만 요청이 확연히 느림
가장 먼저 할 일은 Grafana를 더 이상 보지 않는 것입니다.
JVM 내부로 직접 들어가서 무엇을 하고 있는지 확인해야 합니다.
1단계: 스레드 확인
첫 번째 단계는 항상 스레드 상태입니다.
jstack <pid> > jstack.log
중요한 것은 스레드 수가 아니라 상태 분포입니다:
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
"CPU 낮지만 시스템 느림" 장애에서 가장 흔히 보는 것은:
- RUNNABLE이 거의 없음
- WAITING / TIMED_WAITING이 대부분
전형적인 스레드 스택:
"HTTP-8080-exec-124" prio=5 tid=0x00007f8c940 waiting
at java.util.concurrent.locks.LockSupport.park()
at java.util.concurrent.FutureTask.get()
이는 무엇을 의미할까요?
스레드가 계산하지 않고 결과를 기다리고 있습니다.
무엇을 기다리나요?
- 데이터베이스 응답
- 하위 RPC 응답
- 락 해제
- 스레드 풀 자원
2단계: 스레드 풀 확인
많은 팀이 스레드 풀 크기만 신경쓰고 실행 상태는 무시합니다.
ThreadPoolExecutor를 사용한다면 다음 지표를 주목하세요:
activeCount
queueSize
completedTaskCount
매우 위험한 조합은:
activeCount ≈ maxPoolSize
queueSize 지속적으로 증가
이는 다음을 의미합니다:
- 스레드가 느린 작업으로 가득 참
- 새로운 요청은 대기열에서 대기
- 지연 시간이 기하급수적으로 증가
그리고 CPU는?
여전히 낮습니다.
3단계: GC 확인
많은 사람들이 시스템이 느리면 GC를 부정합니다:
"Full GC가 없으니 GC 문제가 아닐 거야."
하지만 실제 상황은:
- 잦은 Young GC
- Stop The World 시간은 짧지만 횟수가 매우 많음
GC 로그에서 다음과 같은 내용을 볼 수 있습니다:
[GC (Allocation Failure) 256M->128M(512M), 15ms]
15ms는 길지 않지만, 다음과 같다면:
초당 20번
지연 시간에 민감한 서비스에는 재앙입니다.
특히:
- 인터페이스 자체가 느린 경우
- 요청이 이미 대기열에 있는 경우
GC 지터가 사용자가 인지하는 지연 시간을 직접적으로 증폭시킵니다.
4단계: 힙이 가득 차지 않았지만 객체가 너무 오래 살아있음
매우 간과되기 쉬운 점입니다.
jmap -histo <pid> | head -20
다음과 같은 내용이 보일 수 있습니다:
num #instances #bytes class name
---------------------------------------
1: 8,000,000 640MB byte[]
2: 2,300,000 184MB java.lang.String
이는 다음을 의미합니다:
- 객체가 힙에 대량으로 쌓임
- GC가 제거하지 못함
- 스레드가 메모리 할당에서 점점 느려짐
CPU는 낮지만, JVM 효율이 이미 저하되기 시작했습니다.
5단계: 동기화와 락 확인
스레드 스택에 다음이 자주 나타난다면:
java.lang.Object.wait()
java.util.concurrent.locks.AbstractQueuedSynchronizer
다음을 확신할 수 있습니다:
시스템이 느린 이유는 계산 속도가 아니라 락 경쟁 때문입니다.
이러한 문제의 특징은:
- CPU 사용률 낮음
- 처리량 현저히 감소
- 지연 시간이 갑자기 증가
그리고 확장이 거의 효과가 없습니다.
Prometheus + Grafana가 문제를 찾지 못하는 이유
대부분의 모니터링은 자원 관찰만 수행하고 시스템 행동 관찰은 하지 않기 때문입니다.
일반적인 지표는:
node_cpu_seconds_total
node_memory_MemAvailable_bytes
하지만 실제로 주목해야 할 것은:
http_server_requests_seconds_bucket
jvm_threads_state{state="BLOCKED"}
hikaricp_connections_active
mysql_global_status_threads_running
다음이 없다면:
- 인터페이스 분위 지연 시간(P95/P99)
- 스레드 풀 상태
- 연결 풀 사용 현황
- 핵심 의존성 응답 시간
모니터링은 단 한 가지만 말해줍니다:
"서버가 살아 있습니다."
하지만 비즈니스가 건강한지는 알 수 없습니다.
중소 팀이 자주 간과하는 "만성 장애"
많은 장애를 복기한 후 다음을 발견했습니다:
이러한 문제는 거의 즉시 알람이 울리지 않습니다.
보통 사용자가 먼저 인지하고, 그다음에 수동으로 문제를 찾습니다.
이유는 하나입니다:
모니터링 체계가 "사용자 경험 악화"의 초기 신호를 포괄하지 않기 때문입니다.
CPU가 실제로 상승할 때쯤이면 시스템은 이미 눈사태 직전인 경우가 많습니다.
더 신뢰할 수 있는 판단 로직
다음과 같이 묻기보다:
"CPU가 높은가?"
다음 세 가지 질문을 던져보세요:
- 요청이 시스템의 어느 계층에서 막히고 있는가?
- 어떤 자원이 숨은 병목이 되고 있는가?
- 지금 계속 느려진다면 누가 가장 먼저 발견할 수 있는가?
진정으로 성숙한 운영 체계는 시스템이 다운된 후에 알람을 보내는 것이 아니라, "느려짐"이 시작되는 순간에 개입할 수 있어야 합니다.
마무리하며
CPU가 30%인데 시스템이 사용할 수 없을 정도로 느린 것은 결코 우연한 문제가 아닙니다.
이는 종종 다음을 의미합니다:
- 시스템이 이미 아직 건강하지 않은 상태에 진입함
- 아직 치명적인 임계값에 도달하지 않았을 뿐
진정한 분기점은 장애가 발생했는지 여부가 아니라:
시스템이 느려지기 시작하는 순간, 당신이 볼 수 있는가?
단순히 CPU, 메모리, 디스크 등을 모니터링하는 것만으로는 충분하지 않습니다.
스레드는 무엇을 기다리고 있는가?
연결 풀에 여유는 얼마나 되는가?
GC 중단이 눈에 띄지 않게 지연을 악화시키고 있는가?
데이터베이스/Redis 호출이 비정상적인가?
다음 JVM 핵심 아직 건강하지 않은 상태 지표를 실시간으로 수집하고, 시각화하며, 임계값 알람을 설정해야만
"페이지가 막 로딩되기 시작할 때" 문제를 발견할 수 있고 "시스템이 완전히 다운된 후"가 아닙니다.