본문으로 건너뛰기

"PostgreSQL 18" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

PG18 autovacuum 슬롯

· 약 6분

PostgreSQL 18에서 autovacuum_max_workers가 드디어 SIGHUP로 받아지는 파라미터가 됐어요. 정확히는 한 파라미터가 둘로 쪼개졌어요 — 시작 시 한 번 예약하는 autovacuum_worker_slots(재시작 필요)와 그 범위 안에서 자유롭게 움직이는 autovacuum_max_workers(reload만으로 적용)예요. 운영자가 vacuum 부하를 보다가 worker 수를 늘리려고 maintenance window를 잡아야 했던 시대가 끝났어요.

이 글은 The Build의 Christophe Pettus가 "All Your GUCs in a Row" 시리즈에서 다룬 autovacuum_worker_slots 편을 한국어로 풀고, 함정 한두 가지를 더 짚습니다.

왜 max_workers는 재시작 파라미터였나

PostgreSQL의 autovacuum launcher는 postmaster가 띄우는 백그라운드 프로세스입니다. launcher가 깨운 worker는 별도 프로세스로 동작하는데, 이 worker들이 자리 잡을 shared memory 구조는 postmaster가 시작될 때 한 번에 예약됩니다. autovacuum_max_workers가 그 크기를 결정했고, 따라서 변경하려면 재시작이 필요했습니다.

이 설정이 운영에서 만든 압박은 두 가지입니다.

첫째, 초기 과다 provisioning. "혹시 모르니 worker 16개 정도 확보해두자"는 식의 보수적 설정이 일반적이었습니다. 평소엔 절반도 안 쓰면서 shared memory를 점유합니다.

둘째, 워크로드 변동 대응 실패. 새 큰 테이블 N개를 한꺼번에 적재하는 야간 배치를 추가하면 vacuum 부담이 갑자기 커집니다. worker를 5개에서 10개로 늘리고 싶어도 그건 다음 maintenance window에서나 가능했습니다. 그동안 dead tuple은 쌓이고, bloat는 자라고, query latency는 흔들립니다.

"The cost of being wrong is a SIGHUP, not an outage." — Christophe Pettus, The Build

PostgreSQL 18은 이 비용을 SIGHUP 한 번으로 줄였습니다.

PG18의 worker_slots와 max_workers 분리

원래 하나였던 GUC가 둘로 쪼개집니다.

  • autovacuum_worker_slots: postmaster가 시작할 때 예약할 shared memory worker slot의 개수이며 재시작 파라미터입니다.
  • autovacuum_max_workers: 위 slot 범위 안에서 실제로 동시에 깨어 있을 수 있는 worker의 상한이며 SIGHUP로 받아집니다.

쉽게 말하면 worker_slots주차장 크기, max_workers는 지금 동시에 받을 차의 수입니다. 주차장은 미리 지어둬야 하지만, 진입 제한은 그때그때 바꾸면 됩니다.

Before / After

파라미터PostgreSQL 17 이하PostgreSQL 18
worker 상한 GUC 이름autovacuum_max_workersautovacuum_max_workers (역할 변경)
공유 메모리 예약 GUC없음 (= max_workers가 결정)autovacuum_worker_slots
재시작 필요worker_slots 만 예 / max_workers는 SIGHUP
기본값max_workers = 3worker_slots = 16, max_workers = 3
변경 비용maintenance windowreload

기본값이 worker_slots = 16으로 잡혀 있는 점에 주목할 만합니다. "어차피 사후에 늘리지 못 하니 처음부터 넉넉히 잡아두자"는 의도된 권장입니다.

구조도

worker_slots는 부팅 때 한 번 결정되고, max_workers는 SIGHUP로 그때그때 위아래로 움직입니다.

동작 메커니즘

세 가지를 짚어두면 충분합니다.

  1. slot 예약은 postmaster 시작 시 한 번: worker_slots = 16이면 shared memory에 16개의 worker 자리가 잡힙니다. 이 크기는 실행 중 바꿀 수 없습니다.
  2. max_workers > slots는 자동 capping: worker_slots = 16인데 max_workers = 20으로 reload하면 PostgreSQL은 16으로 capping하고 서버 로그에 경고를 남깁니다. 에러가 아니어서 reload는 성공하지만, 의도대로 동작하지 않는다는 신호입니다.
  3. max_workers는 SIGHUP로 즉시 반영: pg_reload_conf() 한 번이면 다음 worker 사이클부터 새 상한이 적용됩니다.

maintenance window 없는 운영 튜닝 흐름

새 흐름은 단순합니다.

