そのバグ、「誰かが書き換えた」せいじゃない?🤯

そのバグ、「誰かが書き換えた」せいじゃない?🤯

これ絶対あるじゃん??これ絶対あるじゃん??

  • バグを直したはずなのに、全然違う画面で同じバグが再現する
  • 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

StockTableOrderfrozen=Trueなので、作った後は誰も書き換えられない。変更したいなら必ず新しいオブジェクトを作る。kitchen_updateadd_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
    イミュータビリティと状態管理をシステム設計レベルで解説している

一次資料

無料で読める入門記事

  • React公式ドキュメント「Managing State」
    https://react.dev/learn/managing-state
    状態をどこに置くか・誰が持つかの判断基準を、実例付きで解説している

Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第10回