서비스가 느려졌다는 알림만으로는 원인을 찾을 수 없습니다. 커넥션, 락, 슬로우 쿼리, 버퍼 캐시 같은 신호를 함께 봐야 DB가 어디서 막혔는지 판단할 수 있습니다. 관측 지표를 읽을 줄 알면 장애 대응 시간이 크게 줄어듭니다.
슬로우 쿼리를 EXPLAIN ANALYZE로 분석하는 방법은 query-execution-plan 모듈에서 다뤘습니다. 이 모듈은 한 발 앞에 있습니다. 장애가 나기 전에 감지하고, 이미 느려진 상황을 데이터로 증명하고, 알람으로 선제 대응하는 것이 목표입니다. 분석 도구가 아니라 감시 체계를 구축합니다.
- 1pg_stat_statements — 슬로우 쿼리 사후 분석의 핵심 뷰
- 2MySQL slow_query_log 설정 및 mysqldumpslow 활용
- 3postgres_exporter 핵심 메트릭 — QPS, 커넥션, 캐시 히트율
- 4Grafana 필수 패널 4개 구성 — 어떤 지표를 왜 보는가
- 5알람 임계값 설정 기준 — 300ms vs 1000ms 판단 근거
- 6잠금 경합 실시간 진단 — pg_locks와 InnoDB trx 뷰
운영 DB 모니터링 & 슬로우 쿼리 알람 설정
오전 11시 37분, 고객 센터에 첫 번째 민원이 들어왔습니다. "주문 조회가 너무 느려요." 처음엔 네트워크 문제인 줄 알았습니다. 11시 49분, 민원이 7건으로 늘었습니다. 백엔드 팀이 APM 대시보드를 열었을 때 주문 목록 API의 p99 레이턴시가 이미 12초를 넘긴 상태였습니다. DB 쿼리 로그를 뒤지기 시작했지만, 슬로우 쿼리 로그는 꺼져 있었고 pg_stat_statements도 비활성화 상태였습니다. 무엇이 느린지 알기 위해 수동으로 EXPLAIN ANALYZE를 하나씩 실행해보는 사이 12시 15분이 됐습니다. 장애 시작부터 원인 파악까지 38분이 걸렸습니다. 모니터링이 켜져 있었다면 3분 안에 끝날 일이었습니다.
학습 목표
이 모듈을 마치면 다음을 할 수 있습니다.
pg_stat_statements를 활성화하고 상위 슬로우 쿼리를 주기적으로 수집한다- MySQL
slow_query_log를 설정하고mysqldumpslow로 분석한다 - Prometheus
postgres_exporter의 핵심 메트릭을 이해하고 수집한다 - Grafana 대시보드에 필수 DB 패널 4개를 구성한다
- 슬로우 쿼리 알람 임계값의 근거를 설명하고 팀에 적합한 값을 결정한다
pg_locks와information_schema.innodb_trx로 잠금 경합을 실시간 진단한다
# postgres_exporter + Prometheus + Grafana 로컬 스택
cat > docker-compose.monitoring.yml << 'EOF'
version: "3.8"
services:
postgres:
image: postgres:15
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
ports:
- "5432:5432"
postgres_exporter:
image: prometheuscommunity/postgres-exporter:latest
environment:
DATA_SOURCE_NAME: "postgresql://postgres:secret@postgres:5432/appdb?sslmode=disable"
ports:
- "9187:9187"
depends_on:
- postgres
prometheus:
image: prom/prometheus:latest
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
grafana:
image: grafana/grafana:latest
environment:
GF_SECURITY_ADMIN_PASSWORD: admin
ports:
- "3000:3000"
EOF
# Prometheus 설정 파일 생성
cat > prometheus.yml << 'EOF'
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'postgres'
static_configs:
- targets: ['postgres_exporter:9187']
EOF
docker compose -f docker-compose.monitoring.yml up -d
실행 완료 또는 조회 결과가 표시됩니다.
- 슬로우 쿼리—응답 시간이 긴 쿼리의 SQL과 실행 빈도를 함께 확인합니다.
- 락 대기—대기 이벤트가 쿼리 실행인지 락 경합인지 구분합니다.
- 커넥션 상태—idle, active, waiting 연결 비율을 운영 기준과 비교합니다.
-- pg_stat_statements 활성화 확인
SHOW shared_preload_libraries;
-- 결과에 'pg_stat_statements'가 없으면 postgresql.conf에 추가 필요
pg_stat_statements — 슬로우 쿼리를 사후에 잡는 뷰
pg_stat_statements는 PostgreSQL에서 실행된 모든 쿼리의 통계를 누적 집계합니다. EXPLAIN ANALYZE가 특정 쿼리 하나를 즉시 해부하는 것이라면, pg_stat_statements는 "지난 24시간 동안 어떤 쿼리가 DB를 가장 많이 갉아먹었는가"를 알려주는 전체 워크로드 X선 사진입니다.

