Skip to content

Split View: DB 성능 튜닝 완전 가이드: 파라미터를 만지기 전에 측정하는 순서

|

DB 성능 튜닝 완전 가이드: 파라미터를 만지기 전에 측정하는 순서

들어가며

이 블로그에는 PostgreSQL 성능 튜닝 글이 이미 여럿 있습니다. PostgreSQL 성능 튜닝 실전 가이드, 쿼리 최적화와 성능 튜닝, PostgreSQL 17 성능 실험실이 각각 다른 각도에서 다룹니다.

이 글은 파라미터가 아니라 순서를 다룹니다. 튜닝 글의 문제는 대개 "무엇을 바꿀 수 있는가"의 목록이라는 점입니다. 목록은 유용하지만 실전에서 필요한 것은 지금 무엇을 먼저 봐야 하는가 입니다. 느려졌다는 신고를 받았을 때 shared_buffers부터 만지는 것과, 어떤 쿼리가 시간을 쓰는지부터 확인하는 것은 완전히 다른 결과를 냅니다.

그래서 이 글은 진단 순서를 따라갑니다. 워크로드 프로파일 → 대기 이벤트 → 캐시와 I/O → 그다음에야 파라미터. 그리고 각 단계에서 무엇을 보고 무엇을 판단하는지를 문서에 적힌 기본값과 함께 정리합니다.

기준 엔진은 PostgreSQL 18 이며, 기본값은 모두 PostgreSQL 18 문서에서 확인했습니다. PostgreSQL 18에서 바뀐 기본값이 몇 개 있으므로 버전을 반드시 확인하세요.

1. 튜닝의 순서

성능 문제를 만났을 때 던지는 질문의 순서가 튜닝의 절반입니다.

  1. 무엇이 느린가 — 전체인가 특정 쿼리인가. 항상인가 특정 시간대인가.
  2. 어디에 시간을 쓰는가 — 실행인가 대기인가. 대기라면 무엇을 기다리는가.
  3. 왜 그런가 — 계획이 나쁜가, 데이터가 많은가, 자원이 부족한가, 경합인가.
  4. 무엇을 바꿀 것인가 — 쿼리인가 스키마인가 인덱스인가 파라미터인가.

파라미터가 4번에 있다는 점이 중요합니다. 파라미터 조정으로 해결되는 문제는 전체의 소수이고, 대부분은 특정 쿼리나 인덱스나 스키마의 문제입니다. 그리고 파라미터는 서버 전체에 영향을 주므로 잘못 바꾸면 다른 워크로드가 망가집니다.

거꾸로, 파라미터로만 해결되는 문제도 분명히 있습니다. 기본값이 작은 서버를 가정하고 정해져 있기 때문입니다. shared_buffers의 기본값은 128MB이고 work_mem은 4MB이며 max_wal_size는 1GB입니다. 메모리 256GB짜리 서버에서 이 값을 그대로 두면 하드웨어를 놀리는 셈입니다.

2. 워크로드 프로파일링 — pg_stat_statements

첫 번째 단계는 "어떤 쿼리가 시간을 쓰는가"입니다. 답은 pg_stat_statements에 있습니다.

설치는 두 단계입니다. shared_preload_libraries에 등록하고(서버 재시작 필요) 확장을 만듭니다.

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
compute_query_id = on
pg_stat_statements.max = 10000
pg_stat_statements.track = all
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

문서 기준 기본값은 pg_stat_statements.max가 5000, tracktop, track_utility가 on, track_planning이 off, save가 on입니다. track_planning은 계획 수립 시간을 따로 기록하지만 문서가 성능 부담을 언급하므로 기본값 off로 두다가 필요할 때만 켜는 편이 낫습니다.

읽는 방법이 중요합니다. 평균 시간이 아니라 총 시간으로 정렬하세요.

