
하루에 2천 년을 사는 데이터베이스 — TigerBeetle과 결정론적 시뮬레이션 테스트
은행 잔액이 틀리는 버그를 어떻게 재현할까. TigerBeetle은 시간·디스크·네트워크를 전부 가짜로 만들고, 정수 하나로 우주 전체를 되살리는 길을 택했다. 1,024코어에서 24시간 돌아가는 비행 시뮬레이터, 함수당 정확히 2개의 단언, 그리고 시작 후 malloc을 부르지 않는 데이터베이스 — 15만 줄의 Zig 코드를 읽고 정리했다.

은행 잔액이 틀리는 버그를 어떻게 재현할까. TigerBeetle은 시간·디스크·네트워크를 전부 가짜로 만들고, 정수 하나로 우주 전체를 되살리는 길을 택했다. 1,024코어에서 24시간 돌아가는 비행 시뮬레이터, 함수당 정확히 2개의 단언, 그리고 시작 후 malloc을 부르지 않는 데이터베이스 — 15만 줄의 Zig 코드를 읽고 정리했다.
새벽 3시에 전화가 옵니다. 고객 잔액이 맞지 않는다고.
로그를 봅니다. 어제 오후 2시 17분, 이체 하나가 성공으로 기록됐는데 상대 계정에는 들어오지 않았습니다. 그 시각에 무슨 일이 있었나 — 네트워크 담당자는 스위치 한 대가 30초쯤 이상했다고 하고, 스토리지 담당자는 그날 디스크 하나가 재시도 로그를 남겼다고 합니다. 애플리케이션 로그에는 재시도가 세 번 찍혀 있습니다.
이제 이걸 재현해 보세요.
스위치를 정확히 그 30초만큼 다시 이상하게 만들고, 디스크가 정확히 그 섹터에서만 느려지게 하고, 세 노드의 시계가 정확히 그만큼 어긋난 상태에서, 재시도가 정확히 그 타이밍에 겹치도록.
못 합니다. 그래서 이런 버그는 "간헐적 이슈"라는 딱지가 붙은 채로 티켓 시스템 어딘가에서 늙어갑니다. 그리고 6개월 뒤에 더 큰 규모로 다시 나타납니다.
분산 시스템을 다뤄본 사람이라면 이 무력감을 압니다. 문제는 버그를 못 고치는 게 아닙니다. 버그를 볼 수가 없다는 것입니다.
2020년, 한 팀이 이 문제를 정면으로 다르게 풀기 시작했습니다.
버그를 재현할 수 없는 이유는 세상이 재현 불가능하기 때문입니다. 시계는 제멋대로 흐르고, 디스크는 기분에 따라 느려지고, 패킷은 아무 때나 사라집니다. 그렇다면 — 세상을 통째로 가짜로 만들면 되지 않을까?
시간은 정수 카운터로, 디스크는 메모리 배열로, 네트워크는 큐로. 난수는 씨앗 하나에서 뽑고. 그러면 프로그램이 지나가는 모든 경로가 그 씨앗 하나로 결정됩니다. 버그를 발견하면 정수 하나만 적어두면 됩니다. 언제든, 누구의 노트북에서든, 그 우주가 정확히 되살아납니다.
이 물건의 이름은 TigerBeetle입니다. 금융 거래 전용 분산 데이터베이스이고, 15만 줄의 Zig 코드가 Apache 2.0으로 공개돼 있습니다.
그리고 이 글에서 다룰 진짜 주인공은 데이터베이스가 아니라 그것을 검증하는 방법입니다.
이 글은 저장소의 문서 60여 편과 소스 코드를 직접 읽고 정리했습니다. 왜 은행이 Postgres로 안 되는지, 40년 된 합의 알고리즘이 어떻게 되살아났는지, 함수 하나당 단언 두 개라는 규칙이 무엇을 만들어내는지 — 그리고 AI가 코드를 쏟아내는 2026년에 왜 이 이야기가 갑자기 중요해졌는지까지.
일반 데이터베이스로 계좌 이체를 구현해 봅시다.
2번에서 5번까지, 락이 네트워크 왕복을 건너서 유지됩니다. 애플리케이션 서버가 잠깐 GC를 돌면 그동안 그 계정은 아무도 못 건드립니다.
읽기가 대부분이고 트랜잭션끼리 거의 안 부딪히는 워크로드라면 이 구조가 훌륭합니다. 그런데 금융은 정반대입니다.
비즈니스 거래에는 항상 둘 이상의 계정이 관여합니다. 그리고 그중 하나는 보통 정해져 있습니다.
결제 한 건을 생각해 보세요. 고객 지갑에서 돈이 나가고, 가맹점 계정으로 들어가고, 수수료가 회사 수익 계정으로 빠지고, 세금이 예수금 계정으로 갑니다. 회사 수익 계정은 모든 거래에 등장합니다.
계정 분포는 파레토를 따릅니다 — 소수의 계정이 거래 대부분을 차지합니다. 그리고 그 소수의 계정에 락이 걸립니다.
여기서 흔히 나오는 처방이 샤딩입니다. 서버를 늘려 계정을 나눠 담자. 그런데 금융 거래는 샤딩이 유난히 안 됩니다.
| 샤딩으로 풀려던 것 | 실제로 벌어지는 일 |
|---|---|
| 계정을 서버별로 나눈다 | 대부분의 계정은 깔끔하게 나뉘지 않는다 |
| 각 서버가 독립적으로 처리한다 | 샤드를 넘나드는 이체는 분산 트랜잭션이 되어 더 느려진다 |
| 부하를 고르게 분산한다 | 핫 계정이 있는 샤드가 그대로 병목이 된다 — 심지어 더 나빠진다 |
TigerBeetle 문서는 프랭크 맥셰리의 Scalability! But at what COST?를 인용하며 이렇게 정리합니다 — 금융 거래 처리는 "COST가 무한대인 문제"입니다. 병렬화로 얻는 이득보다 동기화 비용이 항상 큽니다.
참고: COST는 "단일 스레드 구현 대비 손익분기점이 되는 코어 수"를 뜻합니다. COST가 무한대라는 건, 코어를 아무리 늘려도 잘 만든 단일 스레드 구현을 못 이긴다는 말입니다.
TigerBeetle의 답은 상식과 정반대입니다.
엄격 직렬화(strict serializability) 를 공짜로 얻는다는 부분이 특히 재미있습니다. 보통 데이터베이스는 격리 수준을 낮춰서 성능을 삽니다 — READ COMMITTED, REPEATABLE READ 같은 것들이죠. 그리고 그 선택을 잘못 이해한 애플리케이션이 조용히 데이터를 잃습니다.
TigerBeetle에는 격리 수준 설정이 없습니다. 하나뿐이고 그게 가장 엄격한 것입니다. 전부 한 코어에서 한 줄로 실행되니까요.
그럼 이 데이터베이스의 스키마는 어떻게 생겼을까요. CREATE TABLE이 없습니다. 테이블도 없습니다.
계정(Account)과 이체(Transfer), 둘뿐입니다.
이건 새로 발명한 게 아닙니다. 1494년 베네치아의 수학자 루카 파치올리가 정리했고, 실무에서는 그보다 200년 앞서 쓰이던 복식부기입니다.
핵심 통찰은 단순합니다. 모든 이체는 하나 이상의 계정에서 하나 이상의 계정으로 가치가 이동한 기록이다. 돈은 무에서 생기지 않고 무로 사라지지 않는다.
TigerBeetle 문서는 이 스키마가 OLTP가 기록해야 하는 것을 완벽하게 덮는다고 주장합니다.
| 비즈니스 거래가 기록해야 할 것 | 복식부기의 대응 | TigerBeetle 필드 |
|---|---|---|
| 누가 (Who) | 차변 계정 / 대변 계정 | debit_account_id, credit_account_id |
| 무엇을 (What) | 원장 = 자산 종류 | ledger |
| 언제 (When) | 기표 시각 | timestamp (+ user_data_64에 실제 발생 시각) |
| 어디서 (Where) | 거래 발생지 | user_data_32 |
| 왜 (Why) | 계정과목 코드 | code |
| 얼마나 (How Much) | 금액 | amount |
직접 만져보는 게 빠릅니다. 이체를 만들어 보세요 — 그리고 잔액보다 큰 금액도 한번 보내 보세요.
"우리 서비스는 그냥 거래 테이블 하나면 되는데?"라고 생각하기 쉽습니다. 그런데 규모가 커지면 거의 예외 없이 복식부기로 돌아옵니다. TigerBeetle 문서가 드는 사례들:
| 회사 | 무슨 일이 있었나 |
|---|---|
| Uber | 2018년부터 2년, 엔지니어 40명을 투입해 수납·지급 결제 플랫폼을 복식부기 기반으로 이전 |
| Airbnb | 2012~2016년 MySQL 기반 데이터 파이프라인으로 거래를 기록했으나 너무 복잡하고 느려져, 복식부기 기반 재무 시스템을 새로 구축 |
| Stripe | 모든 결제를 복식부기와 불변 이벤트 로그에 기록하는 내부 시스템(Ledger)을 운영 |
여기서 두 번째 원칙이 나옵니다 — 불변성(immutability).
일반 데이터베이스는 UPDATE와 DELETE를 허용합니다. 회계에서는 이게 재앙입니다. 잘못된 이체를 고치는 방법은 그 행을 수정하는 게 아니라 반대 방향 이체를 하나 더 기록하는 것입니다. 그래야 감사 추적이 남습니다.
TigerBeetle의 이체는 처음부터 불변입니다. 잘못 만든 쿼리가 실수로 데이터를 지울 가능성 자체가 없습니다.
개인정보는 어떻게 지우나? 흥미로운 답이 문서에 있습니다.
user_data_128에 애플리케이션의user_id매핑을 저장했다면, 그 매핑을 지우면 됩니다. 그러면 남은 계정과 이체는 실제 사람과 연결할 수 없는 숫자 더미가 되어, 원장의 완전성과 잊힐 권리를 동시에 만족합니다.
여기가 이 글의 본체입니다. 천천히 갑니다.
먼저 전체 흐름을 잡고 시작하겠습니다. 블록을 눌러보세요.
가장 먼저 눈에 띄는 것은 데이터 구조의 크기입니다.
comptime {
assert(stdx.no_padding(Transfer)); <span style="color:#4ade80">// 패딩 한 바이트도 없다</span>
assert(@sizeOf(Transfer) == <span style="color:#fbbf24">128</span>); <span style="color:#4ade80">// 정확히 캐시 라인 2개</span>
assert(@alignOf(Transfer) == 16);
}
};
comptime 블록을 보세요. 컴파일 타임에 실행되는 단언입니다. 누군가 필드를 하나 추가하면 프로그램이 실행되기도 전에 빌드가 깨집니다.
128바이트인 이유는 x86 캐시 라인이 64바이트이기 때문입니다. 이체 하나를 처리할 때 필요한 모든 데이터가 정확히 두 번의 캐시 접근에 들어옵니다. 메모리에 흩어져 있으면 캐시 미스가 나고, 캐시 미스 한 번은 디스크 fsync만큼 비싸질 수 있습니다 — 충분히 자주 일어난다면.
계정도 정확히 128바이트입니다. 그리고 계정은 잔액을 네 개로 나눠 들고 있습니다.
| 필드 | 의미 |
|---|---|
debits_posted | 확정되어 나간 돈 |
credits_posted | 확정되어 들어온 돈 |
debits_pending | 보류 중(2단계 이체의 1단계)으로 잡혀 있는 돈 |
credits_pending | 보류 중으로 들어올 예정인 돈 |
pending이 따로 있다는 게 중요합니다. 카드 승인은 났지만 매입은 아직인 상태, 송금은 걸었지만 상대가 아직 안 받은 상태 — 금융에서 너무나 흔한 이 중간 상태를 애플리케이션이 아니라 데이터베이스가 알고 있습니다.
두 번째로 놀라운 것은 메모리 관리입니다. TigerBeetle은 시작한 뒤에는 메모리를 할당하지 않습니다.
정확히 무슨 뜻인지 문서가 조심스럽게 정의합니다. 임베디드 시스템의 진짜 정적 할당(전역 변수)도 아니고, 아레나 할당도 아닙니다.
| 방식 | 동작 | 한계 |
|---|---|---|
| 일반 동적 할당 | 필요할 때마다 malloc | 단편화, OOM, use-after-free, 지연 시간 튐 |
| 아레나 할당 | 고정 크기 덩어리를 미리 잡고 거기서 잘라 쓴다 | 사용량은 제한되지만 충분한지는 보장 안 됨 — 넘치면 OOM |
| TigerBeetle | 시작 시 모든 객체 종류의 최악 상한을 계산해 정확히 그만큼 할당하고 닫는다 | 상한을 넘길 수 없다 — 애초에 모든 것에 한계가 있으므로 |
이걸 강제하는 코드가 놀랍도록 짧습니다. 전체 83줄입니다.
fn alloc(ctx: anyopaque, len: usize, ...) ?[]u8 { const self: *StaticAllocator = @ptrCast(@alignCast(ctx)); assert(self.state == .init); // ← 운영 중 할당 시도는 여기서 즉사 return self.parent_allocator.rawAlloc(len, ptr_align, ret_addr); }
관례가 아니라 기계가 강제하는 불변식입니다. 코드 리뷰에서 "여기 할당하면 안 되는데요"라고 말할 필요가 없습니다. 프로그램이 그 자리에서 죽습니다.
그런데 진짜 이득은 성능이 아니라고 문서는 말합니다.
정적 할당은 모든 것에 한계가 있음을 보장하는 강제 장치이며, 그 한계들의 자연스러운 귀결이다.
이게 무슨 뜻일까요. 한계를 알면 역압(backpressure) 처리 코드를 따로 쓸 필요가 없어집니다. 무한히 자랄 수 있는 것이 애초에 없으니까요. 큐가 넘칠 걱정도, 동시 작업이 폭증할 걱정도 없습니다. 각 컴포넌트가 서로의 한계를 존중하는 것만으로 시스템 전체의 역압이 저절로 생깁니다.
비동기 작업도 마찬가지입니다. 보통 비동기 태스크는 힙에 클로저를 올려놓고 이벤트 루프에 등록합니다(Box<dyn Future>). TigerBeetle은 힙 할당을 못 하므로, 모든 비동기 작업이 그것을 소유한 컴포넌트의 필드로 미리 박혀 있습니다. 그래서 동시 작업 수에 자연스러운 상한이 생깁니다.
성능의 급소는 여기입니다. 복식부기 규칙을 적용하는 commit 함수는 완전히 동기적이고, 저장소를 전혀 읽지 않습니다.
배치 안의 이체를 실제로 실행하지 않고도 어떤 계정이 필요한지는 미리 알 수 있습니다. 그래서 읽기를 전부 앞당겨 병렬로 처리합니다. 실행은 순차여야 하지만, 읽기가 순차일 이유는 없습니다.
그리고 실행 함수 안에는 인상적인 주석이 하나 있습니다.
<span style="color:#4ade80">// After this point, the transfer must succeed.</span>
defer assert(self.commit_timestamp == timestamp_actual);
self.forest.grooves.transfers.insert(&.{ ... }); <span style="color:#94a3b8">// 이제부터 상태 변경</span>
검증이 전부 끝난 뒤에야 상태를 건드립니다. 그 경계에 주석과 단언이 함께 서 있습니다. 절반만 적용된 이체가 존재할 수 없는 구조입니다.
실패도 예외가 아니라 값으로 돌아옵니다. exceeds_credits, overflows_debits_posted 같은 열거형이죠. 8,190건짜리 배치에서 3번째만 실패하면 나머지는 정상 처리되고 결과 배열 3번 자리에 코드가 담깁니다.
여기서부터가 이 데이터베이스의 진짜 성격이 드러나는 부분입니다.
대부분의 데이터베이스는 이렇게 가정합니다 — 디스크는 고장 나지 않는다. 고장 나더라도 최소한 에러 코드로 정직하게 알려준다. 대체로 합리적인 가정이고, 대부분의 경우 맞습니다.
TigerBeetle은 디스크가 반드시 고장 난다고 가정합니다. 그리고 문서에 실제 논문 수치를 붙여둡니다.
| 고장 유형 | SSD 연간 발생률 | 엔터프라이즈 HDD |
|---|---|---|
| 조용한 데이터 손상 에러 없이 썩은 데이터를 돌려줌 | 0.031% | 1.4% |
| 오배치 I/O 멀쩡한 데이터를 엉뚱한 위치에 씀 | 0.023% | 0.466% (니어라인) |
| 그레이 실패 에러 없이 갑자기 극단적으로 느려짐 | — | 에러 코드가 없어 통계조차 어렵다 |
0.031%가 작아 보이나요. 디스크 1,000개를 3년 굴리면 기댓값이 한 자릿수입니다. 그리고 그게 하필 당신의 원장 블록일 수 있습니다.
두 번째 줄이 특히 고약합니다. 오배치 쓰기는 데이터 자체는 멀쩡합니다. 체크섬을 블록 안에 넣어두는 방식으로는 절대 못 잡습니다 — 데이터도 체크섬도 자기들끼리는 일관되니까요. 다만 있어야 할 자리가 아닌 곳에 있을 뿐입니다.
TigerBeetle의 대응:
그 결과 문서에 이런 문장이 등장합니다.
저장소에서 데이터를 읽는 함수에는 오류 조건이 없다. 요청한 체크섬의 블록을 언제나 받는다. 다만 그 블록이 다른 리플리카에서 투명하게 읽혀올 수 있을 뿐이다.
읽기 실패가 타입 시스템에서 사라졌습니다. 에러 처리 코드가 없으니 에러 처리 버그도 없습니다. 이건 사소한 성취가 아닙니다 — 분산 시스템의 치명적 장애 중 92%가 명시적으로 신호된 비치명적 오류를 잘못 처리해서 발생했다는 OSDI 2014 분석이 있고, TigerStyle은 이 논문을 직접 인용합니다.
왜 페이지 캐시를 안 쓰나? TigerBeetle은 Direct I/O로 디스크에 직접 씁니다. 성능 때문이 아니라 정확성 때문입니다.
fsync가 실패했을 때 커널이 더티 페이지를 조용히 버려서 애플리케이션이 성공으로 오해하게 되는 문제(fsyncgate)를 안전하게 다룰 방법이 없기 때문입니다. 덤으로, 파일시스템 없이 블록 장치에 바로 쓸 수도 있게 됩니다.
합의 알고리즘을 아는 사람이라면 이 부분이 이상하게 보일 겁니다. 보통 클러스터는 과반수를 명확히 하려고 3대나 5대 홀수를 씁니다. TigerBeetle은 6대를 권장합니다.
비밀은 유연한 쿼럼(flexible quorums) 입니다. 하이디 하워드의 Flexible Paxos(2016)가 밝힌 성질을 씁니다.
직접 노드를 죽여 보세요.
핵심은 이것입니다. 커밋 확인은 3대에서만 받고, 프라이머리를 교체할 때는 4대를 모읍니다. 3 + 4 = 7 > 6이므로 두 집합은 반드시 한 대 이상 겹칩니다. 그래서 새 프라이머리는 이미 커밋된 모든 것을 알게 됩니다.
이 성질이 코드에서 어떻게 표현되는지 보세요.
assert(quorum_view_change + quorum_replication > replica_count); // ↑ 교집합 성질을 주석이 아니라 실행 중 단언으로 박아둔다
정리 증명이 주석에 적혀 있는 게 아니라 런타임 단언으로 코드에 박혀 있습니다. 이 함수는 전체 60줄인데 그중 15줄이 단언입니다.
실용적 효과도 좋습니다. 6대 중 3대만 살아 있으면(프라이머리 포함) 클러스터가 계속 돕니다. 3대가 동시에 죽어도 50% 확률로 살아남고, 하나씩 순차로 죽으면 확률은 더 높습니다. 그리고 문서는 클라우드 3곳에 2대씩 배치를 권합니다 — 그러면 클라우드 하나가 통째로 죽어도 클러스터가 삽니다.
여기서 미묘하지만 중요한 문제가 하나 나옵니다.
프라이머리가 클라이언트 요청을 받아 프리페어로 만들고 자기 WAL에 적었습니다. 그런데 다른 리플리카에 뿌리기 전에 죽었습니다. 클러스터는 새 프라이머리를 뽑습니다. 그 사이 원래 프라이머리의 WAL에서 그 프리페어가 손상됩니다.
이제 딜레마입니다.
| 선택 | 위험 |
|---|---|
| 손상된 항목을 버린다 | 만약 그게 이미 커밋된 것이었다면 확정된 거래를 잃는다 |
| 손상된 항목을 기다린다 | 그게 아무 데도 없다면 클러스터가 영원히 멈춘다 |
일반적인 합의 구현은 대부분 여기서 데이터를 잃거나 가용성을 잃습니다. 저장소 고장은 저장소 계층 안에서 캡슐화될 수 없고 합의 알고리즘의 협조가 필요하기 때문입니다.
TigerBeetle의 답은 NACK 프로토콜입니다. 뷰 변경 중에 각 리플리카는 "나는 이 프리페어를 한 번도 받은 적 없다"고 적극적으로 선언할 수 있습니다. 6대 중 4대가 NACK하면, 그 프리페어는 복제 쿼럼에 도달한 적이 없다는 것이 증명됩니다. 그러면 손상되었더라도 안전하게 버릴 수 있습니다.
이 아이디어는 Protocol-Aware Recovery(FAST 2018) 논문에서 왔습니다. 논문의 부제가 이 절의 요약입니다 — 체크섬 불일치를 만났을 때 저널을 잘라내는 것이 왜 재앙인가.
마지막으로 아키텍처에서 가장 조용하지만 결정적인 선택.
상태 기계는 운영체제에 시간을 물어볼 수 없습니다.
이체에는 만료 시각이 있어서 시계가 필요합니다. 그런데 상태 기계가 clock_gettime()을 부르는 순간 결정론이 깨집니다 — 같은 입력에 같은 출력이 안 나오고, 리플리카마다 다른 상태로 갈라집니다.
그래서 프라이머리가 요청을 프리페어로 바꿀 때 타임스탬프를 함께 주입합니다. 상태 기계는 주어진 시간만 봅니다.
그럼 프라이머리의 시계는 믿을 수 있을까요? 여기에도 대비가 있습니다.
덤으로, 이 고품질 시간이 내부 구현에서도 값을 합니다. TigerBeetle의 모든 객체는 전역 고유한 u64 생성 타임스탬프를 가지고, 그게 합성 기본 키 역할을 합니다.
이제 이 글의 주인공입니다.
지금까지 본 아키텍처는 훌륭합니다. 그런데 훌륭한 설계와 그 설계를 올바르게 구현한 코드는 전혀 다른 문제입니다. 형식 증명이 있는 알고리즘도 구현이 틀리면 아무 소용이 없습니다.
VOPR — The Viewstamped Operation Replicator. 이름은 1983년 영화 워게임에 나오는 슈퍼컴퓨터 WOPR에서 따왔습니다. 끊임없이 시나리오를 시뮬레이션하며 배우던 그 기계입니다.
VOPR가 하는 일은 단순합니다. 비결정적인 모든 것을 가짜로 바꿉니다.
| 실제 세계 | 시뮬레이션에서는 | 그래서 가능해지는 것 |
|---|---|---|
| 시계 | 정수 카운터 (ticks × resolution) | 시간을 CPU가 도는 속도만큼 빨리 감기 |
| 디스크 | 메모리 배열 + 결함 주입기 | 비트 부패·오배치 쓰기를 원하는 확률로 |
| 네트워크 | 지연 큐 + 손실·재전송·분단 | 파티션을 원하는 순간에 정확히 |
| 난수 | 씨앗에서 뽑는 결정론적 PRNG | 전체 실행을 정수 하나로 되살리기 |
| 여러 대의 서버 | 한 스레드 안의 여러 객체 | 클러스터 전체를 한 프로세스에서 |
시간 구현이 특히 간결합니다. 전체 파일이 99줄입니다.
fn tick(context: *anyopaque) void { self.ticks += 1; // 시간이 흐른다 = 정수 증가 }
그리고 리플리카마다 시계가 어긋나는 방식을 네 가지로 모델링합니다 — 선형 드리프트, 주기적 흔들림, 계단식 점프(누가 시계를 갑자기 맞췄을 때), 그리고 잡음이 섞인 비이상적 드리프트.
여기에 1분의 시뮬레이션 시간이 며칠의 실제 테스트에 해당한다는 문서의 주장이 나옵니다. 디스크를 기다리지 않고, 네트워크를 기다리지 않고, 타임아웃을 기다리지 않으니까요. 기다림이 전부 정수 덧셈이 됩니다.
VOPR의 진짜 영리한 부분은 결함 파라미터마저 씨앗에서 뽑는다는 것입니다.
씨앗을 바꿔 보세요. 완전히 다른 세계가 만들어집니다.
이 기법의 이름은 스웜 테스팅(swarm testing) 입니다. 유타대 연구진의 2012년 논문에서 온 아이디어로, 하나의 잘 조율된 설정으로 계속 돌리는 대신 매번 무작위로 다른 설정 조합을 만들어 돌리는 방식입니다.
왜 이게 더 나을까요. 고정된 설정은 특정 코드 경로를 영원히 안 밟습니다. 예를 들어 패킷 손실률을 항상 5%로 고정하면, 손실률 28%에서만 나타나는 타임아웃 폭주 버그는 영원히 안 보입니다. 리플리카를 항상 6대로 두면 1대짜리 클러스터의 엣지 케이스를 못 봅니다.
VOPR의 options_swarm()이 매 씨앗마다 뽑는 것들:
여기에 파티션 모드 4종, 시계 왜곡 4종, 크래시·재시작·일시정지 확률, 심지어 리플리카마다 서로 다른 버전의 코드를 돌리는 롤링 업그레이드 시나리오까지 섞입니다.
결함 주입에는 세밀한 배려도 있습니다. 저장소 결함 모델의 주석을 보면:
오배치 쓰기 결함은 Storage당 최대 하나만 허용한다. 단순함을 위해서이고, 이중 결함은 우리 결함 모델이 다루는 범위가 아니기 때문이다. 이는 물리 디스크의 현실과도 부합한다 — 오배치 결함은 비트 부패보다 한 자릿수 드물고, 비트 부패는 잠재 섹터 오류보다 한 자릿수 드물다.
무엇을 테스트하지 않는지를 명시적으로 적어둔 것입니다. 이런 정직함이 오히려 신뢰를 만듭니다.
시뮬레이션이 아무리 많은 상태를 훑어도, 잘못된 상태를 알아채지 못하면 소용없습니다. 그래서 시뮬레이션과 짝을 이루는 것이 단언(assertion)입니다.
저장소 전체를 세어봤습니다.
| 측정 항목 | 값 |
|---|---|
| 전체 Zig 코드 | 154,770줄 |
| 함수 정의 | 4,085개 |
assert(...) 호출 | 8,180개 |
| 함수당 평균 | 정확히 2.00개 |
| 컴파일 타임 단언 | 224개 |
가장 큰 파일 replica.zig | 12,413줄 · 함수 268개 · 단언 1,582개 (함수당 5.9개) |
TigerStyle의 규칙이 "함수당 평균 최소 두 개" 인데, 실측값이 정확히 2.00입니다. 규칙이 문서에만 있는 게 아니라는 뜻입니다.
그런데 진짜 특이한 건 이 단언들을 프로덕션에서도 켜둔다는 점입니다. 보통은 릴리스 빌드에서 단언을 끕니다 — 성능 때문에, 그리고 고객 앞에서 크래시하기 싫어서.
TigerStyle의 논리는 정반대입니다.
단언은 프로그래머의 실수를 탐지한다. 운영상의 오류와 달리 단언 실패는 예상 밖의 일이다. 손상된 코드를 다루는 유일하게 올바른 방법은 크래시하는 것이다. 단언은 치명적인 정확성 버그를 가용성 버그로 강등시킨다.
잔액을 틀리게 계산하며 계속 도는 것보다 멈추는 게 낫다는 것입니다. 금융 원장이라면 동의할 수밖에 없습니다.
단언 작성법에도 규칙이 있습니다. 몇 가지가 특히 실용적입니다.
| TigerStyle 규칙 | 이유 |
|---|---|
| 단언을 짝지어라 | 같은 성질을 서로 다른 두 지점에서 검사한다. 예: 디스크에 쓰기 직전과 읽은 직후. |
| 복합 단언을 쪼개라 | assert(a and b)보다 assert(a); assert(b);. 실패했을 때 어느 쪽인지 알 수 있다. |
| 양의 공간과 음의 공간을 모두 단언하라 | 기대하는 것뿐 아니라 기대하지 않는 것도 단언한다. 흥미로운 버그는 그 경계에서 나온다. |
| 컴파일 타임 상수의 관계를 단언하라 | 프로그램이 실행되기 전에 설계 정합성을 검사할 수 있다. 가장 값싼 검증이다. |
마지막으로, TigerStyle이 스스로 경고하는 대목이 인상적입니다.
단언은 안전망이지, 인간의 이해를 대신하는 것이 아니다. 시뮬레이션 테스트가 있으면 퍼저를 믿고 싶은 유혹이 생긴다. 하지만 퍼저는 버그의 존재만 증명할 수 있을 뿐 부재는 증명하지 못한다.
그래서 순서를 못 박습니다 — ① 먼저 코드에 대한 정확한 심상 모델을 세우고, ② 그 이해를 단언으로 인코딩하고, ③ 리뷰어에게 설명할 코드와 주석을 쓰고, ④ VOPR는 나와 리뷰어의 이해에 있는 버그를 잡는 최후의 방어선으로 쓴다.
VOPR는 개발자가 가끔 돌리는 도구가 아닙니다. 계속 돕니다.
src/scripts/cfo.zig(Continuous Fuzzing Orchestrator)의 헤더 주석에 운영 방식이 적혀 있습니다.
문서에 따르면 이 함대는 1,024코어에서 24시간 내내 최신 main 브랜치를 퍼징합니다. 실패한 씨앗은 devhubdb 저장소에 기록되고, 병합 규칙이 촘촘합니다 — 실패한 씨앗을 성공한 것보다 우선 보관하고, 더 빨리 실패한 씨앗을 선호하고(재현이 빠르므로), 커밋별로 상한을 둡니다.
여기서 이 글의 제목이 나옵니다. 저장소 문서에 흩어져 있는 두 숫자를 곱해 보면 됩니다.
물론 이 곱셈은 어림값입니다. 실제로는 각 코어가 서로 다른 씨앗을 돌리므로 "한 클러스터의 2천 년"이 아니라 "여러 클러스터의 짧은 생애를 합쳐 2천 년"에 가깝고, 그 편이 사실 더 유용합니다 — 같은 우주를 오래 사는 것보다 다른 우주를 많이 사는 쪽이 버그를 더 잘 찾으니까요. 앞 절의 스웜 테스팅이 정확히 그 이야기였습니다.
그리고 이 시뮬레이터는 sim.tigerbeetle.com에서 브라우저 게임으로도 공개돼 있습니다. 직접 번개와 얼음과 망치를 클러스터에 던져볼 수 있습니다.
결정론을 요구하면 얻는 것이 재현성만은 아닙니다. 문서가 나열하는 파급 효과들:
세 번째가 특히 실무적입니다. LSM 트리를 쓰는 데이터베이스의 고질병이 컴팩션 스톨입니다 — 백그라운드 병합이 몰리면 지연 시간이 갑자기 튑니다. 컴팩션을 결정론적으로 스케줄링하면 그 일이 매번 같은 자리에서, 같은 양만큼 일어납니다.
여기까지만 쓰면 홍보물입니다. 이 방법론의 한계를 보여준 사건이 실제로 있었습니다.
2025년 6월, 카일 킹스버리의 Jepsen이 TigerBeetle 0.16.11을 분석했습니다. Jepsen은 분산 시스템의 일관성을 실제 프로세스·실제 네트워크로 두들겨보는 독립 검증으로 유명합니다.
결과는 대체로 좋았습니다. 안전성 문제는 두 건이었고, 디스크 손상에 대한 회복력은 "탁월(exceptional)"하다고 평가받았습니다 — 모든 리플리카의 파일을 손상시켜도 견뎠습니다.
그런데 그 두 건 중 하나가 흥미롭습니다. VOPR가 왜 이걸 못 잡았을까요?
TigerBeetle 팀이 직접 쓴 사후 분석의 요지는 이렇습니다.
여기서 배울 것이 있습니다. 결정론적 시뮬레이션은 상태 공간을 탐색하는 도구지, 상태 공간을 정의하는 도구가 아닙니다. 워크로드 생성기가 특정 조합을 만들 수 없게 되어 있으면, 그 안에 있는 버그는 몇 년을 돌려도 안 나옵니다.
그리고 이 사각지대는 커버리지 지표로도 안 보입니다. 해당 코드는 실행됐습니다. 다만 그 코드를 트리거하는 조건이 한 번도 만들어지지 않았을 뿐입니다.
Jepsen이 찾은 나머지 것들도 함께 적어둡니다. 0.16.11부터 0.16.30까지 테스트하면서 크래시 7건, 단일 노드 장애 시 지연 상승, 영원히 재시도되는 요청을 발견했습니다. 0.16.45 기준으로 무한 재시도를 제외한 모든 문제가 수정됐습니다.
| 기법 | 강한 곳 | 약한 곳 |
|---|---|---|
| 결정론적 시뮬레이션 | 재현 가능성, 시간 가속, 결함 조합 폭발, 실제 구현 검증 | 워크로드 생성기가 못 만드는 상황은 영원히 안 보임 |
| 형식 검증 (TLA+ 등) | 알고리즘 자체의 정확성 증명 | 코드가 그 알고리즘을 제대로 구현했는지, 가정이 맞는지는 못 봄 |
| 외부 검증 (Jepsen) | 내부 가정 바깥에서 봄 — 사각지대를 찾는 데 유리 | 느리고 비쌈, 재현이 어려움 |
TigerBeetle 문서가 형식 검증에 대해 쓴 문장도 균형이 잘 잡혀 있습니다.
TLA 같은 도구는 알고리즘을 디버깅하는 데 매우 값지다. 하지만 당신의 코드가 그 알고리즘을 올바르게 구현했는지 확인하거나 밑바탕 가정을 검증하는 데는 별 도움이 되지 않는다.
세 기법은 경쟁 관계가 아니라 서로의 사각지대를 덮는 관계입니다.
이 글을 여기까지 읽으면서 아마 이런 생각을 하셨을 겁니다 — 멋있긴 한데, 우리 팀이 감당할 수 있는 방식은 아닌데.
맞습니다. 17명짜리 회사가 3만 시간 넘는 GPU… 아니, 1,024코어를 24시간 태우고, 함수마다 단언 두 개를 붙이고, 의존성을 거의 쓰지 않고 스택을 직접 만드는 방식은 대부분의 팀에 과합니다.
그런데 2026년에 무언가가 바뀌었습니다.
코드를 쓰는 비용이 붕괴했습니다. 에이전트가 하루에 수천 줄을 만들어냅니다. 리뷰 대기열이 밀리고, 테스트는 통과하는데 왠지 불안하고, "이거 진짜 맞나?"라는 질문에 답할 시간은 줄었습니다.
병목이 생성에서 검증으로 옮겨간 것입니다.
그리고 이 상황에서 TigerBeetle의 방법론이 갑자기 다르게 읽힙니다.
| TigerBeetle의 선택 | 원래 의도 | 2026년에 읽히는 의미 |
|---|---|---|
| 결정론적 재현 | 버그를 디버깅 가능하게 | 에이전트가 만든 변경을 같은 우주에서 반복 검증. 실패 씨앗이 회귀 테스트가 된다 |
| 함수당 단언 2개 | 프로그래머 실수 탐지 | 사람이 읽지 못한 코드에서 기계가 계약 위반을 잡는다. 리뷰의 부분 대체재 |
| 모든 것에 한계 | 역압·지연 예측 가능성 | 생성된 코드가 무한히 자라는 자원을 만들 수 없다 |
| 함수 70줄 제한 | 사람이 화면에서 읽을 수 있게 | 컨텍스트 윈도우에 통째로 들어가고, 변경 단위가 작아진다 |
| 의존성 최소화 | 장기 프로젝트의 통제권 | 공급망 표면이 작고, 전체를 한 저장소에서 검증 가능 |
단언은 특히 다시 볼 만합니다. 사람이 코드를 다 읽지 못하는 상황에서, 단언은 "이 함수가 지켜야 하는 계약"을 기계가 읽을 수 있는 형태로 남긴 것입니다. 코드가 누구 손에서 나왔든, 계약을 어기면 그 자리에서 멈춥니다.
이 방법론 자체도 더 이상 TigerBeetle만의 것이 아닙니다.
전부 따라 할 필요는 없습니다. 부분 도입이 가능한 것들을 정리했습니다.
| 바로 해볼 만한 것 | 비용 | 효과 |
|---|---|---|
| 시간을 주입 가능하게 만들기 코드에서 now()를 직접 부르지 않고 인터페이스로 받기 | 낮음 | 테스트에서 시간을 마음대로 돌릴 수 있게 된다. 대부분의 DST가 여기서 시작한다 |
| 난수를 씨앗으로 통일 전역 난수 대신 주입받은 PRNG 쓰기 | 낮음 | 실패한 테스트를 씨앗 번호로 재현 가능 |
| 실패 씨앗을 회귀 테스트로 승격 | 낮음 | 한 번 잡은 버그가 다시 안 돌아온다 |
| 핵심 불변식에 단언 달기 전부 말고, 돈·재고·권한처럼 틀리면 안 되는 곳부터 | 중간 | 조용한 데이터 오염이 시끄러운 실패로 바뀐다 |
| 스웜 방식으로 설정 흔들기 고정 설정 대신 매 실행마다 파라미터를 무작위 조합 | 중간 | 고정 설정이 가리고 있던 경로가 드러난다 |
| 워크로드 생성기 의심하기 "내 퍼저가 만들 수 없는 상황은 무엇인가?" | 생각 비용 | 5부의 사각지대를 피하는 유일한 방법 |
방법론과 별개로, 제품으로서도 짚어둘 만합니다.
| TigerBeetle이 맞는 경우 | 아닌 경우 | |
|---|---|---|
| 워크로드 | 쓰기 위주, 핫 계정 경합이 심한 거래 기록 | 읽기 위주, 복잡한 조인과 임의 질의 |
| 스키마 | 가치의 이동으로 모델링되는 것 — 결제·정산·포인트·크레딧·에너지 미터링·API 과금 | 사용자 프로필·상품 카탈로그처럼 자주 안 바뀌는 일반 데이터 |
| 역할 | 일반 DB 옆에 두는 원장 전용 엔진 | 일반 DB의 대체재로 쓰려는 시도 |
| 운영 | 리눅스 · io_uring · 6대 구성이 가능한 환경 | 서버리스나 관리형만 쓰는 환경 (관리형 서비스는 별도 제공) |
문서가 반복해서 강조하는 것은 대체가 아니라 병렬 배치입니다. 자주 안 바뀌는 데이터는 일반 데이터베이스에 두고, 고빈도 거래만 TigerBeetle이 맡고, user_data 필드로 둘을 잇는 구조입니다.
Apache 2.0이고, 단일 바이너리입니다.
# 리눅스 기준 (다른 플랫폼은 docs.tigerbeetle.com/start 참고)
curl -Lo tigerbeetle.zip https://linux.tigerbeetle.com && unzip tigerbeetle.zip
./tigerbeetle version
# 단일 리플리카 클러스터 만들기 (--development는 프로덕션 안전장치를 끄는 플래그)
./tigerbeetle format --cluster=0 --replica=0 --replica-count=1 --development 0_0.tigerbeetle
./tigerbeetle start --addresses=3000 --development 0_0.tigerbeetle
다른 터미널에서 REPL을 붙입니다.
./tigerbeetle repl --cluster=0 --addresses=3000
> create_accounts id=1 code=10 ledger=700, id=2 code=10 ledger=700;
> create_transfers id=1 debit_account_id=1 credit_account_id=2 amount=10 ledger=700 code=10;
> lookup_accounts id=1, id=2;
format이 데이터 파일을 만들고, start가 그 파일 하나만 가지고 돕니다. 데이터 파일은 정확히 하나입니다 — 파일시스템 없이 블록 장치에 바로 만들 수도 있습니다.
시뮬레이터를 직접 돌려보고 싶다면:
git clone https://github.com/tigerbeetle/tigerbeetle && cd tigerbeetle
./zig/download.sh # 저장소가 Zig 컴파일러 버전을 직접 고정한다 (0.14.1)
./zig/zig build vopr -- 12345 # 씨앗 12345로 클러스터 하나를 시뮬레이션
씨앗을 바꿔가며 돌려보세요. 그리고 코드를 조금 망가뜨린 다음 다시 돌려보면, 이 방법론이 무엇을 잡아내는지 몸으로 알게 됩니다.
읽어볼 만한 문서를 순서대로 꼽자면:
| 문서 | 무엇이 있나 |
|---|---|
docs/TIGER_STYLE.md | 이 글의 절반은 여기서 나왔습니다. 프로그래밍 언어와 무관하게 읽을 가치가 있는 엔지니어링 방법론 문서 |
docs/ARCHITECTURE.md | 모든 설계 결정에 왜가 붙어 있는 700줄. 참고문헌 목록만으로도 분산 시스템 독서 목록이 됩니다 |
docs/internals/vsr.md | 합의 프로토콜의 메시지 흐름 전부 (mermaid 시퀀스 다이어그램 포함) |
src/static_allocator.zig | 83줄. Zig를 몰라도 5분이면 읽힙니다 |
이 프로젝트를 관통하는 태도가 하나 있습니다. 가정을 줄이는 것입니다.
오른쪽 목록은 비관이 아닙니다. 측정된 사실입니다 — 문서의 모든 항목에 논문 인용이 붙어 있습니다.
그리고 이 태도가 만든 가장 인상적인 결과물은 성능 숫자가 아니라, 저장소 곳곳에 적힌 "왜" 입니다. TigerStyle의 한 줄이 그걸 요약합니다.
항상 동기를 밝혀라. 항상 왜인지 말하라. 결정의 근거를 설명하면 듣는 사람의 이해가 깊어질 뿐 아니라, 그 결정을 평가할 기준을 함께 나누게 되기 때문이다.
마지막 문장이 핵심입니다. 왜를 적으면 상대가 그 결정을 반박할 수 있게 됩니다. 그게 목적입니다.
새벽 3시의 전화로 돌아가 봅시다.
TigerBeetle 세계에서 그 버그는 이렇게 처리됩니다. 시뮬레이터가 어느 새벽에 씨앗 4913227에서 단언 하나를 깼습니다. 개발자는 아침에 그 숫자를 자기 노트북에 입력합니다. 30초 뒤, 똑같은 30초짜리 스위치 이상과 똑같은 디스크 지연과 똑같은 시계 어긋남이 눈앞에서 재현됩니다. 원하는 만큼, 원하는 지점에 중단점을 걸고, 몇 번이든.
버그가 어려운 게 아니었습니다. 버그를 볼 수 없던 것이 어려웠던 겁니다.
그리고 그건 세상의 성질이 아니라, 우리가 세상을 붙잡는 방식의 문제였습니다.
저장소와 문서
독립 검증
바탕이 된 연구
결정론적 시뮬레이션의 계보