PostgreSQL WAL과 크래시 복구의 내부 동작

Write-Ahead Logging, 체크포인트, REDO 복구가 내구성을 지키는 방법

COMMIT은 정확히 무엇을 보장하는가

은행 이체 트랜잭션이 COMMIT을 반환했다. 그 직후 서버의 전원이 끊겼다. 다시 켰을 때, 이체는 반영되어 있을까?

ACID의 D(Durability, 내구성)는 “한 번 커밋된 트랜잭션은 이후 어떤 장애가 와도 사라지지 않는다”는 약속이다. 그런데 이 약속을 지키기는 생각보다 까다롭다. 데이터를 디스크에 안전하게 쓰는 일은 느리기 때문이다.

순진하게 생각하면, 커밋할 때마다 변경된 데이터 페이지를 전부 디스크에 써버리면 된다. 하지만 이건 두 가지 이유로 끔찍하다.

  • 하나의 작은 UPDATE가 테이블 페이지, 인덱스 페이지 등 여러 곳의 8KB 페이지를 더럽힌다. 디스크의 여기저기에 흩어진 이 페이지들을 매번 쓰는 것은 랜덤 I/O다.
  • 8KB 페이지를 디스크에 쓰는 도중 전원이 나가면, 페이지가 절반만 기록되어 손상될 수 있다.

PostgreSQL의 답이 바로 WAL(Write-Ahead Logging)이다. 이 글에서는 WAL이 어떻게 내구성을 지키고, 크래시 후에 어떻게 일관 상태를 복원하며, 나아가 복제와 PITR의 기반이 되는지를 내부 동작 수준에서 파헤친다.


Write-Ahead Logging의 핵심 원칙

WAL의 원칙은 이름 그대로다.

[!IMPORTANT] 데이터 페이지의 변경을 디스크에 반영하기 전에, 그 변경을 기술하는 로그 레코드를 먼저 영속 저장소에 기록한다.

“먼저 쓴다(write-ahead)”의 의미를 정확히 짚자. 두 가지 규칙으로 나뉜다.

  1. 커밋 규칙 — 트랜잭션이 커밋되었다고 클라이언트에 알리기 전에, 그 트랜잭션의 모든 변경을 담은 WAL 레코드(특히 커밋 레코드)가 디스크에 안전하게 내려가(fsync) 있어야 한다.
  2. 버퍼 규칙 — 메모리에서 변경된 더티 데이터 페이지를 디스크에 내려쓰기 전에, 그 페이지를 마지막으로 변경한 WAL 레코드까지 WAL이 먼저 디스크에 flush되어 있어야 한다.

핵심은, 무겁고 랜덤한 데이터 페이지 쓰기를 미루는 대신, 가볍고 순차적인 로그 쓰기만 커밋 경로에 두는 것이다. WAL은 append-only — 항상 파일 끝에 덧붙이기만 하므로 디스크 입장에서 순차 쓰기(sequential write)이고, 이것이 랜덤 쓰기보다 압도적으로 빠르다.

크래시가 나더라도, 데이터 페이지가 아직 디스크에 안 내려갔어도 WAL만 살아있으면 그 로그를 다시 재생해서 변경을 복원할 수 있다. 로그가 곧 진실의 원본(source of truth)이다.


WAL 레코드와 LSN

WAL은 디스크상에서 pg_wal/ 디렉터리 안의 세그먼트 파일(기본 16MB)들로 존재하며, 그 안에 WAL 레코드가 끝없이 이어 붙는다. 각 레코드는 “어느 테이블의 어느 페이지를, 어떻게 바꿨는가”를 기술한다.

이 거대한 로그 스트림에서 모든 바이트 위치는 LSN(Log Sequence Number)으로 식별된다. LSN은 64비트 정수이며, 보통 16진수/16진수 형태로 표기한다.

SELECT pg_current_wal_lsn();
--  pg_current_wal_lsn
-- --------------------
--  3A/16B8A20

