14장. 모니터링과 트러블슈팅 — "느려요"를 지표로 번역하기
"DB가 느려요"는 증상이지 진단이 아닙니다. pg_stat_* 뷰와 로그만으로 대부분의 장애를 좁혀 들어가는, 새벽 2시에도 통하는 사고 순서를 몸에 새깁니다.
① 장애 시 최상위 진단 5단계 — 연결→Lock→긴 트랜잭션→쿼리→리소스 ② pg_stat_* 뷰 지도와 핵심 지표 ③ Lock 대기 체인을 pg_blocking_pids()로 푸는 법 ④ 슬로우 쿼리 추적 워크플로우와 알림 임계값
14.1 장애의 첫 5분 — 진단 순서를 몸에 새겨라
장애 대응에서 가장 비싼 것은 "어디부터 볼까" 고민하는 시간입니다. 현업 DBA는 항상 같은 순서로 봅니다: 값싸고 흔한 원인부터, 비싸고 드문 원인 순으로.
장애 신고가 들어왔습니다. 첫 확인은 ① 연결 수 — max_connections에 근접했다면 그것만으로 신규 요청이 전부 거절됩니다. pgBouncer 풀과 idle 세션부터 봅니다.
연결에 여유가 있으면 ② Lock 대기 체인 — pg_blocking_pids()로 "누가 누구를 막고 있는지"를 봅니다. 한 세션이 수백 세션을 막는 그림이 자주 나옵니다.
Lock이 아니면 ③ 긴 트랜잭션 — 특히 idle in transaction 상태로 몇 시간 방치된 세션은 Lock과 Bloat의 단골 원흉입니다(8장).
다음은 ④ 쿼리 프로파일 — pg_stat_statements에서 실행 시간 급증 쿼리를 찾아 EXPLAIN (ANALYZE, BUFFERS)로 계획을 확인합니다.
여기까지 아니면 ⑤ 리소스 — CPU, 디스크 I/O(pg_stat_io, v16+), Checkpoint 폭주, 복제 지연을 봅니다. 이 5단계 순서를 플레이북으로 문서화해 두는 것이 이 장의 결론입니다.
-- 5단계 진단 쿼리 묶음 (즐겨찾기 필수)
-- ① 연결 수
SELECT count(*), state FROM pg_stat_activity GROUP BY state;
-- ③ 긴 트랜잭션
SELECT pid, state, now()-xact_start AS age, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL AND now()-xact_start > interval '1 minute'
ORDER BY age DESC;
-- ④ 무거운 쿼리 TOP 10
SELECT query, calls, round(mean_exec_time::numeric, 2) AS mean_ms
FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;
14.2 Lock 체인 — 한 명이 모두를 세운다
Lock 장애의 전형적인 그림: 누군가 트랜잭션을 열어 ALTER TABLE(또는 커밋을 잊은 UPDATE)을 잡고 있고, 그 뒤로 수백 개의 세션이 기차처럼 줄줄이 기다립니다. 관건은 대기자가 아니라 맨 앞의 blocker를 찾는 것입니다.
pid 101이 orders를 UPDATE한 뒤 커밋하지 않고 잠들었습니다(idle in transaction). 행 락과 RowExclusive 락을 쥔 채로요.
pid 202가 ALTER TABLE을 실행합니다. AccessExclusive 락이 필요한데 101이 놓아주지 않아 대기합니다.
여기서 연쇄 붕괴: ALTER가 락 대기열에 서는 순간, 그 뒤에 오는 평범한 SELECT들까지 전부 줄을 섭니다. 겉보기엔 "모든 쿼리가 멈춤" — 하지만 진짜 범인은 맨 앞의 잠든 101입니다.
pg_blocking_pids(202)가 {101}을 알려줍니다. pg_cancel_backend(쿼리만 취소) → 안 되면 pg_terminate_backend(세션 종료, 트랜잭션 롤백)로 해소. 재발 방지는 idle_in_transaction_session_timeout입니다.
-- 누가 누구를 막고 있는가 (v9.6+)
SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query,
blocking.pid AS blocking_pid, blocking.state AS blocking_state,
blocking.query AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
-- 재발 방지: 잊힌 트랜잭션 자동 정리
-- postgresql.conf: idle_in_transaction_session_timeout = '10min'
강제 종료된 세션의 미커밋 트랜잭션은 롤백됩니다. 대상이 3시간짜리 대량 쓰기였다면 롤백에도 상당한 시간이 걸리고, 그동안 락은 유지됩니다. 먼저 pg_cancel_backend(쿼리 취소)를 시도하는 것이 순서입니다.
14.3 슬로우 쿼리 추적 — 증상→증거→수정→검증 루프
"어제부터 이 화면이 느려요"를 해결하는 표준 루프입니다. 핵심은 감(感)이 아니라 재현 가능한 증거로 움직이는 것.
평소에 증거를 자동 수집해 둡니다: log_min_duration_statement = 1000(1초 이상 로깅) + pg_stat_statements(누적 프로파일) + 필요하면 auto_explain.
장애/리포트가 오면 pg_stat_statements를 total_exec_time 순으로 정렬해 용의자를 특정합니다. 주간 단위로는 pgBadger 로그 리포트가 트렌드를 보여줍니다.
용의 쿼리를 EXPLAIN (ANALYZE, BUFFERS)로 분석합니다. 의도치 않은 Seq Scan, Rows Removed by Filter가 큰 비효율 필터, Buffers: read 과다(캐시 밖 I/O)가 3대 단서입니다.
인덱스 추가나 쿼리 수정 후 배포하고, pg_stat_statements_reset()으로 카운터를 초기화한 뒤 N일 후 재측정합니다. 수치로 개선이 확인되어야 루프가 닫힙니다.
14.4 pg_stat_* 뷰 지도 — 어디서 무엇을 보나
| 뷰 | 계층 | 대표 질문 |
|---|---|---|
pg_stat_activity | 세션 | 지금 누가 무엇을 실행/대기 중인가? |
pg_locks | 세션 | 어떤 락을 보유/대기 중인가? |
pg_stat_database | DB | 캐시 히트율, deadlock, temp 파일은? |
pg_stat_user_tables | 객체 | dead tuple 비율, 마지막 autovacuum은? |
pg_stat_user_indexes | 객체 | 한 번도 안 쓰인 인덱스는? |
pg_stat_bgwriter | I/O | Checkpoint가 강제(req)로 얼마나 도나? |
pg_stat_wal (v14+) | I/O | WAL 생성량과 FPI 비율은? |
pg_stat_io (v16+) | I/O | 어떤 backend가 어떤 맥락으로 I/O를 내나? |
pg_stat_replication | 복제 | 스탠바이별 write/flush/replay lag은? |
pg_stat_statements | 쿼리 | 가장 무거운 쿼리 TOP N은? (extension) |
자주 보는 건강 지표 몇 가지의 해석 기준:
- 캐시 히트율 (
blks_hit / (blks_hit+blks_read)): 보통 99% 이상이 정상. 95% 아래면 shared_buffers 부족 또는 비효율적 시퀀셜 스캔 의심. state = 'idle in transaction': 트랜잭션을 열어놓고 노는 세션 — Bloat·Lock의 원흉.temp_files/temp_bytes증가: work_mem 부족으로 디스크 정렬·해시 중.checkpoints_req비중 30% 이상: max_wal_size 부족 (9장).
-- DB 건강 요약 한 방 쿼리
SELECT datname, numbackends, xact_commit, deadlocks,
round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS cache_hit_pct,
temp_files, temp_bytes
FROM pg_stat_database
WHERE datname NOT IN ('template0','template1');
14.5 알림 임계값 — 시작점 치트시트
| 지표 | Warning | Critical |
|---|---|---|
| Connection 사용률 | 70% | 90% |
| Cache hit ratio | < 95% | < 90% |
| Replication lag | 100MB / 30s | 1GB / 120s |
| Long-running 트랜잭션 | > 5min | > 30min |
| Dead tuple % (주요 테이블) | > 20% | > 40% |
| Checkpoint req 비율 | > 30% | > 50% |
| 디스크 사용률 (데이터/WAL) | 75% | 90% |
장애 후에 켜면 늦습니다: log_min_duration_statement = 1000, log_lock_waits = on, log_checkpoints = on, log_autovacuum_min_duration = '1s', log_temp_files = 0. 단, log_statement = 'all'은 디스크·CPU를 잡아먹으니 감사 목적이면 pgaudit(13장)을 쓰세요.
too many connections → pg_stat_activity 카운트·pgBouncer / 쿼리 급락 → pg_stat_statements·EXPLAIN / Lock 폭주 → pg_blocking_pids() / WAL 디스크 급증 → pg_replication_slots·pg_stat_archiver / XID 경고 → age(datfrozenxid) 후 긴급 VACUUM FREEZE (8장)
✍️ 이해도 체크
pg_blocking_pids()로 추적하니 체인의 맨 앞에 idle in transaction 세션 하나가 있습니다. 가장 적절한 첫 조치는?idle_in_transaction_session_timeout을 설정하세요. 서버 재시작은 정상 트랜잭션까지 날리는 과잉 대응입니다.이 장의 원문 문서: chapters/ch14_monitoring_troubleshooting.md — Lock 모드 호환성 표, Deadlock 로그 해석, pg_stat_io/pg_stat_wal 상세, 외부 도구(pgBadger·Prometheus) 구성이 담겨 있습니다.