SELECT calls,
       round(total_exec_time::numeric, 1)          AS total_ms,
       round(mean_exec_time::numeric, 2)           AS mean_ms,
       rows,
       shared_blks_hit, shared_blks_read,
       round(100.0 * shared_blks_hit
             / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
       left(query, 70) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

평균 200ms짜리 쿼리가 하루 100번 도는 것보다, 평균 3ms짜리가 500만 번 도는 것이 훨씬 큰 부하입니다. 후자는 "느린 쿼리 목록"에 절대 나타나지 않습니다. 이것이 슬로우 쿼리 로그만으로는 부족한 이유입니다.

함께 볼 컬럼들입니다.

  • rows 나누기 calls — 한 번에 몇 행을 돌려주는가. 이 값이 크면 애플리케이션이 필요 이상으로 가져오고 있을 수 있습니다.
  • temp_blks_written — 임시 파일 쓰기. 0이 아니면 work_mem이 부족해 정렬이나 해시가 디스크로 넘친 것입니다.
  • wal_bytes — 이 쿼리가 만든 WAL 양. 쓰기 부하의 근원을 찾을 때 씁니다.

측정 구간을 명확히 하려면 관찰 시작 시점에 초기화하세요. 함수 시그니처는 pg_stat_statements_reset(userid, dbid, queryid, minmax_only)이며 인자 없이 호출하면 전체를 초기화합니다.

3. 대기 이벤트 — 병목의 성격을 가르기

두 번째 단계는 "CPU를 쓰는가 기다리는가"입니다. 이것을 구분하지 못하면 엉뚱한 곳을 튜닝하게 됩니다.

pg_stat_activitywait_event_type이 답을 줍니다. 문서가 정의한 값은 다음과 같습니다.

의미시사점
LockSQL에서 보이는 객체에 대한 무거운 잠금 대기경합. DDL이나 긴 트랜잭션
LWLock내부 자료구조를 보호하는 경량 잠금 대기내부 경합. 버퍼나 WAL
BufferPin버퍼에 대한 배타 접근 대기드묾
IOI/O 완료 대기스토리지나 캐시 부족
IPC다른 서버 프로세스와의 상호작용 대기병렬 워커, 복제
Client클라이언트 소켓 활동 대기데이터베이스는 병목이 아님
Timeout타임아웃 만료 대기의도된 대기
Activity주 처리 루프에서 유휴배경 프로세스의 정상 상태
Extension확장이 정의한 조건 대기확장별 확인 필요

샘플링해서 분포를 보는 것이 요령입니다. 한 번 찍어 본 스냅샷은 우연일 수 있습니다.

-- 1초 간격으로 반복 실행해 분포를 모은다
SELECT coalesce(wait_event_type, 'CPU') AS wait_type,
       wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;

해석의 기본선입니다. wait_event가 NULL인 활성 백엔드는 실제로 CPU를 쓰고 있습니다. 이 비율이 높으면 쿼리 자체가 무겁거나 계획이 나쁜 것이고, 파라미터가 아니라 쿼리와 인덱스를 봐야 합니다. IO가 지배적이면 캐시 부족이나 스토리지 한계입니다. Lock이 지배적이면 자원 문제가 아니라 설계와 트랜잭션 경계 문제입니다.

Client가 많다면 데이터베이스는 놀고 있는 것입니다. 이때 데이터베이스를 튜닝하는 것은 시간 낭비입니다.

4. 캐시와 I/O 지표

세 번째 단계는 "읽기가 어디서 오는가"입니다.

-- 테이블별 버퍼 적중률, 읽기가 많은 순
SELECT relname,
       heap_blks_read, heap_blks_hit,
       round(100.0 * heap_blks_hit
             / nullif(heap_blks_hit + heap_blks_read, 0), 2) AS heap_hit_pct,
       idx_blks_read, idx_blks_hit
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 15;

여기서 주의할 함정이 있습니다. heap_blks_readPostgreSQL 버퍼 풀에 없어서 운영체제에 요청한 블록입니다. 운영체제 페이지 캐시에 있었다면 실제 디스크 I/O는 발생하지 않았습니다. 그러므로 이 숫자만으로 디스크 부하를 판단하면 과대평가합니다.

PostgreSQL 16 이후의 pg_stat_io가 더 정확한 그림을 줍니다. backend_type, object, context별로 reads, hits, evictions, fsyncs를 나눠 보여 줍니다.

SELECT backend_type, object, context,
       reads, hits, evictions, fsyncs
FROM pg_stat_io
WHERE reads > 0 OR evictions > 0
ORDER BY reads DESC
LIMIT 20;

contextbulkreadvacuum인 항목이 큰 것은 정상입니다. 대량 스캔과 VACUUM은 원래 버퍼 풀을 오염시키지 않도록 제한된 링 버퍼를 씁니다. 문제는 contextnormal인데 evictions가 큰 경우입니다. 작업 집합이 shared_buffers에 들어가지 않아 버퍼를 계속 밀어내고 있다는 뜻입니다.

테이블 접근 패턴도 함께 봅니다.

SELECT relname, seq_scan, seq_tup_read, idx_scan,
       n_live_tup, n_dead_tup,
       last_autovacuum, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC
LIMIT 15;

seq_scan이 크다고 무조건 나쁜 것은 아닙니다. 작은 테이블은 순차 스캔이 정답입니다. 봐야 할 것은 seq_tup_readseq_scan으로 나눈 값, 즉 순차 스캔 한 번에 몇 행을 읽는가입니다. 이 값이 크고 idx_scan이 작으면 인덱스가 필요한 후보입니다.

5. 메모리 예산

이제 파라미터입니다. 세 개의 메모리 파라미터가 서로 다른 목적을 갖습니다.

shared_buffers — PostgreSQL 자체 버퍼 풀. 기본값 128MB. 문서의 지침은 "전용 데이터베이스 서버에 1GB 이상의 RAM이 있다면 합리적인 시작값은 시스템 메모리의 25%"이며, "PostgreSQL이 운영체제 캐시에도 의존하므로 RAM의 40%를 넘게 할당하는 것이 더 적은 양보다 나을 가능성은 낮다"고 합니다. 변경에는 서버 재시작이 필요합니다.

work_mem — 정렬과 해시의 작업 영역. 기본값 4MB. 이 값의 계산이 가장 자주 틀립니다. 문서 원문은 이렇습니다. "복잡한 질의는 여러 정렬과 해시 연산을 동시에 수행할 수 있고, 각 연산은 일반적으로 임시 파일에 쓰기 시작하기 전까지 이 값이 지정하는 만큼의 메모리를 쓸 수 있다. 또한 여러 세션이 그런 연산을 동시에 수행할 수 있다. 따라서 사용되는 총 메모리는 work_mem 값의 여러 배가 될 수 있으며, 값을 고를 때 이 사실을 염두에 두어야 한다."

커넥션 수 곱하기 work_mem은 최악을 과소평가한 값입니다. 해시 계열은 hash_mem_multiplier(기본값 2.0)까지 곱해집니다. 안전한 접근은 전역값을 보수적으로 두고, 무거운 분석 쿼리에서만 세션 단위로 올리는 것입니다.

-- 이 세션에서만, 이 쿼리를 위해
SET LOCAL work_mem = '256MB';

maintenance_work_mem — 유지보수 작업의 메모리. 기본값 64MB. 문서는 이 값이 "VACUUM, CREATE INDEX, ALTER TABLE ADD FOREIGN KEY 같은 유지보수 작업이 쓰는 최대 메모리"라고 정의합니다. 동시에 도는 유지보수 작업 수가 적으므로 work_mem보다 훨씬 크게 잡아도 안전합니다.

effective_cache_size — 메모리를 할당하지 않고 플래너의 가정만 바꿉니다. 기본값 4GB. 운영체제 캐시를 포함한 사용 가능 캐시 추정치이며, 실제보다 작으면 플래너가 인덱스 스캔을 과소평가합니다.

PostgreSQL 18에서 I/O 관련 기본값이 바뀌었습니다. effective_io_concurrency의 기본값이 16 이고, 새로 도입된 io_method의 기본값은 worker, io_combine_limit의 기본값은 128kB입니다. 이전 버전에서 올라왔다면 이 항목들을 반드시 문서에서 확인하세요.

6. 쓰기 경로 — 체크포인트와 WAL

쓰기가 느리거나 주기적으로 지연이 튄다면 체크포인트를 의심합니다.

문서 기준 기본값입니다. checkpoint_timeout은 5분, checkpoint_completion_target은 0.9, max_wal_size는 1GB, min_wal_size는 80MB입니다.

증상과 원인의 대응은 이렇습니다. 주기적으로(수십 초 간격) 지연이 튄다면 max_wal_size가 작아 체크포인트가 시간이 아니라 WAL 양으로 유발되고 있을 가능성이 큽니다. 확인은 log_checkpoints를 켜고 로그에서 체크포인트 사유를 보는 것입니다. time 대신 xlog가 사유로 찍히면 그렇습니다.

ALTER SYSTEM SET log_checkpoints = on;
SELECT pg_reload_conf();

max_wal_size를 늘리면 체크포인트 빈도가 줄어 지연 스파이크가 완화됩니다. 대가는 크래시 복구 시간이 길어지는 것과 디스크 사용량이 늘어나는 것입니다. 이 두 가지는 사업 요건(RTO)과 함께 결정해야 합니다.

synchronous_commit의 기본값은 on입니다. 커밋마다 WAL이 디스크에 안전하게 기록될 때까지 기다린다는 뜻입니다. 이 값을 끄면 쓰기 처리량이 크게 늘지만, 크래시 시 마지막 몇 건의 커밋이 사라질 수 있습니다. 데이터 정합성 자체는 깨지지 않지만 커밋했다고 응답한 트랜잭션이 없어질 수 있습니다. 이 거래를 받아들일 수 있는지는 도메인이 결정합니다. 세션이나 트랜잭션 단위로 켜고 끌 수 있으므로, 감사 로그 같은 일부 테이블 쓰기에만 완화하는 방식도 가능합니다.

wal_compression의 기본값은 off입니다. 켜면 전체 페이지 이미지를 압축해 WAL 양을 줄이지만 CPU를 씁니다. 복제 대역폭이 병목이면 검토할 가치가 있습니다.

commit_delay의 기본값은 0이고 commit_siblings의 기본값은 5입니다. 동시 커밋이 매우 많은 환경에서 그룹 커밋 효과를 노리는 손잡이인데, 잘못 쓰면 지연만 늘어납니다. 다른 항목을 다 확인한 뒤에 손대세요.

7. 유지보수 경로 — autovacuum

느려짐의 원인이 bloat인 경우가 생각보다 많습니다. autovacuum이 따라오지 못하면 죽은 행이 쌓이고, 순차 스캔이 읽어야 할 페이지가 늘고, 인덱스가 커집니다.

문서 기준 기본값입니다. autovacuum은 on, autovacuum_max_workers는 3, autovacuum_naptime은 1분, autovacuum_vacuum_threshold는 50 튜플, autovacuum_vacuum_scale_factor는 0.2(테이블의 20%), autovacuum_analyze_threshold는 50 튜플, autovacuum_analyze_scale_factor는 0.1(10%), autovacuum_vacuum_cost_delay는 2밀리초, autovacuum_freeze_max_age는 2억 트랜잭션입니다.

PostgreSQL 18에는 autovacuum_vacuum_max_threshold가 추가되었고 기본값은 1억 튜플입니다. 이 상한 덕분에 아주 큰 테이블에서도 vacuum이 무한정 미뤄지지 않습니다.

큰 테이블에서 기본 scale factor가 문제가 됩니다. 1억 행 테이블이면 20%는 2천만 행입니다. 그만큼 죽어야 vacuum이 돕니다. 테이블 단위로 낮추는 것이 표준 대응입니다.

-- 큰 테이블은 비율이 아니라 절대량으로 관리한다
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor  = 0.01,
  autovacuum_analyze_scale_factor = 0.005
);

