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가 수백 마이크로초인 환경에서는 이 직렬 누적이 잔인하게 드러난다.

PG도 손 놓고 있던 건 아니다. 17 버전까지는 OS의 readahead와 posix_fadvise(POSIX_FADV_WILLNEED) 정도로 커널에게 “앞으로 이 영역 읽을 거야, 미리 좀 가져와” 라고 힌트만 줬다. 동작은 하지만, PG가 직접 I/O 깊이를 통제하지 못하기 때문에 어떤 페이지가 언제 도착할지를 PG는 알 수 없었다. 통계 기반 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

PG18의 한 줄 요지는 이거다. “여러 read를 큐에 넣고 동시에 보낸다.” 페이지마다 멈추지 말고, 100개를 한꺼번에 던져놓고 NVMe가 알아서 병렬로 처리하게 두자는 것.


세 가지 io_method

PG18은 새 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_limit / io_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의 구조는 의외로 깔끔하다.

flowchart TD
    B["Backend
(쿼리 실행 중)"] Q["공유 큐
(shared memory)"] W1["I/O Worker 1"] W2["I/O Worker 2"] W3["I/O Worker 3"] K["커널 read() syscall"] SB["공유 버퍼
(shared buffers)"] B -- "enqueue: 이 페이지 읽어줘" --> Q Q --> W1 Q --> W2 Q --> W3 W1 --> K W2 --> K W3 --> K K -- "page 적재" --> SB SB -. "백엔드가 필요할 때 조회" .-> B classDef backend fill:#e8f0fe,stroke:#3367d6,color:#000 classDef queue fill:#fff4cc,stroke:#b08900,color:#000 classDef worker fill:#e6f4ea,stroke:#137333,color:#000 classDef kernel fill:#fce8e6,stroke:#c5221f,color:#000 class B backend class Q,SB queue class W1,W2,W3 worker class K kernel

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

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


io_uring 모드 — 커널에 ring을 박는다

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

flowchart LR
    subgraph User["User space (Backend 프로세스)"]
        B["Backend"]
    end

    subgraph SHM["공유 메모리 (mmap)"]
        SQ["Submission Ring
read req #1
read req #2
..."] CQ["Completion Ring
done #1, #2, ..."] end subgraph Kernel["Kernel space"] K["io_uring"] end B -- "1. 요청 push" --> SQ SQ -- "2. 커널이 pop" --> K K -- "3. 결과 push" --> CQ CQ -- "4. 백엔드가 pop" --> B classDef backend fill:#e8f0fe,stroke:#3367d6,color:#000 classDef ring fill:#fff4cc,stroke:#b08900,color:#000 classDef kernel fill:#fce8e6,stroke:#c5221f,color:#000 class B backend class SQ,CQ ring class K kernel

PG18 구현의 흥미로운 결정 하나는 ring 인스턴스를 backend마다 따로 둔다는 점이다. 한 인스턴스를 여러 백엔드가 공유하면 lock contention이 생기니, 격리해버렸다. 그런데 ring 자체는 fork 전에 postmaster가 미리 생성해서 shared memory에 박아둔다 (credativ deep-dive).

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


pg_aios — 처음으로 들여다보는 PG의 I/O 큐

운영자 입장에서 더 반가운 변화는 새 시스템 뷰 하나다. pg_aios는 진행 중인 비동기 I/O 요청을 그대로 보여준다.

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

지금까지 PG의 I/O는 거의 블랙박스였다. pg_stat_io (PG16에서 들어옴)는 누적 통계, 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_uringPG17 대비 2.7배 빠르다. worker도 1.6배. 같은 PG18을 sync로만 켜두면 거의 차이가 없다는 것도 중요한 신호다 — 모드 선택이 곧 성능이다.

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


한계 — 아직 비동기가 아닌 것들

PG18 AIO는 읽기 작업 일부에만 적용된다.

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

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

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


운영 관점 — 무엇을 켤지

선택은 사실 단순하다.

환경추천
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년이 지난 지금 PG18이 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

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

다음 PG18 시리즈 글로는 UUIDv7과 B-tree 인덱스의 관계를 다룰 예정이다. 같은 “스토리지 효율"이라는 축에서, 이번엔 인덱스 페이지 분할 쪽 이야기.


참고 자료