어제까지 50밀리초에 끝나던 쿼리가 오늘 30초가 됐다. 코드는 그대로고 데이터도 크게 안 늘었다. 이런 일이 일어나는 이유는 대개 하나다. 옵티마이저가 다른 실행계획을 골랐다.
계획이 바뀌는 계기는 통계 갱신, 데이터 분포 변화, 파라미터 값 차이, 버전 업그레이드, 설정 변경 등이다. 대응은 두 갈래다. 옵티마이저가 올바른 선택을 하도록 정보를 개선하거나, 특정 계획을 강제하거나.
나는 힌트를 오래 싫어했다. "옵티마이저를 믿어라"는 말이 옳다고 생각했다. 그런데 새벽 3시에 서비스가 멈춰 있고 원인이 실행계획 변경일 때, 근본 해결을 논하기엔 너무 늦다. 그때 배운 게 있다. 힌트는 응급처치이고, 응급처치도 필요하다. 다만 응급처치를 영구 해법으로 두지 않는 규율이 함께 있어야 한다.
왜 계획이 바뀌나
| 계기 | 전형적인 증상 | 확인 방법 |
|---|---|---|
| 통계 갱신(ANALYZE) | 배치 작업 직후 갑자기 느려짐 | 통계 수집 시각과 장애 시각 대조 |
| 데이터 분포 변화 | 특정 시점부터 서서히 악화 | 히스토그램·MCV 확인 |
| 파라미터 값 차이 | 같은 쿼리인데 요청마다 다름 | 바인드 값별 실행계획 비교 |
| 플랜 캐시 | 첫 실행 값에 최적화된 계획이 고정 | 준비된 문장 재실행 시 계획 확인 |
| 버전 업그레이드 | 업그레이드 직후 광범위한 변화 | 업그레이드 전후 계획 비교 |
| 설정 변경 | work_mem 등 조정 후 | 설정 변경 이력 확인 |
EXPLAIN (ANALYZE, BUFFERS)로 추정 행 수와 실제 행 수의 차이를 확인한다. 차이가 큰 노드가 문제의 시작점이다. 그 지점에서 잘못된 조인 방식이나 인덱스 선택이 결정된다.파라미터 스니핑 — 같은 쿼리, 다른 최적값
바인드 파라미터를 쓰는 쿼리는 첫 실행 값을 기준으로 계획을 만들어 캐시할 수 있다. 값의 선택도가 크게 다르면, 한쪽에 최적인 계획이 다른 쪽에는 최악이 된다.
-- 예: 상태값 분포가 극단적으로 치우친 경우
-- status='pending' → 전체의 0.1% (인덱스 스캔이 유리)
-- status='completed' → 전체의 95% (순차 스캔이 유리)
PREPARE q(text) AS
SELECT * FROM orders WHERE status = $1 AND created_at > now() - interval '7 days';
-- 첫 실행이 'pending' 이면 인덱스 스캔 계획이 캐시될 수 있고,
-- 이후 'completed' 요청이 그 계획을 쓰면 느려진다.
-- 확인: 일반 계획인지 커스텀 계획인지 본다
EXPLAIN (ANALYZE) EXECUTE q('completed');
- 계획 캐시를 끄고 매번 새로 세우기(plan_cache_mode 조정)
- 값 범위별로 쿼리를 분리
- 선택도가 극단적인 컬럼에 부분 인덱스
- 애플리케이션에서 값에 따라 다른 쿼리 사용
- 매번 계획 수립은 CPU 비용 증가
- 쿼리 분리는 코드 복잡도 상승
- 부분 인덱스는 유지보수 대상 증가
- 설정 변경은 전체에 영향
구조로 푸는 것이 먼저
힌트에 손을 뻗기 전에, 계획이 흔들릴 여지 자체를 줄이는 방법들이 있다. 이쪽이 지속 가능하다.
-- 확장 통계로 상관관계 알려 주기
CREATE STATISTICS st_orders (dependencies, ndistinct, mcv)
ON customer_id, status FROM orders;
ANALYZE orders;
-- 특정 컬럼 통계 정밀도 상향
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
-- 치우친 값에 대한 부분 인덱스 (희귀 값만 인덱싱)
CREATE INDEX idx_orders_pending ON orders (created_at)
WHERE status = 'pending';
그래도 필요할 때 — 힌트와 계획 고정
구조적 해결에 시간이 걸리거나, 특정 쿼리 하나 때문에 서비스가 위험할 때는 계획을 강제해야 한다. 데이터베이스마다 수단이 다르고, 포스트그레SQL은 확장을 통해 제공된다.
| 수단 | 성격 | 주의점 |
|---|---|---|
| 힌트(확장 기반) | 특정 쿼리에 조인 방식·인덱스 지정 | 확장 설치 가능 여부 확인, 업그레이드 호환성 |
| 계획 고정 | 수집된 계획을 특정 쿼리에 묶음 | 데이터가 변해도 그 계획을 계속 쓴다 |
| 설정 국소 조정 | 세션·트랜잭션 단위로 옵션 조정 | 영향 범위를 반드시 좁힐 것 |
| 쿼리 구조 변경 | 옵티마이저 선택지를 줄이는 재작성 | 가장 이식성 높은 방법 |
-- 세션 단위로 특정 계획을 유도 (임시 대응)
BEGIN;
SET LOCAL enable_seqscan = off; -- 트랜잭션 안에서만 적용
SELECT ... ;
COMMIT;
-- 주의: 전역 설정으로 끄면 다른 쿼리가 망가진다.
-- SET LOCAL 을 쓰고 범위를 최소화할 것.
-- 작업 메모리를 이 쿼리에만 늘리기 (정렬·해시 조인 개선)
BEGIN;
SET LOCAL work_mem = '256MB';
SELECT ... ;
COMMIT;
운영 체계 — 계획 변경을 감지하기
계획이 바뀌었다는 사실을 사용자 신고로 알게 되면 이미 늦다. 감지 체계가 필요하다.
-- 실행계획을 느린 쿼리 로그에 자동 기록 (auto_explain)
-- postgresql.conf
-- shared_preload_libraries = 'auto_explain'
-- auto_explain.log_min_duration = '3s'
-- auto_explain.log_analyze = on
-- auto_explain.log_buffers = on
-- auto_explain.log_nested_statements = on
-- 사후 분석: 부하 상위 쿼리의 변동성 확인
SELECT queryid, calls,
round(mean_exec_time::numeric,1) AS mean_ms,
round(stddev_exec_time::numeric,1) AS stddev_ms,
round(max_exec_time::numeric,1) AS max_ms
FROM pg_stat_statements
WHERE calls > 100
ORDER BY stddev_exec_time DESC
LIMIT 20;
-- stddev 가 큰 쿼리 = 계획이 흔들리고 있을 가능성
업그레이드 전 점검
메이저 버전 업그레이드는 옵티마이저 동작이 바뀌는 대표적 계기다. 대부분은 개선이지만, 일부 쿼리는 나빠진다. 사전 점검 방법은 단순하다.
- 부하 상위 쿼리 목록을 뽑는다(
pg_stat_statements기준 상위 50개 정도). - 복제본이나 스테이징을 새 버전으로 올린다.
- 같은 쿼리를 같은 파라미터로 실행해 계획과 시간을 비교한다.
- 악화된 쿼리를 목록화하고 대응책(통계·인덱스·재작성)을 준비한다.
- 업그레이드 직후 반드시
ANALYZE를 수행한다. 통계가 비면 계획이 엉뚱해진다.
자주 묻는 질문
쿼리가 갑자기 느려졌습니다. 무엇부터 보나요?
EXPLAIN ANALYZE로 현재 실행계획을 확인하고, 추정 행 수와 실제 행 수의 차이가 큰 노드를 찾으세요. 동시에 통계 갱신 시각, 데이터 적재 이력, 설정 변경 이력을 성능 변화 시점과 대조하면 원인이 좁혀집니다.
파라미터 스니핑 문제는 어떻게 해결하나요?
값의 선택도가 극단적으로 다른 경우가 원인입니다. 값 범위별로 쿼리를 분리하거나, 희귀 값에 부분 인덱스를 만들거나, 계획 캐시 동작을 조정해 매번 계획을 세우게 하는 방법이 있습니다. 각 방법마다 비용이 다르므로 측정 후 선택하세요.
힌트를 쓰는 것은 나쁜 습관인가요?
응급 상황에서는 정당한 수단입니다. 문제는 영구 해법으로 두는 것입니다. 적용 사유와 재검토 시점을 기록하고, 구조적 해결이 완료되면 제거를 시도하는 규율이 함께 있어야 합니다.
계획 고정은 안전한가요?
그 시점의 데이터 분포를 전제하므로, 데이터가 크게 변하면 오히려 최악의 계획을 계속 쓰게 됩니다. 고정한 쿼리 목록을 관리하고 주기적으로 해제해 재평가하는 절차가 필요합니다.
계획 변경을 어떻게 미리 감지하나요?
쿼리별 실행 시간의 분산과 최대값을 추적하세요. 평균만 보면 변화가 묻힙니다. 여기에 버퍼 읽기 수 급증을 함께 보고, 느린 쿼리 로그에 실행계획을 자동 기록해 두면 사후 분석이 훨씬 빨라집니다.
메이저 업그레이드 전에 무엇을 확인해야 하나요?
부하 상위 쿼리를 새 버전 환경에서 같은 파라미터로 실행해 계획과 시간을 비교하세요. 악화된 쿼리에 대한 대응책을 미리 준비하고, 업그레이드 직후에는 반드시 통계를 다시 수집해야 합니다.

댓글 0