LSN은 단조 증가한다. 즉 두 LSN을 비교하면 어느 변경이 먼저 일어났는지 알 수 있다. 이 성질이 복구와 복제의 모든 곳에서 쓰인다.

특히 중요한 것은, 모든 데이터 페이지의 헤더에 그 페이지를 마지막으로 변경한 WAL 레코드의 LSN(pd_lsn)이 적혀 있다는 점이다. 페이지의 LSN과 WAL 레코드의 LSN을 비교하면 “이 변경이 이미 이 페이지에 반영되었는가?”를 판단할 수 있고, 이것이 복구를 멱등(idempotent)하게 만든다. 뒤에서 다시 보자.


쓰기 경로: 변경은 어떻게 디스크에 도달하는가

하나의 UPDATE가 실행되어 커밋되기까지, 데이터는 다음 경로를 거친다.

 1. 백엔드가 shared_buffers 안의 데이터 페이지를 메모리에서 변경 (더티 페이지)
 2. 동시에 그 변경을 기술하는 WAL 레코드를 WAL 버퍼에 기록
 3. COMMIT 시점 → 해당 커밋 레코드까지 WAL 버퍼를 디스크로 flush (fsync)
 4. flush 완료 → 클라이언트에 커밋 성공 반환   ← 내구성 확보 지점
 5. (나중에) background writer / checkpointer가
    더티 데이터 페이지를 디스크로 천천히 내려씀

여기서 반드시 분리해서 이해해야 할 두 가지가 있다.

  • 커밋 = WAL 레코드가 디스크에 안전하게 기록된 시점. 이때 데이터 페이지는 아직 메모리(shared_buffers)에만 더티 상태로 있을 수 있다.
  • 데이터 페이지가 디스크에 반영되는 것은 그와 무관한 별도의 비동기 작업이다. 누가 하는가?
    • 백그라운드 라이터(background writer): 평소에 더티 페이지를 조금씩 흘려보내, 백엔드가 깨끗한 버퍼를 쉽게 확보하도록 돕는다.
    • 체크포인터(checkpointer): 체크포인트 시점에 더티 페이지를 한꺼번에 디스크로 내린다.
    • 버퍼가 부족하면 일반 백엔드도 직접 더티 페이지를 쫓아낼(evict) 때 디스크에 쓴다.

즉 커밋이 빠른 이유는, 무거운 데이터 페이지 쓰기를 커밋 경로에서 빼고 순차적인 WAL flush 하나로 내구성을 보장하기 때문이다.

[!NOTE] WAL 버퍼를 디스크로 옮기는 일은 커밋하는 백엔드가 직접 하기도 하고, 평소에는 WAL writer 프로세스가 wal_writer_delay(기본 200ms) 주기로 도와주기도 한다. 하지만 synchronous_commit=on인 한, 커밋의 내구성은 커밋 레코드가 실제로 fsync될 때까지 기다리는 것으로 보장된다.


synchronous_commit과 fsync: 내구성의 손잡이

WAL flush와 관련해 자주 혼동되는 두 설정이 있다. 둘은 전혀 다른 차원의 손잡이다.

synchronous_commit

커밋할 때 WAL flush가 끝날 때까지 기다릴 것인가를 정한다.

동작 크래시 시
on (기본) 커밋 레코드가 로컬 디스크에 flush될 때까지 기다린 후 반환 커밋 손실 없음
off flush를 기다리지 않고 즉시 반환, flush는 잠시 뒤 비동기로 최근 일부 커밋이 사라질 수 있음
local 로컬 flush만 보장, 복제본 응답은 안 기다림 로컬 기준 손실 없음
remote_write / remote_apply 동기 복제본의 수신/적용까지 기다림 더 강한 보장