설치할 때 worker_slots는 "최악의 상황에서 필요할 worker 수"에 맞춰 넉넉히 잡습니다. 16이면 대부분 충분하며, shared memory 비용은 worker 1개당 수 KB 수준이라 부담이 작습니다.

평상시에는 max_workers를 보수적으로 설정합니다(예: 3~5). vacuum이 늦지 않으면 그대로 유지합니다. 큰 테이블 적재, partition 증가, dead tuple 누적이 보이면 postgresql.conf에서 max_workers를 올리고 pg_reload_conf()를 실행합니다. 다음 vacuum 사이클부터 worker가 늘어납니다. 부하가 진정되면 다시 max_workers를 내리고 reload하며, shared memory는 그대로 두면 됩니다.

이 흐름에서 중요한 변화는 부하 증가에 대응하는 3번째 단계가 SIGHUP라는 점입니다. vacuum 부하를 더 받기 위해 데이터베이스를 내리고 다시 띄울 필요가 없습니다.

함정과 주의사항

크게 셋입니다.

첫째, worker_slots의 hard ceiling. 설치 시점에 worker_slots를 짜게 잡으면 사후에 그 위로 올릴 수 없습니다. 16이 적당해 보여도, partition 1만 개에 야간 배치까지 도는 환경이라면 32로 잡아두는 편이 낫습니다. shared memory 예약 비용이 vacuum 지연 비용보다 훨씬 쌉니다.

둘째, worker_slots를 거꾸로 줄이고 싶을 때. 재시작이 필요합니다. "지금 max_workers = 5로 줄어들었으니 slot도 8 정도면 충분하지 않을까"는 자연스러운 생각이지만, 그 효과는 다음 재시작 때만 봅니다. 평소에는 그냥 두고, 다른 재시작 사유가 생겼을 때 함께 조정하는 게 실무적입니다.

셋째, parallel autovacuum과의 관계. PostgreSQL 18에서는 같은 시기에 autovacuum_max_parallel_workers도 들어왔습니다. 한 vacuum이 인덱스 정리를 위해 추가로 끌어쓰는 worker는 별도 카운팅이라, 동시에 도는 worker 수를 셀 때는 둘을 함께 봐야 합니다. 자세한 내용은 같은 저자의 "Parallel Autovacuum: It's Not About The CPU"에 잘 정리돼 있습니다.

넷째, max_workers를 올릴 때의 메모리 곱셈. worker_slots가 차지하는 shared memory는 slot 1개당 대략 5~20KB 수준입니다 — PGPROC 슬롯, PgBackendStatus 엔트리, LWLock 같은 자투리를 합산한 값입니다. worker_slots = 16으로 잡아도 총 수백 KB 안쪽이라 셋업 부담은 거의 없습니다. 하지만 slot이 깨어나서 실제 vacuum을 도는 동안에는 별도 비용이 붙습니다. autovacuum_work_mem(미설정 시 maintenance_work_mem, 기본 64MB)이 worker 프로세스의 private memory로 잡힙니다. max_workers = 16으로 올린 상태에서 16개가 동시에 도는 순간 OS RSS에 약 1GB가 추가된다는 뜻입니다. 정리하면 worker_slots는 넉넉히 키워도 무방하지만, max_workers는 RAM과 maintenance_work_mem의 곱셈을 같이 보고 결정합니다. "주차장은 크게, 동시 진입은 천천히"가 기준입니다.

maintenance window라는 비용

OLTP 클러스터를 운영해 봤다면 maintenance window의 무게를 압니다. 사용자에게 공지를 띄우고, 야간 새벽 시간대를 잡고, 운영자 두세 명이 대기하는 비용입니다. autovacuum_max_workers 하나 늘리겠다고 그 비용을 다 치르기는 어렵습니다. 그래서 한참을 견딥니다 — dead tuple이 쌓이고, 큰 테이블이 bloat로 1.5배쯤 부풀고, query plan이 흔들리기 시작한 다음에야 다음 정기 점검에 끼워 넣습니다. PostgreSQL 18의 분리는 이 견디는 구간이 사라진다는 뜻입니다. dead tuple 알람이 뜨면 그날 안에 worker를 두세 개 더 풀어 vacuum을 따라잡게 한 뒤, 부하가 가라앉으면 다시 줄여둡니다. 운영자 입장에서는 vacuum 튜닝이 처음으로 "회의 없이 가능한 일"이 됐습니다.

정리

PG17 이하PG18
worker 수 변경 비용restartSIGHUP
초기 셋업 부담"충분히 크게" 1회 결정worker_slots만 크게, max_workers는 작게
워크로드 변동 대응다음 maintenance window다음 reload
운영 사고 빈도bloat 누적이 잦음즉시 대응 가능