이 설정은 테이블 저장 파라미터 변경이므로 SHARE UPDATE EXCLUSIVE 잠금만 잡습니다. 읽기와 쓰기를 막지 않습니다.

autovacuum이 도는데도 못 따라간다면 속도 제한을 봅니다. autovacuum_vacuum_cost_delay의 기본값 2밀리초는 vacuum이 서비스에 주는 영향을 줄이려는 값인데, 쓰기가 많은 시스템에서는 이 때문에 vacuum이 영영 따라잡지 못합니다. autovacuum_max_workers를 늘리는 것도 방법이지만, 워커 수를 늘려도 전체 비용 한도는 워커들이 나눠 쓰므로 지연 값 조정과 함께 봐야 합니다.

진행 상황은 이렇게 확인합니다.

SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
       last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

경고: bloat이 이미 심각하다고 해서 VACUUM FULL이나 CLUSTER를 운영 중에 실행하면 안 됩니다. 두 명령 모두 ACCESS EXCLUSIVE 잠금을 잡아 읽기까지 차단하고, 테이블 전체를 새로 쓰므로 원본 크기만큼의 여유 디스크가 필요합니다. PostgreSQL 문서 자체가 "관리자는 표준 VACUUM을 쓰도록 노력하고 VACUUM FULL은 피해야 한다"라고 권고합니다. 인덱스만 문제라면 REINDEX INDEX CONCURRENTLY가 안전한 대안입니다.

