← all posts
DEV 2026.05.02 · 13 min read Intermediate

성능 병목은 어디서 오는가 — USE 방법론부터 스레드 덤프까지

CPU·메모리·DB·외부 API·스레드 풀·네트워크 각 계층의 병목을 5분 안에 좁히는 USE 방법론부터 jstack 분석까지, 진단 프레임워크를 추적한다.


성능 저하 알림이 울렸다. 대시보드에는 메트릭이 20개 떠 있다. 무엇을 먼저 봐야 하는가? “CPU 사용률 85%“를 보고 스케일 업을 결정하는 순간, 실제 원인이 IOWait이거나 DB 쿼리이거나 synchronized 블록이라면 비용만 낭비하고 문제는 남는다. 진단의 출발점이 틀리면 최적화 방향도 틀린다.

진단 프레임워크: USE 방법론

모든 병목 진단은 하나의 질문으로 시작한다 — 이 리소스가 포화(saturated)되었는가?

USE 방법론은 각 리소스마다 세 가지를 순서대로 확인한다. Utilization(사용률), Saturation(포화도), Errors(에러). 여기서 핵심은 Utilization이 높아도 Saturation이 0이면 문제가 없다는 점이다.

CPU Utilization 90%, Saturation 0
→ CPU가 열심히 일 중. 정상.

CPU Utilization 45%, Saturation 8 (2 core 기준)
→ CPU는 여유가 있는데 대기 큐가 8개. 심각.

실무에서 흔한 실수는 Utilization 수치 하나로 판단하는 것이다. 메모리 사용률 80%는 정상이다. 메모리는 쓰는 게 맞다. 판단 기준은 swap 사용 여부와 GC 빈도다. 디스크 사용률 60%도 정상이다. avgqu-sz(I/O 대기 큐 길이)가 기준이다.

리소스       Saturation 지표              높음 기준
CPU          run queue length            core 수 초과
Memory       swap rate, GC 빈도          swap > 0 또는 GC > 10회/sec
Disk         avgqu-sz (I/O wait queue)   disk 수 초과
Network      TCP retransmit, overflow    > 0.1%

5분짜리 체크리스트는 이 표를 따라 vmstat, iostat -x, ss -s를 순서대로 실행하는 것이다. 병목이 CPU인지, 디스크인지, 네트워크인지 범위가 좁혀지면 그때 깊이 파고든다.

CPU 병목: User·System·IOWait를 분리하라

CPU Utilization이 높을 때 원인은 세 가지로 나뉜다. 각각 해결책이 완전히 다르다.

User Time이 높으면 애플리케이션 코드 자체가 CPU를 많이 쓴다. 알고리즘 최적화나 캐싱이 답이다. System Time이 높으면 context switch가 과다하다는 신호다. vmstatcs 컬럼이 5000/sec를 넘으면 스레드 풀이 너무 크다는 뜻이다. 스레드 풀을 cores * 2~4로 줄이면 System Time이 절반 이하로 떨어진다. IOWait이 높으면 CPU 문제가 아니다. CPU를 아무리 늘려도 IOWait은 줄어들지 않는다. DB 쿼리나 디스크를 봐야 한다.

# 60초 동안 CPU 성분 추이를 본다
mpstat -P ALL 1 60
# User 42%, System 28%, IOWait 8%
# → System 28%는 비정상 (정상 <15%)
# → context switch 확인
vmstat 1 10  # cs 컬럼
CPU 스케일 업의 함정

IOWait 20%인 상황에서 CPU를 2배로 늘리면 응답시간이 개선되지 않는다. IOWait은 CPU가 I/O 완료를 기다리는 시간이다. CPU 코어가 늘어도 I/O 대기 시간은 그대로다.

JVM 메모리 병목: GC Pause가 응답시간에 더해진다

메모리 사용률 90%는 경고가 아니다. GC pause time > 100ms가 경고다. GC pause는 응답시간에 직접 더해진다. Full GC가 500ms 걸리면, 그 요청의 응답시간은 정상 처리 시간 + 500ms가 된다.

jstat -gcutil $(jps | grep App | awk '{print $1}') 1000
# YGC  YGCT   FGC  FGCT
# 1234  5.20   15   45.0
# → Full GC 15회, 총 45초 → 평균 3초/회 (심각)