여기서 결정적으로 중요한 점: synchronous_commit=off데이터를 손상시키지 않는다. 크래시가 나면 마지막 몇백 밀리초어치 커밋이 통째로 사라질 뿐, 사라진 트랜잭션은 처음부터 없었던 것처럼 원자적으로 빠진다. 일관성은 유지된다. 그래서 “약간의 커밋 손실은 감내 가능하지만 처리량은 끌어올리고 싶다”는 로그성 워크로드에서 합리적인 선택이 된다.

fsync

이건 차원이 다르다. fsync=off는 PostgreSQL이 OS에게 “디스크 캐시를 실제 디스크로 내려라”라고 요청하는 것 자체를 생략한다.

[!WARNING] fsync=off는 크래시 시 데이터베이스 자체가 복구 불가능하게 손상(corruption)될 수 있다. synchronous_commit=off가 “최근 커밋 몇 개 손실”인 것과 차원이 다르다. 운영 환경에서는 절대 끄지 마라.

요약하면, synchronous_commit은 “얼마나 최근 커밋까지 지킬 것인가”의 손잡이고, fsync는 “내구성을 포기할 것인가 말 것인가”의 스위치다.


체크포인트: 무엇을, 언제, 왜

WAL만 무한히 쌓이면 두 가지 문제가 생긴다. 디스크가 가득 차고, 크래시 복구 시 재생해야 할 WAL이 끝없이 길어진다. 이 문제를 끊어주는 것이 체크포인트(checkpoint)다.

체크포인트가 하는 일:

  1. 현재 시점까지 더럽혀진 모든 데이터 페이지(더티 버퍼)를 디스크로 flush한다.
  2. WAL에 체크포인트 레코드를 남기고, 그 시작 지점을 redo point로 표시한다.
  3. pg_control 파일에 마지막 체크포인트 위치를 갱신한다.

체크포인트가 끝나면, 그 redo point 이전의 WAL은 크래시 복구에는 더 이상 필요하지 않다. 이미 모든 변경이 데이터 파일에 안전하게 반영되었기 때문이다. (복제나 PITR을 위해 보관할 수는 있다.) 즉 체크포인트는 복구의 시작점을 앞으로 당겨, 복구 시간을 짧게 유지하는 장치다.

언제 발생하는가

트리거 관련 설정 의미
시간 경과 checkpoint_timeout (기본 5min) 마지막 체크포인트 후 일정 시간이 지나면
WAL 누적량 max_wal_size (기본 1GB) 마지막 체크포인트 후 생성된 WAL이 한계를 넘으면
수동 실행 CHECKPOINT; 관리자가 직접
정상 종료 셧다운, 백업 시작 등

왜 분산시키는가 — checkpoint_completion_target

체크포인트가 더티 페이지를 한순간에 전부 쏟아내면 디스크 I/O가 폭주해 다른 쿼리가 느려진다. 그래서 PostgreSQL은 checkpoint_completion_target(기본 0.9)에 따라 체크포인트 작업을 다음 체크포인트까지 시간의 90%에 걸쳐 천천히 분산시킨다.

트레이드오프

체크포인트 간격을 늘리면 (timeout↑, max_wal_size↑)
  + 평소 I/O 부담 감소, full-page write로 인한 WAL 양 감소
  - 크래시 복구 시간 증가 (재생할 WAL이 길어짐)

체크포인트 간격을 줄이면
  + 복구 빠름
  - 잦은 더티 flush로 평소 I/O 부담 증가

대부분의 시스템에서 기본값보다 max_wal_size를 넉넉히 키우는 쪽이 유리하다. 강제 체크포인트(아래 진단 절 참고)가 잦다는 신호가 보이면 특히 그렇다.


full_page_writes: torn page를 막는 원리

앞서 “8KB 페이지를 쓰는 도중 전원이 나가면 페이지가 손상될 수 있다”고 했다. 이를 torn page(찢어진 페이지)라 한다. 디스크는 보통 512바이트나 4KB 섹터 단위로 쓰므로, 8KB 페이지 하나는 여러 번에 나눠 기록된다. 그 중간에 크래시가 나면 페이지의 앞부분은 새 내용, 뒷부분은 옛 내용인 누더기 상태가 된다.

