「昨日の取引」がシステムごとに違って数えられた
一言でいうと
瞬間(instant)とカレンダー上の日付は別物です。銀行の決済日は、瞬間をどの標準時のカレンダーに載せるかを決め、締め時刻と営業日カレンダーを適用して計算で求める値です。
なぜ必要なのか
「昨日の取引は何件でしたか」という質問に、3つのシステムが互いに違う数字を出したとしましょう。1つはUTC午前0時で区切り、1つはKST午前0時で区切り、1つは前日の締め時刻から今日の締め時刻までで数えました。3つとも、自分の基準では正しいのです。そのため、このずれは障害には見えず、「集計が少し違うね」で埋もれます。埋もれたまま、清算の突合で表に出ます。
原因は、ほぼいつも同じです。データには受付の瞬間しかないのに、人と規定は日付で語ります。瞬間は地球のどこで測っても1つですが、その瞬間が何日なのかは、標準時を決めて初めて答えが出ます。この変換をコードのあちこちでその場しのぎに行うと、変換ルールがコードの数だけできます。
ここに、銀行ならではの事情がもう1つ加わります。お金が実際にやり取りされる日、つまり決済日(value date)は、受付した日と異なることがあります。締め時刻(cutoff)を過ぎてから入ったリクエストは翌営業日に回り、その翌日が土曜日か休業日なら、さらに後ろにずれます。そのため、決済日は保存しておく事実ではなく、ルールの出力物です。
どう動くのか
設計は3つの層に分かれます。
- 保存は瞬間で行います。受付時刻はUTCで、RFC 3339の
2026-07-02T07:30:00Zの形で残します。RFC 3339は、オフセットのない時刻を「ローカル時刻」と呼び、それだけでは瞬間を特定できないと明記しています。オフセットのない文字列をDBに入れた瞬間、その列はもう瞬間ではありません。 - 表示とバケット化はローカル時刻で行います。Pythonのzoneinfoは、オペレーティングシステムのIANAタイムゾーンデータベースを読んで、
ZoneInfo("Asia/Seoul")を作ります。astimezone()で移したあとに.date()を取ると、その瞬間がソウルのカレンダーで何日かが出ます。固定で9時間を足すコードと結果が分かれるのが、まさにここです。 - 決済日はルールで求めます。ローカル時刻の時分が締め時刻以上なら1日足し、その日付から営業日を探して前へ送ります。営業日の判定は、週末と休業日のカレンダーから出ます。
固定オフセットがなぜ危険かは、韓国の資料ですぐにわかります。Asia/Seoulは今はUTC+9固定ですが、常にそうだったわけではありません。ラボイメージでzoneinfoを使って実際に測ってみると、次のとおりです。
1987-05-15 12:00 Asia/Seoul -> UTC+10 (KDT) # 실습 이미지에서 실측
1987-12-15 12:00 Asia/Seoul -> UTC+9 (KST) # 실습 이미지에서 실측
2026-07-15 12:00 Asia/Seoul -> UTC+9 (KST) # 실습 이미지에서 실측
전환 순간: 1987-05-10 02:00 에 +10 으로, 1987-10-11 03:00 에 +9 로 (1988년도 같은 모양)
このコードブロックの韓国語コメントは、最初の3行がラボイメージで実測した値であること、最後の行が切り替えの瞬間として、1987-05-10 02:00に+10へ、1987-10-11 03:00に+9へ切り替わり、1988年も同じ形であることを述べています。
1987・1988年の夏に韓国でサマータイムがあったという事実がtzdbにそのまま入っていて、だからzoneinfoはその期間にUTC+10を返します。固定で9時間を足すコードは、この期間の移行データを1時間ずつ間違って読みます。午前0時付近の取引なら、日付がまるごと1日ずれます。tzdbの設計文書は、タイムゾーンの定義が政治的な決定で変わり続け、過去の記録も新たに判明すれば修正されると書いています。そのため、ルールはコードではなくデータにあるべきで、そのデータはIANAタイムゾーンデータベースが更新します。
日付の計算をDBに任せるときも、同じ落とし穴があります。SQLiteの日付・時刻関数はlocaltime修飾子を提供しますが、それはサーバーのローカル時刻であって、業務が決めた標準時ではありません。コンテナのTZ設定1つで決済日が変わるシステムを作りたくなければ、標準時は設定値として明示し、変換はアプリケーションが行います。
現場での姿
最もよくある事故は、「UTCの日付をそのまま業務日付に使った」場合です。開発環境では、1日中UTCとKSTの日付が同じに見える時間帯でしかテストせず、本番で夜9時(= UTC 12時)以降の取引が積み上がるにつれて表に出ます。KST 09:00より前、つまりUTC午前0時の前後の取引が、前日に付いてしまうのです。
2つ目は、締め時刻をUTCで比較する場合です。received_utc[11:16] >= "16:30"のような比較は、今はたまたま答えが合うこともありますが、標準時の異なる相手機関の締めを同じコードで扱った瞬間に崩れます。比較は必ず、ローカル時刻に移したあとで行います。
3つ目は、相手側の日付です。ソウルで7月3日の朝に送った電文は、ロンドンではまだ7月2日です。相手機関のファイルのvalue_dateをこちらのカレンダーで読んで突合すると、1日分がまるごと未決済として残ります。datetimeのドキュメントが区別するawareとnaiveの違いが、実務でお金に変わる場面です。
実務で本当に大切なこと
- 瞬間は保存し、日付は計算します。決済日の列を元帳に埋め込むと、ルールが変わったとき、どれが古いルールの値かわからなくなります。再計算の結果は別に置き、根拠を残します。
- カレンダーと締め時刻はデータです。休業日は毎年変わり、機関ごとに違います。コードに埋め込むと、翌年に必ず間違います。
- 境界の時刻をテストします。締めの1秒前とちょうど、午前0時の前後、休業日の前日。バグはいつも境界にあります。
- 過去のデータには、過去のルールが適用されます。移行データを今日のオフセットで読まないようにします。
次のラボですること
12日分の送金受付元帳を自分で作り、今の基幹系が使う「UTC日付=決済日」というルールが、何件を間違えさせているかを数えます。休業日カレンダーと締め時刻を読む決済日計算エンジンを作り、営業日の加減算、締め時刻の判定、相手側標準時への変換を順に加えて、1987・1988年の移行分からサマータイムの区間を拾い出します。最後に、元帳を上書きせずに決済日を全件再計算し、何がなぜ変わるかをレポートにします。