-
WAL Compaction 설계기Development/Distributed System 만들어보기 [Peacock] 2026. 5. 3. 04:39
"30가지 패턴으로 배우는 분산 시스템 설계와 구현 기법" 책으로 스터디를 하면서 머릿속으로만 이해했던 개념들을 직접 코드로 옮겨보고 싶었다. 그래서 작은 인메모리 KV 스토어 peacock을 만들기 시작했다. WAL을 붙이고, 시간이 지나니 자연스럽게 "로그가 무한히 자라는 문제"에 부딪혔다. 이 글은 그 문제를 풀어가며 마주친 설계 결정들과, 그것을 어떻게 정리했는지에 대한 기록이다.
WAL이 뭔가?
Write-Ahead Log는 이름 그대로 "변경을 메모리에 적용하기 전에 디스크에 먼저 기록한다"는 패턴이다. 데이터베이스 엔진의 거의 표준적인 영속성 메커니즘이다. PostgreSQL, MySQL의 InnoDB, RocksDB, LevelDB, etcd 등 안정성을 따지는 거의 모든 storage system이 어떤 형태로든 WAL을 갖고 있다.
이게 왜 필요한지는 단순한 시나리오로 명확해진다.
Put("a", 1) → 메모리 맵에 적용 Put("b", 2) → 메모리 맵에 적용 ☠️ 크래시 재시작 → 메모리는 휘발됐다. 데이터 손실.메모리만 가진 시스템의 운명이다. 영속성을 더하려면 디스크에 뭔가 남겨야 하는데, 매 쓰기마다 메모리 맵 전체를 디스크에 dump하는 건 비용이 크다. 그래서 차선의 답은 "변경 자체"를 디스크에 append하는 것이다. 메모리 맵을 통째로 저장하지 않고, "이런 일이 일어났다"는 사실만 기록한다.
Put("a", 1) → ① 디스크 로그에 "Put a=1" entry append ② 메모리 맵에 적용 Put("b", 2) → ① 디스크 로그에 "Put b=2" entry append ② 메모리 맵에 적용 ☠️ 크래시 재시작 → 디스크 로그를 처음부터 끝까지 읽어 entry 순서대로 다시 적용 → 메모리 맵이 크래시 직전 상태로 복원됨이게 WAL의 핵심 아이디어다. 메모리 상태는 휘발성이지만, 그 상태를 만든 변경 이력은 디스크에 영속화돼 있다. 재시작 시 이력을 처음부터 다시 적용하면 마지막 상태가 재구성된다.
메모리에 적용하기 전에 로그가 먼저 디스크에 도달해야 한다. 만약 메모리에 먼저 쓰고 로그를 나중에 쓴다면, 두 단계 사이에 크래시 시 메모리에는 적용됐지만 로그엔 없는 변경이 생긴다 — 재시작 시 이 변경이 사라져 inconsistency가 발생한다. 그래서 디스크 로그가 항상 "선행"한다.
peacock의 KV는 이 패턴 그대로다.
PUT(k, v) → ① WAL에 entry append (디스크에 fsync까지) ② 메모리 맵에 적용 GET(k) → 메모리 맵에서 직접 lookup각 entry는 다음 바이너리 레이아웃으로 직렬화된다.
TotalLen(4) | CRC32(4) | Op(1) | Index(8) | TimeStamp(8) | DataLen(4) | Data(var) ↑─────────────── CRC32 대상 ──────────────────↑TotalLen: entry body의 바이트 수. 다음 entry로 점프할 때 사용.CRC32: 본문 무결성 체크. 디스크가 일부만 fsync된 상태에서 크래시한 경우(tail truncation)를 감지한다.Op:OpPut또는OpDelete.Data: KV 레이어가 정의한 페이로드 (KeyLen | Key | Value).
여러 entry가 하나의 segment 파일(
wal-NNNNNNNNNN.log)에 append되고, segment가 일정 크기를 넘기면 다음 시퀀스 파일로 roll된다. 파일 하나가 무한히 커지지 않게 자르는 단위다.인메모리 맵은 그저 빠른 lookup만 신경 쓰면 되고, 영속성은 WAL이 다 알아서 한다.
그런데 이 단순함이 곧 한계가 된다.
어쩌다 compaction까지 왔나
같은 키를 100번 덮어쓰면 WAL에는 100개 entry가 쌓인다. 99개는 replay 시 읽히기만 하고 버려지는 죽은 데이터다.
Put("counter", 1) → entry 0 Put("counter", 2) → entry 1 ... Put("counter", 1000) → entry 999재시작하면 1000개 entry를 다 읽어 999번의 무의미한 덮어쓰기를 거친 뒤에야 최종 상태(
counter=1000)에 도달한다. 디스크 점유와 startup 시간이 운영 시간에 비례해 선형으로 증가한다.해결의 방향은 둘 중 하나다.
Snapshot은 주기적으로 메모리 맵 전체를 디스크에 덤프하는 방식이다. snap만 있으면 그 시점 상태를 즉시 복원할 수 있어 단순하다. 그런데 snapshot을 만드는 동안 메모리 맵의 상태를 일관되도록 잠궈야 하는 게 문제점이 있다. 쓰기 잠금을 걸면 stall이 발생하고, deepCopy로 우회하면 큰 맵에서 메모리 spike와 복제 stall이 따라온다.
Compaction은 로그가 롤링되어 더 이상 Write 되지 않는 WAL segment 파일을 읽어서 최신 상태의 엔트리 로그만 남긴 새 파일을 Write한다. 과거의 segment는 더 이상 쓰이지 않으니 메모리 맵과 어떤 자원도 공유하지 않기 때문에 제거할 수 있다. 이 과정은 백그라운드 goroutine이 디스크만 I/O하고 끝낸다. 덕분에 writer를 잠그는 시간을 최소화 할 수 있게 된다.
사이드 프로젝트 성격의 간단한 서버라 Snapshot 방식으로도 충분하지만 이왕 해보는거 Compaction 방식으로 구현해 보고 싶어서 이 방식을 택했다.
Phase 1: 매니페스트 도입
compaction을 하려면 "지금 살아있는 segment 파일은 정확히 어떤 것들인가?"를 파악해야 한다.
첫 시도는 디렉터리에 있는
wal-*.log를 모두 시퀀스 번호로 읽는 것이었다. 평상시엔 동작한다. 그런데 compaction 도중 죽으면 끔찍해진다.T0: [1.log, 2.log, 3.log, 4.log, 5.log] (활성=5) T1: 압축 시작 → 새 체크포인트 작성 중 T2: ☠️ 크래시 디스크 상태: - 1.log ~ 4.log: 옛 데이터 - 5.log: 활성 - tmp 또는 부분 체크포인트 파일: 알 수 없는 상태이걸 디스크 스캔으로 다시 열면, "어디까지가 권위 있는 데이터인가"를 알 수 없다. 부분 파일을 합법적인 segment로 오인하면 데이터가 망가진다.
그래서 manifest파일에 segment 목록을 관리하도록 했다. 이 파일이 "지금 살아있는 segment는 정확히 이것뿐이다"를 체크할 수 있게 해준다.
manifest 파일 (v1) 레이아웃: Magic (4) "PCKM" Version (2) uint16, 1 Reserved (2) uint16, 0 고정 Generation (8) uint64, 갱신마다 단조 증가 SegmentCount (4) uint32 Records SegmentCount × Seq(int64, 8) CRC32 (4)이 파일이 가리키지 않는 모든 디스크 상의
wal-*.log는 고아로 간주해 무시한다. compaction 도중 죽어 부분 파일이 떠 있어도 매니페스트가 그것을 가리키지 않으면 무시된다. 덕분에 "어떤 파일이 진짜인가"라는 모호함이 사라진다.Phase 2: Compaction
매니페스트가 자리잡으면 compaction의 큰 그림은 단순하다.
1. 과거의 segment를 읽어 키별 최신 값을 빌드 2. 결과를 새 체크포인트 파일에 직렬화 3. 매니페스트 갱신 → {checkpointSeq, [active]} 4. 과거의 segment 파일 제거체크포인트의 결정
처음엔 가볍게 가정했다. segment 파일이 [1, 2, 3, 4, 5(활성화 상태)] 있다고 생각했을 때, "압축 결과도 그냥 또 하나의 segment 파일이지." 즉 봉인 [1..4]를 압축하면 새 segment seq를 부여해서(예: max+1=6)
wal-6.log로 저장.하지만 활성이 5인데 압축 결과가 6이라면 파일명이 의미상 1..5의 데이터를 담고 있는데 현재 Write 중인 5보다 높은 seq를 가지므로 "더 새로운 데이터"라는 직관에 어긋난다.
세 가지 옵션을 두고 고민했다.
옵션 (a) 옛 source의 seq 상속 + 덮어쓰기. 활성 세그먼트 이전의 max seq를 그대로 받아 컴팩션하고 그 자리를 덮어쓴다. 즉, 4.log가 압축본으로 바뀐다. 하지만 운영자가 디스크를 봤을 때
wal-4.log가 원본인지 압축본인지 식별 불가능하다. 중간에 예상치 못한 문제로 종료가 되면 WAL 로그 자체가 깨질 수 있다.옵션 (b) max+1 새 seq. 압축 결과가 새 seq를 받는다. 모든 파일이 다른 seq를 가져 운영 식별이 명확하지만, WAL의
seq필드가 두 의미를 짊어지게 된다 — "활성 식별"과 "다음 할당 카운터". 분리하려면 두 필드 필요.옵션 (c) 별도 명명:
*.checkpoint. 압축 결과는wal-N.checkpoint라는 다른 이름. seq 공간 자체가 분리.Before: [1.log, 2.log, 3.log, 4.log, 5.log] 활성=5 After: [4.checkpoint, 5.log] 활성=5, seq counter=5 다음 roll: [4.checkpoint, 5.log, 6.log] 활성=6체크포인트 seq는 흡수한 마지막 봉인 seq를 상속하지만, 파일명 suffix가 달라 활성과 절대 충돌하지 않는다.
(c)를 택한 결정적 이유는 "체크포인트는 segment와 의미가 다르다"는 인식이었다. 체크포인트는 스냅샷이고 segment는 이력이다.
비용은 매니페스트 포맷 변경 한 번. 매니페스트가 체크포인트의 존재를 알아야 하니 v1에
CheckpointSeq필드를 추가한 v2를 만들었다.manifest v2: Magic | Version=2 | Reserved | Generation | CheckpointSeq(8) | SegmentCount | Seq... | CRC ↑ v2 신규0이면 "체크포인트 없음", > 0이면
wal-NNN.checkpoint가 매니페스트 segments보다 먼저 replay된다.Compaction commit point
매니페스트 갱신은 단일 commit point가 되어야 한다. 압축 commit은 단일 syscall이 아니다. 여러 단계가 순차적으로 일어난다.
1. tmp 파일에 체크포인트 entry들 쓰기 (bufio + Sync) 2. tmp.Close 3. os.Rename(tmp → wal-N.checkpoint) 4. writeManifest(새 매니페스트) ← tmp + rename + dir-fsync 5. 옛 segment / 옛 체크포인트 unlinkPOSIX는 단일 syscall만 atomic하므로 이 묶음을 통째로 atomic하게 만들 수 없다. 그런데 매니페스트 갱신(4)을 commit point로 정의하면 어디서 죽어도 안전하다.
죽는 시점 디스크 상태 매니페스트 결과 1 도중 부분 tmp 과거 tmp는 다음 시도에서 O_TRUNC로 덮임1 후, 3 전 완전한 tmp 과거 tmp는 다음에 덮임 3 도중 rename atomic — 과거 또는 새 버전 체크포인트 과거 어느 쪽이든 매니페스트 밖 → 무시 3 후, 4 전 완전한 새 버전 체크포인트 과거 매니페스트 밖 → 고아 무시 4 도중 atomic 과거 또는 새 버전 둘 다 정확 4 후, 5 전 과거 segment 잔존 새 버전 매니페스트 밖 5 도중 일부 unlink 새 버전 매니페스트 밖 매니페스트 갱신 이전에 죽으면 압축은 무효, 이후에 죽으면 적용된다.
동시 호출 보호
(*WAL).Compact는 공개 API다. 두 goroutine이 동시에 호출하면 같은 sealed 파일을 두 번 처리하고 매니페스트 갱신이 충돌한다.goroutine A: BeginCompaction() → sealed=[1,2,3,4] 스냅샷 goroutine B: BeginCompaction() → sealed=[1,2,3,4] 같은 스냅샷 A: 파일 read + checkpoint write B: 파일 read + checkpoint write A: CommitCompaction([1,2,3,4]) → 성공 B: CommitCompaction([1,2,3,4]) → 검증 실패 (sealed에 더 이상 없음) → B의 체크포인트는 매니페스트 밖이라 고아로 남음정확성은 무너지지 않지만 디스크 leak와 운영 잡음이 발생한다. 이를 해결하기 위해 WAL에
compacting bool필드를 두고 락 안에서 체크하도록 했다.func (w *WAL) beginCompaction(trigger int) (compactionPlan, bool) { w.mu.Lock() defer w.mu.Unlock() if w.closed || w.compacting { return compactionPlan{}, false } w.compacting = true return plan, true }압축 본체는
compacting플래그가 true인 상태에서 락 밖으로 나가서 진행된다. 그래서 Append/roll은 압축의 read 단계와 동시 진행 가능하고(자원이 다르니까), 충돌 구간은 commit 시점뿐이다.반응형