문제는 일반 WAL 레코드가 “이 페이지의 이 위치를 이렇게 바꿔라”라는 증분(delta) 기록이라는 점이다. 기반이 되는 페이지 자체가 손상되어 있으면, 그 위에 증분을 덮어봐야 의미가 없다.

full_page_writes=on(기본값)은 이 문제를 정면으로 해결한다.

[!IMPORTANT] 체크포인트 이후 어떤 페이지가 처음으로 변경될 때, 그 페이지의 8KB 전체 이미지(FPI, Full Page Image)를 WAL에 통째로 기록한다.

복구 시에는 이 FPI를 기반 페이지를 통째로 덮어쓰는 용도로 사용한다. torn page였든 아니든 상관없이, FPI가 온전한 페이지를 복원해주고, 그 위에 이후의 증분 WAL 레코드들을 순서대로 재생한다. 손상된 기반이 깨끗한 기반으로 교체되므로 복구가 안전해진다.

대가는 WAL 양의 증가다. 체크포인트 직후에는 모든 “첫 변경”이 8KB 풀 이미지를 동반하므로 WAL이 일시적으로 부풀어 오른다. 체크포인트를 너무 자주 돌리면 이 풀 이미지가 더 자주 발생해 WAL 양과 I/O가 늘어난다 — 체크포인트 간격을 함부로 줄이면 안 되는 또 하나의 이유다.


크래시 복구: REDO만으로 충분한 이유

이제 서버가 비정상 종료된 뒤 다시 켜지는 순간을 따라가 보자. PostgreSQL은 다음을 수행한다.

 1. pg_control에서 마지막 체크포인트 위치를 읽는다
 2. 그 체크포인트의 redo point부터 WAL을 순차적으로 읽기 시작한다
 3. 각 WAL 레코드를 대상 페이지에 다시 적용한다 (REDO)
 4. WAL의 끝에 도달하면 복구 완료 → 일관 상태

3번이 핵심이다. 이를 REDO(전진 복구, roll-forward)라 한다. 각 WAL 레코드를 적용하기 전에, 대상 데이터 페이지의 pd_lsn과 그 WAL 레코드의 LSN을 비교한다.

  • 페이지의 LSN이 레코드의 LSN보다 크거나 같다 → 이 변경은 이미 디스크의 페이지에 반영됨 → 건너뛴다.
  • 페이지의 LSN이 레코드의 LSN보다 작다 → 아직 반영 안 됨 → 적용한다.

이 LSN 비교 덕분에, 같은 WAL을 여러 번 재생해도 결과가 똑같다(멱등성). 복구가 도중에 또 죽어도 처음부터 다시 돌리면 그만이다.

그런데 UNDO는 어디 있는가

전통적인 DBMS 교과서는 복구를 REDO와 UNDO 두 단계로 가르친다. REDO로 커밋된 변경을 전진 복구하고, UNDO로 미커밋 트랜잭션의 변경을 되돌린다. 그런데 PostgreSQL의 크래시 복구에는 UNDO 단계가 없다. 왜일까?

답은 PostgreSQL의 MVCC 설계에 있다. (Part 1 MVCC와 VACUUM에서 자세히 다뤘다.)

  • PostgreSQL은 UPDATEDELETE 시 기존 행을 제자리에서 덮어쓰지 않고, 새 버전의 튜플을 추가하며 각 튜플에 이를 만든 트랜잭션 ID(xmin)와 지운 트랜잭션 ID(xmax)를 박아둔다.
  • 어떤 튜플이 보이는지는 그 튜플을 만든 트랜잭션이 커밋되었는지로 판단하며, 이 커밋 여부는 별도의 커밋 로그(pg_xact/clog)에 기록된다.
  • 따라서 크래시로 중단된 미커밋 트랜잭션이 힙에 남긴 튜플은 그냥 “커밋되지 않은 트랜잭션의 튜플”이므로 누구에게도 보이지 않는다. 굳이 물리적으로 지울(UNDO) 필요가 없다.

