はじめに — 1円が合わないと銀行は止まる
銀行システムに初めて触れる開発者が最も驚くのは、「残高が1円でも合わなければ日次締めが完了しない」という事実です。一般的なWebサービスであれば、データが多少ずれても補正バッチを回して先へ進めますが、銀行の元帳は違います。元帳は顧客資産の法的記録であり、会計監査や金融当局の検査の対象であり、借方と貸方の合計が一致しなくなった瞬間、その営業日の日次締め自体が中断されます。
この記事では、銀行の元帳(ledger)をゼロから設計すると仮定し、複式簿記の基本原理からデータモデル、残高計算戦略、イベントソーシング、同時実行制御、分散環境における整合性の問題まで順に扱います。フィンテックスタートアップで内部元帳を作る方や、ポイント・前払チャージ金のような擬似元帳を設計する方にもそのまま適用できる内容です。
なお、この記事はシステム設計の観点からの技術資料であり、会計・税務・法律の助言ではありません。実際の金融業の許認可や会計処理は、必ず専門家と関連規制をご確認ください。
元帳はなぜ銀行の信頼できる唯一の情報源なのか
銀行の勘定系(コアバンキング)システムにおいて、元帳はすべての取引の最終記録です。それ以外のすべてのデータ — 画面に表示される残高、情報系の分析データ、モバイルアプリの取引履歴 — は、元帳から派生したコピーに過ぎません。
元帳が信頼できる唯一の情報源(source of truth)でなければならない理由は3つあります。
1. **法的証拠力**: 紛争発生時、元帳の記録が法的根拠になります。したがって記録は不変(immutable)でなければならず、変更は修正ではなく訂正記録の追加によってのみ行われます。
2. **会計の完全性**: 銀行の財務諸表は元帳から集計されます。総勘定元帳(General Ledger)の借方合計と貸方合計は常に一致しなければなりません。
3. **監査の追跡可能性**: 誰が、いつ、どの取引を、どの根拠で記録したかを再構成できなければなりません。
この3つの要求が、元帳設計のすべての決定 — 追記専用(append-only)構造、複式簿記、冪等性、監査カラム — を導き出します。
複式簿記の基本 — 借方と貸方
複式簿記(double-entry bookkeeping)は、すべての取引を最低2つの勘定に同時に記録する方式です。一方は借方(Debit)、もう一方は貸方(Credit)であり、1つの取引の中で借方金額の合計と貸方金額の合計は必ず等しくなければなりません。
勘定科目は大きく5つに分類され、分類によって増加が借方か貸方かが決まります。
| 勘定分類 | 例 | 増加時の記録 | 減少時の記録 |
| --- | --- | --- | --- |
| 資産(Asset) | 現金、貸出債権 | 借方 | 貸方 |
| 負債(Liability) | 顧客預金 | 貸方 | 借方 |
| 資本(Equity) | 資本金 | 貸方 | 借方 |
| 収益(Revenue) | 利息収益、手数料収益 | 貸方 | 借方 |
| 費用(Expense) | 利息費用 | 借方 | 貸方 |
ここで開発者がよく混乱するポイントがあります。顧客預金は銀行から見ると**負債**です。顧客から預かったお金は、いつか返さなければならない借金だからです。逆に貸出は銀行から見ると**資産**(受け取るべきお金)です。
例として、顧客Aが現金100万ウォンを入金する取引を仕訳(journal entry)すると次のようになります。
| 勘定科目 | 借方 | 貸方 |
| --- | --- | --- |
| 現金(資産の増加) | 1,000,000ウォン | |
| 顧客預り金(負債の増加) | | 1,000,000ウォン |
顧客Aが顧客Bに30万ウォンを振り込む行内振替はこうなります。
| 勘定科目 | 借方 | 貸方 |
| --- | --- | --- |
| 顧客預り金-A(負債の減少) | 300,000ウォン | |
| 顧客預り金-B(負債の増加) | | 300,000ウォン |
貸出利息5万ウォンを受け取る取引は次の通りです。
| 勘定科目 | 借方 | 貸方 |
| --- | --- | --- |
| 顧客預り金-A(負債の減少) | 50,000ウォン | |
| 利息収益(収益の増加) | | 50,000ウォン |
すべての仕訳で借方合計と貸方合計が等しいという不変条件 — これを貸借平均(balanced entry)と呼びます — が元帳整合性の核心です。この不変条件は、アプリケーションコード、DB制約、バッチ検証の三重で守らなければなりません。
元帳データモデル — 仕訳、ポスティング、残高
実務で実証されている元帳モデルは、3つのコアテーブルで構成されます。
- **journal_entries(仕訳ヘッダ)**: 1つのビジネス取引。取引ID、取引種別、発生時刻、冪等性キーを持ちます。
- **postings(仕訳明細)**: 仕訳を構成する個々の借方・貸方記録。1つの仕訳に最低2件が属します。
- **account_balances(残高)**: 勘定別の現在残高を持つ、マテリアライズドビュー的な性格のテーブル。
PostgreSQLベースのスキーマ例です。
-- 勘定マスタ
CREATE TABLE accounts (
account_id BIGINT PRIMARY KEY,
account_no VARCHAR(20) NOT NULL UNIQUE,
account_type VARCHAR(10) NOT NULL, -- ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE
currency CHAR(3) NOT NULL DEFAULT 'KRW',
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 仕訳ヘッダ(ジャーナル)
CREATE TABLE journal_entries (
entry_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
txn_id UUID NOT NULL UNIQUE, -- 冪等性キー
txn_type VARCHAR(30) NOT NULL, -- DEPOSIT, TRANSFER, INTEREST ...
business_date DATE NOT NULL, -- 起算日(営業日基準)
posted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reversal_of BIGINT REFERENCES journal_entries(entry_id),
description TEXT,
created_by VARCHAR(50) NOT NULL
);
-- 仕訳明細(ポスティング) — 追記専用、UPDATE/DELETE禁止
CREATE TABLE postings (
posting_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entry_id BIGINT NOT NULL REFERENCES journal_entries(entry_id),
account_id BIGINT NOT NULL REFERENCES accounts(account_id),
direction CHAR(1) NOT NULL CHECK (direction IN ('D', 'C')),
amount NUMERIC(19, 4) NOT NULL CHECK (amount > 0),
currency CHAR(3) NOT NULL,
business_date DATE NOT NULL
);
CREATE INDEX idx_postings_account_date
ON postings (account_id, business_date, posting_id);
-- 勘定別残高(派生データ)
CREATE TABLE account_balances (
account_id BIGINT PRIMARY KEY REFERENCES accounts(account_id),
balance NUMERIC(19, 4) NOT NULL DEFAULT 0,
last_posting_id BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
設計のポイントを確認しましょう。
1. **金額はNUMERIC(またはDECIMAL)**: 浮動小数点(FLOAT、DOUBLE)は絶対禁止です。0.1 + 0.2が0.3にならない世界でお金を扱うことはできません。通貨の最小単位(円、セント)の整数で保存する方式も広く使われています。
2. **directionとamountの分離**: 負の金額で貸借を表現する方式より、方向カラムと正の金額に分離した方が、集計クエリと検証が明確になります。
3. **business_date(起算日)とposted_at(システム時刻)の分離**: 23時59分に入った取引が翌営業日として起算されるケースのように、会計上の日付と物理的な記録時刻は異なり得ます。
4. **postingsは追記専用**: データベースの権限レベルでUPDATEとDELETEを遮断します。
-- アプリケーション用ロールにはINSERT/SELECTのみ許可
REVOKE UPDATE, DELETE ON postings FROM app_user;
GRANT INSERT, SELECT ON postings TO app_user;
残高計算戦略 — 合算 vs リアルタイム残高 vs スナップショット
勘定残高を求める方法は大きく3つあり、それぞれトレードオフが異なります。
| 戦略 | 方式 | 長所 | 短所 |
| --- | --- | --- | --- |
| ポスティング合算 | 毎回SUMクエリ | 常に正確、派生状態なし | 取引が蓄積すると遅くなる |
| リアルタイム残高テーブル | 取引ごとに残高更新 | 参照はO(1)、残高チェックが即時可能 | 更新競合、整合性の二重管理 |
| スナップショット+増分 | 日次締め時点の残高を保存し、以降の増分のみ合算 | 参照が速く検証可能 | スナップショットバッチの運用が必要 |
実務ではこの3つを組み合わせます。取引時点では残高テーブルを更新し(残高不足の遮断のため)、日次締めでスナップショットを残し、検証バッチがポスティング合算と残高テーブルを照合します。
スナップショットベースの残高計算クエリは次のような形になります。
-- 昨日の締めスナップショット + 今日の増分 = 現在残高
SELECT s.balance
+ COALESCE(SUM(
CASE WHEN p.direction = 'C' THEN p.amount
ELSE -p.amount END), 0) AS current_balance
FROM balance_snapshots s
LEFT JOIN postings p
ON p.account_id = s.account_id
AND p.business_date > s.snapshot_date
WHERE s.account_id = 1234567
AND s.snapshot_date = (
SELECT MAX(snapshot_date) FROM balance_snapshots
WHERE account_id = 1234567)
GROUP BY s.balance;
負債勘定(預金)は貸方が増加なので、上記のようにCを加算しDを減算します。資産勘定は符号が逆になります。勘定分類ごとの符号ルールを1か所に集約しないと、必ずバグが発生します。
イベントソーシングと元帳 — 訂正は反対仕訳で
元帳は本質的にイベントソーシング(event sourcing)と同じ構造です。不変の追記専用ジャーナルがイベントストリームであり、残高はイベントをフォールド(fold)した派生状態です。会計が数百年前からやってきた方式が、そのままイベントソーシングなのです。
最も重要な原則は、**記録を絶対に書き換えない**ことです。誤った仕訳はUPDATEで修正するのではなく、元の仕訳を相殺する反対仕訳(reversal entry)を追加し、正しい仕訳を改めて記録します。
def reverse_entry(conn, original_entry_id: int, reason: str, operator: str):
"""元の仕訳を相殺する反対仕訳を生成する。
元仕訳の各ポスティングについて、方向(D/C)を反転した
ポスティングを作成する。元データは絶対に変更しない。
"""
with conn.cursor() as cur:
すでに反対仕訳済みかどうか確認(二重反対仕訳の防止)
cur.execute(
"SELECT 1 FROM journal_entries WHERE reversal_of = %s",
(original_entry_id,))
if cur.fetchone():
raise AlreadyReversedError(original_entry_id)
cur.execute(
"""INSERT INTO journal_entries
(txn_id, txn_type, business_date,
reversal_of, description, created_by)
SELECT gen_random_uuid(), 'REVERSAL', CURRENT_DATE,
entry_id, %s, %s
FROM journal_entries WHERE entry_id = %s
RETURNING entry_id""",
(reason, operator, original_entry_id))
reversal_id = cur.fetchone()[0]
方向を反転したポスティングをコピー
cur.execute(
"""INSERT INTO postings
(entry_id, account_id, direction, amount,
currency, business_date)
SELECT %s, account_id,
CASE direction WHEN 'D' THEN 'C' ELSE 'D' END,
amount, currency, CURRENT_DATE
FROM postings WHERE entry_id = %s""",
(reversal_id, original_entry_id))
return reversal_id
反対仕訳方式の長所は明確です。
- 元の記録と訂正記録が両方残るため、監査証跡が完全です。
- 残高再計算ロジックが単純です。すべてのポスティングを合算すれば常に現在の状態が得られます。
- 「ある時点の残高」を再構成できます(時点照会、point-in-time query)。
注意すべき点は、反対仕訳が発生した日付です。元取引が昨日であっても、反対仕訳は今日の起算日で記録するのが一般的です。すでに締めた営業日の数字を変えると、レポートと会計帳簿が遡及的に変更されてしまうからです。
同時実行制御 — ホットアカウント問題
個々の顧客口座では同時取引はまれですが、手数料収益勘定や行内の未決済勘定のような場所には毎秒数千件のポスティングが集中します。このような勘定をホットアカウント(hot account)と呼びます。
取引ごとに残高テーブルを更新する構造では、ホットアカウントがロック競合の中心になります。
[ホットアカウントのロック競合]
取引1 ──┐
取引2 ──┤ 全員が同じ行を UPDATE account_balances
取引3 ──┼─ 更新しようと待機 ──▶ SET balance = balance + ?
... ──┤ WHERE account_id = 9999 (手数料勘定)
取引N ──┘
──▶ 直列化され、スループットが単一行更新速度に制限される
解決戦略は段階的に適用します。
1. **残高の即時更新が本当に必要か再検討**: 手数料収益勘定には限度チェックが不要なので、ポスティングだけを記録し、残高は非同期で集計しても構いません。限度・残高不足チェックが必要な勘定だけ同期更新します。
2. **ロック順序の固定**: 振込のように2つの口座を更新する場合は、常にaccount_idの昇順でロックを取得し、デッドロックを予防します。
3. **サブアカウントのシャーディング**: ホットアカウントをN個のサブ勘定に分割し、ポスティングをハッシュ分散し、参照時に合算します。
-- ロック順序固定の例: 常に小さいaccount_idから
SELECT * FROM account_balances
WHERE account_id IN (1001, 2002)
ORDER BY account_id
FOR UPDATE;
[サブアカウントのシャーディング]
手数料収益勘定 (論理)
│
├── 手数料収益-00 ◀─ hash(txn_id) % 4 == 0
├── 手数料収益-01 ◀─ hash(txn_id) % 4 == 1
├── 手数料収益-02 ◀─ hash(txn_id) % 4 == 2
└── 手数料収益-03 ◀─ hash(txn_id) % 4 == 3
参照時: 4つのサブ勘定残高をSUM
締め時: サブ勘定を本勘定へ集約仕訳(任意)
シャーディングの代償は、「論理勘定の正確なリアルタイム残高」を単一行の読み取りで得られないことです。限度チェックが必要な勘定には適用しにくく、主に集計用の内部勘定に使います。
冪等性と重複防止 — 取引IDの設計
金融システムにおいてリトライは日常です。ネットワークタイムアウト後にクライアントがリトライしたとき、振込が2回実行されてはいけません。核心は、**クライアントが生成した取引ID(冪等性キー)をジャーナルのユニーク制約で強制する**ことです。
-- txn_idのUNIQUE制約が重複記帳をDBレベルで遮断
INSERT INTO journal_entries (txn_id, txn_type, business_date, created_by)
VALUES ('3fa85f64-5717-4562-b3fc-2c963f66afa6', 'TRANSFER', CURRENT_DATE, 'api')
ON CONFLICT (txn_id) DO NOTHING
RETURNING entry_id;
-- RETURNINGが空なら処理済みの取引 → 既存の結果を照会して返す
取引ID設計時に確認すべき事項です。
- **生成主体**: 呼び出し側(チャネル系、外部機関)が生成してこそ、リトライ間の同一性が保証されます。サーバーが生成すると冪等性が壊れます。
- **範囲と寿命**: 機関コード+日付+連番の組み合わせ(金融電文でよくある方式)の場合、日付が変わるときに再利用されるか、ユニーク制約の範囲に日付を含めるべきかを確認します。
- **レスポンスの再生**: 重複リクエストにはエラーではなく、**最初の処理結果と同一のレスポンス**を返さなければなりません。そのために処理結果を取引IDで照会可能な形で保存します。
整合性検証 — 信頼せよ、されど検証せよ
元帳の不変条件はコードだけでは守られません。デプロイ事故、手動データ補正、バグはいつでも整合性を壊し得るため、検証を常時運用しなければなりません。
**1. 仕訳単位の貸借平均チェック** — すべての仕訳で借方合計と貸方合計が等しいか確認します。
SELECT entry_id,
SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END) AS debit_sum,
SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END) AS credit_sum
FROM postings
WHERE business_date = CURRENT_DATE
GROUP BY entry_id
HAVING SUM(CASE WHEN direction = 'D' THEN amount ELSE 0 END)
<> SUM(CASE WHEN direction = 'C' THEN amount ELSE 0 END);
-- 結果が0件なら正常
**2. 試算表(trial balance)チェック** — 元帳全体の借方総合計と貸方総合計が一致するか確認します。
**3. 残高テーブルの照合** — account_balancesの値とpostings合算値を勘定別に比較します。不一致の勘定はアラートを発生させ、自動補正ではなく**原因調査**が先です。
**4. 対外照合(reconciliation)** — 他行振込であれば決済機関の電文履歴と、カード売上であればカード会社の精算ファイルと照合します。内部整合性と外部整合性は別の問題です。
検証バッチは日次締めの直前と直後にそれぞれ実行し、差異がゼロでなければ締めを中断するゲートとして配置します。
分散元帳の問題 — マイクロサービス時代の二重記帳
元帳サービスと決済サービスが分離されたマイクロサービス環境では、「決済は成功したのに元帳記帳が失敗」という部分失敗が発生します。分散トランザクション(2PC)は可用性と運用コストの理由から金融業界でも次第に避けられるようになり、サーガ(saga)パターンと補償トランザクションが標準的なアプローチになりました。
[振込サーガ — 正常フローと補償フロー]
チャネル ──▶ 振込オーケストレーター
│
├─ 1. 出金の元帳記帳 (出金口座 D / 未決済 C)
│ 成功
├─ 2. 対外送金リクエスト (決済機関/他行)
│ │
│ ├─ 成功 ──▶ 3. 未決済の解消仕訳 (未決済 D / 他行債権 C)
│ │
│ └─ 失敗 ──▶ 3'. 補償: 出金の反対仕訳 (未決済 D / 出金口座 C)
│
└─ すべてのステップは冪等 — 同じtxn_idでリトライしても結果は同一
設計原則は次の通りです。
- **未決済(suspense)勘定を明示的にモデリング**します。「お金が宙に浮いている状態」を勘定として表現すれば、サーガの中間状態でも貸借平均が維持されます。
- **補償は削除ではなく反対仕訳**です。失敗したフローも記録として残ります。
- **タイムアウトは失敗ではありません。** 対外機関の応答タイムアウト時は結果が不明なので、取引状態を未確認(UNKNOWN)とし、照合または取引結果照会で確定してから補償の要否を決定します。金融事故の定番の原因が、「タイムアウトを失敗とみなして補償したが、元取引も成功していた」という二重処理です。
会計システム連携 — 勘定系から総勘定元帳へ
顧客単位の取引元帳(補助元帳、sub-ledger)と財務会計の総勘定元帳(GL)は、通常分離されています。勘定系の数百万件の取引をそのままGLに入れるのではなく、勘定科目・組織・通貨単位で**集約仕訳**を作成し、日次でGLに転記(posting)します。
[勘定系 → 総勘定元帳の連携]
勘定系元帳 (取引単位、数百万件/日)
│
▼ 日次締めバッチ: 勘定科目 x 部署 x 通貨でGROUP BY
集約仕訳ファイル (数千件/日)
│
▼ GLインターフェース (検証: 貸借平均、勘定科目マッピング存在)
総勘定元帳 (財務報告用)
ここでの落とし穴は**マッピング漏れ**です。新商品や新取引種別が追加されたのにGL勘定科目マッピングがない場合、集約バッチが失敗するか(幸運)、マッピング漏れ分が静かに欠落します(災難)。マッピングのない取引種別を発見したらバッチを失敗させる方が安全です。
監査証跡 — 誰が、いつ、なぜ
元帳データには取引そのもの以外に、次のメタデータが必要です。
- 記録主体: チャネル(モバイル/窓口/バッチ)、オペレーターID、承認者ID
- 根拠: 元取引への参照、反対仕訳の理由、関連電文(message)ID
- 時刻: システム時刻、起算日、(必要に応じて)チャネルリクエスト時刻
電子金融取引の記録は、韓国の電子金融監督規定などの規制により、種類ごとに数年単位の保存義務があります(種類により1年、5年など)。保存期間の設計はコンプライアンス部門と必ず一緒に決定し、パーティショニングとアーカイブ戦略を最初から考慮すべきです。
よくあるバグ事例
実際によく遭遇する元帳バグの類型です。
1. **浮動小数点の使用**: 利息計算をdoubleで行い、累積誤差で日次締めが1円不一致。金額は最初から最後まで十進精度型で。
2. **符号ルールの分散**: 「資産は借方が増加」というルールがサービスAとバッチBにそれぞれハードコードされ、片方だけ修正される。符号ルールは単一モジュールに。
3. **リトライ時に新規取引IDを発行**: クライアントSDKがリトライのたびにUUIDを新規生成し、冪等性が無力化される。
4. **反対仕訳の反対仕訳**: 同一仕訳に反対仕訳が2回かかり、残高が逆方向に狂う。反対仕訳の存在有無をユニーク制約で遮断。
5. **締め後の遡及記帳**: 締め済みの営業日にポスティングが入り、レポートと元帳が不一致。business_dateに対する締めガードが必要。
6. **タイムゾーン問題**: UTCとJSTが混在し、深夜0時付近の取引の起算日がずれる。起算日決定ロジックを1か所に。
設計チェックリスト
- [ ] 金額の型は十進精度(NUMERIC/DECIMALまたは最小単位の整数)か
- [ ] postingsは追記専用で、DB権限でUPDATE/DELETEが遮断されているか
- [ ] すべての仕訳が貸借平均であることをアプリケーションとバッチの両方で検証しているか
- [ ] 取引IDは呼び出し側が生成し、ユニーク制約で重複が遮断されるか
- [ ] 重複リクエストに最初の結果と同一のレスポンスを再生しているか
- [ ] 訂正は反対仕訳のみで行い、二重反対仕訳が遮断されているか
- [ ] 起算日とシステム時刻が分離されており、締め日への遡及記帳が遮断されているか
- [ ] ホットアカウントを特定し、同期残高更新が必要な勘定を最小化したか
- [ ] 残高テーブルとポスティング合算の照合バッチが運用されているか
- [ ] 未決済勘定で分散取引の中間状態を表現しているか
- [ ] タイムアウトを未確認状態として扱い、照合で確定しているか
- [ ] GLマッピング漏れ時にバッチが失敗するようになっているか
- [ ] 監査メタデータ(主体、根拠、時刻)と保存期間が定義されているか
おわりに
元帳設計の本質は、華やかな技術ではなく**不変条件を最後まで守り抜く執念**です。複式簿記という数百年来の不変条件、追記専用という単純なルール、冪等性という分散システムの基本 — この3つをコード、スキーマ、バッチ検証の三重で強制すれば、1円の整合性は守られます。逆に、どれか1つの層でも「後でやろう」と先送りすれば、そのコストは必ず未明の締め障害として返ってきます。
次の記事では、この元帳の上で動く金融機関のバッチと日次締め(EOD)アーキテクチャを扱います。
参考資料
- ISO 20022 公式サイト: https://www.iso20022.org/
- BIS(国際決済銀行): https://www.bis.org/
- バーゼル銀行監督委員会(BCBS): https://www.bis.org/bcbs/
- SWIFT 標準ドキュメント: https://www.swift.com/standards
- 韓国金融決済院(KFTC): https://www.kftc.or.kr/
- 韓国金融監督院(FSS): https://www.fss.or.kr/
- Martin Fowler — Event Sourcing: https://martinfowler.com/eaaDev/EventSourcing.html
- Martin Fowler — Accounting Patterns: https://martinfowler.com/eaaDev/AccountingNarrative.html
- PostgreSQL 公式ドキュメント(Numeric Types): https://www.postgresql.org/docs/current/datatype-numeric.html
- PostgreSQL 公式ドキュメント(Explicit Locking): https://www.postgresql.org/docs/current/explicit-locking.html
- IFRS財団: https://www.ifrs.org/
현재 단락 (1/267)
銀行システムに初めて触れる開発者が最も驚くのは、「残高が1円でも合わなければ日次締めが完了しない」という事実です。一般的なWebサービスであれば、データが多少ずれても補正バッチを回して先へ進めますが、...