1편에서 dirty 페이지를 디스크에 쓰기 전에 "WAL이 먼저 flush되어야 한다"고 언급했다.
PostgreSQL이 어떻게 갑작스러운 크래시 후에도 일관된 상태를 복원하는지 알아본다.
이 글에서 다루는 것:
- Write-Ahead Logging 원칙
- LSN과 pd_lsn, full page writes (torn page 문제)
- Checkpoint (redo point · checkpoint_timeout · max_wal_size · checkpoint_completion_target)
- 크래시 복구 과정
- synchronous_commit
Write-Ahead Logging — 데이터보다 로그가 먼저
WAL은 파일에 저장되는 로그다. WAL의 원칙은 한 줄이다.
데이터를 디스크에 쓰기 전에, 그 변경 내용을 먼저 로그에 쓴다.
UPDATE 한 번이 일어나면 PostgreSQL이 하는 일을 순서대로 보면 이렇다.

여기서 핵심은 WAL를 디스크에 flush 해야(3번)이 Commit(4번)이 일어난다는 것이다. COMMIT이 성공했다면 WAL은 이미 디스크에 있다. 5번(데이터 페이지를 디스크에 쓰기)이 아직 안 일어났더라도 상관없다 — 크래시가 나면 WAL을 재생해서 복원하면 되니까.
그래서 데이터 파일에 대한 쓰기는 느긋하게(lazy) 일어난다. 모든 COMMIT마다 데이터 파일에 쓰지 않는다. 대신 WAL만 빠르게 flush한다. 이게 가능한 이유는 WAL은 순차 쓰기(append-only) 라서 디스크 입장에서 매우 저렴하기 때문이다. 반면 데이터 페이지 쓰기는 랜덤 I/O라 비싸다. WAL은 그 비용을 모아서 한꺼번에 처리할 수 있게 해준다.
WAL 도 똑같이 디스크에 쓰는데 더 빠른 이유
데이터 파일 (힙 페이지) — 8KB(기본 단위) 페이지를 제자리에 덮어쓴다(in-place overwrite). 기존 페이지가 있던 디스크 위치에 새 내용을 쓴다. 이 8KB 쓰기가 OS 입장에서 원자적(atomic)이 아니다. OS는 보통 4KB 단위로 I/O를 처리하는데, 8KB 페이지의 앞 4KB만 쓰이고 전원이 나가면 페이지의 절반은 새 데이터, 나머지 절반은 옛 데이터인 상태가 된다. 이게 아래에서 다루는 torn page다.
- PostgreSQL은 이 torn page 문제를 WAL로 해결한다.
WAL 파일 — 순차로 끝에 추가(append-only)한다. 기존 데이터를 덮어쓰지 않는다. WAL 레코드 하나의 크기는 가변이지만, 내부에 체크섬이 있어서 레코드가 완전히 쓰였는지 아닌지를 검증할 수 있다. 쓰기 도중 크래시가 나면, 불완전한 레코드는 체크섬 검증에서 실패하고, 복구 시 그 레코드는 무시된다. 즉 "반쯤 쓰인 WAL"은 감지 가능하고 안전하게 버릴 수 있지만, "반쯤 쓰인 데이터 페이지"는 감지가 어렵고 그 위에 뭘 덧씌워야 할지 알 수 없다.
정리하면:
| 데이터 파일 | WAL 파일 | |
| 쓰기 방식 | 제자리 덮어쓰기 (in-place) | 끝에 추가 (append-only) |
| 단위 | 8KB 페이지 (OS의 원자 단위보다 큼) | 가변 크기 레코드 + 체크섬 |
| 반쯤 쓰이면 | torn page — 감지 어렵고 복구 불가 | 체크섬 실패 — 감지 가능하고 안전하게 버림 |
LSN과 페이지의 관계
WAL의 모든 레코드에는 LSN(Log Sequence Number) 이 붙는다. WAL 파일 안에서의 바이트 오프셋이고, 단조 증가한다. 0/1A3B8C0 같은 형태로 표시된다.

