CPU 30%에 시스템이 멈춘 이유

실제 운영 환경에서 자주 발생하는 장애 시나리오입니다.

업무에서 시스템 응답 속도가 눈에 띄게 느려졌다는 피드백이 들어왔습니다. 사용자들은 "인터페이스가 멈춘다", "화면이 계속 로딩된다"고 불평합니다.

모니터링 시스템을 확인해보면 다음과 같습니다:

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가 높은가?"

다음 세 가지 질문을 던져보세요:

  1. 요청이 시스템의 어느 계층에서 막히고 있는가?
  2. 어떤 자원이 숨은 병목이 되고 있는가?
  3. 지금 계속 느려진다면 누가 가장 먼저 발견할 수 있는가?

진정으로 성숙한 운영 체계는 시스템이 다운된 후에 알람을 보내는 것이 아니라, "느려짐"이 시작되는 순간에 개입할 수 있어야 합니다.

마무리하며

CPU가 30%인데 시스템이 사용할 수 없을 정도로 느린 것은 결코 우연한 문제가 아닙니다.

이는 종종 다음을 의미합니다:

  • 시스템이 이미 아직 건강하지 않은 상태에 진입함
  • 아직 치명적인 임계값에 도달하지 않았을 뿐

진정한 분기점은 장애가 발생했는지 여부가 아니라:

시스템이 느려지기 시작하는 순간, 당신이 볼 수 있는가?

단순히 CPU, 메모리, 디스크 등을 모니터링하는 것만으로는 충분하지 않습니다.

스레드는 무엇을 기다리고 있는가?

연결 풀에 여유는 얼마나 되는가?

GC 중단이 눈에 띄지 않게 지연을 악화시키고 있는가?

데이터베이스/Redis 호출이 비정상적인가?

다음 JVM 핵심 아직 건강하지 않은 상태 지표를 실시간으로 수집하고, 시각화하며, 임계값 알람을 설정해야만

"페이지가 막 로딩되기 시작할 때" 문제를 발견할 수 있고 "시스템이 완전히 다운된 후"가 아닙니다.

태그: JVM 스레드 덤프 GC Grafana prometheus

5월 24일 10:56에 게시됨