활성화 방법
# postgresql.conf 수정
# Docker 환경이면 postgres 컨테이너 셸 진입 후 또는 environment 변수로 설정
echo "shared_preload_libraries = 'pg_stat_statements'" >> /etc/postgresql/15/main/postgresql.conf
echo "pg_stat_statements.track = all" >> /etc/postgresql/15/main/postgresql.conf
# PostgreSQL 재시작
sudo systemctl restart postgresql
# 또는 Docker에서
docker run -e POSTGRES_PASSWORD=secret \
-e POSTGRES_INITDB_ARGS="--encoding=UTF8" \
postgres:15 \
-c shared_preload_libraries=pg_stat_statements \
-c pg_stat_statements.track=all
-- 데이터베이스에 확장 설치 (한 번만)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- 설치 확인
SELECT * FROM pg_extension WHERE extname = 'pg_stat_statements';
상위 슬로우 쿼리 조회
-- 평균 실행 시간 기준 상위 10개 쿼리
SELECT
query,
calls,
total_exec_time / calls AS avg_ms,
rows
FROM pg_stat_statements
ORDER BY avg_ms DESC
LIMIT 10;
이 쿼리 하나로 "어떤 쿼리가 평균 몇 ms가 걸렸는지"를 바로 확인할 수 있습니다. calls가 높고 avg_ms도 높은 쿼리가 1순위 최적화 대상입니다. calls가 낮더라도 avg_ms가 수천 ms인 쿼리는 배치 또는 리포트성일 가능성이 높습니다.
유용한 분석 쿼리
-- 총 실행 시간 기준 (DB 자원을 가장 많이 소비한 쿼리)
SELECT
LEFT(query, 80) AS query_snippet,
calls,
ROUND(total_exec_time) AS total_ms,
ROUND(total_exec_time / calls) AS avg_ms,
ROUND(rows::numeric / calls, 1) AS avg_rows,
ROUND(100.0 * total_exec_time /
SUM(total_exec_time) OVER (), 2) AS pct_total
FROM pg_stat_statements
WHERE calls > 100
ORDER BY total_exec_time DESC
LIMIT 15;
-- 통계 초기화 (분석 사이클을 새로 시작할 때)
SELECT pg_stat_statements_reset();
pct_total 컬럼이 핵심입니다. 어떤 쿼리 하나가 DB 전체 실행 시간의 40%를 차지하고 있다면, 그 쿼리 하나 최적화가 시스템 전체 성능을 바꿉니다.
실습: MySQL slow_query_log 설정
MySQL 환경에서는 slow_query_log가 파일 기반으로 슬로우 쿼리를 수집합니다. long_query_time을 초 단위로 설정하며, 이 시간을 초과한 쿼리가 로그 파일에 기록됩니다.
MySQL 전역 설정 런타임 변경
안전한 실행 조건: my.cnf에 동일하게 추가해야 영구 적용됩니다
실행 전 반드시 확인
- 로그 파일 저장 경로의 디스크 여유 공간 확인
- long_query_time 임계값을 운영 쿼리 특성에 맞게 설정
- 운영 피크 시간을 피해 설정 변경
SET GLOBAL slow_query_log = 'ON'위 항목을 모두 확인한 후 복사할 수 있습니다
-- MySQL에서 슬로우 쿼리 로그 활성화 (재시작 없이 적용)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1초 이상인 쿼리 기록
SET GLOBAL log_queries_not_using_indexes = 'ON'; -- 인덱스 미사용 쿼리도 포함
-- 설정 확인
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
SHOW VARIABLES LIKE 'log_queries_not_using_indexes';
영구 적용을 위해 my.cnf에도 추가합니다.
# /etc/mysql/my.cnf 또는 /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
min_examined_row_limit = 100 # 소량 행 쿼리는 제외 (노이즈 감소)
로그 파일이 쌓이면 mysqldumpslow로 요약합니다.
# 평균 실행 시간 기준 상위 10개 출력
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
# 총 시간 기준 (DB 자원을 가장 많이 소비한 쿼리)
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 특정 테이블이 포함된 쿼리만 필터
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log | grep -A5 "orders"
임계값 선택 기준: OLTP 서비스(사용자가 직접 사용하는 API)라면
long_query_time = 0.3(300ms)부터 시작하는 것이 좋습니다. 배치 처리, 리포트 쿼리 위주 시스템이라면long_query_time = 1(1초)이 적당합니다. 임계값이 너무 낮으면 로그가 폭발해 정작 중요한 쿼리를 놓치고, 너무 높으면 사용자가 이미 고통받고 있는 쿼리를 감지하지 못합니다.
postgres_exporter 핵심 메트릭 — 무엇을 왜 보는가
prometheus/postgres_exporter는 PostgreSQL의 내부 통계 뷰(pg_stat_*)를 Prometheus가 수집할 수 있는 메트릭 형식으로 변환합니다. 수십 개의 메트릭 중 운영 현장에서 반드시 봐야 하는 3가지 범주가 있습니다.
1. QPS (Queries Per Second) — 부하 추세
# 초당 쿼리 수 (커밋+롤백 기준)
rate(pg_stat_database_xact_commit{datname="appdb"}[5m])
+ rate(pg_stat_database_xact_rollback{datname="appdb"}[5m])
QPS 자체보다 QPS의 갑작스러운 변화가 중요합니다. 평소 대비 3배 이상 급등하거나 갑자기 0에 가까워지면 모두 이상 신호입니다.
2. 활성 커넥션 수 — 풀 포화 감지
# 현재 활성 연결 수
pg_stat_activity_count{datname="appdb", state="active"}
# 최대 연결 수 대비 사용률 (%)
100 * pg_stat_activity_count{datname="appdb"}
/ pg_settings_max_connections
연결 수가 max_connections의 80%를 넘으면 새 연결이 실패하기 시작합니다. 70%에서 알람을 걸어야 대응 시간이 생깁니다.
3. 캐시 히트율 — 메모리 충분성 판단
# 캐시 히트율 (%) — 데이터를 디스크가 아닌 메모리에서 읽은 비율
100 * pg_stat_database_blks_hit{datname="appdb"}
/ (pg_stat_database_blks_hit{datname="appdb"}
+ pg_stat_database_blks_read{datname="appdb"})
90% 이상이 정상입니다. 90% 미만이면 데이터의 상당 부분이 shared_buffers 밖에 있어 디스크 I/O가 반복됩니다. shared_buffers를 늘리거나 (총 메모리의 25~40% 권장), Full Scan을 유발하는 쿼리를 최적화하는 것이 해결 방향입니다.
추가로 수집할 유용한 메트릭
| 메트릭 이름 | 의미 | 알람 조건 |
|---|---|---|
pg_replication_lag | 복제 지연(초) | 30초 이상 |
pg_stat_database_deadlocks | 데드락 발생 횟수 | 분당 1건 이상 |
pg_stat_database_temp_bytes | 임시 파일 사용량 | 지속 증가 추세 |
pg_stat_bgwriter_maxwritten_clean | checkpoint 강제 발생 | 0이 아닐 때 |
실습: Grafana 대시보드 필수 패널 4개
Grafana에서 새 대시보드를 만들고 아래 4개 패널을 구성합니다. 각 패널은 "무엇을 왜 보는가"가 명확해야 합니다.
패널 1 — QPS (초당 트랜잭션 수)
# Grafana PromQL 입력란에 붙여넣기
rate(pg_stat_database_xact_commit{datname="appdb"}[1m])
+ rate(pg_stat_database_xact_rollback{datname="appdb"}[1m])
- 패널 유형: Time series
- 목적: 트래픽 급증/급감을 가장 먼저 감지
- 임계 라인: 평소 최대값 × 2 (주석으로 표시)
패널 2 — 활성 커넥션 vs max_connections
# 활성 커넥션
pg_stat_activity_count{datname="appdb", state="active"}
# 위험 임계선 (max_connections의 70%)
0.7 * pg_settings_max_connections
- 패널 유형: Time series (2개 라인 겹치기)
- 목적: 커넥션 포화 직전 경보
패널 3 — 캐시 히트율
100 * pg_stat_database_blks_hit{datname="appdb"}
/ (pg_stat_database_blks_hit{datname="appdb"}
+ pg_stat_database_blks_read{datname="appdb"} + 1)
- 패널 유형: Gauge (0~100%)
- 색상 임계값: 90 이상 = 초록, 80~90 = 노랑, 80 미만 = 빨강
패널 4 — 슬로우 쿼리 상위 5개 (테이블)
Grafana에서 PostgreSQL 데이터소스를 추가 연결하면 SQL 패널 유형을 쓸 수 있습니다.
SELECT
LEFT(query, 100) AS "쿼리",
calls AS "호출 수",
ROUND(total_exec_time / calls) AS "평균(ms)",
ROUND(total_exec_time) AS "총 시간(ms)"
FROM pg_stat_statements
WHERE calls > 10
ORDER BY total_exec_time / calls DESC
LIMIT 5;
- 패널 유형: Table
- 목적: 대시보드를 열자마자 최우선 최적화 대상 쿼리가 눈에 들어오도록
팁: Grafana Labs에는 PostgreSQL용 공식 대시보드 ID 9628이 있습니다. Import 기능으로 즉시 불러올 수 있으며, 위 4개 패널 커스터마이징의 출발점으로 활용하세요.
알람 임계값 설정 기준 — 300ms vs 1000ms의 근거
알람 임계값은 "이 숫자를 넘으면 사용자가 영향을 받는가"를 기준으로 결정합니다. 보편적인 정답은 없지만, 판단 프레임워크가 있습니다.
서비스 유형별 권장 임계값
| 서비스 유형 | 슬로우 쿼리 임계값 | 근거 |
|---|---|---|
| 사용자 직접 호출 API (OLTP) | 100~300ms | 사용자 체감 속도 기준. 200ms 이상은 "느리다"고 느낌 |
| 내부 API / 관리자 화면 | 500~1000ms | 내부 사용자는 상대적으로 허용 범위 넓음 |
| 배치 / 집계 쿼리 | 3000~10000ms | 사람이 직접 기다리지 않으므로 넓게 설정 |
| 실시간 검색 | 50~100ms | 검색은 빠르지 않으면 이탈률 증가 |
Prometheus 알람 규칙 예시
# prometheus/rules/postgres.yml
groups:
- name: postgres_alerts
rules:
# 커넥션 포화 경고
- alert: PostgresHighConnections
expr: |
pg_stat_activity_count{datname="appdb"}
/ pg_settings_max_connections > 0.7
for: 2m
labels:
severity: warning
annotations:
summary: "PostgreSQL 커넥션 사용률 70% 초과"
description: "현재 커넥션 수가 max_connections의 {{ $value | humanizePercentage }} 수준입니다."
# 캐시 히트율 저하
- alert: PostgresLowCacheHitRate
expr: |
100 * pg_stat_database_blks_hit{datname="appdb"}
/ (pg_stat_database_blks_hit{datname="appdb"}
+ pg_stat_database_blks_read{datname="appdb"} + 1) < 90
for: 5m
labels:
severity: warning
annotations:
summary: "PostgreSQL 캐시 히트율 90% 미만"
description: "캐시 히트율이 {{ $value | humanize }}%입니다. shared_buffers 증가 또는 쿼리 최적화가 필요합니다."
# 데드락 발생
- alert: PostgresDeadlockDetected
expr: |
rate(pg_stat_database_deadlocks{datname="appdb"}[5m]) > 0
for: 1m
labels:
severity: critical
annotations:
summary: "PostgreSQL 데드락 감지"
description: "분당 {{ $value | humanize }}건의 데드락이 발생하고 있습니다."
알람 피로(Alert Fatigue) 방지
알람이 너무 자주 울리면 팀이 무시하기 시작합니다. for: 2m 조건은 "2분 이상 지속될 때만 알람"을 의미합니다. 순간적 스파이크는 무시하고 지속적인 문제만 알람을 보내는 것이 핵심입니다.
실습: 잠금 경합 실시간 진단
슬로우 쿼리의 원인이 항상 쿼리 자체에 있는 것은 아닙니다. 다른 트랜잭션이 잠금을 쥐고 있어 대기하는 경우도 흔합니다.
PostgreSQL — pg_locks로 잠금 대기 확인
-- 현재 잠금 대기 중인 세션과 원인 세션을 함께 표시
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocked.wait_event_type,
blocked.wait_event,
EXTRACT(EPOCH FROM NOW() - blocked.query_start)::int AS wait_seconds
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.cardinality(pg_blocking_pids(blocked.pid)) > 0
ORDER BY wait_seconds DESC;
wait_seconds가 수십 초를 넘는 세션이 있다면 blocking_pid를 찾아 원인 쿼리를 확인하고, 필요하면 SELECT pg_terminate_backend(blocking_pid)로 강제 종료합니다.
-- 잠금 타입별 현황 요약
SELECT
locktype,
relation::regclass AS table_name,
mode,
granted,
COUNT(*) AS count
FROM pg_locks
WHERE relation IS NOT NULL
GROUP BY locktype, relation, mode, granted
ORDER BY count DESC;
MySQL — InnoDB 트랜잭션 대기 확인
-- 현재 실행 중인 트랜잭션 목록
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_locked,
trx_rows_modified,
LEFT(trx_query, 120) AS current_query
FROM information_schema.innodb_trx
ORDER BY trx_started ASC;
duration_sec이 길고 trx_state = 'LOCK WAIT'인 트랜잭션이 있다면 잠금 경합이 진행 중입니다.
-- 잠금 대기 그래프 (MySQL 8.0 — performance_schema 활용)
SELECT
r.trx_id AS waiting_trx_id,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_query AS blocking_query,
b.trx_mysql_thread_id AS blocking_thread
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
예방 패턴: 트랜잭션은 짧게 유지하고, 같은 순서로 잠금을 획득하도록 코드를 설계하면 데드락과 잠금 경합을 구조적으로 줄일 수 있습니다.
이 에러는 PostgreSQL의 max_connections 한도에 도달했을 때 발생합니다. PostgreSQL은 마지막 몇 개의 연결 슬롯을 슈퍼유저와 복제 연결을 위해 예약(superuser_reserved_connections, 기본값 3)하며, 일반 사용자 연결이 이 예약분까지 채우면 신규 연결이 즉시 거부됩니다.
즉각 대응 (운영 중 연결 정리):
-- 현재 연결 상태 확인
SELECT state, COUNT(*) FROM pg_stat_activity GROUP BY state;
-- 유휴 연결 강제 종료 (idle 상태 5분 이상)
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle'
AND query_start < NOW() - INTERVAL '5 minutes'
AND pid <> pg_backend_pid();
중기 해결책 (설정 조정):
-- 현재 max_connections 확인
SHOW max_connections;
-- 현재 연결 수 확인
SELECT COUNT(*) FROM pg_stat_activity;
# postgresql.conf
max_connections = 200 # 기본값(100)보다 크게
superuser_reserved_connections = 5 # 슈퍼유저 예약 슬롯
근본 해결책 (커넥션 풀 도입):
애플리케이션이 직접 DB에 연결하는 구조라면 PgBouncer를 앞에 두는 것이 장기 해결책입니다. max_connections를 늘리는 것만으로는 언젠가 다시 같은 에러가 발생합니다. 스케일아웃 환경(컨테이너 오토스케일 등)에서는 파드 수 × 풀 크기가 max_connections를 초과하는 것은 시간문제입니다.
connection-pooling 모듈에서 PgBouncer + HikariCP 2계층 아키텍처를 다룹니다.
"DBA 없는 팀에서 백엔드 개발자가 직접 셋업하는 모니터링 스택"
스타트업이나 중소 서비스팀에는 DBA가 없는 경우가 대부분입니다. DB 모니터링이 없어도 평소에는 아무 문제가 없어 보입니다. 그러다 트래픽이 몰리거나 대형 배치 작업이 실행되는 순간, 아무도 원인을 모르는 장애가 발생합니다.
이 모듈에서 다룬 스택(pg_stat_statements + postgres_exporter + Prometheus + Grafana)은 오픈소스로 라이선스 비용이 없으며, Docker Compose 하나로 로컬에서도 돌아갑니다. 설정에 걸리는 시간은 반나절입니다. 그 반나절 투자가 다음 장애의 원인 파악 시간을 38분에서 3분으로 줄입니다.
현업에서 이 스택을 처음 셋업할 때 가장 중요한 순서는 다음과 같습니다:
pg_stat_statements활성화 → 즉시 슬로우 쿼리 누적 시작- Grafana 대시보드 4개 패널 → 팀 모두가 같은 화면을 볼 수 있는 기반
- 커넥션 포화 알람 → 가장 빈번한 운영 장애를 사전 감지
- 슬로우 쿼리 알람 → SLA 기반 임계값 설정으로 품질 기준 명문화
"모니터링은 나중에"는 항상 장애 다음에 후회하는 말입니다.
다음 모듈에서는 DB 연결 지연을 없애는 HikariCP 설정과 커넥션 풀 대기 성능 튜닝 방법을 다룹니다.