- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- 1. 튜닝의 순서
- 2. 워크로드 프로파일링 — pg_stat_statements
- 3. 대기 이벤트 — 병목의 성격을 가르기
- 4. 캐시와 I/O 지표
- 5. 메모리 예산
- 6. 쓰기 경로 — 체크포인트와 WAL
- 7. 유지보수 경로 — autovacuum
- 8. 바꾸기 전과 후를 비교하기
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
이 블로그에는 PostgreSQL 성능 튜닝 글이 이미 여럿 있습니다. PostgreSQL 성능 튜닝 실전 가이드, 쿼리 최적화와 성능 튜닝, PostgreSQL 17 성능 실험실이 각각 다른 각도에서 다룹니다.
이 글은 파라미터가 아니라 순서를 다룹니다. 튜닝 글의 문제는 대개 "무엇을 바꿀 수 있는가"의 목록이라는 점입니다. 목록은 유용하지만 실전에서 필요한 것은 지금 무엇을 먼저 봐야 하는가 입니다. 느려졌다는 신고를 받았을 때 shared_buffers부터 만지는 것과, 어떤 쿼리가 시간을 쓰는지부터 확인하는 것은 완전히 다른 결과를 냅니다.
그래서 이 글은 진단 순서를 따라갑니다. 워크로드 프로파일 → 대기 이벤트 → 캐시와 I/O → 그다음에야 파라미터. 그리고 각 단계에서 무엇을 보고 무엇을 판단하는지를 문서에 적힌 기본값과 함께 정리합니다.
기준 엔진은 PostgreSQL 18 이며, 기본값은 모두 PostgreSQL 18 문서에서 확인했습니다. PostgreSQL 18에서 바뀐 기본값이 몇 개 있으므로 버전을 반드시 확인하세요.
1. 튜닝의 순서
성능 문제를 만났을 때 던지는 질문의 순서가 튜닝의 절반입니다.
- 무엇이 느린가 — 전체인가 특정 쿼리인가. 항상인가 특정 시간대인가.
- 어디에 시간을 쓰는가 — 실행인가 대기인가. 대기라면 무엇을 기다리는가.
- 왜 그런가 — 계획이 나쁜가, 데이터가 많은가, 자원이 부족한가, 경합인가.
- 무엇을 바꿀 것인가 — 쿼리인가 스키마인가 인덱스인가 파라미터인가.
파라미터가 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, track이 top, 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_activity의 wait_event_type이 답을 줍니다. 문서가 정의한 값은 다음과 같습니다.
| 값 | 의미 | 시사점 |
|---|---|---|
Lock | SQL에서 보이는 객체에 대한 무거운 잠금 대기 | 경합. DDL이나 긴 트랜잭션 |
LWLock | 내부 자료구조를 보호하는 경량 잠금 대기 | 내부 경합. 버퍼나 WAL |
BufferPin | 버퍼에 대한 배타 접근 대기 | 드묾 |
IO | I/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_read는 PostgreSQL 버퍼 풀에 없어서 운영체제에 요청한 블록입니다. 운영체제 페이지 캐시에 있었다면 실제 디스크 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;
context가 bulkread나 vacuum인 항목이 큰 것은 정상입니다. 대량 스캔과 VACUUM은 원래 버퍼 풀을 오염시키지 않도록 제한된 링 버퍼를 씁니다. 문제는 context가 normal인데 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_read를 seq_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_settings의 source 컬럼은 값이 어디서 왔는지 알려 줍니다. 설정 파일을 고쳤는데 반영되지 않는 문제의 대부분이 여기서 풀립니다. pending_restart가 true면 재시작해야 적용됩니다.
4단계 — 같은 지표로 다시 잽니다. 1단계와 같은 쿼리, 같은 관찰 기간이어야 합니다. 그리고 개선 여부를 하나의 대표 지표로 정해 두세요. 상위 20개 쿼리의 total_exec_time 합, p95 응답 시간, 초당 처리 건수 중 하나면 충분합니다.
5단계 — 결정을 기록합니다. 왜 바꿨고, 무엇이 근거였고, 얼마나 개선되었는지를 남깁니다. 6개월 뒤에 "이 값이 왜 이렇게 되어 있지?"라는 질문이 반드시 나옵니다.
퀴즈: 실력을 확인해 보세요
퀴즈 1: 슬로우 쿼리 로그에는 아무것도 안 잡히는데 서버 CPU가 계속 80%입니다. 무엇을 봐야 할까요?
정답: pg_stat_statements를 total_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_statements의 temp_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_buffers나 work_mem을 만지는 것은 아무 효과가 없습니다. 봐야 할 것은 애플리케이션 쪽입니다. 결과를 한 행씩 가져오고 있지는 않은지(페치 크기), 네트워크 왕복이 과도하지는 않은지(N+1), 클라이언트가 결과 처리에 시간을 쓰고 있지는 않은지. 함께 확인할 것은 idle in transaction 상태의 수입니다. 이 값이 크면 애플리케이션이 트랜잭션을 열어 둔 채 다른 일을 하고 있다는 뜻이고, 그 자체가 VACUUM을 막는 별도의 문제입니다.
퀴즈 5: 파라미터 세 개를 동시에 바꿨더니 전체적으로는 빨라졌는데 일부 쿼리가 크게 느려졌습니다. 어떻게 해야 할까요?
정답: 되돌리고 하나씩 다시 적용하며 각각을 측정해야 합니다.
설명: 동시 변경은 원인 귀속을 불가능하게 만듭니다. 특히 플래너 관련 파라미터(random_page_cost, effective_cache_size, work_mem)는 계획 선택을 바꾸므로, 어떤 쿼리에는 이득이고 어떤 쿼리에는 손해입니다. 절차는 이렇습니다. 먼저 ALTER SYSTEM RESET으로 전부 되돌리고 pg_settings의 source로 실제 적용값을 확인합니다. 그다음 하나씩 적용하며 매번 pg_stat_statements를 초기화하고 같은 기간 관찰합니다. 판단 지표는 상위 쿼리들의 total_exec_time 합처럼 하나로 정해 두되, 개별 쿼리의 회귀도 함께 봐야 합니다. 전체 합이 좋아져도 핵심 트랜잭션 하나가 두 배 느려졌다면 그 변경은 채택하면 안 됩니다.
마치며
성능 튜닝에서 가장 큰 낭비는 잘못된 파라미터가 아니라 측정 없이 시작하는 것입니다. 어떤 쿼리가 시간을 쓰는지 모르는 상태에서 shared_buffers를 두 배로 늘리면, 좋아졌는지 나빠졌는지조차 판단할 수 없습니다.
순서를 다시 정리합니다. pg_stat_statements로 부하의 출처를 찾고, 대기 이벤트로 병목의 성격을 가르고, 캐시와 I/O 지표로 자원 상황을 확인하고, 그다음에 파라미터를 하나씩 바꾸며 매번 측정합니다. 이 순서를 지키면 튜닝은 기술이 아니라 절차가 되고, 절차는 팀에 전수될 수 있습니다.
그리고 마지막으로, 버전을 확인하세요. PostgreSQL 18에서 effective_io_concurrency의 기본값이 16이 되었고 io_method가 새로 생겼으며 EXPLAIN ANALYZE가 버퍼 정보를 자동으로 포함하게 되었습니다. 인터넷에 떠도는 튜닝 값의 절반은 몇 년 전 버전 기준입니다.
이 글의 진단 쿼리는 Postgres 놀이터에서 직접 실행해 볼 수 있습니다.
참고 자료
- PostgreSQL 18, Resource Consumption: https://www.postgresql.org/docs/18/runtime-config-resource.html (2026-08-15 확인)
- PostgreSQL 18, Write Ahead Log: https://www.postgresql.org/docs/18/runtime-config-wal.html (2026-08-15 확인)
- PostgreSQL 18, Automatic Vacuuming: https://www.postgresql.org/docs/18/runtime-config-autovacuum.html (2026-08-15 확인)
- PostgreSQL 18, Query Planning: https://www.postgresql.org/docs/18/runtime-config-query.html (2026-08-15 확인)
- PostgreSQL 18, Monitoring Database Activity: https://www.postgresql.org/docs/18/monitoring-stats.html (2026-08-15 확인)
- PostgreSQL 18, pg_stat_statements: https://www.postgresql.org/docs/18/pgstatstatements.html (2026-08-15 확인)
- PostgreSQL 18, Routine Vacuuming: https://www.postgresql.org/docs/18/routine-vacuuming.html (2026-08-15 확인)
- PostgreSQL 18, EXPLAIN: https://www.postgresql.org/docs/18/sql-explain.html (2026-08-15 확인)
이어서 읽기
- 이전 편: 데이터 모델링 완전 가이드 — 논리 모델에서 물리 모델까지
- 첫 편으로: PostgreSQL 인덱스 완전 가이드 — 인덱스의 수명 주기
- SQL 실행계획 완전 가이드 — 옵티마이저가 계획을 고르는 과정
- PostgreSQL 성능 튜닝 실전 가이드 — 파라미터 카탈로그
- PostgreSQL VACUUM과 MVCC 내부 구조 — bloat의 원리
- Postgres 놀이터 — 진단 쿼리 실행