PostgreSQL 18은 "worker 풀은 사전에 크게, 사용량은 그때그때"라는 평범한 운영 패턴을 vacuum에도 들여왔어요. 작은 변화 같지만 운영 자동화 관점에서는 큰 차이예요. 한밤중 dead tuple 누적 알림에 더 이상 maintenance window를 잡지 않아도 돼요.

참고 자료

PostgreSQL 18 비동기 I/O

· 약 10분

17년을 동기 I/O로 버텨온 데이터베이스

PostgreSQL은 1996년에 첫 릴리스가 나온 이래로 process-per-connection 모델 + 동기 I/O라는 단순한 조합을 17년 넘게 유지해왔어요. 클라이언트 하나가 붙으면 백엔드 프로세스 하나가 fork되고, 그 프로세스는 디스크에서 페이지를 읽을 때 read() 시스템 콜을 직접 호출해서 결과가 돌아올 때까지 그냥 멈춰서 기다려요.

이 모델은 단순함이 가장 큰 무기였습니다. fork 한 번이면 격리되고 신호 처리도 직관적이며, 잠금이나 컨텍스트 스위치 같은 까다로운 동시성 이슈도 적게 신경 써도 됩니다. MySQL이나 SQL Server가 thread-per-connection 모델로 가면서 동시성 버그와 평생을 싸우는 동안, PostgreSQL은 다른 길을 갔습니다.

문제는 2020년대의 NVMe와 클라우드 스토리지가 그 단순함의 대가를 점점 더 비싸게 만들고 있다는 점입니다.

PostgreSQL 18(2025-09-25 GA)은 17년 만에 처음으로 비동기 I/O 서브시스템을 들고 왔습니다. 이 글에서는 그 구조와 트레이드오프를 살펴봅니다.

동기 I/O의 정확한 병목

PostgreSQL이 디스크에서 데이터를 읽을 때의 기본 단위는 8KB 페이지 한 장입니다. Sequential Scan 한 번에 100만 페이지를 읽어야 한다면, 옛 모델은 이렇게 동작합니다.

Sequential Scan (cold cache):

read(page 1) → 디스크 응답 대기 (lat) → 처리
read(page 2) → 디스크 응답 대기 (lat) → 처리
read(page 3) → 디스크 응답 대기 (lat) → 처리
...

총 시간 ≈ 페이지 수 × 디스크 latency (직렬)

CPU는 매 페이지마다 디스크를 기다리며 놀고, NVMe는 큐가 대부분 비어 있는 채로 능력의 일부만 씁니다. 클라우드 EBS처럼 한 IOP의 latency가 수백 마이크로초인 환경에서는 이 직렬 누적이 잔인하게 드러납니다.

PostgreSQL도 손 놓고 있던 건 아닙니다. 17 버전까지는 OS의 readahead와 posix_fadvise(POSIX_FADV_WILLNEED) 정도로 커널에 "앞으로 이 영역을 읽을 테니 미리 좀 가져와"라고 힌트만 줬습니다. 동작은 하지만, PostgreSQL이 직접 I/O 깊이를 통제하지 못하기 때문에 어떤 페이지가 언제 도착할지를 알 수 없었습니다. 통계 기반 readahead는 패턴이 깨지면 무력해집니다.

"This feature allows backends to queue multiple read requests, which allows for more efficient sequential scans, bitmap heap scans, vacuums, etc."PostgreSQL 18 Release Notes, E.4.3.1.3

PostgreSQL 18의 한 줄 요지는 이렇습니다. "여러 read를 큐에 넣고 동시에 보냅니다." 페이지마다 멈추지 않고 100개를 한꺼번에 던져놓은 뒤, NVMe가 알아서 병렬로 처리하게 두자는 이야기입니다.

세 가지 io_method

PostgreSQL 18은 새 GUC 파라미터 io_method로 비동기 I/O 동작을 고릅니다. 세 가지 옵션이 있습니다.

모드어디서 동작환경 요건장점단점
sync메인 백엔드모든 OSPG17과 동일, 호환성 100%, 추가 프로세스 없음사실상 비동기 X, NVMe/EBS 큐 활용 못 함
workerI/O 워커 프로세스모든 OS (default)어디서든 돌고 sync 대비 1.6배, 운영 안정성 검증된 디폴트워커 프로세스 비용, 컨텍스트 스위치/메모리 카피 오버헤드
io_uring커널과 ring buffer 공유Linux 5.1+sync 대비 2.7배, 워커 없이 syscall 거의 0일부 배포판/컨테이너에서 seccomp으로 차단, ARM/macOS/BSD 불가