8. 바꾸기 전과 후를 비교하기

마지막 단계이자 가장 자주 생략되는 단계입니다. 비교 없는 변경은 튜닝이 아니라 추측입니다.

절차를 정리하면 이렇습니다.

1단계 — 기준선을 기록합니다. 변경 직전에 pg_stat_statements를 초기화하고 일정 시간(최소 한 번의 업무 주기) 관찰한 결과를 저장합니다. 대기 이벤트 분포와 pg_stat_io 스냅샷도 함께 남깁니다.

2단계 — 한 번에 하나만 바꿉니다. 세 개를 동시에 바꾸면 어느 것이 효과였는지 알 수 없고, 나빠졌을 때 무엇을 되돌려야 하는지도 모릅니다.

3단계 — 되돌릴 방법을 먼저 확인합니다. ALTER SYSTEM으로 바꾼 값은 ALTER SYSTEM RESET으로 되돌립니다. 재시작이 필요한 파라미터인지도 미리 확인하세요. shared_buffers는 재시작이 필요하고 work_mem은 필요 없습니다.

-- 변경
ALTER SYSTEM SET work_mem = '32MB';
SELECT pg_reload_conf();

-- 확인: 어디서 온 값인가
SELECT name, setting, unit, source, pending_restart
FROM pg_settings
WHERE name IN ('work_mem', 'shared_buffers', 'max_wal_size',
               'effective_cache_size', 'random_page_cost');

-- 되돌리기
ALTER SYSTEM RESET work_mem;
SELECT pg_reload_conf();

pg_settingssource 컬럼은 값이 어디서 왔는지 알려 줍니다. 설정 파일을 고쳤는데 반영되지 않는 문제의 대부분이 여기서 풀립니다. pending_restart가 true면 재시작해야 적용됩니다.

4단계 — 같은 지표로 다시 잽니다. 1단계와 같은 쿼리, 같은 관찰 기간이어야 합니다. 그리고 개선 여부를 하나의 대표 지표로 정해 두세요. 상위 20개 쿼리의 total_exec_time 합, p95 응답 시간, 초당 처리 건수 중 하나면 충분합니다.

5단계 — 결정을 기록합니다. 왜 바꿨고, 무엇이 근거였고, 얼마나 개선되었는지를 남깁니다. 6개월 뒤에 "이 값이 왜 이렇게 되어 있지?"라는 질문이 반드시 나옵니다.

퀴즈: 실력을 확인해 보세요

퀴즈 1: 슬로우 쿼리 로그에는 아무것도 안 잡히는데 서버 CPU가 계속 80%입니다. 무엇을 봐야 할까요?

정답: pg_stat_statementstotal_exec_time 내림차순으로 봐야 합니다.

설명: 슬로우 쿼리 로그는 임계값을 넘는 개별 실행만 남깁니다. 평균 3ms짜리 쿼리가 초당 2천 번 돌면 로그에는 한 줄도 남지 않지만 CPU의 상당 부분을 씁니다. pg_stat_statements는 정규화된 쿼리별로 누적 통계를 남기므로 이런 워크로드를 정확히 잡아냅니다. 정렬 기준을 mean_exec_time이 아니라 total_exec_time으로 두는 것이 핵심입니다. 함께 볼 것은 calls이며, 호출 횟수가 비정상적으로 많다면 그것은 데이터베이스 문제가 아니라 애플리케이션의 N+1 문제나 캐시 부재일 가능성이 높습니다.

퀴즈 2: work_mem을 4MB에서 256MB로 올렸더니 피크 시간에 서버가 메모리 부족으로 죽었습니다.

정답: work_mem은 커넥션당이 아니라 연산당이므로 총 사용량이 예상보다 훨씬 큽니다.

설명: 문서 원문 그대로입니다. "복잡한 질의는 여러 정렬과 해시 연산을 동시에 수행할 수 있고, 각 연산은 일반적으로 이 값이 지정하는 만큼의 메모리를 쓸 수 있다. 또한 여러 세션이 그런 연산을 동시에 수행할 수 있다. 따라서 사용되는 총 메모리는 work_mem 값의 여러 배가 될 수 있다." 해시 계열은 hash_mem_multiplier(기본값 2.0)까지 곱해집니다. 커넥션 200개가 각각 정렬 세 개를 포함한 쿼리를 돌리면 200 곱하기 3 곱하기 256MB가 이론적 최악입니다. 올바른 접근은 전역값을 보수적으로 두고 무거운 쿼리에서만 SET LOCAL work_mem으로 올리는 것입니다. 어떤 쿼리가 실제로 부족한지는 pg_stat_statementstemp_blks_written이나 EXPLAIN (ANALYZE) 출력의 디스크 정렬 표시로 확인합니다.

퀴즈 3: 30초마다 응답 시간이 튑니다. 어떤 지표를 확인해야 할까요?

정답: 체크포인트입니다. log_checkpoints를 켜고 사유를 확인합니다.

