본문 바로가기
Database2026년 9월 20일10분 읽기

실행계획이 갑자기 바뀌었을 때 — 플랜 고정과 쿼리 힌트 다루기

YS
김영삼
조회 184
실행계획이 갑자기 바뀌었을 때 — 플랜 고정과 쿼리 힌트 다루기

어제까지 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 비용 증가
  • 쿼리 분리는 코드 복잡도 상승
  • 부분 인덱스는 유지보수 대상 증가
  • 설정 변경은 전체에 영향

구조로 푸는 것이 먼저

힌트에 손을 뻗기 전에, 계획이 흔들릴 여지 자체를 줄이는 방법들이 있다. 이쪽이 지속 가능하다.

1
통계 개선
확장 통계로 컬럼 간 상관관계를 알려 주고, 중요한 컬럼은 통계 정밀도를 올린다.
2
인덱스 정비
옵티마이저가 고를 수밖에 없는 좋은 인덱스를 만든다. 선택지가 명확하면 흔들리지 않는다.
3
쿼리 재작성
조인 전에 대상을 줄이면 추정 오차의 영향이 작아진다. CTE나 서브쿼리로 단계를 명확히 한다.
4
데이터 모델 조정
자주 쓰는 집계는 미리 계산해 두고, 극단적으로 치우친 컬럼은 분리를 검토한다.
5
배치 이후 통계 갱신
대량 적재 후 ANALYZE를 파이프라인에 넣어, 낡은 통계로 인한 오판을 막는다.
-- 확장 통계로 상관관계 알려 주기
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;
힌트의 유효기간 힌트나 고정된 계획은 그 시점의 데이터 분포를 전제로 한다. 데이터가 10배가 되면 그 계획이 최악이 될 수 있다. 적용할 때 반드시 재검토 시점을 함께 적어 두고, 주기적으로 제거를 시도하라.

운영 체계 — 계획 변경을 감지하기

계획이 바뀌었다는 사실을 사용자 신고로 알게 되면 이미 늦다. 감지 체계가 필요하다.

모니터링 항목
쿼리별 실행 시간의 분산과 최대값 — 평균만 보면 계획 변경이 묻힌다
동일 쿼리 ID의 성능이 특정 시점에 계단식으로 변했는가
버퍼 읽기 수 급증 — 계획 변경의 좋은 선행 지표
통계 갱신 시각과 성능 변화 시각의 상관
자동 배큠·분석 로그 — 언제 무엇이 갱신됐는지
느린 쿼리 로그에 실행계획을 함께 남기는 설정
-- 실행계획을 느린 쿼리 로그에 자동 기록 (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 가 큰 쿼리 = 계획이 흔들리고 있을 가능성

업그레이드 전 점검

메이저 버전 업그레이드는 옵티마이저 동작이 바뀌는 대표적 계기다. 대부분은 개선이지만, 일부 쿼리는 나빠진다. 사전 점검 방법은 단순하다.

  1. 부하 상위 쿼리 목록을 뽑는다(pg_stat_statements 기준 상위 50개 정도).
  2. 복제본이나 스테이징을 새 버전으로 올린다.
  3. 같은 쿼리를 같은 파라미터로 실행해 계획과 시간을 비교한다.
  4. 악화된 쿼리를 목록화하고 대응책(통계·인덱스·재작성)을 준비한다.
  5. 업그레이드 직후 반드시 ANALYZE를 수행한다. 통계가 비면 계획이 엉뚱해진다.

자주 묻는 질문

쿼리가 갑자기 느려졌습니다. 무엇부터 보나요?

EXPLAIN ANALYZE로 현재 실행계획을 확인하고, 추정 행 수와 실제 행 수의 차이가 큰 노드를 찾으세요. 동시에 통계 갱신 시각, 데이터 적재 이력, 설정 변경 이력을 성능 변화 시점과 대조하면 원인이 좁혀집니다.

파라미터 스니핑 문제는 어떻게 해결하나요?

값의 선택도가 극단적으로 다른 경우가 원인입니다. 값 범위별로 쿼리를 분리하거나, 희귀 값에 부분 인덱스를 만들거나, 계획 캐시 동작을 조정해 매번 계획을 세우게 하는 방법이 있습니다. 각 방법마다 비용이 다르므로 측정 후 선택하세요.

힌트를 쓰는 것은 나쁜 습관인가요?

응급 상황에서는 정당한 수단입니다. 문제는 영구 해법으로 두는 것입니다. 적용 사유와 재검토 시점을 기록하고, 구조적 해결이 완료되면 제거를 시도하는 규율이 함께 있어야 합니다.

계획 고정은 안전한가요?

그 시점의 데이터 분포를 전제하므로, 데이터가 크게 변하면 오히려 최악의 계획을 계속 쓰게 됩니다. 고정한 쿼리 목록을 관리하고 주기적으로 해제해 재평가하는 절차가 필요합니다.

계획 변경을 어떻게 미리 감지하나요?

쿼리별 실행 시간의 분산과 최대값을 추적하세요. 평균만 보면 변화가 묻힙니다. 여기에 버퍼 읽기 수 급증을 함께 보고, 느린 쿼리 로그에 실행계획을 자동 기록해 두면 사후 분석이 훨씬 빨라집니다.

메이저 업그레이드 전에 무엇을 확인해야 하나요?

부하 상위 쿼리를 새 버전 환경에서 같은 파라미터로 실행해 계획과 시간을 비교하세요. 악화된 쿼리에 대한 대응책을 미리 준비하고, 업그레이드 직후에는 반드시 통계를 다시 수집해야 합니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록