각 데이터 페이지의 헤더에는 pd_lsn이라는 필드가 있다. 이 페이지를 마지막으로 수정한 WAL 레코드의 LSN이다.
여기서 한 가지 중요한 규칙이 나온다.
dirty 페이지를 디스크에 쓰려면, 그 페이지의 pd_lsn까지의 WAL이 먼저 디스크에 flush되어 있어야 한다.
만약 데이터 페이지가 먼저 쓰여 버리면, 크래시 시 그 변경을 어떻게 만들었는지 설명할 WAL이 없게 되어 복구가 깨진다. 그래서 백그라운드에서 dirty 페이지를 쓰는 모든 프로세스(checkpointer, bgwriter, 일반 backend)는 쓰기 직전에 WAL flush를 강제한다.
Full Page Writes — torn page 문제
OS가 크래시되거나 전원이 나갈 때 까다로운 문제가 하나 있다. 8KB 페이지를 디스크에 쓰는 도중 전원이 나가면, 그 8KB 중 일부만 쓰이고 나머지는 옛날 데이터인 채로 남을 수 있다. 이걸 torn page(찢어진 페이지)라고 부른다.
WAL 레코드는 보통 "이 페이지의 이 부분을 이렇게 바꿔라" 같은 논리적인 변경 정보다. 그런데 torn page는 페이지 자체가 일관되지 않은 상태라, 그 위에 WAL 변경을 덧씌워봐야 무엇이 옛날이고 무엇이 새것인지 알 수 없다.
PostgreSQL의 해결책: 체크포인트 이후 각 페이지의 첫 번째 수정 시, 페이지 전체(8KB)를 WAL에 통째로 기록한다.
이걸 full_page_writes라고 한다 (기본 on).
체크포인트 이후 각 페이지의 첫 번째 수정 시, 논리적 변경뿐만 아니라 페이지 전체(8KB)를 WAL에 통째로 기록한다. 복구 시 torn page를 만나면:
- WAL에 저장된 full page image로 페이지를 통째로 덮어쓴다 — torn page가 완전히 사라진다
- 그 위에 이후의 WAL 레코드(논리적 변경)를 순서대로 재생한다
- 페이지가 일관된 상태에서 시작하니 논리적 변경이 정상 적용된다
비용은 용량이다. 체크포인트 직후 첫 변경마다 8KB가 WAL에 통째로 쓰이니, 체크포인트 직후 WAL 볼륨이 급증한다.
Checkpoint — "여기까지는 디스크에 다 썼다"
WAL만으로는 복구 시간이 무한정 길어질 수 있다. 만약 데이터 파일에 한 번도 안 썼다면 모든 WAL을 처음부터 재생해야 한다. 체크포인트(checkpoint) 로 이 문제를 해결한다.
체크포인트가 일어나는 순간:
- 모든 dirty 페이지를 디스크에 flush
- 체크포인트 레코드를 WAL에 기록 (현재 위치를 redo point로 표시)
- pg_control 파일에 redo point 저장
체크포인트 이후, redo point 이전의 WAL 세그먼트는 더 이상 복구에 필요 없으니 재활용하거나 삭제할 수 있다. 복구 시점에서는 pg_control을 읽고, redo point부터만 WAL을 재생하면 된다.
체크포인트가 자주 일어나면:
- 복구가 빠름 (재생할 WAL이 적음)
- 운영 중 I/O 부담 큼 (dirty 페이지를 자주 flush)
- WAL 볼륨도 큼 (full_page_writes가 자주 발동)
체크포인트가 드물게 일어나면:
- 운영 중 I/O 부드러움
- 복구가 오래 걸림
이 트레이드오프를 조절하는 주요 설정 두 가지.
| 파라미터 | 역할 | 기본 값 |
| checkpoint_timeout | 체크포인트 사이 최대 시간 | 5분 |
| max_wal_size | WAL이 이 크기를 넘으면 체크포인트 강제 | 1GB |
둘 중 하나라도 도달하면 체크포인트가 시작된다. 운영 DB에서는 보통 둘 다 키우는 방향으로 튜닝한다.
예시:
- checkpoint_timeout = 15min
- max_wal_size = 16GB 같은 식으로. 체크포인트 빈도를 낮추면 full_page_writes 비용도 줄어든다.
checkpoint_completion_target — I/O 스파이크 방지
체크포인트가 시작되자마자 모든 dirty 페이지를 한꺼번에 flush하면 I/O 스파이크가 생긴다. PostgreSQL은 이걸 시간에 걸쳐 분산시킨다.
checkpoint_completion_target(기본 0.9)이 그 비율이다. 0.9면 체크포인트 작업이 다음 체크포인트까지의 시간 중 90%를 사용해 천천히 dirty 페이지를 쓰겠다는 뜻이다. 5분 주기라면 약 4분 30초에 걸쳐 분산된다.
이 값을 작게 하면(예: 0.5) 체크포인트가 빨리 끝나서 다음 체크포인트까지 I/O가 한가하지만, 시작 시점의 스파이크가 커진다. 대부분의 경우 기본값 0.9가 가장 부드럽다.
크래시 후 복구 과정
서버가 비정상 종료로 죽었다가 다시 시작될 때 일어나는 과정이다.
- pg_control을 읽어서 마지막 체크포인트의 redo point를 확인
- redo point부터 WAL 끝까지 순차로 재생
- 각 WAL 레코드를 적용하면서, 만약 full page image가 있으면 페이지를 통째로 덮어씀 (torn page 처리)
- WAL 끝에 도달하면 복구 완료
이 시점에서 두 가지가 보장된다.
- 커밋된 트랜잭션은 모두 복원된다 (COMMIT 시점에 WAL이 flush됐으니까).
- 커밋되지 않은 트랜잭션은 흔적이 남아 있어도 가시성 규칙(2편)에 의해 보이지 않는다. 다음 VACUUM이 정리한다.
복구 시간을 추정하려면 평소 WAL 생성률을 보면 된다. (pg_stat_wal)
SELECT wal_records, wal_bytes,
pg_size_pretty(wal_bytes) AS wal_size
FROM pg_stat_wal;
synchronous_commit — 안전성과 지연시간의 트레이드오프
기본 설정에서 COMMIT은 WAL이 디스크에 fsync될 때까지 기다린다. 이게 내구성을 보장하지만, fsync가 느린 환경(특히 클라우드 디스크)에서는 COMMIT 지연시간이 크다.
synchronous_commit = off로 설정하면 COMMIT 시 fsync를 안 기다리고 즉시 응답한다. 처리량이 크게 늘지만, 크래시 시 마지막 약 200~600ms치 트랜잭션이 손실될 수 있다. "DB가 살아 있다"고 응답한 것까지는 보장되지만 마지막 몇백 밀리초의 응답 데이터는 사라질 수 있다는 뜻이다.
| 설정 | Commit 지연 | 크래시 시 데이터 손실 |
| on (기본) | fsync 대기 | 없음 |
| off | 즉시 응답 | 마지막 ~600ms |
은행 거래나 결제는 on이 필수다. 로그 수집이나 분석용 적재처럼 약간의 손실이 용납되는 워크로드에서는 off로 처리량을 올릴 수 있다.
WAL의 다른 기능
WAL은 단순히 "크래시 복구"만을 위한 것이 아니다. WAL이라는 변경 로그로 PostgreSQL의 다른 핵심 기능들을 가능하게 한다.
| 기능 | WAL 활용 방식 |
| 스트리밍 복제 | WAL을 standby로 전송하여 동일하게 재생 |
| PITR (Point-In-Time Recovery) | 아카이브된 WAL을 특정 시점까지 재생 |
| 논리적 복제 / CDC(debezium) | WAL을 디코딩하여 행 단위 변경 추출 |
이들은 전부 WAL 기반 위에 있다. WAL로 복제 지연이나 PITR(Point-in-Time Recovery) 동작도 쉽게 설명된다.
스트리밍 복제
멀티 DB상황에서 Primary 서버가 WAL을 생성하면, 그 WAL을 Standby 서버로 전송한다. Standby는 받은 WAL을 그대로 재생한다. 크래시 복구와 원리가 같다 — "WAL을 순서대로 재생하면 같은 상태가 된다."
Primary: 데이터 변경 → WAL 생성 → WAL을 Standby로 전송
Standby: WAL 수신 → 재생 → Primary와 동일한 데이터
복제 지연(replication lag) 은 "Primary가 WAL을 생성한 시점"과 "Standby가 그 WAL을 재생한 시점"의 차이다. WAL이라는 공통 메커니즘 위에서 동작하니, 지연도 WAL의 LSN 차이로 측정할 수 있다.
PITR (Point-In-Time Recovery)
WAL은 시간순으로 모든 변경을 기록한다. 그래서 특정 시점까지만 WAL을 재생하면 그 시점의 데이터베이스 상태를 복원할 수 있다.
1. 과거의 베이스 백업을 복원
2. 아카이브된 WAL을 "2026-04-18 14:30:00까지만" 재생
3. → 해당 시점의 DB 상태가 복원됨
예를 들어 오후 3시에 실수로 테이블을 DROP했다면, 오후 2시 59분까지의 WAL만 재생해서 DROP 직전 상태로 되돌릴 수 있다.
논리적 복제 / CDC
WAL에는 행 단위 변경 내용이 들어 있으니, 이걸 디코딩하면 "어떤 테이블의 어떤 행이 INSERT/UPDATE/DELETE됐는가"를 추출할 수 있다. 이게 논리적 복제(Logical Replication)와 CDC(Change Data Capture)의 기반이다.
세 기능 모두 "WAL을 재생하면 같은 결과가 나온다" 는 동일한 원리 위에 있다.
전체를 재생하면 복제
특정 시점까지 재생하면 PITR
내용을 해석하면 CDC.
WAL 눈으로 보기
Window 기준
wal 경로: C:/Program Files/PostgreSQL/17/data/pg_wal/{숫자}
pg_waldump 경로: C:/Program Files/PostgreSQL/17/bin/pg_waldump.exe
wal 파일 경로는 나같은 경우 위와 같다.그리고 wal 파일은 바이너리 파일로 저장되어 있는데 이걸 확인하는 방법은 pg_waldupb.exe를 이용하는거다.
실제 입력 명령어는 다음과 같다.
& "C:/Program Files/PostgreSQL/17/bin/pg_waldump.exe" "C:/Program Files/PostgreSQL/17/data/pg_wal/0000000100000002000000E2