그렇게 버려진 튜플들은 어떻게 되는가? 바로 Part 1에서 말한 dead tuple이 되어, 나중에 VACUUM이 회수한다. 즉 다른 DB라면 복구 시 UNDO가 즉시 처리할 일을, PostgreSQL은 MVCC의 가시성 규칙으로 미뤄두고 VACUUM에게 청소를 위임하는 셈이다.

  미커밋 변경 처리 복구 단계
Oracle, MySQL(InnoDB) UNDO 로그로 즉시 롤백 REDO + UNDO
PostgreSQL dead tuple로 남겨두고 VACUUM이 회수 REDO only

이것이 “PostgreSQL은 롤백을 위한 UNDO 로그가 없다”는 말의 정확한 의미다. 트랜잭션 ROLLBACK조차도 별도의 되돌리기 작업 없이, 단지 그 트랜잭션을 “커밋 안 됨”으로 표시하는 것으로 끝난다 — 빠르다. 대신 그 비용을 VACUUM이 나눠 치른다. WAL의 REDO와 MVCC의 가시성은 이렇게 한 몸처럼 맞물려 있다.


WAL이 복제와 PITR의 기반인 이유

WAL은 단지 크래시 복구용이 아니다. “이 데이터베이스에 일어난 모든 변경의 완전하고 순서 있는 기록”이라는 본질 때문에, 두 가지 핵심 기능의 토대가 된다.

스트리밍 복제 (Streaming Replication)

복구가 “내 WAL을 나에게 재생하는 것”이라면, 복제는 “내 WAL을 남에게 재생시키는 것”이다.

[Primary] --- WAL 레코드 스트림 ---> [Standby]
   변경 발생                          동일 WAL을 계속 replay
                                      → 항상 거의 같은 상태 유지

프라이머리는 생성하는 WAL 레코드를 스탠바이로 실시간 전송하고, 스탠바이는 이를 끊임없이 재생한다. WAL이 변경의 완전한 기록이므로, 같은 WAL을 적용한 스탠바이는 프라이머리와 동일한 상태로 수렴한다. synchronous_commit=remote_apply 같은 설정으로 동기 복제 수준을 조절할 수 있는 것도 이 구조 덕분이다.

PITR (Point-In-Time Recovery)

WAL을 버리지 않고 아카이브(archive)해 두면, 과거 임의의 시점으로 데이터베이스를 되돌릴 수 있다.

베이스 백업(특정 시점의 전체 스냅샷)
   +
그 이후 아카이브된 WAL을 원하는 시점까지 replay
   =
"어제 오후 3시 17분 상태"로 정확히 복원

recovery_target_time 같은 옵션으로 “어느 LSN까지” 또는 “어느 시각까지” 재생할지 지정한다. 실수로 DROP TABLE을 친 직전으로 되돌리는 식의 복구가 가능한 이유다. 크래시 복구가 “마지막 체크포인트부터 WAL 끝까지”라면, PITR은 “베이스 백업부터 내가 지정한 지점까지”일 뿐, 재생(replay)이라는 메커니즘은 완전히 같다.


실무 진단: WAL을 들여다보는 법

WAL 생성량 측정

두 LSN 사이의 거리를 바이트로 환산하면 그동안 만들어진 WAL 양을 알 수 있다.

-- 현재 WAL 위치
SELECT pg_current_wal_lsn();

-- 일정 시간 간격으로 두 번 측정한 LSN의 차이 = 그 사이 생성된 WAL 바이트
SELECT pg_size_pretty(
    pg_wal_lsn_diff('3A/16B8A20', '3A/16A0000')
);
--  pg_size_pretty
-- ----------------
--  99 kB

