Split View: 이중 기입 원장: 돈을 잃지 않는 회계 시스템 설계
이중 기입 원장: 돈을 잃지 않는 회계 시스템 설계
- 들어가며 — 600년 된 버그 방지 장치
- 순진한 설계는 왜 돈을 잃는가
- 차변과 대변 — 모든 거래는 두 번 기록된다
- 변경 불가능한 append-only 기록
- 계정과 잔액을 모델링하기
- "그냥 잔액 컬럼 하나 두면 안 되나?"
- 대사 — 원장이 진실인지 확인하기
- 동시성 아래의 정합성
- TigerBeetle — 원장을 위한 전용 데이터베이스
- 마치며
- 참고 자료
들어가며 — 600년 된 버그 방지 장치
결제 시스템을 만들다 보면 반드시 이런 질문에 부딪힙니다. "사용자의 잔액을 어떻게 저장하지?" 가장 순진한 답은 "계정 테이블에 잔액 컬럼을 하나 두고, 돈이 들어오면 더하고 나가면 빼자"입니다. 그런데 이 순진한 설계는 실제 서비스에서 조용히, 그러나 확실하게 돈을 잃습니다.
돈을 다루는 사람들은 이 문제를 아주 오래전에 풀었습니다. 15세기 이탈리아의 상인들과 수학자 루카 파치올리(Luca Pacioli)가 정리한 복식부기(double-entry bookkeeping), 우리말로 이중 기입 원장이 그것입니다. 600년이 지난 지금도 은행, 카드사, 핀테크의 원장은 이 원리 위에서 돌아갑니다. 그 이유는 단순합니다. 이중 기입은 그 자체로 오류를 잡아내는 구조를 품고 있기 때문입니다.
이 글은 이중 기입 원장을 소프트웨어 엔지니어의 시선으로 다룹니다. 차변과 대변이 왜 항상 같아야 하는지, 왜 기록을 지우거나 고치지 않고 오직 덧붙이기만(append-only) 하는지, 계정과 잔액을 어떻게 모델링하는지, "잔액 컬럼 하나면 안 되나"라는 유혹이 왜 위험한지, 대사와 동시성 정합성을 어떻게 확보하는지, 그리고 TigerBeetle 같은 전용 원장 데이터베이스가 왜 등장했는지를 짚겠습니다. 여기 나오는 스키마와 쿼리는 이 사이트의 SQL 놀이터와 Postgres 놀이터에서 직접 실행해 보며 감을 잡을 수 있습니다.
순진한 설계는 왜 돈을 잃는가
먼저 "잔액 컬럼 하나" 설계가 왜 위험한지 구체적으로 봅시다. 이런 테이블을 상상해 보세요.
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL -- 현재 잔액을 하나의 숫자로 저장
);
그리고 송금은 이렇게 처리한다고 합시다.
UPDATE accounts SET balance = balance - 1000 WHERE id = 1; -- 보내는 쪽
UPDATE accounts SET balance = balance + 1000 WHERE id = 2; -- 받는 쪽
겉보기에는 문제없어 보입니다. 하지만 이 설계에는 치명적인 약점이 셋 있습니다.
첫째, 역사가 사라집니다. 잔액 숫자 하나만 남으므로 "이 잔액이 어떻게 이 값이 됐는지"를 알 수 없습니다. 어떤 거래들이 있었는지, 언제 얼마가 오갔는지 추적할 방법이 없습니다. 회계에서 이것은 치명적입니다. 감사(audit)가 불가능한 시스템이기 때문입니다.
둘째, 부분 실패에 취약합니다. 위의 두 UPDATE 사이에서 프로세스가 죽으면, 한쪽에서는 돈이 빠졌는데 다른 쪽에는 들어오지 않은 상태가 됩니다. 돈이 허공으로 증발합니다. 트랜잭션으로 묶으면 이 특정 문제는 막을 수 있지만, 그것만으로는 아래의 문제가 남습니다.
셋째, 검증할 방법이 없습니다. 잔액이 틀렸을 때, 그것이 틀렸다는 사실조차 알아챌 수 없습니다. 대조할 기준이 없기 때문입니다. 버그나 경쟁 조건으로 잔액이 미묘하게 어긋나도, 시스템은 그저 틀린 숫자를 진실인 양 계속 들고 갑니다.
이중 기입 원장은 이 세 문제를 근본적으로 해결합니다. 핵심 발상의 전환은 이것입니다. 잔액을 저장하지 말고, 거래를 저장하라. 잔액은 거래로부터 계산하라.
차변과 대변 — 모든 거래는 두 번 기록된다
이중 기입의 이름에 답이 있습니다. 모든 거래는 두 번(double) 기록됩니다. 한 번은 **차변(debit)**으로, 한 번은 **대변(credit)**으로. 그리고 하나의 거래에서 차변의 합과 대변의 합은 항상 정확히 같아야 합니다.
이 "차변 합 = 대변 합"이라는 규칙이 이중 기입의 심장입니다. 돈은 무에서 생겨나거나 무로 사라지지 않습니다. 어딘가에서 나오면 반드시 어딘가로 들어갑니다. 이 보존 법칙을 회계가 강제하는 방식이 바로 차변과 대변의 균형입니다.
예를 들어 A가 B에게 1000원을 보내는 거래를 봅시다. 이것은 하나의 거래이고, 두 줄의 기입으로 표현됩니다.
거래 T1: A -> B 로 1000원 이체
계정 차변(debit) 대변(credit)
---- ----------- ------------
A - 1000 (A에서 1000 나감)
B 1000 - (B로 1000 들어옴)
---- ----------- ------------
합계 1000 1000 <- 차변 합 == 대변 합 (균형!)
여기서 중요한 것은 용어의 방향입니다. 회계에서 차변/대변은 "증가/감소"와 단순히 일치하지 않고, 계정의 종류(자산, 부채 등)에 따라 의미가 달라집니다. 하지만 소프트웨어 원장을 설계하는 엔지니어에게 핵심은 이 한 가지입니다. 모든 거래는 여러 개의 기입으로 이루어지고, 그 기입들의 차변 합과 대변 합은 반드시 같아야 한다. 이 불변식(invariant)을 코드로 강제하는 것이 이중 기입 원장의 첫걸음입니다.
이 규칙의 아름다움은 자기 검증에 있습니다. 시스템 전체의 모든 기입을 다 더하면, 차변의 총합과 대변의 총합은 언제나 같아야 합니다. 만약 다르다면? 어딘가에 버그가 있다는 뜻입니다. 이 등식이 깨지는 순간을 감지하는 것만으로, 돈이 새는 버그를 조기에 잡을 수 있습니다.
변경 불가능한 append-only 기록
이중 기입의 두 번째 기둥은 **불변성(immutability)**입니다. 원장의 기입은 한 번 쓰이면 절대 수정하거나 삭제하지 않습니다. 오직 새로운 기입을 덧붙일(append) 뿐입니다.
왜 그럴까요? 회계 기록은 "무슨 일이 있었는가"에 대한 역사적 사실이기 때문입니다. 이미 일어난 일은 바꿀 수 없습니다. 만약 어떤 거래가 잘못됐다면, 그 기록을 지우는 게 아니라 그것을 되돌리는 새로운 거래를 추가합니다. 이것을 역분개 또는 보정 거래(reversing/adjusting entry)라 부릅니다.
잘못된 거래를 수정하는 방법:
(X) 기존 기입을 UPDATE 하거나 DELETE 한다 <- 절대 안 됨
(O) 원래 거래를 상쇄하는 새 거래를 append 한다 <- 올바름
예: T1이 잘못됐다면
T2: T1과 반대 방향의 기입을 추가해 상쇄
T3: 올바른 거래를 새로 추가
-> T1의 흔적은 그대로 남고, 최종 잔액만 올바르게 됨
이 append-only 원칙은 몇 가지 강력한 성질을 줍니다.
- 완전한 감사 추적(audit trail). 모든 거래의 역사가 그대로 보존되므로, 어떤 잔액이든 "왜 이 값인지"를 처음부터 재구성할 수 있습니다. 규제 산업에서 필수적인 성질입니다.
- 재현 가능성. 잔액은 기입들의 합으로 언제든 다시 계산할 수 있습니다. 잔액이 의심스러우면 처음부터 다시 합산해 검증하면 됩니다.
- 경쟁 조건에 강함. 기존 값을 읽어서 고치는(read-modify-write) 대신 새 행을 추가하기만 하므로, 여러 거래가 동시에 들어와도 서로의 기록을 덮어쓰지 않습니다.
append-only 원장은 개념적으로 이벤트 소싱(event sourcing)과 통합니다. 상태(잔액)를 직접 저장하는 대신, 상태를 만들어 낸 사건들(거래)을 저장하고, 상태는 그 사건들로부터 도출합니다. 사건의 로그가 진실의 원천(source of truth)이 됩니다.
계정과 잔액을 모델링하기
이제 실제 스키마를 그려 봅시다. 이중 기입 원장의 최소 구성은 계정(accounts)과 기입(entries) 두 테이블입니다.
-- 계정: 돈이 담기는 논리적 그릇
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
currency TEXT NOT NULL -- 통화를 계정 단위로 고정
);
-- 기입: 변경 불가능한 거래 라인. 오직 INSERT만 함
CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL, -- 같은 거래에 속한 기입들을 묶는 ID
account_id BIGINT NOT NULL REFERENCES accounts(id),
direction TEXT NOT NULL, -- 'debit' 또는 'credit'
amount BIGINT NOT NULL CHECK (amount > 0), -- 항상 양수, 방향으로 부호 표현
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
몇 가지 설계 포인트를 짚겠습니다.
첫째, 금액은 정수로 저장합니다. 부동소수점(float)은 절대 쓰면 안 됩니다. 0.1 + 0.2가 정확히 0.3이 아닌 부동소수점의 성질은 돈에 치명적입니다. 대신 통화의 최소 단위(예: 센트, 원)를 정수로 저장합니다. 100 USD는 정수 10000(센트)으로, 이런 식입니다.
둘째, 금액은 항상 양수로 두고 방향(direction)으로 부호를 표현합니다. 이렇게 하면 "차변의 합"과 "대변의 합"을 각각 집계해 균형을 검증하기가 쉽습니다.
셋째, 잔액 컬럼이 없습니다. 잔액은 저장하는 값이 아니라 기입으로부터 계산하는 값입니다. 특정 계정의 잔액은 그 계정에 속한 모든 기입을 방향에 따라 합산해서 얻습니다.
-- 계정 42의 잔액 = (그 계정으로 들어온 것) - (나간 것)
SELECT
COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0)
AS balance
FROM entries
WHERE account_id = 42;
거래를 삽입할 때는 반드시 하나의 트랜잭션 안에서 균형 잡힌 기입들을 함께 넣습니다.
BEGIN;
-- 거래 T1: 계정 1 -> 계정 2 로 1000
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (1, 2, 'debit', 1000), -- 받는 계정으로 1000 들어옴
(1, 1, 'credit', 1000); -- 보내는 계정에서 1000 나감
-- 애플리케이션(또는 제약)이 이 거래의 debit 합 == credit 합 을 검증
COMMIT;
이 두 INSERT는 하나의 트랜잭션이므로, 둘 다 커밋되거나 둘 다 롤백됩니다. 앞서 본 순진한 설계의 "한쪽만 반영되는" 부분 실패가 원천적으로 불가능해집니다. 이런 스키마와 트랜잭션을 실제로 돌려 보고 싶다면 Postgres 놀이터에서 그대로 실행해 볼 수 있습니다.
"그냥 잔액 컬럼 하나 두면 안 되나?"
여기서 많은 엔지니어가 던지는 질문에 정면으로 답해 봅시다. "매번 모든 기입을 합산하는 건 느리지 않나? 그냥 잔액 컬럼을 하나 두고 캐시처럼 쓰면 안 되나?"
핵심은 잔액 컬럼을 진실의 원천으로 삼지 않는 것입니다. 성능을 위해 잔액을 어딘가에 미리 계산해 두는(materialized) 것 자체는 흔하고 합리적입니다. 하지만 그것은 어디까지나 기입으로부터 파생된 캐시여야 합니다. 진실은 언제나 append-only 기입의 로그에 있고, 잔액 캐시는 그것을 빠르게 읽기 위한 보조물일 뿐입니다.
이 구분이 왜 중요한지는 두 설계를 비교하면 분명합니다.
| 관점 | 잔액 컬럼이 진실 | 기입이 진실 (+ 잔액 캐시) |
|---|---|---|
| 역사 추적 | 불가능 | 완전한 감사 추적 |
| 검증 | 대조 기준 없음 | 기입 합산으로 언제든 재검증 |
| 부분 실패 | 돈 증발 위험 | 트랜잭션으로 원천 차단 |
| 잔액이 틀렸을 때 | 알 수 없음 | 재계산으로 감지·복구 |
| 성능 | 빠름 | 캐시로 빠름, 진실은 보존 |
즉 답은 "잔액 컬럼을 두지 마라"가 아니라 "잔액 컬럼을 진실로 착각하지 마라"입니다. 잔액을 미리 계산해 두되, 그것을 언제든 기입으로부터 다시 만들어 낼 수 있어야 하고, 주기적으로 재계산해 캐시가 진실과 어긋나지 않았는지 검증해야 합니다. 이것이 순진한 설계와 견고한 원장을 가르는 결정적 차이입니다.
실무에서 흔한 패턴은 계정마다 "running balance(누적 잔액)"를 기입과 함께 기록하되, 그 값이 항상 이전 잔액에 이번 기입을 더한 것과 일치하도록 강제하는 것입니다. 이렇게 하면 빠른 조회와 검증 가능성을 동시에 얻습니다.
대사 — 원장이 진실인지 확인하기
이중 기입 원장은 검증을 스스로 돕는 구조지만, 그 검증을 실제로 돌리는 작업이 필요합니다. 이것이 **대사(reconciliation)**입니다. 원장 맥락에서 대사는 크게 두 층위로 이루어집니다.
내부 정합성 검증. 원장 자체가 자기 규칙을 지키고 있는지 확인합니다. 가장 기본은 앞서 본 전역 등식입니다. 시스템의 모든 기입을 다 더하면 차변 총합과 대변 총합이 같아야 합니다.
-- 전역 불변식: 모든 기입의 debit 합 == credit 합 이어야 함
SELECT
SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END) AS total_debit,
SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END) AS total_credit
FROM entries;
-- 두 값이 다르면 -> 원장이 깨졌다는 알람. 즉시 조사 대상.
또한 잔액 캐시를 쓴다면, 캐시된 잔액이 기입을 처음부터 합산한 값과 일치하는지 주기적으로 대조합니다. 어긋나면 캐시를 재계산하고 원인을 추적합니다.
외부 대사. 내부 원장이 스스로 일관되더라도, 그것이 바깥 세계(은행 계좌, PG 정산 내역)와 맞는지는 별개의 문제입니다. 그래서 원장의 기록을 은행 명세나 PG 정산 파일과 대조합니다. 앞선 결제 시스템 글에서 다룬 대사가 바로 이 층위입니다. 내부 원장이 "우리가 기록한 진실"이라면, 외부 대사는 "그 진실이 실제 돈과 일치하는가"를 확인합니다.
잘 설계된 원장은 이 두 대사를 매일 자동으로 돌리고, 불일치를 지표로 관측합니다. 대사가 깨끗하게 맞는다는 것은 시스템이 돈을 잃지 않고 있다는 가장 강력한 증거입니다. 그리고 대사가 가능한 유일한 이유는, 원장이 애초에 모든 거래를 변경 불가능하게 보존하고 있기 때문입니다.
동시성 아래의 정합성
원장은 여러 거래가 동시에 몰려도 정합성을 지켜야 합니다. 여기서 두 가지 대표적인 동시성 문제를 짚겠습니다.
첫째, 잃어버린 갱신(lost update). 순진한 잔액 컬럼 설계에서, 두 거래가 동시에 잔액을 읽고 각자 계산한 뒤 쓰면, 나중에 쓴 것이 앞선 것을 덮어써 갱신 하나가 사라집니다. 이중 기입의 append-only 방식은 이 문제에 근본적으로 강합니다. 기존 값을 읽어 고치는 대신 새 행을 추가하기만 하므로, 두 거래가 동시에 들어와도 각자 자기 기입을 삽입할 뿐 서로를 덮어쓰지 않습니다.
둘째, 잔액 제약의 원자적 검증. "잔액이 마이너스가 되면 안 된다" 같은 제약은 동시성 아래에서 까다롭습니다. 두 출금이 동시에 각각 "지금 잔액이 충분하네"라고 판단한 뒤 둘 다 진행하면, 합쳐서 잔액을 초과할 수 있습니다. 이를 막으려면 해당 계정에 대한 접근을 직렬화해야 합니다. 흔한 방법은 그 계정 행에 락을 걸어(예: SELECT ... FOR UPDATE) 한 번에 하나의 출금만 잔액을 검증·반영하도록 만드는 것입니다.
BEGIN;
-- 출금 전, 해당 계정을 잠가 동시 출금을 직렬화
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
-- (여기서 계정 1의 현재 잔액을 기입 합산으로 계산해 충분한지 확인)
-- 충분하면 균형 잡힌 기입을 INSERT
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (99, 2, 'debit', 500),
(99, 1, 'credit', 500);
COMMIT;
트랜잭션의 격리 수준(isolation level)도 중요합니다. 더 강한 격리(예: serializable)는 이런 이상 현상을 데이터베이스가 막아 주지만, 경합이 잦으면 성능 비용과 재시도(직렬화 실패 시)가 따릅니다. 여기서 다시, 앞선 글에서 강조한 원칙이 이어집니다. 그런 재시도가 안전하려면 거래가 멱등해야 하고, 그래서 각 거래에 고유한 transaction_id를 부여해 중복 삽입을 막습니다. 원장, 멱등성, 정합성은 이렇게 서로 맞물립니다. 이런 락과 격리 수준의 동작을 직접 관찰하고 싶다면 SQL 놀이터와 Postgres 놀이터에서 실험해 볼 수 있습니다.
TigerBeetle — 원장을 위한 전용 데이터베이스
지금까지 본 원장은 일반 관계형 데이터베이스(예: Postgres) 위에서도 충분히 구현할 수 있습니다. 실제로 많은 핀테크가 그렇게 시작합니다. 하지만 거래량이 극단적으로 커지면, 범용 데이터베이스로는 성능과 정합성을 동시에 만족시키기 어려워집니다. 이 지점에서 원장 전용 데이터베이스라는 새로운 종류가 등장했습니다. 대표적인 것이 TigerBeetle입니다.
TigerBeetle이 던지는 문제의식은 이렇습니다. 금융 원장의 작업 부하는 아주 특수합니다. 대부분이 "계좌 간 이체"라는 좁고 반복적인 연산이고, 각 이체는 여러 계정을 동시에 건드리며, 무엇보다 절대 틀리면 안 됩니다. 범용 데이터베이스는 온갖 쿼리를 다 잘 처리하도록 만들어졌지만, 바로 그 범용성 때문에 이 특수한 부하에서는 오버헤드가 큽니다.
TigerBeetle의 설계 선택을 엔지니어의 관점에서 요약하면 이렇습니다.
- 도메인 특화. 범용 SQL 대신 "계정(account)"과 "이체(transfer)"라는 두 가지 원장 원시 개념에 집중합니다. 스키마가 고정돼 있어 최적화가 극단적으로 가능합니다.
- 차변=대변을 데이터베이스가 강제. 균형 규칙을 애플리케이션이 아니라 데이터베이스 코어가 보장합니다. 잘못 균형 잡힌 이체는 아예 커밋되지 않습니다.
- 높은 처리량을 위한 배치. 이체를 개별이 아니라 묶음(batch)으로 처리해, 초당 수십만 건 이상을 목표로 합니다.
- 내장된 복제와 합의. 금융 데이터의 내구성을 위해 합의 프로토콜 기반 복제를 코어에 내장해, 노드가 죽어도 데이터가 유실되거나 어긋나지 않게 합니다.
- 결정론적 설계와 혹독한 테스트. 결정론적으로 동작하도록 만들어, 시뮬레이션으로 온갖 장애(디스크 오류, 네트워크 분단)를 주입해 정합성을 검증합니다.
TigerBeetle을 반드시 써야 한다는 이야기가 아닙니다. 핵심 교훈은 이것입니다. 원장은 아주 특수하고 아주 중요한 작업 부하라서, 그것만을 위한 도구가 존재할 만큼 진지하게 다뤄진다. 대부분의 서비스는 Postgres 위의 잘 설계된 이중 기입 원장으로 충분히 시작할 수 있고, 규모가 커졌을 때 이런 전용 도구를 고려하게 됩니다. 어느 쪽이든, 그 밑에 깔린 원리는 600년 전의 그 복식부기 그대로입니다.
마치며
돈을 잃지 않는 시스템의 비밀은 화려한 기술이 아니라, 600년 된 회계 원리를 소프트웨어에 충실히 옮기는 데 있습니다. 잔액을 저장하지 말고 거래를 저장하라. 모든 거래를 차변과 대변으로 두 번 기록하고, 그 합이 항상 같도록 강제하라. 기록은 절대 고치거나 지우지 말고 오직 덧붙여라. 잔액은 그 기입들로부터 계산하고, 잔액 캐시를 쓰더라도 그것을 진실로 착각하지 마라.
이 원칙들이 함께 작동할 때, 원장은 스스로를 검증하는 시스템이 됩니다. 차변과 대변의 균형이 오류를 드러내고, append-only 로그가 완전한 감사 추적을 제공하며, 대사가 매일 진실을 확인하고, 트랜잭션과 락이 동시성 아래에서도 정합성을 지킵니다. 규모가 극단적으로 커지면 TigerBeetle 같은 전용 원장을 고려하되, 그 밑바탕의 원리는 변하지 않습니다.
결제 시스템 3부작을 관통하는 하나의 문장으로 마무리하겠습니다. 돈을 다루는 시스템은 틀릴 여지를 구조적으로 없애야 한다. 멱등성이 중복을 없애고, 이중 기입이 오류를 드러내고, 대사가 진실을 확인합니다. 이 세 가지가 함께 있을 때, 비로소 우리는 "돈을 잃지 않는 시스템"이라고 자신 있게 말할 수 있습니다.
참고 자료
- Martin Fowler: Event Sourcing — https://martinfowler.com/eaaDev/EventSourcing.html
- Modern Treasury: What is double-entry accounting? — https://www.moderntreasury.com/learn/double-entry-accounting
- TigerBeetle: The Financial Transactions Database — https://tigerbeetle.com/
- TigerBeetle Docs: Debit/Credit accounting — https://docs.tigerbeetle.com/coding/system-architecture/
- Square Engineering: Books, an immutable double-entry accounting database — https://developer.squareup.com/blog/books-an-immutable-double-entry-accounting-database-service/
- Wikipedia: Double-entry bookkeeping — https://en.wikipedia.org/wiki/Double-entry_bookkeeping
Double-Entry Ledgers: Designing Systems That Never Lose Money
- Introduction — A 600-Year-Old Bug Preventer
- Why the Naive Design Loses Money
- Debits and Credits — Every Transaction Is Recorded Twice
- Immutable, Append-Only Records
- Modeling Accounts and Balances
- "Why Not Just One Balance Column?"
- Reconciliation — Confirming the Ledger Is True
- Consistency Under Concurrency
- TigerBeetle — A Purpose-Built Database for Ledgers
- Conclusion
- References
Introduction — A 600-Year-Old Bug Preventer
If you build a payment system, you will inevitably hit this question: "How do I store the user's balance?" The most naive answer is "put a balance column on the accounts table, add when money comes in, subtract when it goes out." But this naive design quietly and reliably loses money in a real service.
People who handle money solved this problem a very long time ago. It is double-entry bookkeeping, systematized by fifteenth-century Italian merchants and the mathematician Luca Pacioli. Six hundred years later, the ledgers of banks, card companies, and fintechs still run on this principle. The reason is simple: double-entry carries within itself a structure that catches errors.
This post treats the double-entry ledger from a software engineer's point of view. We will look at why debits and credits must always be equal, why we never modify or delete records but only append (append-only), how to model accounts and balances, why the temptation of "just one balance column" is dangerous, how to secure reconciliation and consistency under concurrency, and why a purpose-built ledger database like TigerBeetle appeared. You can run the schemas and queries here in this site's SQL Playground and Postgres Playground to build intuition.
Why the Naive Design Loses Money
First let us look concretely at why the "one balance column" design is dangerous. Imagine this table.
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL -- store the current balance as a single number
);
And suppose a transfer is handled like this.
UPDATE accounts SET balance = balance - 1000 WHERE id = 1; -- the sender
UPDATE accounts SET balance = balance + 1000 WHERE id = 2; -- the receiver
It looks fine on the surface. But this design has three fatal weaknesses.
First, history disappears. Only a single balance number remains, so you cannot know "how this balance became this value." There is no way to trace what transactions there were, when and how much moved. In accounting this is fatal, because it is a system that cannot be audited.
Second, it is vulnerable to partial failure. If the process dies between the two UPDATEs above, you end up in a state where money left one side but did not arrive at the other. Money evaporates into thin air. Wrapping them in a transaction prevents this particular problem, but that alone leaves the problem below.
Third, there is no way to verify. When a balance is wrong, you cannot even notice that it is wrong, because there is no reference to compare against. Even if a bug or race condition makes a balance drift subtly, the system just keeps carrying the wrong number as if it were the truth.
The double-entry ledger fundamentally solves all three. The key shift in thinking is this: do not store the balance; store the transactions. Compute the balance from the transactions.
Debits and Credits — Every Transaction Is Recorded Twice
The name of double-entry holds the answer. Every transaction is recorded twice — once as a debit and once as a credit. And within a single transaction, the sum of debits and the sum of credits must always be exactly equal.
This rule, "sum of debits equals sum of credits," is the heart of double-entry. Money does not come from nothing or vanish into nothing. If it comes out of somewhere, it must go into somewhere. The way accounting enforces this conservation law is precisely the balance of debits and credits.
For example, consider a transaction where A sends 1000 to B. This is one transaction, expressed as two entry lines.
Transaction T1: transfer 1000 from A -> B
Account debit credit
------- ----- ------
A - 1000 (1000 leaves A)
B 1000 - (1000 enters B)
------- ----- ------
Total 1000 1000 <- sum of debits == sum of credits (balanced!)
Here the direction of the terms matters. In accounting, debit/credit does not simply map to "increase/decrease"; its meaning depends on the account type (asset, liability, and so on). But for an engineer designing a software ledger, the crux is this one thing: every transaction consists of multiple entries, and the sum of their debits must equal the sum of their credits. Enforcing this invariant in code is the first step of a double-entry ledger.
The beauty of this rule is self-verification. If you add up every entry in the whole system, the grand total of debits and the grand total of credits must always be equal. If they are not? It means there is a bug somewhere. Simply detecting the moment this equality breaks lets you catch money-leaking bugs early.
Immutable, Append-Only Records
The second pillar of double-entry is immutability. Once an entry is written to the ledger, it is never modified or deleted. You only append new entries.
Why? Because an accounting record is a historical fact about "what happened." What has already happened cannot be changed. If a transaction was wrong, you do not delete its record; you add a new transaction that reverses it. This is called a reversing or adjusting entry.
How to fix a wrong transaction:
(X) UPDATE or DELETE the existing entry <- never do this
(O) append a new transaction that offsets it <- correct
Example: if T1 was wrong
T2: add entries in the opposite direction of T1 to offset it
T3: add the correct transaction anew
-> T1's trace remains intact, and only the final balance becomes correct
This append-only principle grants several powerful properties.
- A complete audit trail. Since the history of every transaction is preserved as-is, any balance can be reconstructed from the beginning to explain "why it is this value." This is an essential property in regulated industries.
- Reproducibility. The balance can be recomputed at any time as the sum of entries. If a balance is in doubt, you re-sum from the start and verify.
- Resistance to race conditions. Instead of read-modify-write on an existing value, you only add a new row, so even when many transactions arrive at once they do not overwrite each other's records.
An append-only ledger converges conceptually with event sourcing. Instead of storing state (the balance) directly, you store the events (transactions) that produced the state, and derive the state from those events. The log of events becomes the source of truth.
Modeling Accounts and Balances
Now let us sketch a real schema. The minimal composition of a double-entry ledger is two tables: accounts and entries.
-- accounts: the logical vessels that hold money
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
currency TEXT NOT NULL -- fix the currency per account
);
-- entries: immutable transaction lines. Only ever INSERTed
CREATE TABLE entries (
id BIGSERIAL PRIMARY KEY,
transaction_id BIGINT NOT NULL, -- ID that groups entries of the same transaction
account_id BIGINT NOT NULL REFERENCES accounts(id),
direction TEXT NOT NULL, -- 'debit' or 'credit'
amount BIGINT NOT NULL CHECK (amount > 0), -- always positive; sign expressed by direction
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
A few design points.
First, store amounts as integers. Never use floating point. Floating point's property, where 0.1 + 0.2 is not exactly 0.3, is fatal for money. Instead store the currency's smallest unit (cents, for example) as an integer. So 100 USD is the integer 10000 (cents), and so on.
Second, keep amounts always positive and express the sign with the direction. This makes it easy to aggregate the "sum of debits" and the "sum of credits" separately and verify the balance.
Third, there is no balance column. The balance is not a stored value but a value computed from the entries. The balance of a given account is obtained by summing all entries belonging to that account according to their direction.
-- balance of account 42 = (what came into the account) - (what left)
SELECT
COALESCE(SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END), 0)
AS balance
FROM entries
WHERE account_id = 42;
When inserting a transaction, always put the balanced entries in together within a single transaction.
BEGIN;
-- Transaction T1: 1000 from account 1 -> account 2
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (1, 2, 'debit', 1000), -- 1000 enters the receiving account
(1, 1, 'credit', 1000); -- 1000 leaves the sending account
-- the application (or a constraint) verifies debit sum == credit sum for this transaction
COMMIT;
Because these two INSERTs are one transaction, both commit or both roll back. The "only one side applied" partial failure of the naive design becomes fundamentally impossible. If you want to actually run this kind of schema and transaction, you can execute it as-is in the Postgres Playground.
"Why Not Just One Balance Column?"
Let us answer head-on the question many engineers ask here. "Isn't summing all entries every time slow? Why not just keep one balance column and use it like a cache?"
The crux is not treating the balance column as the source of truth. Materializing the balance somewhere for performance is common and reasonable in itself. But it must be a cache derived from the entries. The truth always lives in the append-only log of entries, and the balance cache is merely an aid for reading it quickly.
Why this distinction matters becomes clear when you compare the two designs.
| Aspect | Balance column is truth | Entries are truth (+ balance cache) |
|---|---|---|
| History tracking | Impossible | Complete audit trail |
| Verification | No reference to compare | Re-verify anytime by summing entries |
| Partial failure | Risk of money evaporating | Blocked at the source by transactions |
| When a balance is wrong | Unknowable | Detected and recovered by recomputation |
| Performance | Fast | Fast via cache, truth preserved |
So the answer is not "do not keep a balance column" but "do not mistake the balance column for the truth." Materialize the balance, but you must be able to rebuild it from the entries at any time, and periodically recompute it to verify the cache has not drifted from the truth. This is the decisive difference between a naive design and a robust ledger.
A common pattern in practice is to record a "running balance" per account alongside each entry, while enforcing that this value always equals the previous balance plus the current entry. This gives you fast lookups and verifiability at the same time.
Reconciliation — Confirming the Ledger Is True
A double-entry ledger is structured to help verify itself, but you still need to actually run that verification. This is reconciliation. In the ledger context, reconciliation happens at roughly two levels.
Internal consistency verification. Confirm the ledger itself is obeying its own rules. The most basic is the global equality we saw earlier: adding up every entry in the system, the grand total of debits must equal the grand total of credits.
-- global invariant: sum of all debit entries == sum of all credit entries
SELECT
SUM(CASE WHEN direction = 'debit' THEN amount ELSE 0 END) AS total_debit,
SUM(CASE WHEN direction = 'credit' THEN amount ELSE 0 END) AS total_credit
FROM entries;
-- if the two values differ -> an alarm that the ledger is broken. Investigate immediately.
Also, if you use a balance cache, periodically compare the cached balance against the value summed from entries from scratch. If they diverge, recompute the cache and trace the cause.
External reconciliation. Even if the internal ledger is self-consistent, whether it agrees with the outside world (bank accounts, PSP settlement reports) is a separate matter. So you compare the ledger's records against bank statements or PSP settlement files. The reconciliation covered in the earlier payment systems post is exactly this level. If the internal ledger is "the truth we recorded," external reconciliation confirms "whether that truth agrees with actual money."
A well-designed ledger runs both reconciliations automatically every day and observes mismatches as metrics. Clean reconciliation is the strongest evidence that the system is not losing money. And the only reason reconciliation is possible at all is that the ledger preserves every transaction immutably in the first place.
Consistency Under Concurrency
A ledger must maintain consistency even when many transactions arrive at once. Here we touch two representative concurrency problems.
First, the lost update. In the naive balance-column design, if two transactions simultaneously read the balance, each computes on its own, then writes, the later write overwrites the earlier one and one update vanishes. Double-entry's append-only approach is fundamentally resistant to this. Instead of read-modify-write on an existing value, you only add a new row, so even when two transactions arrive at once, each merely inserts its own entry and does not overwrite the other.
Second, the atomic verification of a balance constraint. A constraint like "the balance must not go negative" is tricky under concurrency. If two withdrawals each simultaneously judge "the balance is sufficient right now" and both proceed, together they can exceed the balance. To prevent this, you must serialize access to that account. A common method is to lock that account row (for example, SELECT ... FOR UPDATE) so that only one withdrawal at a time verifies and applies against the balance.
BEGIN;
-- before withdrawing, lock the account to serialize concurrent withdrawals
SELECT id FROM accounts WHERE id = 1 FOR UPDATE;
-- (here, compute account 1's current balance by summing entries and check it is sufficient)
-- if sufficient, INSERT the balanced entries
INSERT INTO entries (transaction_id, account_id, direction, amount)
VALUES (99, 2, 'debit', 500),
(99, 1, 'credit', 500);
COMMIT;
The transaction isolation level also matters. Stronger isolation (serializable, for example) makes the database prevent such anomalies, but with frequent contention it carries a performance cost and retries (on serialization failure). Here again, the principle emphasized in the earlier post continues: for such retries to be safe, transactions must be idempotent, which is why you give each transaction a unique transaction_id to prevent duplicate inserts. Ledgers, idempotency, and consistency interlock this way. If you want to observe how these locks and isolation levels behave, you can experiment in the SQL Playground and Postgres Playground.
TigerBeetle — A Purpose-Built Database for Ledgers
The ledger we have seen so far can be perfectly well implemented on a general-purpose relational database (Postgres, for example). Many fintechs indeed start that way. But when transaction volume grows to an extreme, it becomes hard for a general-purpose database to satisfy performance and consistency at the same time. At this point a new kind of thing appeared: the ledger-specific database. A prominent example is TigerBeetle.
The problem statement TigerBeetle raises is this. A financial ledger's workload is very peculiar. Most of it is the narrow, repetitive operation of "transfer between accounts," each transfer touches multiple accounts at once, and above all it must never be wrong. General-purpose databases are built to handle all sorts of queries well, but precisely because of that generality, they carry large overhead for this peculiar workload.
Summarizing TigerBeetle's design choices from an engineer's point of view.
- Domain-specific. Instead of general SQL, it focuses on two ledger primitives: the "account" and the "transfer." Because the schema is fixed, optimization is possible to an extreme.
- Debits equal credits, enforced by the database. The balance rule is guaranteed by the database core, not the application. An improperly balanced transfer never commits at all.
- Batching for high throughput. It processes transfers in batches rather than individually, targeting hundreds of thousands per second or more.
- Built-in replication and consensus. For the durability of financial data, it embeds consensus-protocol-based replication in the core, so that even if a node dies, data is neither lost nor made inconsistent.
- Deterministic design and brutal testing. Built to behave deterministically, it injects all sorts of faults (disk errors, network partitions) via simulation to verify consistency.
This is not to say you must use TigerBeetle. The key lesson is this: a ledger is such a peculiar and important workload that it is taken seriously enough for a tool to exist solely for it. Most services can start perfectly well with a well-designed double-entry ledger on Postgres, and consider such a dedicated tool when scale grows. Either way, the principle underneath is the very same double-entry bookkeeping from 600 years ago.
Conclusion
The secret of a system that never loses money is not flashy technology but faithfully porting a 600-year-old accounting principle into software. Do not store the balance; store the transactions. Record every transaction twice, as a debit and a credit, and enforce that their sums are always equal. Never modify or delete records; only append. Compute the balance from those entries, and even if you use a balance cache, do not mistake it for the truth.
When these principles work together, the ledger becomes a self-verifying system. The balance of debits and credits reveals errors, the append-only log provides a complete audit trail, reconciliation confirms the truth every day, and transactions and locks maintain consistency even under concurrency. When scale grows to an extreme, consider a dedicated ledger like TigerBeetle, but the underlying principle does not change.
Let me close the payments trilogy with a single sentence that runs through all three posts. A system that handles money must structurally eliminate room for error. Idempotency removes duplicates, double-entry reveals errors, and reconciliation confirms the truth. Only when these three are present together can we say, with confidence, that we have "a system that never loses money."
References
- Martin Fowler: Event Sourcing — https://martinfowler.com/eaaDev/EventSourcing.html
- Modern Treasury: What is double-entry accounting? — https://www.moderntreasury.com/learn/double-entry-accounting
- TigerBeetle: The Financial Transactions Database — https://tigerbeetle.com/
- TigerBeetle Docs: System architecture — https://docs.tigerbeetle.com/coding/system-architecture/
- Square Engineering: Books, an immutable double-entry accounting database — https://developer.squareup.com/blog/books-an-immutable-double-entry-accounting-database-service/
- Wikipedia: Double-entry bookkeeping — https://en.wikipedia.org/wiki/Double-entry_bookkeeping