WAL과 버퍼 풀

DB의 빠른 쓰기와 durability를 동시에 성립시키는 구조

DB는 매 쓰기를 바로 디스크에 반영하지 않고 버퍼 풀(RAM)에서 수정한 뒤 변경 내역을 WAL 로그로 먼저 디스크에 쓴다. WAL은 순차 I/O라 빠르고, 전원이 나가도 WAL을 재현(redo)해서 복구할 수 있어 durability가 성립한다. 체크포인트는 쌓인 dirty page를 디스크로 내려 WAL 무한 성장을 막고 복구 시간을 제한한다.

Storage

개념

DB에 COMMIT 리턴한 직후 전원이 나가도 그 트랜잭션은 살아남아야 한다. 이게 ACID의 D(durability). 그런데 매 쓰기마다 데이터 파일을 디스크에 반영하면 너무 느리고, 안 하면 날아간다. 이 딜레마를 푸는 구조가 버퍼 풀 + WAL + 체크포인트 세트다.

핵심 원리는 단순하다. "상태를 바로 저장하지 말고, 변경의 순서를 먼저 영속적으로 기록해라. 그러면 언제든 재현 가능하다." git의 commit 로그, Kafka의 log, event sourcing이 전부 같은 뿌리.

버퍼 풀

DB가 자기 메모리 공간에 페이지를 캐싱하는 영역. 모든 페이지 읽기·쓰기가 이걸 거친다.

읽기

SELECT
 ↓
버퍼 풀에 그 페이지 있나?
 ├─ YES → 디스크 안 감 (캐시 히트)
 └─ NO  → 디스크에서 로드 → 버퍼 풀에 올림

쓰기와 dirty page

UPDATE
 ↓
버퍼 풀의 페이지를 RAM에서 수정
 ↓
그 페이지를 "dirty" 표시
 ↓
즉시 디스크에 안 씀

수정됐지만 아직 디스크에 반영 안 된 페이지를 dirty page라 한다. 버퍼 풀이 바로 디스크로 안 내리는 이유:

  • 같은 페이지가 짧은 시간에 여러 번 수정될 수 있음 (묶어서 한 번에 쓰는 게 이득)
  • 디스크 쓰기는 비싸고 랜덤 I/O임
  • 한 트랜잭션이 여러 페이지 건드리면 매번 디스크를 찌르면 낭비

이 자체로는 빠르지만 전원 나가면 dirty page가 전부 날아간다. 여기서 WAL이 들어온다.

WAL (Write-Ahead Logging)

데이터 페이지를 디스크에 반영하기 전에, 변경 내역을 로그로 먼저 디스크에 쓴다. 이름 그대로 write-ahead = 미리 쓰기.

COMMIT 흐름

1. 버퍼 풀의 페이지 수정 (RAM)
2. 변경 내역을 WAL 레코드로 작성 (RAM)
3. WAL을 fsync로 디스크에 강제 반영
4. COMMIT 반환 ← 여기서 durability 성립
5. 실제 데이터 페이지의 디스크 반영은 나중에

순서가 핵심이다. 데이터 페이지보다 WAL이 먼저 디스크에 가야 한다. 뒤집히면 복구가 깨진다.

왜 이게 빠른가

두 번 쓰는 것처럼 보이지만 실상은 반대다.

  • WAL은 순차 I/O — append-only, 디스크 헤드 안 움직임, 매우 빠름
  • 데이터 페이지 쓰기는 랜덤 I/O — 매 COMMIT마다 하면 치명적
  • WAL fsync 1번 + 데이터 페이지는 나중에 묶어서 → 전체 처리량 이득

COMMIT 레이턴시가 WAL fsync 1번으로 결정되는 게 이 구조의 핵심 이득이다.

복구 (redo)

전원 나가고 재시작하면:

1. 디스크의 마지막 체크포인트 지점 확인
2. WAL에서 체크포인트 이후 로그를 순서대로 읽음
3. "이 페이지를 이렇게 수정했었음"을 재현 (redo)
4. 복구 완료

COMMIT된 트랜잭션은 WAL에 있으니 복구되고, COMMIT 안 된 건 WAL에 없으니 자동으로 사라진다 (rollback 효과).

fsync 없으면 의미 없음

파일시스템에서 본 그 fsync. write()만 하면 OS 페이지 캐시(RAM)에만 있다. fsync를 호출해야 진짜 디스크로 간다. WAL 쓴 직후 fsync 안 하면 "WAL에 썼다고 믿었는데 페이지 캐시째로 날아감" 사태가 생긴다. 그래서 DB는 WAL 쓰고 반드시 fsync.

체크포인트