기본값은 worker입니다. 모든 운영체제에서 동작하면서도 동기보다는 확연히 빠르기 때문에 안전한 선택입니다. macOS, BSD 또는 io_uring을 못 쓰는 환경에서는 그대로 default로 두면 됩니다.

함께 추가된 GUC들도 알아둘 만합니다(공식 release notes).

io_workersworker 모드의 워커 프로세스 수이며 기본값은 3입니다. io_combine_limitio_max_combine_limit는 인접 read를 한 요청으로 합치는 한도입니다. effective_io_concurrency의 기본값은 1 → 16으로 올랐으며, 이 값은 사실상 "동시에 던질 read 수"입니다. maintenance_io_concurrency는 VACUUM 같은 유지보수 작업의 동시 I/O를 조절합니다.

fadvise()가 없는 OS에서도 effective_io_concurrency > 0이 의미를 갖게 됐다는 점도 조용히 큰 변화입니다. 이전에는 Linux 외 환경에서 이 파라미터가 사실상 장식이었습니다.

worker 모드

누군가 대신 디스크를 읽는 방식입니다.

worker의 구조는 의외로 깔끔합니다.

백엔드는 read 요청을 큐에 넣고 다른 일을 합니다. 별도의 I/O 워커 프로세스 풀이 큐에서 꺼내 실제 read() syscall을 호출하고, 결과를 공유 버퍼에 채워줍니다. 백엔드는 자기가 요청한 페이지가 필요한 시점에 공유 버퍼만 들여다보면 됩니다.

장점은 호환성입니다. POSIX read만 있으면 어디서든 동작합니다. 단점은 워커 프로세스 자체가 일종의 디스패처라서 컨텍스트 스위치와 메모리 카피 비용이 발생한다는 점입니다.

io_uring 모드

커널에 ring을 심는 방식입니다. io_uring은 Linux 5.1(2019년)에 들어온 커널 비동기 I/O 인터페이스입니다. PostgreSQL과 커널 사이에 공유 메모리 ring 두 개를 두고, 시스템 콜 없이 요청을 주고받습니다.

PostgreSQL 18 구현의 흥미로운 결정 하나는 ring 인스턴스를 backend마다 따로 둔다는 점입니다. 한 인스턴스를 여러 백엔드가 공유하면 lock contention이 생기므로 아예 격리했습니다. 다만 ring 자체는 fork 전에 postmaster가 미리 생성해서 shared memory에 올려둡니다(credativ deep-dive).

워커가 빠지므로 컨텍스트 스위치가 사라지고, syscall도 제출과 대기를 잘 묶으면 거의 0에 수렴합니다. 다만 Linux 5.1+ 전용이며, 일부 배포판은 보안 정책상 io_uring을 비활성화해두기도 합니다(예: 특정 컨테이너 런타임의 seccomp 프로파일).

pg_aios

PostgreSQL의 I/O 큐를 처음으로 들여다볼 수 있게 됐습니다. 운영자 입장에서 더 반가운 변화는 새 시스템 뷰 하나입니다. pg_aios는 진행 중인 비동기 I/O 요청을 그대로 보여줍니다.

SELECT pid, io_method, op, state, target, off, length
FROM pg_aios
ORDER BY pid, off
LIMIT 20;

지금까지 PostgreSQL의 I/O는 거의 블랙박스였습니다. pg_stat_io(PostgreSQL 16에서 들어옴)는 누적 통계를, pg_stat_activity는 wait event 정도를 보여줬습니다. "바로 지금 어떤 read가 큐에 떠 있는가"는 이제야 들여다볼 수 있게 됐습니다.

여기서 잡히는 정보로 특정 파일에 I/O가 몰리는 hot relation을 식별하거나, io_uring이 큐를 정말 깊게 쓰고 있는지를 확인할 수 있습니다.

벤치마크

숫자로 성능 차이를 살펴봅니다.

pganalyze 벤치마크는 AWS c7i.8xlarge에서 3.5GB 테이블의 cold scan을 측정했습니다.

버전 / 모드실행 시간대 PG17대 PG18 sync
PostgreSQL 17 (sync)15,830 ms기준해당 없음
PostgreSQL 18 sync15,071 ms-5%기준
PostgreSQL 18 worker10,051 ms-37%-33%
PostgreSQL 18 io_uring5,723 ms-64%-62%

cold cache Sequential Scan에서 io_uringPostgreSQL 17 대비 2.7배 빠릅니다. worker도 1.6배 빠릅니다. 같은 PostgreSQL 18을 sync로만 켜두면 거의 차이가 없다는 점도 중요한 신호입니다. 모드 선택이 곧 성능입니다.