설명: 규칙적인 간격의 지연 스파이크는 체크포인트의 전형적 증상입니다. checkpoint_timeout의 기본값은 5분이므로 30초 간격이라면 시간이 아니라 WAL 양 때문에 유발되고 있을 가능성이 큽니다. max_wal_size의 기본값은 1GB입니다. log_checkpoints를 켜면 로그에 체크포인트 사유가 남는데, time이 아니라 xlog로 찍히면 확정입니다. 대응은 max_wal_size를 늘려 체크포인트 빈도를 줄이는 것이고, checkpoint_completion_target(기본값 0.9)은 이미 쓰기를 넓게 분산하도록 설정되어 있습니다. 대가는 크래시 복구 시간이 길어지는 것이므로 사업의 RTO와 함께 결정해야 합니다.

퀴즈 4: 대기 이벤트를 샘플링했더니 wait_event_type이 Client인 세션이 대부분입니다.

정답: 데이터베이스는 병목이 아닙니다. 애플리케이션이나 네트워크를 봐야 합니다.

설명: 문서 정의에 따르면 Client는 "사용자 애플리케이션에 연결된 소켓의 활동을 기다리는" 상태입니다. 즉 서버는 할 일을 마치고 클라이언트의 다음 명령이나 데이터 수신을 기다리는 중입니다. 이 상태에서 shared_bufferswork_mem을 만지는 것은 아무 효과가 없습니다. 봐야 할 것은 애플리케이션 쪽입니다. 결과를 한 행씩 가져오고 있지는 않은지(페치 크기), 네트워크 왕복이 과도하지는 않은지(N+1), 클라이언트가 결과 처리에 시간을 쓰고 있지는 않은지. 함께 확인할 것은 idle in transaction 상태의 수입니다. 이 값이 크면 애플리케이션이 트랜잭션을 열어 둔 채 다른 일을 하고 있다는 뜻이고, 그 자체가 VACUUM을 막는 별도의 문제입니다.

퀴즈 5: 파라미터 세 개를 동시에 바꿨더니 전체적으로는 빨라졌는데 일부 쿼리가 크게 느려졌습니다. 어떻게 해야 할까요?

정답: 되돌리고 하나씩 다시 적용하며 각각을 측정해야 합니다.

설명: 동시 변경은 원인 귀속을 불가능하게 만듭니다. 특히 플래너 관련 파라미터(random_page_cost, effective_cache_size, work_mem)는 계획 선택을 바꾸므로, 어떤 쿼리에는 이득이고 어떤 쿼리에는 손해입니다. 절차는 이렇습니다. 먼저 ALTER SYSTEM RESET으로 전부 되돌리고 pg_settingssource로 실제 적용값을 확인합니다. 그다음 하나씩 적용하며 매번 pg_stat_statements를 초기화하고 같은 기간 관찰합니다. 판단 지표는 상위 쿼리들의 total_exec_time 합처럼 하나로 정해 두되, 개별 쿼리의 회귀도 함께 봐야 합니다. 전체 합이 좋아져도 핵심 트랜잭션 하나가 두 배 느려졌다면 그 변경은 채택하면 안 됩니다.

마치며

성능 튜닝에서 가장 큰 낭비는 잘못된 파라미터가 아니라 측정 없이 시작하는 것입니다. 어떤 쿼리가 시간을 쓰는지 모르는 상태에서 shared_buffers를 두 배로 늘리면, 좋아졌는지 나빠졌는지조차 판단할 수 없습니다.

순서를 다시 정리합니다. pg_stat_statements로 부하의 출처를 찾고, 대기 이벤트로 병목의 성격을 가르고, 캐시와 I/O 지표로 자원 상황을 확인하고, 그다음에 파라미터를 하나씩 바꾸며 매번 측정합니다. 이 순서를 지키면 튜닝은 기술이 아니라 절차가 되고, 절차는 팀에 전수될 수 있습니다.

그리고 마지막으로, 버전을 확인하세요. PostgreSQL 18에서 effective_io_concurrency의 기본값이 16이 되었고 io_method가 새로 생겼으며 EXPLAIN ANALYZE가 버퍼 정보를 자동으로 포함하게 되었습니다. 인터넷에 떠도는 튜닝 값의 절반은 몇 년 전 버전 기준입니다.

이 글의 진단 쿼리는 Postgres 놀이터에서 직접 실행해 볼 수 있습니다.

참고 자료

이어서 읽기

The Complete Guide to Database Performance Tuning: Measure Before You Touch a Parameter

Introduction

This blog already has several PostgreSQL performance tuning articles. PostgreSQL Performance Tuning in Practice, Query Optimization and Performance Tuning, and PostgreSQL 17 Performance Lab each approach it from a different angle.

This guide covers order, not parameters. The trouble with tuning articles is that they are usually a list of "what you can change." Lists are useful, but what you actually need in the field is which thing to look at first. Reaching for shared_buffers when a slowness report arrives produces a completely different outcome than first checking which query is spending the time.

So this guide follows a diagnostic order: workload profile, then wait events, then cache and I/O, and only then parameters. At each step it lays out what to look at and what to conclude, together with the defaults documented for the version.

The reference engine is PostgreSQL 18, and every default was confirmed in the PostgreSQL 18 documentation. Several defaults changed in PostgreSQL 18, so check your version.

1. The Order of Tuning

The order in which you ask questions is half of tuning.

  1. What is slow — everything, or one query? Always, or in a particular window?
  2. Where is the time going — executing or waiting? If waiting, on what?
  3. Why — a bad plan, too much data, insufficient resources, or contention?
  4. What will you change — the query, the schema, an index, or a parameter?

