commit해도 데이터 파일은 즉시 안 바뀐다 — PostgreSQL WAL과 체크포인트가 durability를 지키는 법

seonwoo_jung·4일 전

1. commit하면 데이터가 디스크에 써진다는 착각

COMMIT이 성공했다는 건 "이 변경이 디스크에 안전히 기록됐다"는 뜻이다. 그래서 한동안 나는 commit 시점에 그 트랜잭션이 바꾼 테이블 행들이 데이터 파일에 곧장 fsync 된다고 막연히 믿었다. 그런데 그러면 의문이 생긴다. 한 트랜잭션이 여기저기 흩어진 페이지를 건드렸다면, 매 commit마다 데이터 파일 곳곳에 랜덤 위치 fsync를 날려야 한다. 디스크에서 가장 비싼 게 랜덤 쓰기인데, 그러면 OLTP 워크로드가 그렇게 빠를 수가 없다.

답은 "commit은 데이터 파일을 건드리지 않는다"였다. PostgreSQL은 commit 시점에 작은 로그 한 조각만 순차로 디스크에 내리고, 실제 데이터 페이지 flush는 나중으로 미룬다. 이 트릭의 중심에 WAL(Write-Ahead Logging)체크포인트(checkpoint) 가 있다. 이 글은 PostgreSQL 16 공식 문서(Ch.30, Ch.29 §29.4–29.5)를 따라가며 "commit이 빠르면서도 어떻게 durability가 깨지지 않는가"를 정리한 것이다.

2. WAL-before-data 규칙과 LSN

WAL의 핵심 아이디어는 이름 그대로다. 데이터 페이지를 고치기 전에, 그 변경 내용을 먼저(write-ahead) 순차 로그로 기록한다. 모든 변경(INSERT/UPDATE/DELETE, 인덱스 갱신 포함)은 먼저 WAL 레코드로 만들어져 WAL 버퍼에 append 되고, 그 다음에야 shared buffer 안의 데이터 페이지가 수정된다.

여기서 지켜야 할 불변식이 하나 있다.

어떤 더티 데이터 페이지를 디스크에 쓰기 전에, 그 페이지를 변경시킨 WAL 레코드가 먼저 디스크에 flush 되어 있어야 한다. (공식 문서 §30.3)

이 순서를 강제하는 장치가 LSN(Log Sequence Number) 이다. LSN은 WAL 스트림 안에서의 바이트 오프셋으로, 단조 증가한다. 모든 데이터 페이지 헤더에는 그 페이지를 마지막으로 바꾼 WAL 레코드의 LSN(pd_lsn)이 박혀 있다. 버퍼 매니저가 더티 페이지를 디스크로 내보낼 때는 이 LSN을 보고 WAL을 먼저 따라잡게 한다.

flush_data_page(page):
    XLogFlush(page.pd_lsn)   # 이 페이지의 LSN까지 WAL을 먼저 디스크로
    write(datafile, page)    # 그 다음에야 데이터 페이지를 기록

덕분에 크래시가 나도 "데이터 파일엔 반영됐는데 WAL엔 그 기록이 없는" 상태는 절대 생기지 않는다. 데이터보다 로그가 항상 앞서 있으니, 로그만 따라 읽으면 데이터를 재구성할 수 있다.

3. commit 시점에 실제로 일어나는 일 (no-force)

이제 1장의 의문이 풀린다. commit 하면 PostgreSQL은 commit WAL 레코드를 쓴 뒤 XLogFlush(commitLSN) 으로 거기까지의 WAL만 fsync 한다. 그 트랜잭션이 더럽힌 데이터 페이지들은 이 시점에 디스크로 내려가지 않는다. 이걸 no-force 정책이라 부른다. 데이터 페이지는 나중에 background writer나 checkpointer가 한가할 때, 혹은 버퍼가 부족해 evict될 때 비로소 내려간다.

성능의 비밀이 여기 있다. WAL은 순차 append라 디스크의 한 곳에 몰아 쓴다. 반면 데이터 페이지 flush는 테이블 곳곳의 랜덤 위치 쓰기다. 매 commit마다 랜덤 fsync를 흩뿌리는 대신, 순차 로그만 fsync하고 랜덤 쓰기는 모아서 체크포인트로 분산한다.

시간 ──────────────────────────────────────▶
WAL:  [..rec][commit rec]│fsync(commit)        ← commit은 여기서 끝
buf:  page A,B dirty (메모리에만 남아 있음)
                          └─(나중에)─▶ checkpointer가 A,B를 flush

그러니 commit의 durability를 책임지는 것은 데이터 파일이 아니라 WAL fsync 다. commit 직후 곧바로 kill -9로 프로세스를 죽여도, 재기동 시 WAL을 replay하면 데이터가 복원된다. 데이터 파일이 아니라 WAL이 진실의 원천(source of truth)인 셈이다.

4. 체크포인트 — REDO point를 끌어올려 복구 비용을 줄인다

데이터 페이지를 계속 메모리에만 두고 미루면, 복구할 때 읽어야 할 WAL이 무한정 길어진다. 이걸 끊어주는 게 체크포인트다. 체크포인트는 "이 시점 이전의 모든 더티 페이지를 데이터 파일에 안전히 내렸다"고 보장하는 지점이다. 개념적 순서는 이렇다.

  1. 현재 WAL 위치를 REDO point로 기록한다(이후 복구는 여기서 시작).
  2. 그 시점까지 더티였던 shared buffer를 전부 데이터 파일에 write한다.
  3. pg_control에 체크포인트 레코드 위치를 기록하고 fsync한다.
  4. REDO point 이전을 다루던 WAL 세그먼트는 더 이상 복구에 필요 없으므로 recycle/삭제 가능해진다.