이 향상이 어디서 오는지 한 줄로 정리하면, 디스크 latency를 여러 번 직렬로 치르던 것을 한 번 병렬로 치르게 된 것뿐입니다. NVMe는 원래 그렇게 쓰는 물건이었는데, PostgreSQL이 17년 만에 그 사용법을 익혔습니다.

한계

아직 비동기가 아닌 작업도 있습니다.

PostgreSQL 18 AIO는 읽기 작업 일부에만 적용됩니다.

작업PG18 AIO 적용?
Sequential Scan적용
Bitmap Heap Scan적용
VACUUM적용
ANALYZE (일부)적용
Index Scan random read아직 동기
WAL write미적용
Checkpoint write미적용

쓰기는 전부 동기 그대로입니다. 인덱스 random read도 들어가지 않았습니다. 즉 OLTP 점 쿼리 워크로드는 이번 변화로 직접 빨라지지는 않고, 이득은 분석 워크로드/대량 스캔/VACUUM에 몰려 있습니다.

PostgreSQL 19(2026-09 예정) 개발 트리에서는 인덱스 prefetch와 일부 쓰기 경로의 비동기화가 논의되고 있습니다. AIO는 PostgreSQL 18에서 끝난 게 아니라 시작된 것에 가깝습니다.

운영 관점

무엇을 켤지 선택하는 기준은 사실 단순합니다.

환경추천
Linux 5.1+ 직접 운영, io_uring 사용 가능io_uring
Linux지만 io_uring 비활성 배포판/컨테이너worker (default)
macOS / BSD / 구형 Linuxworker (default)
호환성 이슈 발생 시 임시 폴백sync

추가로 함께 조정할 만한 설정은 다음과 같습니다.

# postgresql.conf 예시 (분석 워크로드 기준)
io_method = io_uring
effective_io_concurrency = 32 # 기본 16에서 NVMe 깊이에 맞게 조정
maintenance_io_concurrency = 32 # VACUUM 가속
io_combine_limit = 256kB

effective_io_concurrency는 "동시에 던질 read 수"라고 생각하면 됩니다. NVMe 큐 깊이 / 동시 사용자 수에 맞춰 늘립니다. 너무 키우면 다른 백엔드와 디스크 대역을 놓고 다투게 되니, 분석 전용 인스턴스가 아니라면 기본 16에서 천천히 올립니다.

배포 전에는 다음 항목을 확인합니다.

  1. 커널 버전(uname -r): 5.1 미만이면 io_uring 불가
  2. seccomp/AppArmor 프로파일이 io_uring 시스템 콜을 막는지 확인
  3. pg_aios 뷰가 보이는지로 AIO 활성 검증
  4. cold scan 워크로드에서 EXPLAIN (ANALYZE, BUFFERS) 비교 측정

한국 커뮤니티 반응

GeekNews에는 이미 작년에 Postgres 18을 기다리며: 비동기 I/O로 디스크 읽기 속도 향상이 올라왔습니다. 댓글은 많지 않지만 톤은 거의 "드디어"에 가깝고, 관심사는 io_uring 보안 우려와 클라우드 NVMe에서의 실측 향상에 집중돼 있었습니다. 1년이 지난 지금 PostgreSQL 18이 GA된 상태에서 그 기대가 어느 정도 채워졌다고 봐도 됩니다.

정리

PG17 이하PG18
I/O 모델동기 (메인 백엔드 직접 syscall)동기 + worker + io_uring
모드 선택없음io_method GUC
가시성pg_stat_io (누적 통계)+ pg_aios (실시간 큐)
effective_io_concurrencyLinux fadvise 한정모든 OS, 기본 16
Cold scan 성능 (3.5GB)15.8초5.7초 (io_uring)
적용 범위Sequential/Bitmap Scan, VACUUM
미적용Index random read, WAL/Checkpoint write

PostgreSQL이 process-per-connection이라는 17년짜리 아키텍처 결정을 바꾸지 않고서도 비동기 I/O를 들였다는 점이 이번 변화의 진짜 핵심입니다. 백엔드 프로세스 모델은 그대로 두고 syscall 경계만 다시 그었습니다. 그래서 운영자 입장에서 마이그레이션이 거의 무료이며, io_method 한 줄만 바꾸면 됩니다.

다음 PostgreSQL 18 시리즈 글에서는 UUIDv7과 B-tree 인덱스의 관계를 다룰 예정이에요. 같은 "스토리지 효율"이라는 축에서, 이번에는 인덱스 페이지 분할 쪽 이야기를 해보려고 해요.

참고 자료