어느 쿼리가 느린지 모를 때, pg_stat_statements와 EXPLAIN 읽는 순서

어느 쿼리가 느린지

결론부터 말씀드리면, 어느 쿼리가 느린지 모를 때는 감으로 찾지 말고 pg_stat_statements로 범위를 좁힌 다음 EXPLAIN으로 원인을 확인하는 순서를 지키는 것이 가장 빠릅니다. 로그를 뒤지거나 “이 쿼리가 느릴 것 같다”는 추측으로 튜닝을 시작하면 시간만 쓰고 원인은 못 찾는 경우가 많습니다. 이 글은 PostgreSQL 13 이상 환경을 기준으로, 통계 확장을 켜는 설정부터 EXPLAIN 출력에서 실제로 봐야 할 줄까지 순서대로 정리합니다.

어느 쿼리가 느린지 모를 때 가장 먼저 확인할 것

가장 먼저 확인할 것은 “내가 느리다고 느끼는 쿼리”와 “실제로 DB를 가장 많이 잡아먹는 쿼리”가 다를 수 있다는 점입니다. 사람이 체감하는 느림은 네트워크 지연, 애플리케이션 로직, 커넥션 풀 대기 시간이 섞여 있어서 정확하지 않습니다.

이걸 구분하려면 DB 엔진 쪽에서 실제로 실행 시간을 집계한 통계가 필요합니다. PostgreSQL에는 이 역할을 하는 확장이 기본 번들에 포함되어 있는데, 바로 pg_stat_statements입니다. 이 확장은 실행된 모든 쿼리를 정규화해서 누적 실행 시간, 호출 횟수, 평균 실행 시간을 테이블 형태로 보여줍니다.

pg_stat_statements로 느린 쿼리 순위 뽑기

설정은 postgresql.conf를 수정하고 재시작해야 적용됩니다. shared_preload_libraries에 추가하지 않으면 CREATE EXTENSION만으로는 통계가 쌓이지 않으니 이 순서를 지켜야 합니다.

-- postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
pg_stat_statements.track = top
pg_stat_statements.max = 5000

-- 재시작 후 DB 안에서
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

재시작까지 끝났다면, 아래 쿼리로 누적 실행 시간이 가장 긴 쿼리부터 뽑아볼 수 있습니다. PostgreSQL 13부터 컬럼명이 total_time에서 total_exec_time으로 바뀌었기 때문에, 버전이 12 이하라면 컬럼명을 total_time으로 바꿔야 합니다.

개발자가 모니터로 데이터베이스 성능 대시보드를 분석하는 모습

SELECT
  query,
  calls,
  round(total_exec_time::numeric, 1) AS total_ms,
  round(mean_exec_time::numeric, 1) AS mean_ms,
  rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

여기서 total_exec_time(누적 시간)과 mean_exec_time(평균 시간)을 같이 봐야 범위를 제대로 좁힐 수 있습니다. 누적 시간이 높아도 호출 횟수가 수백만 번이면 쿼리 한 번은 가볍지만 자주 불려서 전체 부담이 큰 경우고, mean_exec_time이 높으면 호출 한 번이 그 자체로 무거운 경우입니다. 둘 중 어느 쪽이냐에 따라 튜닝 방향이 완전히 달라집니다.

EXPLAIN (ANALYZE, BUFFERS) 출력, 어디부터 읽어야 할까요?

후보 쿼리를 추렸다면 그 쿼리를 EXPLAIN으로 돌려서 PostgreSQL이 세운 실행 계획을 확인합니다. ANALYZE 옵션을 넣으면 실제로 쿼리를 실행하면서 각 단계가 걸린 시간을 보여주고, BUFFERS 옵션을 넣으면 디스크/캐시 I/O 횟수까지 같이 나옵니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders
WHERE customer_id = 1024
  AND status = 'paid';

출력 예시는 대략 이런 모양입니다 (실제 수치는 데이터량에 따라 다릅니다).

Seq Scan on orders (cost=0.00..18562.00 rows=12 width=96)
                    (actual time=0.021..142.887 rows=9 loops=1)
  Filter: ((customer_id = 1024) AND (status = 'paid'::text))
  Rows Removed by Filter: 499991
  Buffers: shared hit=1021 read=7540
Planning Time: 0.112 ms
Execution Time: 143.021 ms