WAL 생성량이 비정상적으로 많다면, 잦은 업데이트나 체크포인트 직후의 full-page write 폭증을 의심할 수 있다.

체크포인트 상태 점검

체크포인트가 시간 기반(num_timed)이 아니라 WAL 한계로 강제(num_requested)되는 비율이 높다면, max_wal_size가 작아 체크포인트가 너무 자주 강제되고 있다는 신호다.

-- PostgreSQL 17 이상: 체크포인터 통계가 별도 뷰로 분리됨
SELECT num_timed,        -- 타이머(checkpoint_timeout)로 발생한 체크포인트 수
       num_requested,    -- max_wal_size 등으로 강제된 체크포인트 수
       write_time,       -- 더티 페이지를 쓰는 데 걸린 누적 시간(ms)
       buffers_written   -- 체크포인터가 쓴 버퍼 수
FROM pg_stat_checkpointer;

[!NOTE] PostgreSQL 16 이하에서는 같은 정보가 pg_stat_bgwritercheckpoints_timed, checkpoints_req, buffers_checkpoint 등의 컬럼으로 들어 있었다. 17부터 체크포인터 통계가 pg_stat_checkpointer로 분리되고 컬럼명도 num_timed, num_requested, buffers_written 등으로 정리되었다. 버전에 맞는 뷰를 조회하자.

num_requestednum_timed보다 크게 우세하다면, max_wal_size를 늘려 강제 체크포인트 빈도를 낮추는 것을 검토한다.

백그라운드 라이터 상태

-- 백그라운드 라이터가 얼마나 더티 페이지를 흘려보내고 있는지
SELECT buffers_clean,       -- background writer가 쓴 버퍼 수
       maxwritten_clean     -- 한 번에 너무 많이 써서 중단된 횟수
FROM pg_stat_bgwriter;

maxwritten_clean이 크다면 백그라운드 라이터가 일을 다 못 끝내고 멈추고 있다는 뜻으로, bgwriter_lru_maxpages를 늘리는 것을 고려할 수 있다.


정리

  1. WAL의 원칙은 데이터 페이지보다 변경 로그를 먼저 디스크에 쓴다는 것이다. 무거운 랜덤 데이터 쓰기를 커밋 경로에서 빼고, 가벼운 순차 로그 flush로 내구성(D)을 보장한다.
  2. 커밋 = WAL 레코드의 fsync이지, 데이터 페이지의 디스크 반영이 아니다. 데이터 페이지는 background writer와 checkpointer가 나중에 비동기로 내린다.
  3. synchronous_commit은 “최근 커밋을 얼마나 지킬지”의 손잡이(off여도 손상은 없음), fsync는 “내구성을 켤지 끌지”의 스위치(off면 손상 위험)다 — 차원이 다르다.
  4. 체크포인트는 더티 페이지를 모두 내리고 redo point를 앞당겨 복구 시간을 짧게 유지한다. full_page_writes는 체크포인트 후 첫 변경 시 페이지 전체를 WAL에 담아 torn page를 막는다.
  5. 크래시 복구는 마지막 체크포인트의 redo point부터 WAL을 재생(REDO)하면 끝난다. LSN 비교로 멱등하다.
  6. PostgreSQL은 MVCC 덕분에 UNDO 단계가 없다. 미커밋 트랜잭션의 튜플은 dead tuple로 남아 VACUUM이 회수한다 — Part 1의 내용과 정확히 맞물린다.
  7. WAL은 “모든 변경의 순서 있는 완전한 기록”이므로 스트리밍 복제와 PITR의 공통 기반이 된다. 셋 다 본질은 같은 replay다.

WAL을 이해하면 PostgreSQL의 성능 설정 절반이 비로소 말이 되기 시작한다. synchronous_commit을 왜 끄는지, max_wal_size를 왜 키우는지, 복제가 어떻게 데이터를 잃지 않는지가 모두 이 한 줄에서 나온다 — 로그를 먼저 쓴다.


관련 포스트

Share


CATALOG