> ## Content Index
> Fetch the complete content index at: https://mlog.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# PostgreSQL pg_stat_statements로 최근 느린 쿼리 찾기: 스냅샷 차이 계산
- URL: https://mlog.me/postgresql-pg-stat-statements-snapshot-delta-slow-queries/
- Published: 2026-09-08T05:05:49.000Z
- Updated: 2026-09-08T05:05:49.000Z
- Description: PostgreSQL의 누적 통계를 두 시점 스냅샷으로 비교해 최근 구간의 쿼리 부하를 찾고, 초기화·엔트리 교체로 잘못된 차이를 계산하지 않는 방법입니다.
- Author: mLog
- Tags: 데이터베이스, PostgreSQL, SQL 튜닝, #쿼리-구간-분석

`pg_stat_statements`를 실행했는데 매번 오래된 배치 쿼리만 상위에 보인다면, 지금 보고 있는 값이 어느 기간의 합계인지 먼저 확인해야 한다. 누적 실행 시간으로 정렬한 결과와 최근 측정 구간의 부담은 같은 질문이 아니다.

**최근 구간을 보려면 시작과 끝의 통계를 저장하고, 비교 가능한 행끼리 실행 시간과 호출 수의 차이를 계산한다.** 이를 위해 기존 통계를 지울 필요는 없다.

이 글은 PostgreSQL 18 공식 문서를 2026-09-08 확인해 작성한 진단 예제다. 공식 문서 대조와 합성 자료의 비교 로직 확인을 거쳤지만, 실제 PostgreSQL 서버에서의 실행 검증은 하지 않았다. 특정 운영 DB에서 실행한 복구 기록이나 성능 개선 보고서는 아니다. 예제는 현재 데이터베이스, 하나의 서버, 같은 접속 세션을 대상으로 한다. 이전 버전과 관리형 서비스는 확장 컬럼·권한·접속 방식을 먼저 확인해야 한다.

## 지금 막힌 쿼리와 최근 끝난 쿼리를 구분한다

