PostgreSQL 18 VACUUM과 비동기 I/O(AIO): autovacuum 튜닝으로 대용량 테이블 bloat 줄이기
운영 중인 PostgreSQL 데이터베이스에서 어느 날 갑자기 쿼리가 느려지고, 디스크 사용량은 계속 올라가는데 실제 데이터는 그만큼 늘지 않았다면 — 거의 확실하게 bloat 문제입니다. 저도 처음엔 "왜 이 테이블이 이렇게 크지?"라며 한참 헤맸는데, PostgreSQL MVCC 특성상 자연스럽게 발생하는 현상이고 제대로 된 설정만으로 충분히 억제할 수 있습니다.
2025년 9월에 정식 릴리스된 PostgreSQL 18은 비동기 I/O(AIO) 서브시스템을 새로 도입해, 클라우드 블록 스토리지 환경 기준으로 cold-cache 순차 스캔에서 2~3배의 처리량 향상을 보여주고 있습니다. VACUUM도 이 AIO의 수혜를 직접 받기 때문에, bloat이 쌓이기 전에 dead tuple을 훨씬 빠르게 걷어낼 수 있게 되었습니다. 이 글에서는 PG 18 AIO가 VACUUM 속도를 높이는 원리부터, 현재 운영 환경에서 바로 적용할 수 있는 autovacuum 튜닝 설정과 bloat 제거 전략까지 진단 → 튜닝 → 모니터링 → 제거 순서로 살펴봅니다.
핵심 개념
PostgreSQL이 bloat를 만드는 이유 — MVCC의 구조적 특성
PostgreSQL은 UPDATE나 DELETE가 발생해도 기존 행을 물리적으로 즉시 삭제하지 않습니다. 트랜잭션 격리를 보장하기 위해 "이 행은 이제 유효하지 않다"는 표시(dead tuple)만 남겨두고, 공간은 그대로 점유합니다. 이 dead tuple이 쌓이면 테이블 파일이 실제 필요 이상으로 커지는 현상을 bloat라고 부릅니다.
MVCC(Multi-Version Concurrency Control): 읽기와 쓰기가 서로를 블로킹하지 않도록 데이터의 여러 버전을 동시에 유지하는 동시성 제어 방식. PostgreSQL, MySQL InnoDB, Oracle 등 대부분의 현대 RDBMS가 채택하고 있습니다.
VACUUM은 이 dead tuple을 회수해 후속 INSERT가 빈 공간을 재사용할 수 있게 해줍니다. 단, 일반 VACUUM은 공간을 OS에 반환하지 않고 내부적으로만 재사용 가능 상태로 만들기 때문에, 파일 크기 자체는 그대로입니다. OS에 공간을 돌려주려면 VACUUM FULL이나 pg_repack 같은 도구가 필요합니다.
bloat의 실질적인 영향은 생각보다 심각합니다:
- 순차 스캔(Sequential Scan) 시 쓸모없는 페이지까지 전부 읽어야 함
- 인덱스도 dead tuple 포인터를 포함해 비효율적으로 커짐
- shared_buffers 캐시 효율 저하
PostgreSQL 18 비동기 I/O(AIO) 아키텍처
기존 PostgreSQL(17 이하)은 I/O를 동기 방식으로 처리했습니다. 읽기 요청 → 완료 대기 → 다음 요청 순서로 진행되어, 스토리지 레이턴시가 클수록 비효율이 커졌습니다. 특히 클라우드 환경의 네트워크 스토리지(EBS, Azure Managed Disk, GCP PD)에서 이 문제가 두드러졌습니다.
PG 18은 io_method 파라미터로 세 가지 I/O 방식을 지원합니다:
| io_method | 동작 방식 | 비고 |
|---|---|---|
sync |
기존 동기 방식 (PG 17 이하 동일) | 하위 호환성 유지용 |
worker |
백그라운드 I/O 워커 프로세스 큐 방식 | 기본값 |
io_uring |
Linux 커널 ring buffer 직접 활용 | Linux 5.1+, 최고 성능 |
worker 모드는 백엔드 프로세스가 읽기 요청을 공유 메모리 큐에 등록하면 별도의 I/O 워커 프로세스가 비동기적으로 처리한 뒤 완료 큐에 결과를 돌려주는 구조입니다. 요청 전송과 처리 완료 대기 시간이 겹쳐서 전체 처리량이 올라갑니다.
io_uring 모드는 한 단계 더 나아가, 워커 프로세스 자체가 없고 PostgreSQL과 Linux 커널이 ring buffer(커널과 유저스페이스가 공유하는 메모리 영역)를 직접 공유합니다. 이 구조 덕분에 커널 모드 ↔ 유저 모드 전환(컨텍스트 스위칭) 횟수가 크게 줄어, NVMe SSD나 클라우드 스토리지에서 worker 모드보다 성능이 더 좋습니다.
io_uring: Linux 5.1에서 도입된 고성능 비동기 I/O 인터페이스. 커널과 유저스페이스가 ring buffer를 공유해 시스템 콜 횟수 자체를 줄입니다. 다만 컨테이너 환경에서 적용되는 seccomp(시스템 콜 허용 목록 기반 보안 필터)나 AppArmor(접근 제어 프레임워크) 정책에 따라 io_uring syscall이 차단될 수 있으므로, 컨테이너 환경에서는 사전 확인이 필요합니다.
PG 18에서 effective_io_concurrency 기본값도 1에서 16으로 상향되었습니다. 이 값은 worker/io_uring 모드에서 동시에 큐에 올릴 수 있는 병렬 읽기 요청 수를 제어하며, VACUUM을 포함한 모든 비동기 I/O 작업에 영향을 줍니다.
VACUUM에서 AIO가 어떻게 동작하는가
기존 동기 방식에서는 힙 페이지 한 장을 읽고 → 완료 대기 → 다음 페이지를 읽고 → 완료 대기 순서로 테이블 전체를 훑었습니다.
PG 18에서는 maintenance_io_concurrency(유지보수 작업 전용 병렬 I/O 수, 기본값 16) 설정에 따라 여러 페이지 읽기 요청을 한꺼번에 큐에 올리고, 이미 읽혀온 페이지를 처리하는 동안 다음 요청들도 동시에 진행됩니다. 이 "파이프라이닝" 효과가 VACUUM 속도를 끌어올리는 핵심입니다. io_combine_limit은 이 과정에서 여러 I/O 요청을 하나의 큰 요청으로 합쳐 전송할 때의 최대 크기를 제한하는 파라미터입니다(기본값 128kB).
실전 적용
예시 1: Bloat 현황 파악 — 어떤 테이블부터 손봐야 할지
솔직히 처음에는 "그냥 전체 autovacuum 설정 강하게 올리면 되지 않나"라고 생각했는데, 테이블마다 업데이트 패턴이 달라서 개별 설정이 훨씬 효과적이었습니다. 튜닝에 앞서 어떤 테이블이 문제인지 파악하는 것이 먼저입니다.
-- dead tuple 비율이 높은 테이블 목록 조회
SELECT
schemaname,
tablename,
n_dead_tup,
n_live_tup,
ROUND(
n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0) * 100,
2
) AS dead_ratio,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY dead_ratio DESC;dead_ratio가 20%를 넘는 테이블이 보인다면 autovacuum 튜닝이 필요한 시점입니다. 30~40%를 넘어간다면 pg_repack까지 검토해볼 만합니다.
더 정밀한 분석이 필요하다면 pgstattuple 확장을 활용할 수 있습니다:
-- pgstattuple 설치 (contrib 확장)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
-- orders 테이블 상세 bloat 분석
SELECT * FROM pgstattuple('orders');출력에서 dead_tuple_percent와 tuple_percent(live tuple 비율)를 함께 보면 bloat 심각도를 정량적으로 파악할 수 있습니다.
주의:
pgstattuple은 테이블 전체를 순차 스캔합니다. 수억 건 테이블에 운영 시간대에 실행하면 부하가 발생할 수 있으므로, 주 1회 오프피크 타임에 실행하는 것을 권장합니다.
예시 2: 대용량 테이블을 위한 Autovacuum 튜닝
PG 기본값(autovacuum_vacuum_scale_factor = 0.2)은 100만 행 테이블에서 무려 20만 개의 dead tuple이 쌓여야 vacuum이 트리거됩니다. autovacuum이 언제 실행될지는 다음 공식으로 결정됩니다:
실행 조건 = autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × n_live_tup기본 threshold(50) + 0.2 × 1,000,000 = 200,050개가 되어야 vacuum이 돌기 때문에 bloat이 많이 쌓입니다. 현대 NVMe SSD 환경에서는 이 값이 지나치게 보수적입니다.
전역 설정 변경은 postgresql.conf에서 할 수 있습니다:
# postgresql.conf — SSD 환경 권장 설정
autovacuum_vacuum_cost_delay = 2 # 기본 2ms, SSD면 0~2ms
autovacuum_vacuum_cost_limit = 3000 # 기본 200 → SSD 환경에서 3000 권장
autovacuum_max_workers = 5 # 기본 3 → 병렬 처리 증가
autovacuum_vacuum_scale_factor = 0.05 # 기본 0.2 → 5%로 낮춤하지만 전역 설정보다 훨씬 효과적인 방법은 고빈도 갱신 테이블에 개별 설정을 적용하는 것입니다. 서버 재시작 없이 즉시 적용됩니다:
-- 고빈도 갱신 테이블(예: orders)에 개별 설정 적용
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.01, -- 1% dead tuple 시 즉시 vacuum
autovacuum_vacuum_threshold = 1000, -- 최소 1000개 이상일 때 (scale_factor와 합산)
autovacuum_vacuum_cost_delay = 0, -- critical 테이블: I/O 지연 없음
autovacuum_analyze_scale_factor = 0.005 -- 통계도 자주 갱신
);
-- 설정 확인
SELECT relname, reloptions
FROM pg_class
WHERE relname = 'orders';autovacuum_vacuum_threshold = 1000은 위 공식의 threshold 자리에 들어갑니다. "1000 + 0.01 × live_tup 이상이 되어야 실행"되는 구조이므로, 행이 아무리 적어도 최소 1000개 이상 dead tuple이 있어야 vacuum이 트리거됩니다. 소규모 테이블의 과잉 vacuum을 방지하면서도 대용량 테이블에서는 훨씬 빠르게 반응하게 됩니다.
예시 3: PG 18 AIO 설정 및 VACUUM I/O 모니터링
먼저 현재 환경의 io_method를 확인해볼 수 있습니다:
-- 현재 io_method 확인
SHOW io_method;
-- effective_io_concurrency 확인 (기본 16)
SHOW effective_io_concurrency;postgresql.conf에서 io_uring을 활성화하는 설정은 다음과 같습니다:
# postgresql.conf
# Linux 5.1+ 환경에서 io_uring 활성화
io_method = io_uring
# VACUUM 등 유지보수 작업 동시 I/O 수 (AIO 파이프라이닝 제어)
maintenance_io_concurrency = 16
# 병렬 읽기 선행 요청 수 (worker/io_uring 모드에서만 의미 있음)
effective_io_concurrency = 16
# 병합 I/O 요청 최대 크기 제한
io_combine_limit = 128kBPG 18에 새로 추가된 pg_aios 뷰로 비동기 I/O 파이프라인 상태를 실시간으로 조회할 수 있습니다:
-- AIO 파이프라인 실시간 조회 (PG 18 신규 뷰)
SELECT * FROM pg_aios;pg_aios는 현재 처리 중인 비동기 I/O 요청 목록을 보여주며, I/O 워커 ID, 요청 유형(read/write), 처리 모드, 대상 파일 정보 등을 확인할 수 있습니다. 수억 건 테이블에서 VACUUM을 실행하는 동안 다른 세션에서 조회하면 다음과 같은 출력을 볼 수 있습니다:
worker_id | handle_type | mode | pending | ...
-----------+-------------+------+---------+----
1 | read | aio | t | ...
2 | read | aio | t | ...
3 | read | aio | t | ...소규모 테이블에서는 VACUUM이 너무 빠르게 끝나버려 pg_aios가 항상 비어 있을 수 있습니다. 이는 AIO가 그만큼 효율적으로 처리하기 때문이며, 수억 건 규모의 대용량 테이블이 있어야 파이프라이닝 상태를 실제로 관찰할 수 있습니다.
| 파라미터 | 기본값 | 역할 |
|---|---|---|
io_method |
worker |
I/O 방식 선택 (sync/worker/io_uring) |
effective_io_concurrency |
16 |
동시 병렬 읽기 요청 수 |
maintenance_io_concurrency |
16 |
VACUUM 등 유지보수 작업 동시 I/O 수 |
io_combine_limit |
128kB |
결합 I/O 요청 최대 크기 제한 |
예시 4: pg_repack으로 온라인 bloat 제거
autovacuum이 dead tuple을 회수해도 파일 크기는 줄지 않습니다. 디스크 공간을 OS에 돌려주려면 테이블을 새로 작성해야 하는데, VACUUM FULL은 배타 잠금이 필요해 24/7 운영 환경에서는 사용하기 어렵습니다. 이때 pg_repack이 구원투수가 됩니다.
# pg_repack 설치 (Ubuntu/Debian 예시)
# 운영 중인 PostgreSQL 메이저 버전에 맞춰 설치하세요
apt-get install postgresql-18-repack
# 배타 잠금 없이 온라인 테이블 재구성
pg_repack -h localhost -U postgres -d mydb -t orders
# 인덱스만 재구성 (테이블은 그대로)
pg_repack -h localhost -U postgres -d mydb --index orders_pkey
# 데이터베이스 전체 재구성 (시간이 오래 걸릴 수 있음)
pg_repack -h localhost -U postgres -d mydbpg_repack 동작 원리를 알고 쓰면 더 안심이 됩니다:
- 원본 테이블 운영을 유지하면서 새 복사본 생성 시작
- 복사 진행 중 발생하는 변경사항을 별도 로그 테이블에 기록
- 복사 완료 후 로그를 새 테이블에 반영
- 1초 미만의 짧은 잠금으로 구/신 테이블을 스왑
- 기존 인덱스도 concurrent 방식으로 재구성
실무에서 가장 흔한 실수
-
전역 scale_factor만 낮추고 테이블별 설정을 안 하는 경우: 전역값을 0.05로 낮춰도 소규모 테이블들이 쓸데없이 자주 vacuum되어 워커가 포화될 수 있습니다. 핵심 테이블에는
ALTER TABLE개별 설정을 적용하는 것이 훨씬 정밀합니다. -
cost_limit을 한 번에 너무 크게 올리는 경우: 저도 cost_limit을 200에서 3000으로 한 번에 올렸다가 새벽 배치 작업이 30초씩 느려지는 걸 경험했습니다. 500 → 1000 → 2000 → 3000으로 단계적으로 올리면서 쿼리 레이턴시를 함께 모니터링하는 것을 권장합니다.
-
bloat 모니터링 없이 튜닝하는 경우: autovacuum 설정을 바꿔도 효과가 있는지 확인하지 않으면 의미가 없습니다.
last_autovacuum타임스탬프와n_dead_tup추이를 주기적으로 확인해보시면 튜닝 전후의 차이가 눈에 보이게 됩니다.
장단점 분석
장점
AIO 도입의 가장 직접적인 효과는 VACUUM 속도 향상입니다. PostgreSQL HTX 벤치마크 기준으로 클라우드 블록 스토리지 환경의 cold-cache 순차 스캔에서 23배의 처리량 향상이 보고되고 있습니다. 로컬 NVMe의 경우에도 2035% 수준의 개선 효과가 있습니다. EBS, Azure Managed Disk, GCP PD처럼 네트워크 스토리지에서 효과가 더 두드러지는 이유는 레이턴시가 높을수록 비동기 파이프라이닝의 이점이 커지기 때문입니다.
빠른 VACUUM은 자연스럽게 dead tuple 누적을 억제하고, pg_aios 뷰로 I/O 파이프라인을 실시간으로 볼 수 있어 이전에는 블랙박스였던 VACUUM 내부 동작의 가시성도 개선되었습니다. pg_repack과 조합하면 1초 미만의 잠금으로 디스크 공간까지 OS에 반환할 수 있고, ALTER TABLE ... SET (autovacuum_*) 방식의 테이블별 세밀한 제어도 서버 재시작 없이 즉시 적용됩니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| io_uring 환경 제한 | Linux 5.1+ 필수, Windows/macOS 미지원 | 해당 환경에서는 worker 모드 사용 |
| 컨테이너 보안 정책 | seccomp/AppArmor에서 io_uring syscall 차단될 수 있음 | worker 모드로 폴백하거나 정책 수정 |
| 로컬 NVMe 효과 제한 | 클라우드 대비 20~35% 수준 개선 (latency가 낮아 상대 효과 적음) | 여전히 개선은 있으므로 적용 권장 |
| vacuum cost_limit 증가 | I/O 사용량 증가 → 운영 쿼리와 자원 경합 가능 | 단계적으로 올리며 모니터링 병행 |
| max_workers 증가 | shared_buffers 및 메모리 사용량 비례 증가 | 서버 메모리 여유분 확인 후 적용 |
| pgstattuple 부하 | 전체 테이블 순차 스캔 → 대용량 테이블 실시간 모니터링 부적합 | 오프피크 타임 주 1회 실행 |
autovacuum_vacuum_cost_limit: autovacuum이 I/O를 소비할 수 있는 "비용 토큰" 한도. 기본값 200은 HDD 시대 유산으로, SSD/NVMe 환경에서는 1000~3000 수준으로 높여도 운영 쿼리에 큰 영향이 없습니다.
bloat 제거 방법 비교
| 방법 | 잠금 수준 | 공간 반환 | 운영 중단 | 권장 시점 |
|---|---|---|---|---|
| VACUUM | 없음 | X (내부 재사용만) | 없음 | 일상 유지보수 |
| VACUUM FULL | 배타 잠금 | O (OS 반환) | 있음 | 유지보수 윈도우 확보 시 |
| pg_repack | ~1초 잠금 | O (OS 반환) | 거의 없음 | 24/7 운영 환경 |
| pg_squeeze | ~1초 잠금 | O (OS 반환) | 거의 없음 | pg_repack 대안 |
마치며
이 조합을 실제로 적용한 뒤 last_autovacuum 타임스탬프가 이전보다 훨씬 촘촘해진 것을 확인하는 순간이 이 글의 진짜 마무리입니다. PostgreSQL 18 AIO와 autovacuum 튜닝의 조합은 단순한 설정 변경으로 대용량 테이블 bloat를 구조적으로 억제하는 검증된 접근법입니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 bloat 현황 파악부터 — 앞서 소개한
pg_stat_user_tables쿼리로dead_ratio가 높은 테이블 목록을 뽑아보시면 좋습니다. PG 버전과 무관하게 즉시 실행 가능합니다. -
가장 빈번하게 갱신되는 테이블에 개별 autovacuum 설정 적용 —
ALTER TABLE [테이블명] SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_cost_delay = 0);처럼 운영 중단 없이 즉시 적용할 수 있습니다. 적용 후 1~2일 뒤last_autovacuum타임스탬프와n_dead_tup추이를 비교해보시면 좋습니다. PG 17 이하 사용자는 1, 2단계만으로도 즉시 효과를 볼 수 있습니다. -
PostgreSQL 18로 업그레이드 예정이라면
io_method설정 계획 세우기 — Linux 5.1+ 환경이라면io_uring, 그 외 환경이라면 기본값인worker모드로 충분합니다. 업그레이드 직후pg_aios뷰로 AIO 파이프라인이 실제로 동작하는지 확인해보시면 됩니다.
참고 자료
- PostgreSQL 18 공식 릴리스 노트 | postgresql.org
- PostgreSQL 18 Asynchronous I/O: A Complete Guide | Better Stack
- Exploring why PostgreSQL 18 put asynchronous I/O in your database | Aiven
- Waiting for Postgres 18: Accelerating Disk Reads with Async I/O | pganalyze
- PostgreSQL 18 Async I/O: How it works under the hood | Medium
- PostgreSQL 18 Async I/O in Production: Real-World Benchmarks | PostgreSQL HTX
- PostgreSQL Vacuum Optimization for Large Tables: Deep Dive | Andrew Baker
- How to Prevent Table Bloat with Autovacuum Tuning in PostgreSQL | OneUptime
- Autovacuum Tuning Basics | EnterpriseDB
- PostgreSQL Table Bloat Management: VACUUM FULL, pg_repack | PostgreSQL HTX
- Understanding pg_repack: Eliminate Bloat Without Downtime | alexandrubagu.github.io
- How to Reduce Bloat in Large PostgreSQL Tables | Tiger Data
- PostgreSQL Async-IO using io_uring | Medium