Note that parameters are number four. Only a minority of problems are solved by parameter tuning; most are a specific query, index, or schema problem. And a parameter affects the whole server, so a wrong change breaks other workloads.

Conversely, some problems really are parameter-only, because the defaults assume a small server. shared_buffers defaults to 128MB, work_mem to 4MB, and max_wal_size to 1GB. Leaving those untouched on a server with 256GB of RAM means letting the hardware idle.

2. Profiling the Workload — pg_stat_statements

The first step is "which queries spend the time." The answer lives in pg_stat_statements.

Installation is two steps: register it in shared_preload_libraries (a server restart is required) and create the extension.

# postgresql.conf
shared_preload_libraries = 'pg_stat_statements'
compute_query_id = on
pg_stat_statements.max = 10000
pg_stat_statements.track = all
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

The documented defaults are 5000 for pg_stat_statements.max, top for track, on for track_utility, off for track_planning, and on for save. track_planning records planning time separately, but the documentation notes a performance penalty, so leave it off by default and turn it on only when needed.

How you read it matters. Sort by total time, not by average time.

SELECT calls,
       round(total_exec_time::numeric, 1)          AS total_ms,
       round(mean_exec_time::numeric, 2)           AS mean_ms,
       rows,
       shared_blks_hit, shared_blks_read,
       round(100.0 * shared_blks_hit
             / nullif(shared_blks_hit + shared_blks_read, 0), 1) AS hit_pct,
       left(query, 70) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

A 200ms query running 100 times a day is a far smaller load than a 3ms query running five million times. The latter never appears in a "slow query list." That is why a slow query log alone is not enough.

Columns to look at alongside it:

  • rows divided by calls — how many rows come back per execution. A large value may mean the application is fetching more than it needs.
  • temp_blks_written — temporary file writes. Anything above zero means a sort or hash spilled to disk because work_mem was insufficient.
  • wal_bytes — how much WAL this query generated. Use it to find the source of write load.

To make the measurement window explicit, reset at the start of observation. The function signature is pg_stat_statements_reset(userid, dbid, queryid, minmax_only), and calling it with no arguments resets everything.

3. Wait Events — Splitting the Bottleneck

The second step is "is it burning CPU or waiting?" Fail to distinguish those and you will tune the wrong thing.

wait_event_type in pg_stat_activity gives the answer. Here are the values defined by the documentation.

ValueMeaningImplication
LockWaiting for a heavyweight lock on SQL-visible objectsContention. DDL or long transactions
LWLockWaiting for a lightweight lock protecting internal structuresInternal contention. Buffers or WAL
BufferPinWaiting for exclusive access to a data bufferRare
IOWaiting for an I/O operation to completeStorage limits or insufficient cache
IPCWaiting for interaction with another server processParallel workers, replication
ClientWaiting for activity on a client socketThe database is not the bottleneck
TimeoutWaiting for a timeout to expireIntentional waiting
ActivityIdle in the main processing loopNormal state for background processes
ExtensionWaiting for a condition defined by an extensionCheck the extension

The trick is to sample and look at the distribution. A single snapshot can be a coincidence.

-- Run repeatedly at one-second intervals to build a distribution
SELECT coalesce(wait_event_type, 'CPU') AS wait_type,
       wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active' AND backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;

The baseline interpretation: an active backend with a NULL wait_event is genuinely consuming CPU. A high share there means the query itself is heavy or the plan is bad, and you should look at queries and indexes rather than parameters. A dominant IO share means insufficient cache or a storage limit. A dominant Lock share is not a resource problem at all — it is a design and transaction-boundary problem.

If Client dominates, the database is idle. Tuning the database in that state is a waste of time.

4. Cache and I/O Metrics

The third step is "where do the reads come from?"

-- Per-table buffer hit ratio, ordered by most reads
SELECT relname,
       heap_blks_read, heap_blks_hit,
       round(100.0 * heap_blks_hit
             / nullif(heap_blks_hit + heap_blks_read, 0), 2) AS heap_hit_pct,
       idx_blks_read, idx_blks_hit
FROM pg_statio_user_tables
ORDER BY heap_blks_read DESC
LIMIT 15;

There is a trap to be careful of here. heap_blks_read counts blocks that were not in the PostgreSQL buffer pool and were therefore requested from the operating system. If they were in the OS page cache, no physical disk I/O occurred. Judging disk load from this number alone overstates it.

pg_stat_io, available since PostgreSQL 16, gives a more accurate picture. It breaks out reads, hits, evictions, and fsyncs by backend_type, object, and context.

SELECT backend_type, object, context,
       reads, hits, evictions, fsyncs
FROM pg_stat_io
WHERE reads > 0 OR evictions > 0
ORDER BY reads DESC
LIMIT 20;

Large values where context is bulkread or vacuum are normal — bulk scans and VACUUM deliberately use a limited ring buffer so they do not pollute the buffer pool. The problem case is a large evictions count where context is normal. That means the working set does not fit in shared_buffers and buffers are being pushed out continuously.

Look at table access patterns as well.

SELECT relname, seq_scan, seq_tup_read, idx_scan,
       n_live_tup, n_dead_tup,
       last_autovacuum, last_autoanalyze, n_mod_since_analyze
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC
LIMIT 15;

A large seq_scan is not automatically bad — for a small table a sequential scan is the right answer. What to look at is seq_tup_read divided by seq_scan, that is, how many rows one sequential scan reads. When that value is large and idx_scan is small, you have an index candidate.

5. The Memory Budget

Now the parameters. Three memory parameters serve three different purposes.