Full GC가 빈번한 이유는 대부분 Old Generation에 오래 남는 객체가 많아서다. TTL 없는 정적 캐시, 해제되지 않는 DirectByteBuffer, static 컬렉션에 계속 추가되는 데이터. jmap -histo:live로 어떤 클래스의 인스턴스가 가장 많은지 확인하고, 두 시점의 덤프를 비교해 증가 추세가 있으면 누수를 의심한다.

Heap 증설은 차선책이다. 우선순위는 객체 생성 최소화 → GC 튜닝(G1GC, ZGC) → 그다음 Heap 증설이다.

DB와 스레드 병목: 두 가지 큐

DB 병목과 스레드 병목은 구조가 닮았다. 둘 다 큐(queue)가 쌓이는 현상이다.

HikariCP의 pending > 0은 connection을 기다리는 요청이 있다는 뜻이다. 이 상태에서 요청 응답시간은 connection 대기 시간 + 쿼리 실행 시간이 된다. 먼저 느린 쿼리를 줄여야 한다. 쿼리가 빨라지면 connection 회전율이 올라가고 pending이 사라진다. pool size를 먼저 늘리는 건 임시방편이다.

-- 실행 중인 쿼리 확인
SHOW FULL PROCESSLIST;
-- Time > 1초가 많으면
EXPLAIN ANALYZE SELECT ...;
-- type: ALL (Full Table Scan) → 인덱스 추가

스레드 병목은 jstack으로 확인한다. BLOCKED 스레드가 50개를 넘으면 Lock 경합이 원인이다.

jstack $APP_PID > /tmp/jstack.txt
grep "BLOCKED" /tmp/jstack.txt | wc -l
# 150개
grep -A 3 "BLOCKED" /tmp/jstack.txt | grep "at com.example"
# → OrderService.saveOrder (synchronized 전체 메서드)
트레이드오프

스레드 풀 증설은 BLOCKED가 원인일 때 효과가 없다. Lock을 가진 스레드는 여전히 1개이고, 대기 스레드만 늘어난다. Lock 범위를 최소화하거나 ConcurrentHashMap으로 대체하는 것이 선행이다. 증설은 그다음이다.

외부 API와 네트워크: 최악을 제한하라

외부 API 병목의 위험성은 연쇄 장애에 있다. 결제 API가 30초 응답하면, Timeout 설정이 없는 서비스는 스레드 200개가 모두 그 API를 기다리게 된다. 다른 모든 기능이 멈춘다.

세 가지가 필수다. Timeout(최악을 제한), Circuit Breaker(연속 실패 시 즉시 차단), Fallback(캐시 또는 기본값 반환). Circuit Breaker가 OPEN 상태에서 요청을 받으면 API를 호출하지 않고 1ms 안에 거부한다. 스레드는 해방된다.

네트워크 자체의 병목은 대부분 응답 크기에서 온다. 50MB JSON을 매 요청마다 직렬화하고 전송하면, 서버 처리가 100ms여도 총 응답시간은 6초가 넘는다. 해결 순서는 단순하다 — 필드 축소 → 페이지네이션 → gzip 압축. 압축 한 줄로 50MB가 5MB가 된다. Keep-Alive 없이 반복 호출하면 TCP 연결 수립 비용(75ms)이 매번 붙는다. Connection Pooling은 이를 0으로 만든다.

정리

  • USE 방법론: 각 리소스마다 Utilization → Saturation → Errors 순으로 확인한다. Saturation이 0이면 병목이 아니다.
  • CPU: User/System/IOWait을 분리한다. System 높음 → 스레드 풀 축소. IOWait 높음 → DB·디스크 확인, CPU 증설은 무의미하다.
  • JVM: GC pause time이 응답시간에 직접 더해진다. Full GC 빈도가 문제라면 Heap 증설보다 객체 생성 최소화가 먼저다.
  • DB·스레드: Connection pending과 BLOCKED 스레드는 구조가 같다. 큐가 쌓이면 근본 원인(느린 쿼리, Lock 경합)부터 제거한다.
  • 외부 API·네트워크: Timeout과 Circuit Breaker로 최악을 제한한다. 응답 크기를 줄이는 것이 네트워크 최적화의 시작점이다.

다음 글에서는 이 진단 프레임워크를 JVM 프로파일링으로 확장해, GC 로그에서 실제 pause 분포를 읽어내는 방법을 추적한다.