WAL만 쌓으면 로그가 무한히 커지고 복구 시간도 무한히 길어진다. 체크포인트는 주기적으로 dirty page를 전부 디스크에 내리고 "여기까지 반영 완료"를 마킹하는 작업.

체크포인트 과정:
1. 그 시점의 모든 dirty page를 디스크로 flush
2. "체크포인트 완료" 마커를 WAL에 기록
3. 그 이전의 WAL 로그는 삭제·압축 가능

복구 시엔 체크포인트 이후 WAL만 재현하면 된다. 복구 시간이 bounded 됨.

트레이드오프

  • 체크포인트 잦음 → 복구 빠름, 하지만 평소 쓰기 버스트로 성능 저하
  • 체크포인트 드묾 → 평소 빠름, 복구 시간은 길어짐

Postgres 기본은 5분마다 또는 WAL 1GB 쌓이면 둘 중 빠른 쪽.

버퍼 풀과 OS 페이지 캐시

DB 프로세스의 버퍼 풀과 OS 커널의 페이지 캐시는 동시에 돌아간다. 구조가 이렇게 된다.

DB 프로세스
  └── 버퍼 풀 (예: 2GB)
        ↓ read/write 시스템 콜
OS 커널
  └── 페이지 캐시 (남은 RAM)
        ↓
디스크

Postgres: 공존 전략

Postgres가 페이지를 디스크에서 읽으면 OS 페이지 캐시에 8KB가 들어오고, 그걸 다시 복사해서 버퍼 풀에 올린다. RAM에 같은 데이터가 두 벌 있을 수 있다 (더블 버퍼링).

비효율처럼 보이지만 설계 철학이 이거다.

"OS 페이지 캐시를 적극 활용하자. OS가 알아서 관리하게 두자."

그래서 Postgres는 shared_buffers(버퍼 풀)를 총 RAM의 25% 정도로만 잡는 게 권장. 나머지는 OS 페이지 캐시 몫.

MySQL InnoDB: 우회 전략

InnoDB는 O_DIRECT 플래그로 OS 페이지 캐시를 건너뛰고 자기 버퍼 풀만 쓴다. 이중 캐싱 방지.

그래서 InnoDB는 버퍼 풀을 총 RAM의 70~80%까지 크게 잡는 게 권장.

DB가 자기 버퍼 풀을 따로 두는 이유

OS 페이지 캐시가 있는데도 DB가 자기 버퍼 풀을 둔다. 이유:

  • OS 페이지 캐시는 LRU 기반 범용 정책. DB 접근 패턴(예: 인덱스 내부 노드가 리프보다 더 재사용됨)을 모름
  • DB 버퍼 풀은 DB 의미를 아는 캐싱 가능 — 자주 쓰는 페이지를 오래 붙잡기, dirty page 관리, 락·트랜잭션과의 통합
  • 트랜잭션 정합성에 필요한 메타데이터를 페이지에 덧붙여 관리

전체 흐름

쓰기 트랜잭션:
  1. 버퍼 풀의 페이지 수정 (RAM)
  2. WAL 레코드 작성 (RAM)
  3. WAL fsync → 디스크
  4. COMMIT 반환 ← 여기서 durability 확정
  5. (나중에) dirty page를 디스크로
  6. (주기적) 체크포인트 → WAL 정리

복구:
  마지막 체크포인트 지점부터 WAL redo → 끝

git과의 비유로 보는 append-only log

WAL의 느낌이 git과 닮았다. 단, 대응이 직관과 살짝 다르다.

WAL git 대응
버퍼 풀의 dirty page working tree 수정
WAL fsync git commit (영속 기록)
체크포인트 git gc + packfile 정리
Replication (WAL 스트리밍) git push

"WAL = 로컬, 체크포인트 = 원격" 구도는 틀리다. WAL 자체가 이미 디스크에 영속되어 있고, 체크포인트는 "쌓인 로그를 정리하고 스냅샷을 굳히는" 작업에 가깝다. 진짜 push에 해당하는 건 replication이다.

둘을 관통하는 원리: 상태 대신 변경의 순서를 저장한다. 순서가 있으면 언제든 재현할 수 있다. git, WAL, Kafka, event sourcing이 전부 같은 뿌리.

실무 감각

  • synchronous_commit = off — WAL fsync 안 기다리고 COMMIT 반환. 빠르지만 전원 나가면 최근 몇 초 트랜잭션이 날아갈 수 있음
  • fsync = off — 절대 쓰면 안 됨. Durability 자체를 포기하는 설정
  • Postgres의 streaming replication은 이 WAL을 네트워크로 복제본에 보내는 구조. WAL 없이 복제 없다

더 보기

sunshinemoon · 2026