필사 모드: Write-Ahead Logging 완전 가이드 2025: ARIES 복구, PostgreSQL WAL, InnoDB Redo Log, 체크포인트, 내결함성 심층 분석
한국어들어가며: 데이터베이스가 절대 잊지 않는 비결
한 가지 질문으로 시작하자
당신의 은행 앱에서 10만 원을 송금한다. "송금 완료"가 화면에 뜬 순간, 데이터베이스 서버가 **전원 차단**된다. 서버가 재부팅된 후, 그 10만 원은 어떻게 될까?
답: **사라지지 않는다**. 왜냐하면 데이터베이스는 **"내가 커밋했다고 말한 순간"** 을 절대 잊지 않기 때문이다.
그 마법의 정체가 바로 **Write-Ahead Logging (WAL)** 이다.
단순한 아이디어, 엄청난 결과
WAL의 핵심 원칙은 단 한 줄로 요약된다:
> **"데이터 파일에 변경을 적용하기 전에, 그 변경을 로그에 먼저 기록한다."**
이 단순한 규칙에서 다음이 파생된다:
- **Durability**: 커밋된 트랜잭션은 장애 후에도 복구 가능.
- **Atomicity**: 트랜잭션은 전체가 적용되거나 전혀 적용되지 않음.
- **성능**: 랜덤 I/O가 아닌 **순차 I/O**로 쓰기 집중.
- **복구**: 장애 후 로그를 재생해서 상태 복원.
이 글에서는 WAL이 **왜** 필요한지, **어떻게** 동작하는지, 그리고 PostgreSQL과 InnoDB에서 **실제로** 어떻게 구현되었는지 깊이 들여다본다.
1. WAL이 없던 시절: Shadow Paging
초기 접근법
WAL이 표준화되기 전, 데이터베이스는 **Shadow Paging** 같은 기법을 썼다:
1. 페이지를 수정할 때 **원본을 그대로 두고** 새 페이지를 할당.
2. 새 페이지에 변경 내용 쓰기.
3. 트랜잭션 커밋 시 **페이지 테이블을 원자적으로 교체**.
4. 장애 시 그냥 구 페이지 테이블로 되돌리면 됨.
왜 실패했는가?
Shadow Paging은 개념적으로 우아하지만 치명적 단점이 있었다:
- **페이지 단편화**: 매번 새 페이지를 할당하니 공간 관리 복잡.
- **클러스터링 파괴**: 원본과 복사본이 물리적으로 멀리 떨어져 랜덤 I/O 증가.
- **동시성 어려움**: 페이지 테이블 교체가 원자적이어야 함.
- **작은 변경도 전체 페이지 복사**: 1바이트 수정에 8KB 쓰기.
System R(IBM, 1970년대)과 초기 DB들이 Shadow Paging을 시도했다가 결국 WAL로 돌아섰다.
WAL의 등장
1970년대 후반 IBM의 연구진이 WAL을 공식화했고, 1992년 Mohan 등의 **ARIES** 논문이 현대 WAL의 표준이 되었다. ARIES는 지금도 거의 모든 RDBMS의 근간이다.
2. WAL의 기본 원리
두 가지 규칙
WAL은 **두 가지 단순한 규칙**으로 정의된다:
**규칙 1 (WAL Rule)**:
> 데이터 페이지를 디스크에 쓰기 전에, 해당 변경의 **로그 레코드가 먼저 디스크에 flush** 되어야 한다.
**규칙 2 (Force-at-Commit)**:
> 트랜잭션이 커밋된 것으로 간주되기 전에, 그 트랜잭션의 **모든 로그 레코드가 디스크에 flush** 되어야 한다.
이 두 규칙만 지키면 어떤 장애 시점에서도 일관된 상태로 복구할 수 있다.
로그 레코드 구조
전형적인 WAL 레코드는 다음을 포함한다:
LogRecord {
LSN : 로그 시퀀스 번호 (단조 증가)
PrevLSN : 같은 트랜잭션의 이전 레코드 LSN
TransactionID: 트랜잭션 식별자
Type : UPDATE, INSERT, DELETE, COMMIT, ABORT, CHECKPOINT...
PageID : 영향받은 페이지
Before : 변경 전 이미지 (undo 정보)
After : 변경 후 이미지 (redo 정보)
}
**LSN (Log Sequence Number)** 이 핵심이다. LSN은 **로그 파일 내 바이트 오프셋**에 가까운 개념으로, 한 번 부여되면 절대 재사용되지 않는다.
페이지 LSN
각 데이터 페이지도 **pageLSN** 을 저장한다: 이 페이지에 가장 최근에 적용된 로그 레코드의 LSN. 복구 중에 "이 변경이 이미 적용됐나?"를 판단하는 데 쓰인다.
if pageLSN >= logRecord.LSN:
이미 적용됨 → 건너뛰기
else:
다시 적용 (redo)
3. ARIES: 현대 WAL의 표준
ARIES의 세 가지 원칙
1992년 IBM 연구소의 C. Mohan 등이 발표한 **ARIES (Algorithms for Recovery and Isolation Exploiting Semantics)** 는 세 가지 원칙을 중심으로 한다:
1. **Write-Ahead Logging**: 위에서 설명한 두 규칙.
2. **Repeating History During Redo**: 복구 시 **모든 변경(커밋 여부와 무관)을 재적용**.
3. **Logging Changes During Undo**: 롤백도 로그 레코드로 기록해서 재귀적 복구 지원.
3단계 복구 프로토콜
장애 후 데이터베이스가 시작될 때, ARIES는 세 단계로 복구한다:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Analysis │ → │ Redo │ → │ Undo │
│ (분석 단계) │ │ (재실행) │ │ (취소) │
└──────────────┘ └──────────────┘ └──────────────┘
Phase 1: Analysis
**목적**: 장애 시점의 상태를 파악.
1. 마지막 체크포인트부터 로그를 순방향 스캔.
2. **Dirty Page Table (DPT)** 재구성: 장애 시점에 메모리에만 있던 페이지들.
3. **Transaction Table** 재구성: 장애 시 진행 중이던 트랜잭션들.
4. **redoLSN** 결정: 이 LSN부터 redo 시작.
Phase 2: Redo
**목적**: 모든 로그 레코드를 재적용해서 장애 직전 상태로 복원.
1. redoLSN부터 로그 끝까지 순방향 스캔.
2. 각 로그 레코드에 대해:
- 해당 페이지를 읽어옴.
- `if pageLSN < logRecord.LSN` → 변경 적용, pageLSN 갱신.
- 이미 적용됐으면 건너뛰기.
3. **커밋된 트랜잭션과 커밋되지 않은 트랜잭션 모두의 변경을 재적용**.
"왜 커밋 안 된 것까지?" 이는 Phase 3에서 일관되게 undo하기 위해서다.
Phase 3: Undo
**목적**: 커밋되지 않은 트랜잭션들을 롤백.
1. Transaction Table에서 loser(미커밋) 트랜잭션 목록 확인.
2. 각 loser에 대해 **역방향**으로 로그 따라가며 원상복구.
3. 각 undo 동작도 **CLR (Compensation Log Record)** 로 로그에 기록.
4. 롤백 완료 시 ABORT 레코드 기록.
CLR: 롤백도 로그에 남긴다
CLR(Compensation Log Record)은 ARIES의 영리한 아이디어다. 언뜻 보면 "롤백인데 왜 또 로그에 쓰지?"라는 생각이 들지만:
- **재귀적 복구**: 롤백 중에 또 장애가 발생해도 CLR을 보고 어디까지 롤백했는지 알 수 있다.
- **멱등성**: CLR은 다시 실행해도 결과가 같다.
CLR에는 `undoNextLSN` 필드가 있어서, 다음에 undo할 LSN을 가리킨다. 롤백을 중간부터 이어갈 수 있다.
체크포인트 (Checkpoint)
로그가 무한히 커질 수 없으므로 주기적으로 **체크포인트**를 찍는다:
1. 모든 dirty page를 디스크에 flush.
2. 현재 활성 트랜잭션 목록과 DPT를 로그에 기록.
3. 체크포인트 레코드 작성.
4. 체크포인트 이전의 로그는 (대부분) 버려도 됨.
ARIES의 **Fuzzy Checkpoint**는 더 영리하다: dirty page flush를 강제하지 않고, 현재 상태의 스냅샷만 기록. 백그라운드에서 flush가 이뤄진다.
4. PostgreSQL WAL 심층 분석
WAL 파일 구조
PostgreSQL의 WAL은 `pg_wal` 디렉토리(이전에는 `pg_xlog`)에 저장된다:
$PGDATA/pg_wal/
├── 000000010000000000000001 (16MB)
├── 000000010000000000000002
├── 000000010000000000000003
├── archive_status/
└── ...
각 파일은 기본 **16MB** 이고, 이름은 `TIMELINEID + LOGID + SEGMENT` 형식이다. 파일 내부는 8KB 페이지로 나뉘어 있으며, 각 페이지에 여러 WAL 레코드가 담긴다.
주요 WAL 레코드 타입
| 타입 | 설명 |
|---|---|
| `XLOG_HEAP_INSERT` | 새 튜플 삽입 |
| `XLOG_HEAP_UPDATE` | 튜플 업데이트 (기존 튜플 xmax + 새 튜플) |
| `XLOG_HEAP_DELETE` | 튜플 삭제 (xmax 설정) |
| `XLOG_BTREE_INSERT_LEAF` | B-Tree 리프에 키 삽입 |
| `XLOG_XACT_COMMIT` | 트랜잭션 커밋 |
| `XLOG_XACT_ABORT` | 트랜잭션 롤백 |
| `XLOG_CHECKPOINT_ONLINE` | 온라인 체크포인트 |
| `XLOG_CHECKPOINT_SHUTDOWN` | 셧다운 체크포인트 |
Full-Page Write
PostgreSQL의 독특한 기능: **Full-Page Write**.
문제: 8KB 페이지가 디스크에 쓰이는 도중 장애가 나면(**torn page**), 디스크에는 **일부만 쓰인 페이지**가 남는다. OS/디스크 수준에선 4KB 원자성만 보장되기 때문이다.
해결: 체크포인트 직후 **첫 번째 수정** 때 전체 페이지를 WAL에 기록한다. 이후 수정은 작은 증분만 기록.
-- 기본 활성화 (절대 끄지 마세요)
show full_page_writes; -- on
이로 인해 WAL 크기가 2~3배 늘어나지만, **torn page로 인한 데이터 손상**을 막는다. 체크포인트 직후 쓰기 폭주(write storm)의 주요 원인이기도 하다.
fsync와 커밋
트랜잭션 커밋 시 PostgreSQL은:
1. COMMIT 레코드를 WAL 버퍼에 추가.
2. WAL 버퍼를 디스크로 **fsync**.
3. 클라이언트에 성공 응답.
`fsync = off`로 설정하면 빠르지만 **장애 시 데이터 손실 가능**. 절대 프로덕션에서 끄지 마라.
`synchronous_commit` 설정:
- **on** (기본): fsync 후 응답. 가장 안전.
- **local**: 로컬만 fsync, 복제는 비동기.
- **remote_write**: 복제본이 받기만 하면 OK.
- **remote_apply**: 복제본이 적용까지 해야 OK.
- **off**: fsync 없이 응답. 빠르지만 마지막 몇 ms 손실 가능 (단, **데이터 일관성은 유지**).
체크포인트 튜닝
체크포인트는 성능에 큰 영향을 준다:
postgresql.conf
checkpoint_timeout = 15min # 최대 15분마다
max_wal_size = 4GB # WAL이 이 크기에 도달하면 체크포인트
min_wal_size = 1GB
checkpoint_completion_target = 0.9 # 체크포인트를 이 비율 동안 분산 실행
**Trade-off**:
- 체크포인트 자주: 복구 빨라지나 I/O 부하 증가.
- 체크포인트 드물게: 복구 느려지나 평상시 성능 향상.
프로덕션에선 보통 **timeout 기반** 체크포인트가 자연스럽게 되도록 `max_wal_size`를 넉넉히 잡는다.
WAL과 복제
PostgreSQL의 스트리밍 복제는 **WAL을 그대로 복제본에 전송**한다:
Primary: WAL 생성 → 네트워크로 전송 → Standby가 재생(redo)
- **Asynchronous**: 기본. 빠르지만 약간의 lag 가능.
- **Synchronous**: `synchronous_commit = on` + `synchronous_standby_names` 설정. Standby의 ack를 기다림.
**Logical Replication**은 조금 다르다: 물리 WAL을 논리적 변경(INSERT/UPDATE/DELETE)으로 디코딩해서 전송. 버전 차이나 선택적 테이블 복제가 가능.
WAL 아카이빙과 PITR
Point-in-Time Recovery(PITR)를 위해선:
1. **기본 백업** (`pg_basebackup`)
2. **WAL 아카이빙** (`archive_command`로 WAL 파일을 S3 등으로 복사)
3. 장애 시 기본 백업 복원 + WAL 재생으로 원하는 시점까지 복구.
archive_mode = on
archive_command = 'aws s3 cp %p s3://my-bucket/wal/%f'
5. InnoDB의 Redo Log와 Undo Log
두 개의 로그 시스템
InnoDB는 PostgreSQL과 달리 **두 종류의 로그**를 유지한다:
1. **Redo Log**: 변경을 재적용하기 위한 로그 (WAL 역할).
2. **Undo Log**: 변경을 취소하기 위한 로그 (MVCC에도 사용).
이는 MVCC 구현이 PostgreSQL과 다르기 때문이다.
Redo Log 구조
InnoDB의 redo log는 **고정 크기 원형 파일**이다:
ib_logfile0 (기본 48MB, 여러 개 순환)
ib_logfile1
모든 수정이 redo log에 기록된다:
RedoLogRecord {
type: MLOG_REC_INSERT, MLOG_REC_UPDATE, MLOG_REC_DELETE, ...
space_id: 테이블스페이스 ID
page_no: 페이지 번호
offset: 페이지 내 오프셋
data: 실제 변경 내용
}
Mini-Transaction (mtr)
InnoDB의 핵심 개념: **Mini-Transaction (mtr)**. 이는 **원자적인 페이지 수정 그룹**이다.
예를 들어 B-Tree에 키를 삽입하면:
1. 리프 페이지 수정
2. 부모 페이지 수정 (split 시)
3. 헤더 업데이트
이 모두가 **하나의 mtr**이다. mtr의 로그 레코드들은 원자적으로 flush된다. 복구 시 mtr 단위로 재적용된다.
LSN과 Checkpoint LSN
InnoDB도 **LSN**을 사용하지만 개념이 조금 다르다:
- **LSN**: 로그 파일 시작부터의 바이트 오프셋.
- **checkpoint_lsn**: 이 LSN 이전의 변경은 모두 데이터 파일에 반영됨.
복구 시 `checkpoint_lsn`부터 로그를 재생한다.
Sharp vs Fuzzy Checkpoint
InnoDB는 두 종류의 체크포인트를 한다:
- **Sharp Checkpoint**: 모든 dirty page를 flush (드물게, 셧다운 시).
- **Fuzzy Checkpoint**: 일부만 flush하고 LSN만 기록 (자주, 백그라운드).
Fuzzy checkpoint 덕분에 평상시 성능 영향이 작다.
Undo Log와 Rollback Segment
Undo log는 **별도의 세그먼트**에 저장된다. 각 트랜잭션 시작 시 rollback segment의 undo slot을 할당받는다.
용도:
1. **롤백**: 트랜잭션 취소 시.
2. **MVCC**: 오래된 버전을 조회할 때 undo log를 따라감.
3. **복구 시 undo**: 복구 중 미커밋 트랜잭션 취소.
InnoDB 복구 프로세스
1. **Redo 단계**: `ib_logfile`을 `checkpoint_lsn`부터 읽어 모든 변경 재적용.
2. **Rollback 단계**: 커밋되지 않은 트랜잭션들을 undo log로 취소.
이는 ARIES의 Analysis/Redo/Undo 3단계와 동일한 개념이다.
Doublewrite Buffer: InnoDB의 Torn Page 해결법
PostgreSQL의 Full-Page Write에 대응하는 InnoDB의 기능이 **Doublewrite Buffer**다.
1. Dirty page를 디스크로 쓸 때, **먼저 doublewrite buffer에 쓴다** (순차 I/O).
2. doublewrite buffer에 성공적으로 쓰였으면, **실제 데이터 파일에 쓴다** (랜덤 I/O).
3. 장애 시 실제 파일의 페이지가 손상되었으면, doublewrite buffer에서 복구.
이는 WAL을 두 배로 불리지 않는 대신 **쓰기 I/O를 두 배로** 만든다. 최신 NVMe에선 거의 무시할 수 있는 비용이다.
6. WAL의 성능 특성
순차 I/O의 축복
WAL의 가장 큰 성능 이점은 **순차 쓰기**다:
- 랜덤 HDD: ~100 IOPS, 1MB/s
- 순차 HDD: ~500 MB/s
- 랜덤 SSD: ~10,000 IOPS
- 순차 SSD: ~500 MB/s
WAL은 항상 파일 끝에 추가하므로 디스크 암(arm)이 움직이지 않는다. 초당 수십만 트랜잭션이 가능한 이유다.
fsync의 지연
그러나 **fsync는 여전히 비싸다**:
- 일반 SSD fsync: ~0.1~1ms
- NVMe fsync: ~0.01~0.1ms
- 클라우드 디스크 (EBS): ~1~5ms
커밋마다 fsync를 하면 트랜잭션 처리량이 초당 수천에 갇힌다. 해결책:
Group Commit
**여러 트랜잭션의 커밋을 한 번의 fsync로 처리**한다:
트랜잭션 T1: COMMIT (WAL 버퍼에 기록)
트랜잭션 T2: COMMIT (WAL 버퍼에 기록)
트랜잭션 T3: COMMIT (WAL 버퍼에 기록)
↓
한 번의 fsync()
↓
T1, T2, T3 모두에게 성공 응답
fsync 1번으로 여러 커밋을 처리하니 처리량이 폭발적으로 늘어난다. PostgreSQL과 InnoDB 모두 기본 활성화.
PostgreSQL `commit_delay`
commit_delay = 10 # μs 단위. 그룹 커밋 대기 시간.
commit_siblings = 5 # 이만큼 트랜잭션이 있을 때만 대기.
이 설정은 고동시성 환경에서 처리량을 높일 수 있지만, 응답 시간을 증가시킨다. 벤치마크가 필수.
WAL 압축
PostgreSQL은 `wal_compression = on`으로 **Full-Page Write를 압축**할 수 있다. CPU 비용 대비 I/O 대역폭과 네트워크(복제) 절약.
PG 15부터는 `wal_compression`에 알고리즘 선택 가능 (`pglz`, `lz4`, `zstd`).
7. WAL과 장애 시나리오
시나리오 1: 전원 차단
- **커밋 전 장애**: 해당 트랜잭션의 변경은 로그에도, 데이터에도 없거나 부분적. 복구 시 undo.
- **커밋 후 장애** (데이터 flush 전): 로그에는 있음. 복구 시 redo로 재적용.
- **결과**: **커밋 성공 응답이 클라이언트에 도달한 모든 트랜잭션은 보존**.
시나리오 2: OS 크래시
전원 차단과 동일. OS가 쓴 내용도 디스크에 반영되지 않을 수 있지만, fsync 이후의 데이터는 안전.
시나리오 3: 디스크 오류
**여기서 WAL만으로는 부족**하다. 디스크 자체가 손상되면 복구 불가. 해결책:
- **복제** (Replication): 다른 디스크/서버에 데이터 사본 유지.
- **백업**: 주기적 백업 + WAL 아카이브 → PITR 가능.
- **RAID**: 디스크 장애를 하드웨어 수준에서 방어.
시나리오 4: 부분 쓰기 (Torn Page)
앞서 설명. PostgreSQL의 Full-Page Write 또는 InnoDB의 Doublewrite Buffer로 해결.
시나리오 5: "Lost Write"
디스크가 **쓰기를 받았다고 거짓 응답**하지만 실제로는 안 쓴 경우. fsync를 속이는 잘못된 저가 SSD에서 발생.
**검증 방법**:
- Diskchecker.pl 같은 도구로 fsync가 정말 동작하는지 테스트.
- 엔터프라이즈급 SSD 사용.
8. WAL 운영 모범 사례
1. WAL 디스크 분리
가능하면 WAL을 **데이터 파일과 다른 디스크**에 두자:
PostgreSQL
data_directory: /data/pgdata
pg_wal을 심볼릭 링크로 /wal/pg_wal에 연결
**이유**:
- WAL은 순차 I/O, 데이터는 랜덤 I/O → 서로 경합 방지.
- WAL 디스크 장애와 데이터 디스크 장애 분리.
2. fsync는 무조건 켜라
`fsync = off`, `synchronous_commit = off`는 **벤치마크용**이지 프로덕션용이 아니다.
**예외**: 개발 환경, 일회성 데이터 로드, 복제본을 유지하는 경우 일시적으로 off 가능.
3. 체크포인트 빈도 조정
너무 자주 ← bad
checkpoint_timeout = 1min
균형 ← 권장
checkpoint_timeout = 15min
max_wal_size = 4GB
너무 드물게 ← bad (복구 느림)
checkpoint_timeout = 1h
4. WAL 크기 모니터링
-- PostgreSQL: 현재 WAL 크기
SELECT pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0'));
-- 미아카이빙된 WAL
SELECT count(*) FROM pg_ls_waldir() WHERE name < (SELECT last_archived_wal FROM pg_stat_archiver);
WAL이 비정상적으로 커지면:
- 긴 트랜잭션?
- 복제 지연?
- 체크포인트 안 돌고 있나?
- archive_command 실패?
5. 복구 시간 테스트
**"백업은 있지만 복구해 본 적 없다"** 는 흔한 악몽이다. 주기적으로:
1. 백업으로부터 새 환경 구축.
2. WAL 재생으로 특정 시점까지 복구.
3. 데이터 무결성 검증.
4. 소요 시간 기록.
6. synchronous_commit의 이해
가장 안전 + 느림
synchronous_commit = remote_apply # 복제본 재생까지 대기
데이터 손실 없음 + 빠름
synchronous_commit = remote_write # 복제본 수신까지만 대기
일반적 + 빠름
synchronous_commit = on # 로컬 fsync까지
마지막 수 ms 손실 허용 + 매우 빠름
synchronous_commit = off
트랜잭션별로 바꿀 수도 있다:
SET LOCAL synchronous_commit = off;
INSERT INTO logs ...; -- 로그성 쓰기는 빠르게
COMMIT;
9. 최신 트렌드와 연구
NVMe와 Storage Class Memory
새로운 스토리지 기술은 WAL 설계에 영향을 준다:
- **NVMe**: 병렬 I/O 큐로 초당 수백만 IOPS. fsync 오버헤드 감소.
- **Intel Optane DCPMM**: DRAM 속도에 영구성까지. WAL 자체가 필요 없어질 수 있다는 기대.
- **PMEM**: mmap 기반 영구 메모리. 여러 DB가 실험 중.
그러나 Optane은 2022년 단종되었고, PMEM 채택은 여전히 제한적. 실전에선 **NVMe 위 전통 WAL**이 여전히 표준이다.
Async WAL
일부 NoSQL(예: Redis AOF)은 async flush 옵션을 제공한다. 성능은 빠르지만 마지막 수 ms ~ 수 초 손실 가능.
Log-Structured Everything
**LSM Tree** (RocksDB, Cassandra) 자체가 WAL 기반이다. 메모리 테이블 + WAL → SST 파일로 flush → 주기적 compaction. 이는 "쓰기 증폭" 문제가 있지만 대규모 데이터에 적합.
SMR 드라이브와 WAL
Shingled Magnetic Recording(SMR) HDD는 덮어쓰기가 느리지만 **순차 쓰기는 빠르다**. WAL의 순차 특성과 잘 맞아 연구가 활발하다.
10. 직접 구현해 보자: 간단한 WAL
최소 WAL 구현 (Python)
class SimpleWAL:
def __init__(self, log_path):
self.log_path = log_path
self.lsn = 0
if os.path.exists(log_path):
self._recover()
def log(self, record: dict):
"""WAL에 레코드 기록 (force=True로 fsync)"""
self.lsn += 1
record['lsn'] = self.lsn
data = json.dumps(record).encode()
with open(self.log_path, 'ab') as f:
길이(4B) + 데이터
f.write(struct.pack('>I', len(data)))
f.write(data)
f.flush()
os.fsync(f.fileno()) # 🔑 핵심: 디스크까지 확인
return self.lsn
def _recover(self):
"""시작 시 로그 재생"""
with open(self.log_path, 'rb') as f:
while True:
length_data = f.read(4)
if not length_data:
break
length = struct.unpack('>I', length_data)[0]
record = json.loads(f.read(length))
self.lsn = max(self.lsn, record['lsn'])
self._apply(record)
def _apply(self, record):
"""로그 레코드를 상태에 적용 (redo)"""
print(f"Applying: {record}")
사용 예시
wal = SimpleWAL('demo.wal')
wal.log({'type': 'insert', 'key': 'user:1', 'value': 'alice'})
wal.log({'type': 'update', 'key': 'user:1', 'value': 'bob'})
여기서 프로세스 죽어도, 재시작 시 로그 재생으로 복구
이건 극도로 단순화된 예시지만, **"변경을 로그에 먼저 쓰고 fsync"** 라는 핵심을 보여준다.
실전 구현에 필요한 것들
진짜 프로덕션 WAL은 이보다 훨씬 복잡하다:
- **LSN 관리**: 바이트 오프셋 기반.
- **페이지 단위 I/O**: 4KB/8KB 블록.
- **체크포인트**: 주기적 스냅샷.
- **로그 압축**: 오래된 레코드 정리.
- **병렬 쓰기**: WAL writer 스레드, group commit.
- **복제**: 네트워크로 WAL 스트리밍.
- **CRC**: 로그 레코드 무결성 검증.
- **torn page 방어**: full-page write 또는 doublewrite.
그래서 PostgreSQL의 WAL 코드는 10,000줄 이상이다. 하지만 원리는 위 예시와 같다.
퀴즈로 복습하기
**A.** (1) **WAL Rule**: 데이터 페이지를 디스크에 쓰기 전에 해당 변경의 로그 레코드가 먼저 디스크에 flush되어야 한다. (2) **Force-at-Commit**: 트랜잭션이 커밋된 것으로 간주되기 전에 그 트랜잭션의 모든 로그 레코드가 디스크에 flush되어야 한다. 이 두 규칙만 지키면 어떤 장애 시점에서도 일관된 상태로 복구할 수 있다.
**A.** (1) **Analysis**: 마지막 체크포인트부터 로그를 읽어 장애 시점의 Dirty Page Table과 Transaction Table을 재구성. (2) **Redo**: 모든 로그 레코드(커밋/미커밋 무관)를 재적용해서 장애 직전 상태로 복원. **"Repeating History"**. (3) **Undo**: 미커밋 트랜잭션들을 로그를 역방향으로 따라가며 롤백. 이 과정도 CLR(Compensation Log Record)로 로그에 남겨 재귀적 복구가 가능하게 한다.
**A.** **Torn Page** 문제다. 데이터베이스 페이지(보통 8KB/16KB)가 디스크에 쓰이는 중간에 장애가 발생하면, 일부만 쓰인 손상된 페이지가 남는다. OS/디스크는 보통 4KB 블록 단위 원자성만 보장하기 때문이다. PostgreSQL은 **체크포인트 후 첫 수정 시 전체 페이지를 WAL에 기록**해서 복구 시 사용한다. InnoDB는 **doublewrite buffer에 먼저 쓴 후 실제 위치에 쓰는** 방식으로 해결한다.
**A.** Group Commit은 **수십~수백 배의 처리량 향상**을 가져올 수 있다. 단일 SSD의 fsync가 초당 1000~10000번 정도인데, 각 커밋이 fsync 1번을 요구하면 초당 수천 트랜잭션이 한계다. Group Commit은 여러 트랜잭션의 커밋 레코드를 WAL 버퍼에 모은 후 **한 번의 fsync로 여러 커밋을 처리**한다. fsync는 블로킹 I/O이므로 대기 중인 트랜잭션이 많을수록 효율이 높아진다. PostgreSQL과 InnoDB 모두 기본 활성화되어 있다.
**A.** ARIES의 **"Repeating History"** 원칙 때문이다. 이렇게 하면 복구 과정이 훨씬 단순하고 견고해진다:
1. **상태 일관성**: 일단 장애 직전 상태를 정확히 재현하면, 그 위에서 undo하는 것이 훨씬 쉽다. 어떤 변경이 디스크에 반영됐는지 일일이 구분할 필요가 없다.
2. **구조 일관성**: B-Tree 같은 복잡한 데이터 구조는 mtr(mini-transaction) 단위의 원자성이 필요하다. 일부 mtr만 적용된 상태에서 undo를 시도하면 구조가 깨질 수 있다.
3. **CLR 기록**: undo 과정 자체도 로그에 CLR로 남기므로, undo 중 또 장애가 나도 일관되게 이어갈 수 있다.
4. **재귀성**: 로그 적용과 취소가 같은 프레임워크를 공유하니 구현이 단순해진다.
마치며: WAL이 가르쳐준 것
핵심 정리
1. **변경 전에 로그 먼저**: 단순한 규칙이지만 내결함성의 기초.
2. **순차 I/O의 위력**: 왜 DB가 SSD보다 빠를 수 있는가의 답.
3. **fsync는 타협 불가**: 속도와 안전성의 경계.
4. **Group Commit**: 병렬성을 I/O 차원에서 활용.
5. **ARIES 3단계**: Analysis → Redo → Undo가 현대 DB 복구의 표준.
6. **체크포인트**: 복구 시간과 평상시 성능의 균형.
모든 분산 시스템의 토대
WAL 개념은 단일 DB를 넘어 어디에나 있다:
- **Kafka**: 사실상 분산 WAL 그 자체.
- **etcd/Raft**: 로그 복제 = WAL 복제.
- **Cassandra**: Commit Log + SSTable.
- **Redis AOF**: Append-Only File.
- **ZooKeeper**: transaction log + snapshot.
- **Blockchain**: 불변의 공개 WAL.
**"먼저 로그에 쓰고, 나중에 실제 상태에 적용한다"** 는 아이디어는 컴퓨터 과학에서 가장 유용한 패턴 중 하나다.
마지막 조언
데이터베이스를 운영하면서 WAL을 이해하고 있으면:
- **복구 시간이 왜 이런지** 설명할 수 있다.
- **왜 fsync를 끄면 안 되는지** 자신 있게 말할 수 있다.
- **체크포인트 튜닝**을 데이터 기반으로 할 수 있다.
- **복제 지연**의 원인을 추적할 수 있다.
- **장애 후 무엇이 살아남았는지** 판단할 수 있다.
데이터베이스 내부를 아는 엔지니어와 모르는 엔지니어의 차이는, **장애가 났을 때** 가장 극명하게 드러난다. WAL을 이해하는 당신은 이미 한 걸음 앞서 있다.
참고 자료
- [ARIES: A Transaction Recovery Method Supporting Fine-Granularity Locking (Mohan et al., 1992)](https://www.cs.berkeley.edu/~brewer/cs262/Aries.pdf) - ARIES 원 논문
- [PostgreSQL Reliability and WAL](https://www.postgresql.org/docs/current/wal.html) - 공식 문서
- [MySQL InnoDB Redo Log](https://dev.mysql.com/doc/refman/8.0/en/innodb-redo-log.html)
- [The Internals of PostgreSQL](https://www.interdb.jp/pg/pgsql09.html) - PostgreSQL WAL 상세 설명
- [InnoDB Mini-Transaction](https://dev.mysql.com/doc/dev/mysql-server/latest/PAGE_INNODB_MINI_TRANSACTION.html)
- [Database Internals (Alex Petrov)](https://www.databass.dev/) - 훌륭한 참고서
- [Designing Data-Intensive Applications, Ch.3](https://dataintensive.net/) - 스토리지 엔진 개괄
- [CMU 15-445 Database Systems: Recovery](https://15445.courses.cs.cmu.edu/fall2022/slides/21-recovery.pdf) - 대학 강의
현재 단락 (1/337)
당신의 은행 앱에서 10만 원을 송금한다. "송금 완료"가 화면에 뜬 순간, 데이터베이스 서버가 **전원 차단**된다. 서버가 재부팅된 후, 그 10만 원은 어떻게 될까?