Skip to content

필사 모드: 複式簿記の台帳:お金を失わないシステム設計

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — 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)の二つのテーブルです。

-- accounts: お金が入る論理的な器
CREATE TABLE accounts (
  id         BIGINT PRIMARY KEY,
  name       TEXT NOT NULL,
  currency   TEXT NOT NULL          -- 通貨を勘定単位で固定
);

-- entries: 変更不可能な取引の行。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プレイグラウンドでそのまま実行できます。

「残高カラムひとつでいいのでは?」

ここで多くのエンジニアが投げる問いに正面から答えましょう。「毎回すべての記入を合算するのは遅くないか? 残高カラムをひとつ置いてキャッシュのように使えばいいのでは?」

核心は残高カラムを真実の源にしないことです。性能のために残高をどこかに事前計算しておく(materialize)こと自体はよくある、合理的なことです。しかしそれはあくまで記入から派生したキャッシュであるべきです。真実は常に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;
-- 二つの値が異なれば -> 台帳が壊れたというアラーム。即座に調査対象。

また残高キャッシュを使うなら、キャッシュされた残高が記入を最初から合算した値と一致するか定期的に照合します。ずれていればキャッシュを再計算し、原因を追います。

**外部消込。**内部台帳が自ら一貫していても、それが外の世界(銀行口座、PSPの精算明細)と合うかは別問題です。そこで台帳の記録を銀行明細やPSPの精算ファイルと照合します。先の決済システムの記事で扱った消込がまさにこの層です。内部台帳が「私たちが記録した真実」なら、外部消込は「その真実が実際のお金と一致するか」を確認します。

よく設計された台帳はこの二つの消込を毎日自動で回し、不一致を指標として観測します。消込が綺麗に合うことは、システムがお金を失っていないもっとも強力な証拠です。そして消込が可能な唯一の理由は、台帳がそもそもすべての取引を変更不可能に保存しているからです。

並行性のもとの整合性

台帳は複数の取引が同時に殺到しても整合性を保たねばなりません。ここで二つの代表的な並行性問題を押さえます。

第一に、**失われた更新(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のような専用台帳を検討しますが、その根底の原理は変わりません。

決済システム三部作を貫く一文で締めくくります。**お金を扱うシステムは、間違える余地を構造的になくさねばならない。**冪等性が重複をなくし、複式簿記が誤りを暴き、消込が真実を確認します。この三つが一緒にあるとき、はじめて私たちは「お金を失わないシステム」だと自信をもって言えます。

参考資料

현재 단락 (1/132)

決済システムを作っていると、必ずこの問いにぶつかります。「ユーザーの残高をどう保存するか?」もっとも素朴な答えは「勘定テーブルに残高カラムをひとつ置き、お金が入れば足し、出れば引く」です。ところがこの...

작성 글자: 0원문 글자: 9,283작성 단락: 0/132