🪤

冪等(べきとう)性という名の技術的負債 ── AIコーディングで陥った「エラー隠蔽」の罠

に公開1

はじめに

AIコーディングアシスタントと開発していて、ある落とし穴に気づいた。

エラーが発生したとき、AIはエラーを「抑え込んで」処理を継続させる方向で対応する。 その対応に「冪等性の確保」という名前がつくと、正しい設計判断に見えてしまう。しかし実際には、真の問題を隠蔽していただけだった。

この記事では、Flutter アプリ開発で実際に体験した「冪等性の罠」を通じて、エラー対応の本質について考える。

冪等性とは

冪等性(idempotency)とは、ある操作を何度実行しても1回実行したときと同じ結果になる性質。

APIでいえば、同じリクエストを2回送っても1回送ったときと同じ結果になること。データベースでいえば、同じINSERTを2回実行しても重複レコードが作られないこと。

冪等性は分散システムやネットワーク通信において非常に重要な概念であり、リトライ処理やイベント駆動アーキテクチャの信頼性を支える基盤である。

冪等性が「正しい」場面

軍事システム、医療機器、航空管制 ── これらの分野では、あらゆる状況下で処理を継続させることが最優先される。

  • ミサイル防衛システムで同じ脅威の通知が2回届いても、2発迎撃しない
  • 心臓ペースメーカーが同じ信号を重複受信しても、2回電気ショックを与えない
  • 決済システムで同じ注文が2回送信されても、2回引き落とさない

こうしたミッションクリティカルな領域では、何が起きても処理が正しく継続する ことが絶対条件であり、冪等性の確保は正当な設計判断である。

起きたこと ── レッスン準備処理の競合

アプリの構造

Flutterで言語学習アプリを開発している。画面は3つのタブで構成されている。

  • タブ0: ライブラリ ── コース一覧を表示
  • タブ1: コース ── コース内のレッスン一覧を表示
  • タブ2: レッスン ── 選択されたレッスンを再生

ユーザーはコースタブでレッスンを選び、レッスンタブで再生する。つまりレッスンの選択は常に別タブで行われ、レッスンタブへの遷移を伴う。 この構造が、後で重要な意味を持つ。

レッスンタブに遷移すると、レッスンデータをDBから読み込む準備処理(prepare)が走る。この処理が2つの経路から同時に呼び出され、DBアクセスが競合してクラッシュした。

呼び出しA(Signal購読の即時発火): prepare() → DB読み込み開始...
呼び出しB(Widgetライフサイクル):  prepare() → DB読み込み開始... → 競合エラー!

なぜ2箇所から呼ばれていたのか

// 呼び出しA: タブ切り替えの監視(Signal購読)
void _initializeTabMonitoring() {
  appState.currentTabIndex.subscribe((tab) {
    if (tab == 2) {       // レッスンタブに切り替わった
      prepare(lessonId);  // → DB読み込み開始
    }
  });
}

// 呼び出しB: 親Widgetの再ビルドで発火するライフサイクル
@override
void didUpdateWidget(oldWidget) {
  if (widget.lessonId != oldWidget.lessonId) {
    prepare(lessonId);    // → DB読み込み開始(競合!)
  }
}

subscribe は Signal の現在値で即座に発火する仕様だ。画面生成時にすでにタブ2なら、登録直後に prepare が呼ばれる。同じフレーム内で didUpdateWidget も発火すると、2つのDB呼び出しが競合する。

なぜ2つの呼び出し経路が生まれたのか

2つの経路は最初から意図して作られたものではない。機能追加と不具合修正の積み重ねで、気づかないうちに生まれた。

フェーズ1: initState だけで十分だった

最初はシンプルだった。initStateprepare() を呼ぶだけ。

しかし問題があった。Flutterのタブナビゲーションでは、一度作成されたWidgetが維持される場合、タブを切り替えても initState は再び呼ばれない。ライブラリタブで別のレッスンを選んでレッスンタブに戻っても、lessonId が変わったことを検知できず、画面が更新されなかった。

フェーズ2: didUpdateWidget を追加

didUpdateWidget は、親Widgetが再ビルドされてプロパティが変わったときに呼ばれるライフサイクルメソッドだ。lessonId の変更を検知して prepare() を呼び出すようにした。これで問題は解決した。

フェーズ3: タブ監視を導入、しかし didUpdateWidget は残った

さらに後、タブ切り替えをSignalで監視する仕組み(_initializeTabMonitoring)を導入した。レッスンタブがアクティブになったことをリアクティブに検知でき、より堅牢な初期化が可能になった。

しかしこの時点で、didUpdateWidget が不要になったことを誰も検証しなかった。 動いているコードは触らない ── その慣性で、2つの経路が並存し続けた。

フェーズ1: initState → prepare()                          [1つの入口]
             ↓ タブ切り替えで画面が更新されない問題

フェーズ2: initState → prepare()
           didUpdateWidget → prepare()                    [2つ目の入口を追加]
             ↓ リアクティブな監視を導入

フェーズ3: initState → _initializeTabMonitoring → prepare()  ─┐
           didUpdateWidget → prepare()                      ─┘→ 競合!
             (didUpdateWidgetの検証を怠った)

AIの最初の対応 ── 冪等性チェックの追加

