
そのバグ、「誰かが書き換えた」せいじゃない?🤯
これ絶対あるじゃん??
- バグを直したはずなのに、全然違う画面で同じバグが再現する
console.logで確認した値が、数行後には別の値になってる- 「どこで変わったの?」をgrepし続けて30分が溶けた
- テスト環境では再現しないのに、本番だけ崩れる
これがGlobal Mutable State(グローバルミュータブル状態)、以降「誰でも書き換えボード」って呼ぶね、が原因なんだよね。
てかシンプルに言うとね
プログラムのどこからでも読み書きできる変数が、アプリ全体を支配してる状態っつーこと。
それの何がまずいの?ってなる気持ち、わかる!「みんなが同じデータ見られて便利じゃん」って最初は思うよね。でもこれ、実行順が少し変わるだけで全部崩れる地雷になってて、しかもどこが原因か追跡できないんだよ。
ちょっと待って、先に全然関係ない話していい?🏠
ある居酒屋チェーンの話をするね。
その店には大きなホワイトボードが1枚あって、「テーブルごとの注文状況」「食材の在庫数」「本日の売上合計」が全部そこに書いてあったの。フロアスタッフの吉田さんも、キッチンの松本くんも、会計の伊藤さんも、みんなそのボードを見て動いてた。全員が読めて、全員が書き換えられる。最初は「効率的でいいじゃん」って話だったんだよね。
ある金曜の夜、混んできた頃に事件は起きた。
吉田さんがテーブル3番の追加注文「唐揚げ2個」をボードに書こうとしたとき、松本くんが同時に在庫数を書き換えてた。「あれ、唐揚げ残り1個って書いてある」と松本くんが消して上書きした。吉田さんは「2個って書いといたのに…」と首をかしげながら、とりあえずボードには1個と記入した。
(まあいっか、お客さんには1個でお断りすればいいや)
でもその瞬間、伊藤さんは別の端末でボードを見て会計を打ってた。「テーブル3、唐揚げ2個分で計算したよ」。吉田さんが1個に直す前のボードを読んでたから。
唐揚げは1個しか届かなかったのに、レシートには2個分の金額が記載されていた。
「ちょっと!これ金額おかしくないですか!?」お客さんがレジで声を荒げた。
店長が飛んできて状況を確認しようとしたけど、ボードはすでに別の注文で上書きされてて、何が起きたか誰もわからなかったんだよね。吉田さんは「自分が書き換えたわけじゃない」、松本くんは「自分の更新は在庫通り」、伊藤さんは「ボードの数字通りに計算した」。全員が正しいことをしてたのに、結果は二重請求と欠品。えぐくない?
*(なんで自分のせいになってるんだろ)*と吉田さんは思いながら、お客さんにめっちゃ謝った。
これが本番で起きてるやつが、誰でも書き換えボードってこと。
じゃあコードで見てみよっか👀
# ❌ 誰でも書き換えボード状態
# グローバル変数がアプリ全体を漂ってる
order_state = {
"table_3": {"items": [], "total": 0},
"stock": {"karaage": 2}
}
def add_order(table_id, item, quantity):
# 在庫チェックしてから注文に追加
if order_state["stock"][item] >= quantity: # ① ここで在庫を確認
order_state["table_3"]["items"].append( # ③ ここで追加
(item, quantity)
)
order_state["stock"][item] -= quantity # ④ ここで在庫を減らす
def kitchen_update(item, remaining):
# キッチンが在庫を直接書き換える
order_state["stock"][item] = remaining # ② ←①と③の間に走ったら?
def calculate_bill(table_id):
# 注文内容を元に合計を計算して上書き
total = len(order_state["table_3"]["items"]) * 500
order_state["table_3"]["total"] = total # add_orderの直前に走ったら?
return total
add_orderが「在庫チェック(①)→注文追加(③)」の間にkitchen_updateが割り込んで在庫を書き換えると(②)、チェックは通るのに実際の在庫は足りてない状態が生まれる。
わたしも昔これやったから言えるんだけど、この系のバグって「再現条件が実行順」だから、ローカルで動かしてると全然出ないんだよね。本番のトラフィックが増えた瞬間に深夜Slackが来る。
23:47 松本: テーブル管理がおかしい件、誰か確認できますか
23:53 吉田: 会計の数値もズレてます
00:02 店長: 全テーブルの状態が信頼できない状態です、至急対応お願いします
翌朝、全注文ログを手動で突き合わせる作業が始まる。
やばいのまだあんだけど、バグに気づけない問題😭
「壊れやすい」だけじゃないんだよね。壊れてるのにそれに気づけないのがヤバいとこ。
さっきの居酒屋に戻ると、テストしようとしたとき何が起きるか想像してみて。
「注文追加」をテストするには、在庫の初期値が必要。でも在庫は「キッチン更新」でも変わる。「会計計算」をテストするには、注文状態が揃ってる必要がある。でも注文状態は「注文追加」「キャンセル」「修正」どれでも変わる。つまり3つの機能の全実行順パターンをテストしないといけない。
# テストケースが爆発する例
def test_add_order_alone():
order_state["stock"]["karaage"] = 2 # ← テスト前にグローバルをリセット必要
order_state["table_3"] = {"items": [], "total": 0}
add_order("table_3", "karaage", 1)
assert len(order_state["table_3"]["items"]) == 1 # OK
def test_add_order_after_kitchen_update():
# kitchen_updateが先に走った場合
order_state["stock"]["karaage"] = 2
kitchen_update("karaage", 1) # ← これが先に走ったら?
add_order("table_3", "karaage", 2) # 在庫1なのに2個注文 → どうなる?
# ...
def test_calculate_during_add_order():
# add_orderとcalculate_billが同時に走った場合
# → 組み合わせはまだまだある
# 関数が10個になったら 10! = 3,628,800通り
pass
機能が3つあって、それぞれがグローバル状態を読み書きするなら、テストすべき実行順パターンは 3! = 6通り。機能が10個になったら 10! = 3,628,800通り。全部テストできる?ムリじゃん。だから本番でしか出ないバグが生まれるんだよね。
誰でも書き換えボードは壊れやすいだけじゃなく、バグを隠す設計でもある。
分けてた会社の話もするね〜✨
同じ居酒屋チェーンで、別の店舗の話もするね。
そこでは担当が明確に決まってたの。吉田さんが書き込めるのはテーブルボードだけ。松本くんが書き込めるのはキッチンの在庫ボードだけ。伊藤さんはテーブルボードを「読む」だけで、書き込みは一切しない。
金曜の夜、同じくらい混んでいたけど、別の結果になった。
テーブル3に唐揚げ2個の追加注文が入ったとき、吉田さんはテーブルボードに書いた。松本くんはキッチンボードの在庫を確認して消費処理した。ボードが分かれてて、書き込める人が決まってたから、誰かの更新が誰かの読み取りを壊すことがなかった。
伊藤さんはテーブルボードを読んで会計を出した。吉田さんが書いた通りの内容が、そのまま伝わった。
混乱なし。二重請求なし。深夜のSlack通知なし。全員が定時で帰ったっていう。
コードで見るとこう👀
# ✅ 誰が持って・誰が変えていいかを明確にした設計
from dataclasses import dataclass, field
from typing import List, Tuple
@dataclass(frozen=True)
class Stock:
"""在庫の状態 - Kitchenだけが変更できる。frozen=Trueで外から書き換え不可"""
karaage: int = 2
def consume(self, quantity: int) -> "Stock":
# 元のStockは変えず、新しいStockを返す(イミュータブル)
if self.karaage < quantity:
raise ValueError("在庫不足")
return Stock(karaage=self.karaage - quantity)
@dataclass(frozen=True)
class TableOrder:
"""テーブルの注文状態 - Floorだけが追加できる"""
table_id: str
items: Tuple[Tuple[str, int], ...] = ()
def add_item(self, item: str, quantity: int) -> "TableOrder":
# 元のTableOrderは変えず、新しいものを返す
return TableOrder(
table_id=self.table_id,
items=self.items + ((item, quantity),)
)
class BillingService:
"""会計 - TableOrderを読むだけ。絶対に書き込まない"""
PRICE = {"karaage": 500}
def calculate(self, order: TableOrder) -> int:
# orderを受け取って読むだけ。グローバル状態に一切触れない
return sum(self.PRICE[item] * qty for item, qty in order.items)
# 使い方
stock = Stock(karaage=2)
order = TableOrder(table_id="table_3")
# 注文追加(新しいオブジェクトが返る。元のorderは変わらない)
order = order.add_item("karaage", 2)
# 在庫消費(新しいStockが返る。元のstockは変わらない)
stock = stock.consume(2)
# 会計(orderを読むだけ。何も書き換えない)
billing = BillingService()
total = billing.calculate(order) # → 1000
StockもTableOrderもfrozen=Trueなので、作った後は誰も書き換えられない。変更したいなら必ず新しいオブジェクトを作る。kitchen_updateがadd_orderの途中に割り込んでも、それぞれが自分のデータしか触れないから競合が起きない構造になってる。
ちゃんとした名前もあるから一応言うね📚
グローバルミュータブル状態の問題とは、「状態の所有者と変更権限が定義されていないこと」っつーこと。
これを解決するアプローチは複数あって、まとめて状態管理パターンと呼ばれてる。
- イミュータビリティ(一回作ったら変えない):今あるデータを書き換えるんじゃなくて、新しいデータを作る。プログラミング言語によっては、このルールを強制してるものもある
- 一方通行のデータの流れ(Flux / Redux):「Action → Reducer → State」っていう一方通行の流れでデータの変更を管理するやり方。2014年にFacebookがFluxとして発表して、Reduxがその考えを広めた
- 状態を閉じ込める:全体に見せる必要のないデータはコンポーネント・モジュールの中だけに置く
ちゃんとした名前としては、**Command Query Separation(CQS)**っていう考え方がベースになってるの。「値を変えるやつ(Command)」と「値を見るだけのやつ(Query)」を混ぜるな、っていうルールで、Bertrand Meyerって人が1988年に言い出したやつ。
難しそうに聞こえるけど、要は**「この変数、いま誰が持ってて誰が変えていいの?」を問い続けるだけ**なんだよね。
これだけ覚えて帰って👌
合言葉はこれ。dkkw(どこでも書けるこわ)
まず診断として、この問いを使って:
「この状態が変わるとしたら、どんな出来事が起きたとき?その出来事は1種類だけ?」
例えばuser_stateというグローバル変数があったとして:
- ログインしたとき → 変わる
- プロフィールを更新したとき → 変わる
- セッションが切れたとき → 変わる
出来事が3種類ある。これが「やばい」の検知。じゃあどうするかね。
ルール1:出来事ごとに状態を分ける
1個のグローバル変数に全部詰め込まない。出来事が3種類なら、担当するデータも3つに分ける。
# ❌ 全部1つに詰め込んでる
user_state = {} # ログイン情報も・プロフィールも・セッションも全部ここ
# ✅ 出来事ごとに分ける
class AuthState:
"""ログイン・セッション切れ だけが触る"""
user_id: str | None = None
session_expires_at: datetime | None = None
class ProfileState:
"""プロフィール更新 だけが触る"""
name: str = ""
avatar_url: str = ""
担当するデータが分かれたら、AuthStateがバグってもProfileStateは無傷になる。
ルール2:書き換えるなら新しいオブジェクトを返す
既存の状態を上書きするんじゃなく、変更後の新しい状態を作って返す。そうすると「変更前」が消えないから、何が起きたか追跡できる。
# ❌ 上書きする → 変更前が消える
def on_login(user_id):
user_state["id"] = user_id # 元の状態が何だったか残らない
# ✅ 新しいオブジェクトを返す → 変更前が残る
@dataclass(frozen=True)
class AuthState:
user_id: str | None = None
def login(self, user_id: str) -> "AuthState":
return AuthState(user_id=user_id) # 元のAuthStateは変わらない
# 使い方
auth = AuthState() # user_id=None
auth = auth.login("user_42") # 新しいAuthStateが返る。元のauthは消えてない
ルール3:「読む人」に書き込み権限を渡さない
会計の伊藤さんがテーブルボードを書き換えられなかったように、状態を「読むだけの関数」には変更手段を渡さない。引数で受け取ったデータを読んで返すだけにする。
# ❌ グローバルに直接触れる → いつか誰かが書き換える
def calculate_bill():
total = len(order_state["items"]) * 500 # グローバルを読んでる
order_state["total"] = total # ← 読むだけのはずが書いてる
# ✅ 状態を引数で受け取る → 書き換えようがない
def calculate_bill(order: TableOrder) -> int:
return sum(PRICE[item] * qty for item, qty in order.items)
# orderを受け取るだけ。グローバルに一切触れない
この3つをやるだけで、「なんか知らんけど値が変わってた」がほぼ消えるんだよね。
まとめるとこういうことね
| 誰でも書き換えボード(アンチパターン) | 状態の所有者を決めた設計 | |
|---|---|---|
| 変更権限 | 誰でも・どこからでも書き換え可能 | 変更できるのは所有者だけ |
| バグの追跡 | どこで変わったか追跡できない | 変更の経路が一本道 |
| テスト | 実行順の組み合わせで爆発する | 各モジュールを独立してテスト可能 |
| 本番障害 | 高トラフィック時に突然壊れる | 競合状態が構造的に起きない |
| 影響範囲 | 全コードが依存しているので全体が危険 | 変更が局所化されている |
「なぜか値が変わってた」でgrepし続けたあの時間は、誰でも書き換えボードに吸い取られてた時間なんだよね。「誰が持って・誰が変えていいか」を最初に決めるだけで、それが消える。
沼りたい人はこっちも読んで📖
書籍
-
Robert C. Martin(2008)『Clean Code: A Handbook of Agile Software Craftsmanship』Prentice Hall
副作用のない関数設計と、グローバル状態を避けることの重要性を解説している -
Martin Fowler(2018)『Refactoring: Improving the Design of Existing Code(第2版)』Addison-Wesley
グローバル変数の除去を含むリファクタリング手法を体系化している -
Martin Kleppmann(2017)『Designing Data-Intensive Applications』O'Reilly
イミュータビリティと状態管理をシステム設計レベルで解説している
一次資料
-
Redux公式ドキュメント「Motivation」
https://redux.js.org/understanding/thinking-in-redux/motivation
グローバルなミュータブル状態の問題と、単方向データフローによる解決を説明している -
The Elm Architecture
https://guide.elm-lang.org/architecture/
状態・更新・表示を明確に分離したアーキテクチャの原典
無料で読める入門記事
- React公式ドキュメント「Managing State」
https://react.dev/learn/managing-state
状態をどこに置くか・誰が持つかの判断基準を、実例付きで解説している
Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第10回