읽는 순서는 보통 아래쪽에서 위쪽입니다. Execution Time으로 전체 소요 시간을 확인하고, cost 추정치와 actual time의 차이를 비교해서 플래너가 예측을 얼마나 틀렸는지 봅니다. 이 전체 그림이 바로 PostgreSQL이 세운 실행 계획이고, query plan이라는 개념 자체는 대부분의 관계형 DB에 공통으로 존재합니다.

Seq Scan과 Rows Removed by Filter가 말해주는 것

위 예시에서 눈여겨볼 두 줄은 Seq Scan과 Rows Removed by Filter입니다. Seq Scan은 인덱스를 쓰지 않고 테이블 전체를 순차 스캔했다는 뜻이고, Rows Removed by Filter는 499,991행을 읽은 뒤에야 조건에 맞는 9행을 걸러냈다는 뜻입니다.

표시 의미 확인할 것
Seq Scan 인덱스 미사용, 전체 테이블 읽음 customer_id, status에 인덱스가 있는지
Index Scan 인덱스로 바로 찾아감 cost와 actual time 격차가 작은지
Rows Removed by Filter 조건에 안 맞아 버려진 행 수 이 값이 크면 인덱스 조건 재검토
Buffers read 디스크에서 직접 읽은 블록 수 값이 크면 캐시에 못 들어간 상태

이 네 가지만 짚어도 “어디가 느린지”는 거의 확인이 됩니다. 위 사례라면 (customer_id, status) 복합 인덱스를 하나 추가하는 것으로 Seq Scan을 Index Scan으로 바꿀 수 있는 전형적인 케이스입니다.

이 방법이 안 통하는 경우와 주의할 점

이 순서가 항상 통하는 것은 아닙니다. 몇 가지 조건에서는 다르게 접근해야 합니다.

  • pg_stat_statements는 서버 재시작이나 pg_stat_statements_reset() 호출 시 통계가 초기화되어서, 간헐적으로 튀는 쿼리는 짧은 관찰 구간에는 안 잡힐 수 있습니다.
  • 파라미터 값이 다른 쿼리는 정규화돼서 하나의 행으로 합쳐지기 때문에, 특정 파라미터 값에서만 느린 경우는 EXPLAIN에 그 값을 직접 넣어서 따로 재현해봐야 합니다.
  • EXPLAIN ANALYZE는 쿼리를 실제로 실행합니다. INSERT/UPDATE/DELETE가 포함된 쿼리를 운영 DB에서 그대로 돌리면 데이터가 바뀌므로, 트랜잭션으로 감싸고 ROLLBACK하거나 복제 환경에서 테스트해야 합니다.
  • auto_explain 확장을 같이 쓰면 느린 쿼리의 실행 계획을 로그에 자동으로 남길 수 있지만, 이건 이 글의 범위를 넘어서는 별도 설정입니다.

pg_stat_statements를 켰는데 통계가 안 쌓이는 이유

가장 흔한 원인은 shared_preload_libraries에 추가한 뒤 재시작을 안 한 경우입니다. CREATE EXTENSION은 확장을 데이터베이스에 등록만 할 뿐, 실제 통계 수집 기능은 서버 시작 시점에 로드되는 공유 라이브러리가 담당하기 때문에 reload로는 적용되지 않고 반드시 재시작이 필요합니다.

그다음으로 많은 원인은 track 설정이 none으로 되어 있거나, pg_stat_statements.max를 너무 작게 잡아서 오래된 쿼리 통계가 밀려나는 경우입니다. 설정값은 postgresql.org에서 관리하는 pg_stat_statements 문서에 옵션별 기본값과 설명이 정리되어 있으니, 버전별로 다른 기본값은 거기서 다시 확인하는 것이 가장 정확합니다.

정리하면, 어느 쿼리가 느린지 모르는 상태에서는 pg_stat_statements로 후보를 3~5개로 좁히고, 그 쿼리만 EXPLAIN (ANALYZE, BUFFERS)로 확인하는 두 단계면 충분합니다. 지금 바로 할 수 있는 다음 단계는 postgresql.conf에 shared_preload_libraries를 추가하고 재시작한 뒤, total_exec_time 기준 상위 5개 쿼리부터 EXPLAIN을 돌려보는 것입니다.

트랜잭션 안에서 외부 API를 부르면 idle in transaction이 쌓입니다

Leave a Comment