지금 요청이 계속 멈춰 있다면 구간 통계 수집부터 시작하지 않는다. 현재 실행 상태와 차단 세션을 보는 것이 우선이다. [PostgreSQL 모니터링 쿼리 모음](https://mlog.me/postgresql-monitoring-queries/)에서 장기 실행·락·커넥션 점검 순서를 먼저 확인할 수 있다.

현재 활동을 보여주는 `pg_stat_activity`와 누적 실행 통계를 같은 시계로 읽지 않는 것이 중요하다. 실행 중인 긴 문장의 비용이 이번 수집에 아직 반영되지 않았을 수 있다. 실행 통계는 성공한 실행 단계가 끝날 때 갱신된다. [pg\_stat\_statements 공식 문서](https://www.postgresql.org/docs/18/pgstatstatements.html?ref=mlog.me)

이번 절차가 답하려는 질문은 좁다. “두 번 관측하는 동안 통계에 반영된 SQL 중 어느 것이 많은 실행 시간을 썼는가?”다. API 응답시간, 커넥션 풀 대기, 실패한 모든 요청의 이력까지 한 번에 알려주는 도구로 사용하지 않는다.

## 누적 평균을 빼면 구간 평균이 되지 않는다

구간 계산은 다음 세 식으로 정리할 수 있다.

| 지표          | 계산                                          |
| ----------- | ------------------------------------------- |
| 구간 호출 수     | 종료 calls − 시작 calls                         |
| 구간 총 실행 시간  | 종료 total\_exec\_time − 시작 total\_exec\_time |
| 구간 평균 실행 시간 | 구간 총 실행 시간 ÷ 구간 호출 수                        |

`종료 mean_exec_time - 시작 mean_exec_time`은 구간 평균이 아니다. 평균은 그때까지의 호출 수에 따라 가중치가 바뀌므로 합계와 개수로 다시 계산해야 한다. 구간 호출 수가 0이면 평균을 0으로 만들지 않고 계산 대상에서 제외한다.

Google Cloud의 AlloyDB 성능 스냅샷 안내도 두 시점 실행 시간 차이를 부하 분석에 사용한다. 여기서는 이 원리를 일반 PostgreSQL용 SQL 예제로 구성한다. AlloyDB의 `perfsnap` 함수는 사용하지 않는다. [Google Cloud 스냅샷 비교](https://docs.cloud.google.com/alloydb/docs/optimize-database-performance-compare-snapshots?hl=ko&ref=mlog.me)

단, 이 값은 두 벽시계 시점 사이의 모든 실행을 정확히 잘라낸 트레이스가 아니다. 수집 전에 시작해 수집 사이에 끝난 문장의 비용이 포함되거나, 아직 끝나지 않은 문장이 빠질 수 있다. 짧은 장애 순간을 분석할 때는 애플리케이션 로그와 현재 활동도 같이 본다.

## 1\. 버전·설정·설치 위치를 먼저 확인한다

다음은 조회용 준비 SQL이다.

```sql
SELECT current_database(), current_user,
       current_setting('server_version') AS server_version;

SELECT e.extname, e.extversion, n.nspname AS extension_schema
FROM pg_extension AS e
JOIN pg_namespace AS n ON n.oid = e.extnamespace
WHERE e.extname = 'pg_stat_statements';

SELECT name, setting, pending_restart
FROM pg_settings
WHERE name IN (
    'shared_preload_libraries', 'compute_query_id',
    'pg_stat_statements.track', 'pg_stat_statements.track_planning',
    'track_io_timing'
)
ORDER BY name;

```

`pending_restart`는 설정 파일의 변경을 적용하는 데 재시작이 필요한지 보여준다. 설정이 보이지 않거나 확장 조회 결과가 없으면 수집 SQL로 넘어가지 말고 설치·권한·현재 DB를 먼저 확인한다. [pg\_settings](https://www.postgresql.org/docs/18/view-pg-settings.html?ref=mlog.me)

모듈을 새로 로드하려면 기존 `shared_preload_libraries` 항목을 보존하고 유지보수 절차를 잡아야 한다. 단순히 확장 생성만으로 모든 준비가 끝나는 것은 아니다. 다른 사용자의 SQL·식별자를 조회할 권한도 필요하므로 모니터링 계정의 접근 범위를 별도로 검토한다. 통계 행은 데이터베이스·사용자·쿼리 식별자·최상위 실행 여부의 조합으로 구분된다. [pg\_stat\_statements 공식 문서](https://www.postgresql.org/docs/18/pgstatstatements.html?ref=mlog.me)

아래 예제는 확장이 `public` 스키마에 있고 `stats_since` 컬럼을 제공한다는 전제다. 위 조회에서 다른 스키마가 나왔다면 예제의 `public.pg_stat_statements`와 `public.pg_stat_statements_info`를 실제 위치로 바꾼다. 오래된 확장에서 컬럼이 없으면 검사를 지워 실행하지 말고 해당 버전에 맞는 수집기를 검토한다.

## 2\. 원문 없이 관측용 뷰를 준비한다

진단 전용 세션에서 다음 임시 뷰를 만든다. 애플리케이션 테이블이나 영구 스키마를 바꾸지 않지만 임시 객체 생성 권한과 자원을 사용한다. 운영 정책상 임시 객체도 금지라면 승인된 모니터링 도구에서 같은 필드를 저장한다.

```sql
CREATE TEMP VIEW mlog_pgss_capture AS
SELECT
    statement_timestamp() AS sampled_at,
    pg_postmaster_start_time() AS server_started_at,
    i.stats_reset,
    i.dealloc,
    s.dbid,
    s.userid,
    s.queryid,
    s.toplevel,
    s.stats_since,
    s.calls,
    s.total_exec_time,
    s.shared_blks_read,
    s.temp_blks_written
FROM public.pg_stat_statements(false) AS s
CROSS JOIN public.pg_stat_statements_info AS i
WHERE s.dbid = (
    SELECT oid FROM pg_database WHERE datname = current_database()
)
  AND s.toplevel
  AND s.queryid IS NOT NULL;

```

`false` 인자는 SQL 원문을 가져오지 않도록 한다. 수집 파일이나 보고서에 원문이 불필요하게 퍼지는 것을 줄이려는 선택이다. 이것만으로 통계가 비민감 데이터가 되는 것은 아니다. [pg\_stat\_statements 함수](https://www.postgresql.org/docs/18/pgstatstatements.html?ref=mlog.me#PGSTATSTATEMENTS-FUNCS)

최상위 문장만 수집해 바깥 함수 호출과 내부 SQL의 비용을 섞어 합산하는 일을 피한다. 내부 문장까지 분석하려면 별도 범위로 설계한다. 이 예제의 필터가 애플리케이션 업무 쿼리만 골라주는 것은 아니므로 진단 계정과 배치 계정도 구분해 해석해야 한다.

## 3\. 같은 세션에서 T0와 T1을 저장한다

먼저 시작 스냅샷을 한 번 저장한다.

```sql
CREATE TEMP TABLE mlog_pgss_t0 AS
SELECT * FROM pg_temp.mlog_pgss_capture;

```

계획한 관측 구간 동안 평소 트래픽을 관찰한 뒤, 같은 접속에서 종료 스냅샷을 저장한다. 테스트를 위해 운영 서비스에 대량 요청을 만들 필요는 없다.

```sql
CREATE TEMP TABLE mlog_pgss_t1 AS
SELECT * FROM pg_temp.mlog_pgss_capture;

```

두 명령은 한 번에 붙여 실행하지 않는다. 두 시점을 장기 `BEGIN` 트랜잭션으로 묶어 기다리지도 않는다. 자동 커밋 상태의 전용 세션에서 각각 실행하는 구성을 권장한다.

임시 테이블은 세션에 속한다. GUI가 실행마다 새 연결을 만들거나 트랜잭션 풀링을 사용하면 이 방식에 맞지 않을 수 있다. 같은 실제 연결을 유지할 수 있는지 먼저 확인한다. `CREATE TABLE AS`는 그 시점 조회 결과를 저장하며 이후 원본 뷰의 변화가 자동 반영되는 구조가 아니다. [임시 테이블](https://www.postgresql.org/docs/18/sql-createtable.html?ref=mlog.me) · [CREATE TABLE AS](https://www.postgresql.org/docs/18/sql-createtableas.html?ref=mlog.me)

다시 측정할 때는 새 전용 세션을 사용하거나 이번 세션의 임시 객체만 명확하게 정리한다. 이름 충돌이 났다는 이유로 `public`의 테이블을 삭제하지 않는다.

## 4\. 계산 전에 비교 불가 행을 분류한다

다음 임시 뷰는 양쪽에 존재하는 동일 식별키를 맞추고, 비교에 사용할 수 없는 경우를 이유별로 구분한다.

```sql
CREATE TEMP VIEW mlog_pgss_delta AS
SELECT
    COALESCE(b.dbid, a.dbid) AS dbid,
    COALESCE(b.userid, a.userid) AS userid,
    COALESCE(b.queryid, a.queryid) AS queryid,
    COALESCE(b.toplevel, a.toplevel) AS toplevel,
    a.sampled_at AS window_start,
    b.sampled_at AS window_end,
    b.calls - a.calls AS delta_calls,
    b.total_exec_time - a.total_exec_time AS delta_exec_ms,
    b.shared_blks_read - a.shared_blks_read AS delta_shared_reads,
    b.temp_blks_written - a.temp_blks_written AS delta_temp_writes,
    CASE
        WHEN a.queryid IS NULL THEN 'missing_t0'
        WHEN b.queryid IS NULL THEN 'missing_t1'
        WHEN b.sampled_at <= a.sampled_at THEN 'invalid_time'
        WHEN b.server_started_at IS DISTINCT FROM a.server_started_at
            THEN 'server_changed'
        WHEN b.stats_reset IS DISTINCT FROM a.stats_reset
            THEN 'global_reset'
        WHEN b.dealloc IS DISTINCT FROM a.dealloc
            THEN 'entries_deallocated'
        WHEN b.stats_since IS DISTINCT FROM a.stats_since
            THEN 'entry_epoch_changed'
        WHEN b.calls < a.calls
          OR b.total_exec_time < a.total_exec_time
          OR b.shared_blks_read < a.shared_blks_read
          OR b.temp_blks_written < a.temp_blks_written
            THEN 'counter_decreased'
        WHEN b.calls = a.calls THEN 'no_completed_calls'
        ELSE 'comparable'
    END AS comparison_status
FROM pg_temp.mlog_pgss_t0 AS a
FULL OUTER JOIN pg_temp.mlog_pgss_t1 AS b
USING (dbid, userid, queryid, toplevel);

SELECT comparison_status, count(*) AS statement_groups
FROM pg_temp.mlog_pgss_delta
GROUP BY comparison_status
ORDER BY comparison_status;

```

여기서의 제외 정책은 공식 제품 기능이 아니라 잘못된 차이 계산을 피하기 위한 보수적인 예제 설계다.

| 상태                                         | 이 예제의 처리                       |
| ------------------------------------------ | ------------------------------ |
| missing\_t0 / missing\_t1                  | 한쪽 관측이 없으므로 0으로 채워 비교하지 않음     |
| server\_changed / global\_reset            | 같은 연속 구간으로 취급하지 않고 다시 수집       |
| entries\_deallocated                       | 비교 중 추적 엔트리 교체가 있어 구간을 엄격하게 제외 |
| entry\_epoch\_changed / counter\_decreased | 개별 통계가 이어졌다는 가정이 깨져 제외         |
| no\_completed\_calls                       | 구간 평균을 계산하지 않음                 |
| comparable                                 | 아래 순위 계산 후보로 사용                |

`dealloc`은 서버 전체의 추적 엔트리 교체 신호다. 다른 데이터베이스의 활동 때문에 바뀔 수도 있어 이 규칙은 쓸 수 있는 행까지 제외할 수 있다. 이 예제에서는 결과를 많이 보여주는 것보다 모호한 구간을 숨기지 않는 쪽을 택했다. 신규·퇴출 행을 다루는 정교한 수집기가 있다면 별도 정책으로 운영한다.

서버 시작 시각은 연속성 확인을 돕지만 고유 서버 ID를 대신하지 않는다. 모니터링 시스템으로 확장한다면 인스턴스 ID·버전·역할을 별도 키로 저장하고 서로 다른 서버의 값을 섞지 않는다. [서버 시작시각 함수](https://www.postgresql.org/docs/18/functions-info.html?ref=mlog.me)

통계 읽기와 메타정보 읽기가 모든 엔트리에 대해 하나의 원자적 스냅샷을 보장하는 것은 아니다. 수집 중 초기화·설정 변경·재시작이 있었다면 검사를 통과하더라도 그 구간을 사용하지 않는다. 무중단 수집기에서는 각 수집 전후의 초기화·교체 표식도 기록하는 추가 검증이 필요하다.

## 5\. 구간 부하 순위를 조회한다

비교 상태를 확인한 뒤 사용할 행만 조회한다.

```sql
SELECT
    dbid, userid, queryid,
    window_start, window_end,
    delta_calls,
    round(delta_exec_ms::numeric, 2) AS interval_exec_ms,
    round(
        delta_exec_ms::numeric / NULLIF(delta_calls, 0),
        2
    ) AS interval_mean_ms,
    delta_shared_reads,
    delta_temp_writes
FROM pg_temp.mlog_pgss_delta
WHERE comparison_status = 'comparable'
ORDER BY delta_exec_ms DESC, delta_calls DESC
LIMIT 20;

```

`LIMIT 20`은 표시 개수일 뿐 정상 기준이 아니다. 평균 중심으로 보고 싶다면 마지막 정렬을 `delta_exec_ms / NULLIF(delta_calls, 0) DESC`로 바꾸되 호출 수를 함께 본다. 극히 적게 호출된 쿼리를 반복 업무 쿼리와 같은 중요도로 단정하지 않는다.

결과가 비어 있으면 “문제가 없다”가 아니다. 비교 상태가 모두 제외됐는지, 업무 트래픽이 있었는지, 권한 때문에 식별자를 보지 못했는지, 다른 DB를 보고 있는지 확인한다.

이 순위는 구간 전체의 완전한 부하 순위도 아니다. 시작에 없었던 새 쿼리와 끝에 사라진 쿼리는 제외했기 때문이다. 보고할 때 `비교 가능한 기존 엔트리 기준`이라는 범위를 함께 적는다. 누락이 많은 시기에 이 표만으로 최적화 우선순위를 확정하지 않는다.

## 숫자를 읽을 때 지킬 경계

### 총 실행 시간은 CPU 사용률이 아니다

여러 문장이 동시에 실행되면 실행 시간 합계가 관측 구간의 벽시계 길이보다 커질 수 있다. 이를 구간 초로 나눈 값을 서버 CPU 사용률이나 특정 API의 지연으로 표시하면 안 된다. CPU·I/O·락 지표를 따로 대조한다.

### 평균은 p95·p99가 아니다

합계와 호출 수로 구한 평균만으로 느린 일부 요청의 분포를 복원할 수 없다. 지연 상위 백분위가 필요하면 애플리케이션 계측 또는 해당 목적의 모니터링 데이터를 수집한다. 두 누적 최댓값을 빼서 구간 최댓값으로 사용하는 것도 피한다.

### calls는 JDBC 배치 실행 횟수가 아니다

서버 문장 실행 통계와 JDBC 배치·네트워크 왕복·트랜잭션 커밋은 집계 단위가 다르다. 배치 쓰기 비교라면 [JPA saveAll 배치 INSERT 검증](https://mlog.me/jpa-saveall-postgresql-batch-insert-mybatis-performance/)의 관측 지표 구분과 같이 읽는다.

### 읽은 블록은 곧바로 물리 디스크 읽기가 아니다

PostgreSQL 밖의 운영체제 캐시까지 이 표가 구분해 주지는 않는다. `track_io_timing`이 꺼진 상태에서 I/O 시간 0을 보고 I/O가 없었다고 판단하지도 않는다. 이 설정을 켜는 데도 플랫폼별 계측 부담 검토가 필요하다. [통계 설정](https://www.postgresql.org/docs/18/runtime-config-statistics.html?ref=mlog.me)

## 발견한 SQL을 실제 튜닝으로 연결한다

먼저 부하가 증가한 시간대의 기능·배치·배포를 맞춘다. 예를 들어 집계 기능이 후보라면 [PostgreSQL DAU·MAU 집계 쿼리 최적화](https://mlog.me/postgresql-dau-mau-query-tuning/)처럼 처리 행이 늘어나는 지점을 검토할 수 있다. 순위에 올랐다는 이유만으로 해당 SQL의 인덱스가 부족하다고 확정하지 않는다.

SQL 원문은 권한 있는 내부 화면에서 필요한 후보만 조회한다. 원문은 정규화되더라도 완전한 익명화가 아니며, `queryid`도 모든 서버·메이저 버전에서 유지되는 전역 ID가 아니다. 스크린샷·CSV를 외부에 공유하기 전에 식별자와 업무 정보의 공개 범위를 검토한다.

다음 순서로 검증하면 이번 글의 범위를 넘어선 조치를 분리할 수 있다.

1. 쿼리의 호출 맥락과 실제 바인딩 조건을 확인한다.
2. 먼저 일반 `EXPLAIN`으로 예상 계획을 읽는다.
3. 실행이 허용된 격리 환경에서만 실제 계획을 측정한다.
4. 결과 집합의 정확성을 먼저 비교한다.
5. 비슷한 트래픽·데이터·캐시 조건에서 새 T0/T1 구간을 수집한다.

`EXPLAIN ANALYZE`는 SQL을 실제 실행한다. 데이터 변경과 함수 부작용이 일어날 수 있으며 단순한 조회형 진단 명령이 아니다. [EXPLAIN 공식 경고](https://www.postgresql.org/docs/18/sql-explain.html?ref=mlog.me)

실행계획에서 행 증폭이나 반복 스캔이 확인됐다면 [JOIN 행 증폭·선집계·부분 인덱스](https://mlog.me/postgresql-query-optimization-pre-aggregation-partial-index/)를 후속으로 볼 수 있다. 이 글의 구간 순위는 고칠 후보를 고르는 도구이고, 해결책 자체는 아니다.

## 측정을 위해 통계를 지우지 않는다

새 구간을 보고 싶다는 이유로 `pg_stat_statements_reset()`을 실행하면 다른 운영자가 참고하던 관측값도 잃을 수 있다. 이 예제는 초기화 대신 두 관측값을 보존한다. 현재 통계만 가지고 이미 지나간 임의 시간대의 값을 복원할 수 없다는 한계도 함께 받아들여야 한다.

이번 절차를 끝낼 때 남길 것은 “상위 SQL 20개” 하나가 아니다. 관측 시작·종료 시각, 대상 DB와 서버, 제외 사유별 행 수, 비교 가능한 SQL의 구간 호출 수·총 시간·평균, 확인하지 못한 범위를 함께 기록한다. 그래야 다음 배포나 튜닝 뒤에도 같은 질문으로 비교할 수 있다.

## 참고 문서

공식 문서 확인일: 2026-09-08.

- [PostgreSQL 18 pg\_stat\_statements](https://www.postgresql.org/docs/18/pgstatstatements.html?ref=mlog.me)
- [PostgreSQL 통계 모니터링](https://www.postgresql.org/docs/18/monitoring-stats.html?ref=mlog.me)
- [pg\_settings](https://www.postgresql.org/docs/18/view-pg-settings.html?ref=mlog.me)
- [CREATE TABLE — 임시 테이블](https://www.postgresql.org/docs/18/sql-createtable.html?ref=mlog.me)
- [CREATE TABLE AS](https://www.postgresql.org/docs/18/sql-createtableas.html?ref=mlog.me)
- [시스템 정보 함수](https://www.postgresql.org/docs/18/functions-info.html?ref=mlog.me)
- [통계 설정](https://www.postgresql.org/docs/18/runtime-config-statistics.html?ref=mlog.me)
- [EXPLAIN](https://www.postgresql.org/docs/18/sql-explain.html?ref=mlog.me)
- [Google Cloud 스냅샷 비교](https://docs.cloud.google.com/alloydb/docs/optimize-database-performance-compare-snapshots?hl=ko&ref=mlog.me)