Object-Storage-Native WAL

“S3가 인터넷의 malloc이라면, conditional write는 그 위에 포인터 자료구조를 만드는 도구다.”


핵심 개념

Write-Ahead Log(WAL)는 전통적으로 로컬 디스크의 fsync에 durability를 의존해왔다. Kafka도, PostgreSQL도, RocksDB도 마찬가지다. 그래서 로그를 운영한다는 것은 곧 디스크 fleet을 운영한다는 것이었고, 디스크가 죽으므로 복제가 필요하고, 복제가 있으므로 리더 선출과 ISR과 rebalance가 필요했다.

Object-Storage-Native WAL은 이 계층을 통째로 들어낸다. durability를 오브젝트 스토리지(S3/GCS)에 위임하고, 로그 서버는 stateless 프로세스가 되어 오브젝트 스토리지를 겨누기만 한다. 이 접근을 실용적으로 만든 결정적 사건이 2024년 11월 S3의 conditional write 지원이다.

왜 지금 가능해졌나 — conditional write

S3에서 eventual → strong consistency 전환 이후 가장 실질적인 변화. writer가 두 가지를 명시할 수 있다:

헤더의미메모리 대응물
If-None-Match: *그 키가 존재하지 않을 때만 쓴다원자적 할당 + 초기화
If-Match: <etag>현재 내용이 정확히 이 digest일 때만 덮어쓴다compare-and-swap

이 두 프리미티브만 있으면 오브젝트 스토리지 위에 lock-free 자료구조를 직접 구성할 수 있다. 외부 조율 서비스(ZooKeeper, etcd, DynamoDB 락)가 필요 없다.

Jeff Bezos가 S3를 명세할 때 원했던 것이 **“인터넷을 위한 malloc”**이었다는 관점을 취하면 자연스럽다 — S3 키는 불투명한 가변 크기 메모리 영역을 가리키는 불투명한 포인터이고, conditional write는 그 포인터를 원자적으로 조작하는 명령어다.

30년 된 알고리즘의 재사용

Chroma의 wal3는 이 위에 교과서적인 lock-free 단일 연결 리스트 큐를 그대로 올렸다.

메모리 버전오브젝트 스토리지 버전
노드 할당 (malloc)fragment 파일을 if-none-match로 PUT
head 포인터 로드manifest 파일 GET
head CASmanifest를 if-match로 PUT
락 없이 동시 prepend락 없이 동시 append

“억지가 아니다 — 메모리에 적용되는 같은 알고리즘이 오브젝트 스토리지에 적용될 수 있다.”

확장성: 컬렉션당 하나의 로그

중앙 집중 로그(단일 Kafka 클러스터)는 새 고객을 받는 능력을 본질적으로 제한한다. 오브젝트 스토리지 기반 로그는 disjoint prefix에 로그를 다중화함으로써 이 제약을 벗어난다.

scalable commutativity rule — 두 연산이 데이터를 공유하지 않으면 확장 가능한 구현이 존재한다. 오브젝트 스토리지에서는 컬렉션이 서로 다른 prefix를 가질 때 이 규칙이 자연스럽게 구현으로 번역된다. 각 컬렉션이 자기 전용 로그 = 자기 read/write 처리량 스케일링 단위를 갖는다.

Kafka에서는 확장 단위가 topic인데 topic이 대체로 클러스터에 묶여 있어, 실무에서는 topic 마이그레이션·topic 관리·일반 확장성을 다루는 자명하지 않은 shim 레이어를 위에 얹게 된다.

검증 레이어 — setsum

오브젝트 스토리지 WAL의 진짜 위험은 durability가 아니라 garbage collection이다.

불변·append-only 스토리지 기반 시스템에서 데이터를 지우는 곳이 정의상 하나뿐이면, 모든 의도적 데이터 손실을 한 곳에 집중시킨 것이고 거기가 데이터 손실 버그가 사는 곳이다.

wal3의 답은 setsum — 결합적·교환적(order-agnostic) 체크섬:

일반 체크섬:  fn checksum(String)   -> Digest
setsum:      fn setsum([String]) -> Digest   +  digest에 대한 multi-set 연산