AIに修正を依頼したところ、以下のような対応が返ってきた。

bool _isPreparing = false;

Future<void> prepare(LessonId lessonId) async {
  if (_isPreparing) return;  // ← 冪等性ガード
  _isPreparing = true;
  try {
    // DB読み込み処理...
  } finally {
    _isPreparing = false;
  }
}

エラーは出なくなった。AIは「冪等性を確保しました」と報告してきた。

一見、正しい対応に見える。 実際、処理は正常に動くようになった。

しかし、何かがおかしい

この「修正」で入ったコードを見直すと、気になる点があった。

  1. フラグ管理の複雑化 ── _isPreparing フラグのセット・リセットを適切なタイミングで行う必要がある
  2. 拡張時のリスク ── 呼び出し経路が増えるたびに、フラグ処理を忘れずに行う必要がある
  3. そもそもなぜ prepare が2箇所から呼ばれているのか?

真の原因 ── ドメイン知識が示すシンプルな答え

改めてアプリの構造を思い出そう。

レッスンの選択は 常に別タブ(コース)で行われる。レッスンタブで再生するには、必ずタブ切り替えが発生する。つまり Signal によるタブ監視だけで、すべてのシナリオをカバーできる。

didUpdateWidget は完全に冗長だった。呼び出しAだけで、呼び出しBがカバーしていた全シナリオを網羅できる。

問題の本質は 2つの異なる仕組みによる初期化経路が存在していた ことであり、冪等性で守るべき問題ではなかった。

真の修正 ── 入口を1つにする

冪等性チェックもフラグ管理も不要だった。必要なのは、prepare の呼び出し元を1箇所に限定する ことだけだった。

/// タブ切り替えを監視してレッスン準備を行う(唯一の入口)
void _initializeTabMonitoring() {
  appState.currentTabIndex.subscribe((tab) {
    if (tab == 2 && mounted) {
      prepare(lessonId);  // ← これだけ
    }
  });
}
// didUpdateWidget → 削除
// 冪等性チェック → 削除
// フラグ管理 → 削除

コミット履歴が語る遠回り

1. 機能追加 → DB競合エラー発生
2. AI対応 → 冪等性チェック追加(エラー抑制)
3. 根本対応 → 呼び出し元を1箇所に集約(冪等性チェック削除)

ビフォー・アフター

Before: 冪等性チェックで守られた複雑なコード

initState → _initializeTabMonitoring()
               └→ subscribe即時発火 ──────────→ prepare()
                                                    ↑ 冪等性チェック
didUpdateWidget → prepare() ──────────────────→ prepare()
                                                    ↑ フラグ管理
  • 入口が2つ(Signal購読 + Widgetライフサイクル)
  • 冪等性チェックで競合を防止
  • フラグのセット/リセットが必要
  • 新しいトリガー追加時にフラグ処理を忘れるリスク

After: 入口を1つにしたシンプルなコード

_initializeTabMonitoring ─→ prepare()
  • 入口が1つ
  • 冪等性チェック不要
  • フラグ管理不要
  • 新しいトリガーを追加する余地がない(設計で防止)

教訓

1. 冪等性は万能薬ではない

冪等性の確保は、分散システムやネットワーク通信では必須の設計原則である。しかし単一プロセス内での状態管理においては、冪等性が必要になること自体が設計の問題を示唆している場合がある。

2. AIは「処理を止めない」方向にバイアスがある

AIコーディングアシスタントにエラーを報告すると、エラーを抑え込んで処理を継続させる方向で対応する傾向がある。これは多くの場合において合理的な行動だが、真の原因の探求を後回しにするリスクがある。

AIに修正を依頼する際は、「エラーを出なくする」ではなく「なぜこのエラーが発生するのか」を明確に問うことが重要だ。

3. 「入口を減らす」は最強のバグ修正

防御的プログラミングでガードを増やすのではなく、そもそもガードが必要ない設計にする。 関数の呼び出し元を1箇所に限定すれば、競合は原理的に発生しない。

これは「根本原因の除去」そのものであり、対症療法的な冪等性チェックよりも遥かに効果的で保守性が高い。

4. 新しい仕組みを追加したら、古い仕組みを再検証せよ

フェーズ3でSignal監視を追加した時点で、didUpdateWidget は不要になっていた。しかし「動いているコードは触らない」という慣性が、不要な経路を残し続けた。

AIコーディングでは特に、「削除」よりも「追加」の提案が多くなりがちだ。新しい仕組みを導入したら、それが既存の仕組みを置き換えるものではないか、必ず検証しよう。

まとめ

冪等性は重要な設計原則だが、適用すべき場面を見極める必要がある。

  • 適切な場面: 分散システム、ネットワーク通信、外部イベントの重複 ── 呼び出し元を制御できない場合
  • 疑うべき場面: 単一プロセス内での競合防止 ── 呼び出し元を制御できるはずの場合

冪等性チェックを追加する前に、なぜその処理が複数回呼ばれるのかを問い直そう。真の原因に対処すれば、防御コードは不要になる。

入口が1つなら、競合は原理的に発生しない。

Discussion

mmm65536mmm65536

これ人間でもよくあります、根本原因調べないで対処療法で解決する人。対応が速いので評価されますがシステムは腐っていきます。