본문 바로가기
데이터베이스/PostgreSQL 내부 구조

PostgreSQL 내부 구조 (5) — WAL과 Checkpoint, 장애 복구의 핵심

by 조민서 2026. 4. 18.

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 쓰기 흐름 — UPDATE부터 디스크 반영까지

 

여기서 핵심은 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를 만나면:

  1. WAL에 저장된 full page image로 페이지를 통째로 덮어쓴다 — torn page가 완전히 사라진다
  2. 그 위에 이후의 WAL 레코드(논리적 변경)를 순서대로 재생한다
  3. 페이지가 일관된 상태에서 시작하니 논리적 변경이 정상 적용된다

비용은 용량이다. 체크포인트 직후 첫 변경마다 8KB가 WAL에 통째로 쓰이니, 체크포인트 직후 WAL 볼륨이 급증한다.


Checkpoint — "여기까지는 디스크에 다 썼다"

WAL만으로는 복구 시간이 무한정 길어질 수 있다. 만약 데이터 파일에 한 번도 안 썼다면 모든 WAL을 처음부터 재생해야 한다. 체크포인트(checkpoint) 로 이 문제를 해결한다.

 

체크포인트가 일어나는 순간:

  1. 모든 dirty 페이지를 디스크에 flush
  2. 체크포인트 레코드를 WAL에 기록 (현재 위치를 redo point로 표시)
  3. 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가 가장 부드럽다.


크래시 후 복구 과정

서버가 비정상 종료로 죽었다가 다시 시작될 때 일어나는 과정이다.

  1. pg_control을 읽어서 마지막 체크포인트의 redo point를 확인
  2. redo point부터 WAL 끝까지 순차로 재생
  3. 각 WAL 레코드를 적용하면서, 만약 full page image가 있으면 페이지를 통째로 덮어씀 (torn page 처리)
  4. 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

 

wal dump 결과

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가 인덱스에서 어떻게 쓰이는지 알아보자.