タイムカードアプリに「ねこ」を住まわせた話 第3回 ― 外部サービスと繋げたら地獄を見た

シリーズ第3回は、MyTIMECATに「ログインすれば他の端末とデータが自動で同期される」機能(Firebaseという外部サービスを使いました)を足したときの話です。この部分の実装や調査はすべてAI(Claude)にお願いしていたのですが、正直、このプロジェクトでいちばん時間がかかったのがここでした。難しい内容なので、細かい仕組みは省いて「何が起きていたのか」を中心に書きます。

症状:一瞬出て消える

ある日、こんな不具合に気づきました。

ねこのおめかしを選んだ瞬間、選んだ項目とお知らせ(トースト)が一瞬だけ表示されて、そのあとすぐ元に戻ってしまう

しかもこの不具合、自分のスマホでしか再現しないうえに、選ぶ回数やタイミングによって挙動が変わるという、なんとも掴みどころのないものでした。ここから、原因を1つ潰しても別の原因が出てくる、という展開が6回続きました。

なぜこんなことが起きるのか

同期機能というのは、簡単に言うと「自分のスマホでの変更をインターネット上のサーバーに送る」「サーバー側の変更を自分のスマホに反映する」という2つのやり取りを、常に裏側でやり続ける仕組みです。

問題は、この2つのやり取りが同時に起きてしまう瞬間があることでした。

  • 自分が「首輪を選んだ」という変更を、今まさにサーバーへ送っている最中
  • ちょうど同じタイミングで、サーバー側から「まだ首輪を選ぶ前の、古いデータ」が届いてしまう
  • 届いた古いデータでスマホ側の表示が上書きされてしまい、選んだはずの首輪が一瞬で消えたように見える

これが不具合の正体でした。「送信中は、サーバーから届いたデータで上書きしない」というルールを作れば直りそうです。実際その通りだったのですが、このルールを正しく作るのが想像以上に大変でした。

1回目〜3回目:ルールの抜け穴を1つずつ塞ぐ

最初のうちは、「送信中かどうか」を1つの印(フラグ)で管理する方法で対応しました。

  • 送信を始めるときに印を立てる
  • 送信中は、サーバーから届いたデータを無視する
  • 送信が終わったら印を消す

考え方としては間違っていなかったのですが、実際にはいくつも抜け穴がありました。「アプリが起動した直後、外部サービスへの接続がまだ準備できていないタイミングで変更をすると、そもそも送信の予約自体がされない」「別の経路から直接送信を始めると印が立たない」など、条件を1つ見つけて直しても、また別の条件で同じ症状が再現する、という状態がしばらく続きました。

4回目:本当の原因は「印1つでは足りなかった」こと

ここでようやく核心にたどり着きます。決め手になったのは、こんな非常に具体的な報告でした。

1回目の選択は問題なし。2回目の選択だけ表示がすぐ消えてもう一度押すと出る。3回目も同様

1回目は必ず成功するのに、2回目・3回目だけ失敗する。この規則性から、「短い間隔で連続して選ぶと、1回目の送信が終わった瞬間に、2回目の“まだ送信中です”という印まで一緒に消えてしまっているのでは」という仮説が立ちました。

調べてみると、まさにその通りでした。「送信中かどうか」をON/OFFの印1つで管理していたので、「未送信の変更が1件あるか、0件か」しか区別できなかったのです。1回目の送信が完了した瞬間、印は問答無用でOFFに戻ります。ところがそのときにはすでに2回目の変更が発生していて、「2回目がまだ送信できていない」という情報ごと、印と一緒に消えてしまっていました。

印がOFFになっている無防備な隙間に、サーバーから古いデータが届くと、2回目の選択だけが巻き戻ってしまう――これが「2回目・3回目だけ失敗する」の正体でした。

印1つ→チケット番号方式へ

そこで、ON/OFFの印を使うのをやめて、「変更が起きるたびに増えていく通し番号(チケット番号のようなもの)」で管理する方式に作り直しました。

  • 変更するたびに、手元の通し番号を1つ増やす
  • サーバーへの送信が完了したら、「何番までの変更を送り終えたか」をそのつど記録する
  • 「手元の番号」と「送り終えた番号」が一致していなければ、まだ送信できていない変更が残っている、とわかる

たとえるなら、「作業中の札を1枚だけ出しておく」方式から、「発券機のように番号が増え続けるチケットを渡す」方式に変えたようなものです。1回目の処理が終わっても、2回目のチケット番号がまだ残っていれば、「2回目はまだ終わっていない」とはっきり区別できるようになります。

この作り直しで、ようやく本当に解決しました。この一連の調査・修正を確認するためのテストは、最終的に16項目まで積み上がりました。

周辺の対策も積み増した

これと並行して、似たような理由で起きる不具合をいくつか見つけて対処しました。

  • 同じ処理が重複して動いてしまう問題:アプリの起動時、ログイン状態のチェックが複数回走ることがあり、そのたびに同期の仕組みが二重・三重に立ち上がってしまっていた
  • 削除したデータが復活してしまう問題:外部サービス側の「データを部分的に上書きする」仕組みの癖で、ローカルで削除したはずのデータが、次の同期のタイミングでサーバー側から復活してしまうことがあった
  • 一度手に入れたものが後退してしまう問題:解禁済みのアイテム数などは「一度増えたら減らない」はずの値ですが、サーバーから古いデータが届いたときにうっかり後退しないよう保護を追加した
  • お知らせ(トースト)の表示を画面全体の作り直しから切り離す:これによって表示がちらつく問題を減らした

分離したことで生まれた新しい不具合

最後の「お知らせの表示を切り離す」対応には、ちょっとした教訓がありました。それまでは「お知らせを表示する処理」が、画面全体を作り直す処理も一緒に担っていました。これを分離した結果、「プレゼントを選ぶ処理」の方が、画面を作り直す処理を明示的に呼ばなくなってしまっていたのです。

症状は、「プレゼントが複数件たまっている状態で連続して選ぶと、1回目・2回目を押した時点では画面が無反応で、3回目まで押し終えて何か別の操作をしたときに、まとめて全部反映される」というものでした。内部のデータ自体は正しく進んでいたのに、画面の見た目だけが更新されていなかった、という状態です。

原因が分かれば直し方は単純で、「プレゼントを選ぶ処理」の最後に「画面を作り直す」という一言を必ず入れるようにしました。ここから得た教訓は、「1つの処理が2つの役目を持っているとき、それを分離するなら、その処理を呼んでいる場所すべてで、分離した分の役目がちゃんと引き継がれているか確認する必要がある」ということでした。

まとめ

外部サービスとの同期にまつわる不具合は、「自分のスマホでしか再現しない」「再現する条件が絶妙に細かい」という、個人開発ではとても厄介なタイプのバグでした。パソコン上のテストだけでは何度も見落とし、実機での再現報告のたびに一段深い原因にたどり着く、というのを4回繰り返すことになりました。

いちばんの学びは、「同時に複数の変更が起きうる場面では、ON/OFFの印だけでは足りないことがある」ということです。発券機のチケット番号のように、「今どこまで進んだか」を数字で管理する方が、ずっと確実だと分かりました。次に似たような仕組みを作るときは、最初からこの考え方を使おうと思います。

Diary の記事一覧へ戻る