shared_buffers — PostgreSQL's own buffer pool. Default 128MB. The documentation's guidance: "If you have a dedicated database server with 1GB or more of RAM, a reasonable starting value for shared_buffers is 25% of the memory in your system," and "because PostgreSQL also relies on the operating system cache, it is unlikely that an allocation of more than 40% of RAM to shared_buffers will work better than a smaller amount." Changing it requires a server restart.

work_mem — the working area for sorts and hashes. Default 4MB. This is the calculation people get wrong most often. The documentation's own words: "Note that a complex query might perform several sort and hash operations at the same time, with each operation generally being allowed to use as much memory as this value specifies before it starts to write data into temporary files. Also, several running sessions could be doing such operations concurrently. Therefore, the total memory used could be many times the value of work_mem; it is necessary to keep this fact in mind when choosing the value."

In other words, connection count times work_mem understates the worst case. Hash-family operations multiply further by hash_mem_multiplier (default 2.0). The safe approach is a conservative global value, raised per session only for heavy analytical queries.

-- In this session only, for this query
SET LOCAL work_mem = '256MB';

maintenance_work_mem — memory for maintenance work. Default 64MB. The documentation defines it as "the maximum amount of memory to be used by maintenance operations, such as VACUUM, CREATE INDEX, and ALTER TABLE ADD FOREIGN KEY." Few maintenance operations run at once, so it is safe to set it much higher than work_mem.

effective_cache_size — allocates no memory and only changes the planner's assumption. Default 4GB. It is an estimate of available cache including the OS cache, and if it is smaller than reality the planner underrates index scans.

Some I/O defaults changed in PostgreSQL 18. effective_io_concurrency now defaults to 16, the newly introduced io_method defaults to worker, and io_combine_limit defaults to 128kB. If you upgraded from an earlier version, check these items in the documentation.

6. The Write Path — Checkpoints and WAL

If writes are slow or latency spikes periodically, suspect checkpoints.

The documented defaults: checkpoint_timeout is 5 minutes, checkpoint_completion_target is 0.9, max_wal_size is 1GB, and min_wal_size is 80MB.

Symptom-to-cause mapping: periodic latency spikes at intervals of tens of seconds most likely mean max_wal_size is small and checkpoints are being triggered by WAL volume rather than by time. To confirm, turn on log_checkpoints and look at the checkpoint reason in the log. Seeing xlog instead of time as the reason confirms it.

ALTER SYSTEM SET log_checkpoints = on;
SELECT pg_reload_conf();

Raising max_wal_size reduces checkpoint frequency and smooths the latency spikes. The costs are longer crash recovery and more disk usage. Both of those must be decided together with the business requirement (RTO).

synchronous_commit defaults to on, meaning every commit waits until the WAL is safely on disk. Turning it off increases write throughput substantially, but the last few commits can be lost in a crash. Data consistency itself is not broken, but transactions you told the client were committed can disappear. Whether that trade is acceptable is a domain decision. It can be toggled per session or per transaction, so relaxing it for writes to a subset of tables such as audit logs is also possible.

wal_compression defaults to off. Turning it on compresses full page images and reduces WAL volume at the cost of CPU. Worth considering when replication bandwidth is the bottleneck.

commit_delay defaults to 0 and commit_siblings defaults to 5. These are knobs for chasing group-commit effects under very high commit concurrency, and used carelessly they only add latency. Touch them after everything else has been checked.

7. The Maintenance Path — autovacuum

Bloat is the cause of slowness more often than people expect. When autovacuum cannot keep up, dead rows accumulate, sequential scans read more pages, and indexes grow.

The documented defaults: autovacuum on, autovacuum_max_workers 3, autovacuum_naptime 1 minute, autovacuum_vacuum_threshold 50 tuples, autovacuum_vacuum_scale_factor 0.2 (20% of the table), autovacuum_analyze_threshold 50 tuples, autovacuum_analyze_scale_factor 0.1 (10%), autovacuum_vacuum_cost_delay 2 milliseconds, and autovacuum_freeze_max_age 200 million transactions.

PostgreSQL 18 adds autovacuum_vacuum_max_threshold with a default of 100 million tuples. That ceiling keeps vacuum from being deferred indefinitely even on very large tables.

The default scale factor becomes a problem on large tables. On a 100-million-row table, 20% is 20 million rows. That many must die before vacuum runs. Lowering it per table is the standard response.

-- Manage large tables by absolute volume rather than by ratio
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor  = 0.01,
  autovacuum_analyze_scale_factor = 0.005
);

This setting changes a table storage parameter, so it takes only a SHARE UPDATE EXCLUSIVE lock. It blocks neither reads nor writes.

If autovacuum runs but cannot keep up, look at the throttling. The 2-millisecond default of autovacuum_vacuum_cost_delay exists to limit vacuum's impact on service traffic, but on a write-heavy system it can prevent vacuum from ever catching up. Raising autovacuum_max_workers is another option, but the total cost budget is shared among the workers, so it must be considered together with the delay setting.

Check progress like this.

SELECT relname, n_live_tup, n_dead_tup,
       round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
       last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

Warning: Do not run VACUUM FULL or CLUSTER in production just because bloat is already severe. Both take an ACCESS EXCLUSIVE lock that blocks even reads, and both rewrite the whole table, requiring free disk space equal to the original size. The PostgreSQL documentation itself advises that "administrators should strive to use standard VACUUM and avoid VACUUM FULL." If only the indexes are the problem, REINDEX INDEX CONCURRENTLY is the safe alternative.

8. Comparing Before and After

The last step, and the one most often skipped. A change without a comparison is not tuning; it is guessing.

The procedure:

