銀行はなぜ残高をUPDATEしないのか
一言でいうと
銀行のコアは、残高を書き換える代わりに取引を積み上げて合算します。残高だけを保管すると、その数字が間違っていたとき、突き合わせる相手がこの世に1つも残らないからです。
なぜ必要なのか
一般的なサービスで残高を扱う方法は単純です。UPDATE users SET balance = balance - 1000 WHERE id = 7。1行で、速く、読みやすい方法です。
この方式の問題は性能ではなく、あとから辿れないことです。ある日、残高がおかしいという問い合わせが来たとき、手元にあるのは今の数字1つだけです。この数字が合っているか間違っているかを判断するには、別の場所で計算した値と比べる必要がありますが、比べる相手がありません。ログを調べようという話になりますが、ログには保存期間があり、形式もまちまちで、何より帳簿ではありません。
銀行はこの問題を何百年も前にすでに解決しています。答えは複式簿記です。
どう動くのか
複式簿記のルールは1つです。すべての取引は2行以上で記録され、借方の金額の合計と貸方の金額の合計は必ず等しくなります。
차변(Debit) 자산이 늘거나, 부채가 줄거나, 비용이 생기는 쪽
대변(Credit) 자산이 줄거나, 부채가 늘거나, 수익이 생기는 쪽
このコードブロックの韓国語コメントは、借方は資産が増える、負債が減る、または費用が生じる側、貸方は資産が減る、負債が増える、または収益が生じる側、という意味です。
顧客が現金100万ウォンを預け入れると、次のように記録されます。
전표 JE-20260825-0001 (입금)
차변 1010 현금 1,000,000
대변 2010 요구불예수금 1,000,000
銀行から見ると、現金という資産が100万ウォン増え(借方)、同時に、そのお金を顧客に返す義務である預り金という負債が100万ウォン増えました(貸方)。銀行の預金は銀行の資産ではなく負債であること。これが、銀行システムを初めて見るときに最も混乱しやすい点です。
伝票(journal entry)は取引1件で、仕訳行(journal line)はその伝票を構成する1行1行です。1つの伝票に3行以上が付くことも珍しくありません。預金利息を支払うときに源泉税を差し引くと、借方1行に貸方2行が付きます。
この構造では、残高は保存された値ではなく導出された値です。ある勘定の残高は、その勘定に付いたすべての仕訳行を合算して出ます。そして、すべての勘定を合算した表が試算表(trial balance)で、試算表の借方総計と貸方総計は必ず等しくなければなりません。
この恒等式が、銀行システムが持つ唯一の自己検証の仕組みです。1日に数百万件が入ってきても、そのすべてを1行に要約して「0か」と問えます。
実際のコアは、性能のために勘定ごとに残高の列も持っています。照会のたびに数億件を合算することはできないからです。ただし、その列は元帳の写しであって原本ではありません。両者がずれたら、常に元帳が勝ちます。
現場での姿
第一に、訂正はUPDATEではなく反対仕訳です。間違った伝票を見つけたら、その伝票を直すのではなく、元の伝票を鏡のように反転した伝票(反対仕訳)を入れて相殺し、正しい伝票を新たに起票します。元帳には3枚が並んで残ります。面倒に見えますが、2か月後の監査で「この伝票は元々この金額だったのか」と尋ねる人に答えられる唯一の方法です。元帳にUPDATEをかけた痕跡があるシステムは、それ自体が監査の指摘事項になります。
反対仕訳をするときは、金額を記録されたとおりに書きます。間違った金額をそのまま反転して初めて、間違いが正確に相殺されます。「どうせ間違っているのだから正しい金額で反転しよう」とすると、その瞬間に差が半分しか消えず、残りの半分はどこにも記録されないまま帳簿に残ります。
第二に、残高が合わないという問い合わせの原因は、たいてい次の3つのどれかです。元帳が間違っているか、残高の写しが間違っているか、どちらも合っているが見ている時点が違うかです。この3つは対応がまったく異なります。元帳が間違っていれば訂正伝票を入れ、写しが間違っていれば元帳から再計算して上書きし、時点の問題なら何も直さずに説明だけします。どれなのかを切り分けずに手を出すのが、この仕事で最も高くつく間違いです。
第三に、残高の列だけを見る調査は必ず行き詰まります。残高1つはただの数字です。その数字だけでは、合っているか間違っているかはわかりません。一方、元帳を合算して出した値は残高の列と比べられますし、伝票単位の借方と貸方の合計はそれ自体で検証されます。調査のときにどの表を先に開くかが、その日何時に帰れるかを決めます。
元帳をデータベースに載せるときのルール
複式簿記をテーブルに落とす方法はいろいろありますが、あとで後悔しない形はたいてい同じです。
仕訳は1つのトランザクションに2行以上、合計は0です。借方と貸方を別のテーブルに置いたり、1行に2つの金額を入れたりすると、いつか片方だけが入ります。同じテーブルに複数行で入れ、その束の合計が0かどうかをデータベースに確認させます。
create table entries(
id bigserial primary key,
txn_id uuid not null,
account_id bigint not null references accounts(id),
amount_minor bigint not null, -- 차변은 양수, 대변은 음수
currency char(3) not null,
posted_at timestamptz not null,
business_date date not null);
create index on entries(txn_id);
-- 묶음의 합이 0이 아니면 커밋을 막는다(지연 제약 또는 트리거로).
2つの韓国語コメントは、順に、借方は正の数で貸方は負の数であることと、束の合計が0でなければ(遅延制約またはトリガーで)コミットを止めることを述べています。
更新も削除もしません。誤って入れた仕訳は削除するのではなく、反対の仕訳をもう1つ入れて相殺し、2つの束を互いにひも付けます。そうしておけば、「どの時点で残高がいくらだったか」にいつでも答えられます。
残高は保存しますが、計算で検証します。毎回すべてを足すと遅いので残高を別に持つことになりますが、その値と仕訳の合計がずれる瞬間が必ず来ます。ある時点のスナップショット+その後の仕訳の形で持ち、夜間に全体を足し直して突き合わせます。
金額は最小単位の整数で持ちます。ウォンは1、ドルはセントです。浮動小数点数は使いません。通貨が複数あるなら、金額と通貨を決して切り離しません。通貨が異なる2つの金額を足すコードがあれば、それはバグです。
業務日付と起票時刻を分けます。午前1時に入った取引が前日の営業日に属することがあります。1つにまとめてしまうと、締めの数字が人によって変わります。
同時実行は勘定単位で押さえます。同じ勘定に同時に起票すると、残高がずれます。勘定の行をselect ... for updateで押さえるか、残高を仕訳の合計だけで見て残高行を置かない設計を選びます。
次のラボですること
3日分の元帳スナップショットを自分で作り、その中に隠れている借方と貸方の不一致1件を見つけ出します。試算表から始めて伝票1件、仕訳行1行まで絞り込み、残高の列と元帳の合算を突き合わせて、2つのずれの性質がなぜ違うのかを確認します。最後に、元の伝票には手を触れず、反対仕訳と再起票で直します。