내가 헷갈렸던 지점이 바로 여기다. "체크포인트가 WAL을 디스크에 flush한다"고 거꾸로 알고 있었다. 실제로 WAL flush는 commit마다(XLogFlush) 일어나고, 체크포인트가 내리는 건 데이터 페이지(더티 버퍼) 다. 그 결과로 오래된 WAL을 지울 수 있게 되는 것이다.

체크포인트 발동 조건은 checkpoint_timeout(기본 5분) 경과, max_wal_size(기본 1GB) 초과 임박, 또는 수동 CHECKPOINT 명령이다. 한 번에 더티 버퍼를 몰아 flush하면 I/O 스파이크가 생기므로, checkpoint_completion_target(기본 0.9)에 맞춰 다음 체크포인트 예상 시점의 90% 지점까지 쓰기를 천천히 분산(spread checkpoint) 한다.

checkpoint ─────── checkpoint_timeout(5m) ─────── checkpoint
   │REDO point                                       │
   ▼                                                 ▼
   ├── dirty buffer flush를 이 구간의 0.9까지 천천히 ──┤

5. Full Page Writes — torn page를 막는 보험

복구의 토대가 WAL이라면, 그 WAL이 의존하는 베이스 페이지가 깨지면 어떻게 될까? 이 빈틈을 막는 게 full page writes다.

OS/디스크의 원자적 쓰기 단위(흔히 512B~4KB)는 PostgreSQL 페이지(기본 8KB)보다 작다. 그래서 크래시가 8KB 페이지를 쓰는 도중에 나면, 앞 4KB는 새 버전, 뒤 4KB는 옛 버전인 torn page(찢어진 페이지)가 생길 수 있다. WAL 레코드는 보통 "이 페이지의 이 오프셋을 이렇게 바꿔라"는 델타라서, 베이스 페이지가 이미 깨졌다면 델타를 적용해도 복구가 안 된다.

그래서 full_page_writes=on(기본)이면, 각 체크포인트 직후 그 페이지를 처음 변경할 때 페이지 전체 이미지(FPI, Full Page Image)를 WAL에 통째로 싣는다. 복구 시엔 이 풀 이미지로 페이지를 통째 덮어쓴 뒤 이후 델타를 적용하므로, torn page가 있어도 안전하다. 대가로 체크포인트 직후에는 WAL이 부풀어 오른다. 체크포인트를 너무 자주 돌리면 안 되는 이유 중 하나가 이것이다.

6. 크래시 복구 (REDO)는 왜 몇 번을 돌려도 안전한가

재기동 시 PostgreSQL은 pg_control에서 마지막 체크포인트의 REDO point를 읽고, 거기서부터 WAL을 순차로 읽으며 replay 한다. 각 WAL 레코드에 대해 대상 페이지를 읽어, page.pd_lsn < record.LSN 이면(아직 미반영) 적용하고, 아니면(이미 반영됨) 건너뛴다.

이 LSN 비교가 멱등 복구(idempotent recovery) 를 만든다. 같은 WAL을 몇 번을 다시 돌려도 결과가 같으므로, 복구 중간에 또 크래시가 나도 처음부터 다시 돌리면 그만이다. 그리고 commit 레코드가 WAL에 남아 있는 트랜잭션만 최종 커밋으로 살아남는다. commit 직전에 죽은 트랜잭션은 자연히 사라진다.

직접 확인해보고 싶다면 LSN이 바이트 오프셋이라는 사실부터 짚어볼 수 있다.

-- 현재 WAL 위치(LSN). 단조 증가하는 바이트 오프셋이다
SELECT pg_current_wal_lsn();              -- 예: 3/A52C1F0

-- 강제 체크포인트 → 이후 REDO point가 위로 당겨진다
CHECKPOINT;

-- 두 LSN의 차이는 '바이트 거리'로 계산된다 (LSN이 오프셋이라는 증거)
SELECT pg_wal_lsn_diff('3/A52C2A0', '3/A52C1F0');   -- = 177 (바이트)
$ pg_controldata $PGDATA | grep -E "REDO|checkpoint location"
Latest checkpoint location:        3/A52C2A0
Latest checkpoint's REDO location: 3/A52C2A0   # 복구는 여기서 시작

7. 정리

  • commit은 데이터 파일을 건드리지 않는다. 순차 WAL fsync 한 번으로 durability를 확보하고(no-force), 랜덤한 데이터 페이지 쓰기는 체크포인트로 미뤄 분산한다 — 이것이 빠른 commit과 충돌 복구를 동시에 잡는 핵심이다.
  • 체크포인트는 WAL을 내리는 게 아니라 더티 데이터 페이지를 내리고, 그 보상으로 오래된 WAL을 지울 수 있게 한다.
  • WAL은 복제·백업용 부가 기능이 아니라 단일 노드 durability와 crash recovery의 본체다. streaming replication과 PITR이 같은 WAL을 재사용하는 건 부수 효과에 가깝다.

더 파고들 만한 주제로는 (1) synchronous_commit 단계(on/remote_apply/local/off)와 group commit이 만드는 commit 지연 vs durability 트레이드오프, (2) InnoDB의 redo log + doublewrite buffer와의 비교 — PostgreSQL의 full page writes가 doublewrite와 같은 문제(torn page)를 다른 방식으로 푸는 지점이 있다.

참고 자료

  • PostgreSQL 16 Documentation — Ch.30 "Write-Ahead Logging (WAL)", Ch.29 §29.4 "WAL Configuration", §29.5 "WAL Internals"
  • PostgreSQL 소스 src/backend/access/transam/xlog.c (REDO point / CreateCheckPoint 주석)

0개의 댓글