Step 1 — record a baseline. Just before the change, reset pg_stat_statements and save the result of observing for a fixed period (at least one business cycle). Save the wait-event distribution and a pg_stat_io snapshot too.

Step 2 — change one thing at a time. Change three at once and you cannot tell which one had the effect, nor which to revert when things get worse.

Step 3 — confirm how to revert first. A value set with ALTER SYSTEM is reverted with ALTER SYSTEM RESET. Check in advance whether the parameter needs a restart: shared_buffers does, work_mem does not.

-- Change
ALTER SYSTEM SET work_mem = '32MB';
SELECT pg_reload_conf();

-- Confirm: where did the value come from
SELECT name, setting, unit, source, pending_restart
FROM pg_settings
WHERE name IN ('work_mem', 'shared_buffers', 'max_wal_size',
               'effective_cache_size', 'random_page_cost');

-- Revert
ALTER SYSTEM RESET work_mem;
SELECT pg_reload_conf();

The source column of pg_settings tells you where a value came from. Most "I edited the config file but nothing changed" problems are solved right here. If pending_restart is true, a restart is required for the value to apply.

Step 4 — measure again with the same metrics. The same query and the same observation window as step 1. And decide on one representative metric for whether it improved. The sum of total_exec_time for the top 20 queries, the p95 response time, or throughput per second — any one is enough.

Step 5 — record the decision. Why you changed it, what the evidence was, and how much it improved. Six months later, someone will definitely ask why this value is set the way it is.

Quiz: Check Your Understanding

Question 1: The slow query log shows nothing, but server CPU sits at 80%. What should you look at?

Answer: pg_stat_statements sorted by total_exec_time descending.

Explanation: A slow query log only records individual executions that exceed a threshold. A 3ms query running two thousand times per second never leaves a single line in the log, yet consumes a large share of CPU. pg_stat_statements accumulates statistics per normalized query, so it catches exactly this workload. Sorting by total_exec_time rather than mean_exec_time is the key. Look at calls alongside it — if the call count is abnormally high, that is not a database problem but likely an application N+1 problem or a missing cache.

Question 2: You raised work_mem from 4MB to 256MB and the server died of memory exhaustion at peak.

Answer: work_mem applies per operation, not per connection, so total usage is far larger than expected.

Explanation: Straight from the documentation: "a complex query might perform several sort and hash operations at the same time, with each operation generally being allowed to use as much memory as this value specifies. Also, several running sessions could be doing such operations concurrently. Therefore, the total memory used could be many times the value of work_mem." Hash-family operations multiply further by hash_mem_multiplier (default 2.0). With 200 connections each running a query containing three sorts, the theoretical worst case is 200 times 3 times 256MB. The right approach is a conservative global value raised only for heavy queries with SET LOCAL work_mem. To find which queries actually run short, check temp_blks_written in pg_stat_statements or the on-disk sort indication in EXPLAIN (ANALYZE) output.

Question 3: Response time spikes every 30 seconds. Which metric should you check?

Answer: Checkpoints. Turn on log_checkpoints and check the reason.

Explanation: Regularly spaced latency spikes are the classic checkpoint symptom. checkpoint_timeout defaults to 5 minutes, so a 30-second interval most likely means checkpoints are triggered by WAL volume rather than time. max_wal_size defaults to 1GB. Turning on log_checkpoints writes the checkpoint reason to the log, and seeing xlog rather than time confirms it. The response is to raise max_wal_size to reduce checkpoint frequency; checkpoint_completion_target (default 0.9) is already configured to spread the writes broadly. The cost is a longer crash recovery time, so it must be decided together with the business RTO.

Question 4: You sampled wait events and most sessions have wait_event_type = Client.

Answer: The database is not the bottleneck. Look at the application or the network.

Explanation: By the documentation's definition, Client means "waiting for activity on a socket connected to a user application." The server has finished its work and is waiting for the client's next command or for data to arrive. Touching shared_buffers or work_mem in that state has no effect at all. What to look at is the application side: is it fetching one row at a time (fetch size), are there excessive network round trips (N+1), is the client spending time processing results? Check the count of idle in transaction sessions alongside it. A large value there means the application is holding transactions open while doing other work, which is a separate problem that blocks VACUUM.

Question 5: You changed three parameters at once. Overall throughput improved, but some queries got much slower. What now?

Answer: Revert, then reapply one at a time and measure each.

Explanation: Simultaneous changes make attribution impossible. Planner-related parameters in particular (random_page_cost, effective_cache_size, work_mem) change plan selection, so they help some queries and hurt others. The procedure: revert everything with ALTER SYSTEM RESET and confirm the effective values via the source column of pg_settings. Then apply one at a time, resetting pg_stat_statements and observing for the same period each time. Fix one judgment metric such as the summed total_exec_time of the top queries, but also watch for individual regressions. If the total improved while one critical transaction got twice as slow, that change must not be adopted.

Closing Thoughts

The biggest waste in performance tuning is not a wrong parameter but starting without measuring. Double shared_buffers without knowing which query spends the time and you cannot even tell whether things got better or worse.

To restate the order: find the source of the load with pg_stat_statements, split the bottleneck by wait event, confirm the resource situation with cache and I/O metrics, and only then change parameters one at a time, measuring each time. Follow that order and tuning becomes a procedure rather than a craft, and a procedure can be handed to a team.

And finally, check your version. In PostgreSQL 18 the default for effective_io_concurrency became 16, io_method is new, and EXPLAIN ANALYZE now includes buffer information automatically. Half the tuning values floating around the internet are based on versions from years ago.

The diagnostic queries in this guide can be run directly in the Postgres Playground.

References

Further Reading