union(digest({A}), digest({B})) == digest({A, B}) 가 성립하되 계산 복잡도가 다르다. 집합의 addition/union/removal/difference를 digest에 대해 상수 시간에 수행한다.

로그의 단조성(쓰기는 항상 끝, GC는 항상 앞)과 결합하면 불변식이 나온다:

digest(GC된 prefix) ⊎ digest(라이브 데이터) == digest(전체 이력)

이 등식이 매 append·trim 후 O(1)로 유지되고, 깨지면 즉시 버그다. 실제로 idempotent GC 버그를 잡았다 — A, B, C에서 A, B를 두 번 GC하는 것은 허용되어야 하는데 setsum 구성이 그것을 깔끔히 탐지하지 못해 합이 맞지 않았다.

독립 구현 검증: 표준화된 setsum이 있으면 서로 다른 바이너리·릴리스·구현으로 같은 로그의 digest를 계산해 일치를 확인할 수 있다. 서로 다른 구현이 같은 버그를 보일 가능성은 극도로 낮다.

”시스템은 두 번 만들어져야 한다”

wal3가 남긴 가장 일반화 가능한 원칙:

최고의 시스템은 두 번 만들어져야 함을 인식하고 — 기능을 위해 한 번, 검증을 위해 한 번 — 그것을 설계 안에 인코딩한다.

프로그래머에게 시스템의 현실은 픽션이다. 볼 수 없고 관측성 메커니즘을 통해서만 본다. 지표·로그·trace는 시스템을 닮았지만 결정적으로 시스템 자체가 아니다. wal3는 사실상 세 시스템을 만들었다 — 데이터 로그, 그 digest, 그리고 둘의 공존을 보여주는 관측성. 셋이 얽혀서 steady state에서 시스템이 자기 검증적이 된다.

대가는 명확하다: 서로 얽힌 같은 코드의 두 병렬 구현을 개발해야 하고 한쪽 변경이 다른 쪽에 반영되어야 한다. 엔지니어링 규율이 필요하다.

언제 쓰고 언제 안 쓰는가

쓸 때

  • 테넌트/컬렉션 수가 확장 축일 때 — 로그 하나당 관리 비용이 거의 0이어야 하는 경우
  • 운영 인력이 얇을 때 — 관리할 디스크·persistent volume·리더 선출이 없다
  • durability를 하드웨어가 아니라 운영자 실수 방지로 프레이밍할 때 (11 nines보다 실수가 훨씬 가능성 높다). 오브젝트 스토리지에 베팅하고 delete 경로만 신중히 감사하면 된다
  • 인프라를 소유하고 싶을 때 — 코드를 채택하면 소유하게 된다. 소유하지 않으면 소유한 시스템과 그 위에 만드는 시스템 사이의 impedance mismatch를 우회하느라 타협한다

안 쓸 때 / 대안

  • 레이턴시가 마이크로초 단위로 중요할 때 — 오브젝트 스토리지 PUT은 수십~수백 ms
  • Kafka 프로토콜 호환이 요구사항일 때 — wal3는 prefix 아래 로그에 대한 read/append/trim만 지원한다. 이 단순함이 구현 면에서 유리하게 작용했지만, 그 제약이 유스케이스에 영향을 주는지 신중히 평가해야 한다
  • WarpStream — 오브젝트 스토리지 위의 disk-less Kafka 호환 구현으로 가장 직접 비교 가능하다. Kafka를 충실히 재현했지만 오픈소스가 아니다
  • CDC — SQL DB 위 CDC로 쓰기 이력을 roll up할 수 있지만 컬렉션당 독립 스트림이 필요하고, 대상 프리미티브에 CDC 시스템이 없으면 처음부터 만들어야 한다

비교

축KafkaWarpStreamwal3
durability 원천로컬 디스크 + 복제오브젝트 스토리지오브젝트 스토리지
확장 단위topic (클러스터에 묶임)topicprefix = 컬렉션
운영 부담디스크 fleet낮음낮음 (stateless)
오픈소스✅❌✅ (Rust)
API 표면큼Kafka 호환read/append/trim
내장 검증——setsum 불변식

연관 개념


Source: wal3 - A Write-Ahead Log for Chroma Built on Object Storage