rmgr: Heap, len: 290, tx: 255834, desc: INSERT off: 18, blkref #0: rel 1663/261058/262718 blk 300
위 예시는 다음과 같은 의미다.
- rmgr (Resource Manager): 작업을 처리하는 관리자
- Heap: 실제 데이터가 저장되는 테이블 영역
- Btree: 인덱스 영역
- lsn / prev: 현재 로그의 위치와 이전 로그의 위치로 체인처럼 연결되어 있다.
- blkref #0: rel 1663/261058/262718: 대상 오브젝트의 ID
- 1663: Tablespace ID
- 261058: Database ID
- 262718 또는 262724: Table 또는 Index의 고유 ID (OID)
- blk 300 / blk 26: 해당 파일 내의 페이지(블록) 번호. 300번 페이지나 26번 페이지에 데이터를 썼다는 뜻
- off 18 / off 233: 페이지 내부에서의 오프셋(위치) 번호
다음 글부터는 조회 쪽으로 넘어간다. 6편은 인덱스 — B-tree, GiST, 그리고 1편에서 본 튜플 헤더의 t_ctid가 인덱스에서 어떻게 쓰이는지 알아보자.
'데이터베이스 > PostgreSQL 내부 구조' 카테고리의 다른 글
| PostgreSQL 내부구조 (6) ㅡ Index (0) | 2026.07.18 |
|---|---|
| PostgreSQL 내부 구조 (4) — VACUUM, 옛 버전은 누가 치우는가 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 (3) — Locking, 동시성의 또 다른 축 (1) | 2026.04.18 |
| PostgreSQL 내부 구조 (2) — MVCC와 격리 수준 (1) | 2026.04.18 |
| PostgreSQL 내부 구조 (1) — 페이지와 Shared Buffers (1) | 2026.04.18 |
| PostgreSQL 기본 (0) — 연결과 프로세스 구조 (0) | 2026.04.18 |
| PostgreSQL 내부 구조 — 시리즈를 시작하며 (0) | 2026.04.16 |