JavaScript | 非同期処理:設計・理解の深化 - 非同期バグの再現

JavaScript JavaScript
スポンサーリンク

なぜ「非同期バグの再現」がこんなに難しいのか

非同期バグって、ほんとイヤらしいですよね。
さっきまで出ていたのに、もう一回やると出ない。
本番では起きるのに、ローカルでは起きない。

理由はシンプルで、非同期バグは「タイミング」と「順番」に依存しているからです。
同じコードでも、ネットワークの速さ、クリックのタイミング、処理の重さが少し変わるだけで、
バグが出たり出なかったりします。

だからこそ大事になるのが
「どうやって“同じ状況”を人工的に作り出すか」
つまり 非同期バグを再現する技術 です。

ここでは、初心者でもできる「再現のための考え方」と「コードでの工夫」を、例題付きで整理していきます。


ステップ1:まず「何がズレているバグなのか」を言葉にする

非同期バグの典型パターンを知っておく

非同期バグは、だいたい次のようなパターンに分類できます。

結果が「古い」ものになってしまう(レースコンディション)
一部のケースでだけエラーが握りつぶされる
特定の順番で操作したときだけ UI が壊れる
キャンセルしたはずの処理が後から結果を反映してしまう

まずは、自分が遭遇しているバグが
「どのタイプっぽいか」を言葉にしてみると、
再現の糸口が見えやすくなります。

例えば、こんな感じです。

検索ボックスに素早く文字を打つと、最後の入力ではなく途中の結果が表示される
画面を切り替えたあとに、前の画面用の API の結果が反映されてしまう
ネットワークが遅いときだけ、ローディング表示が消えない

この「言語化」が、再現の第一歩です。


ステップ2:バグの例を小さく再現できるコードに落とす

典型例:検索ボックスのレースコンディション

よくある非同期バグの例として、「検索ボックス」を考えます。

ユーザーが文字を入力するたびに API を叩いて結果を表示するコードです。

まずは、バグを含んだ素直な実装を書いてみます。

const input = document.querySelector("#search");
const resultArea = document.querySelector("#result");

input.addEventListener("input", async () => {
  const keyword = input.value;
  const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
  const data = await res.json();
  resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScript

一見問題なさそうですが、
ユーザーが「a」「ab」「abc」と素早く入力したとき、
こういうことが起きます。

  1. 「a」でリクエスト A が飛ぶ
  2. 「ab」でリクエスト B が飛ぶ
  3. 「abc」でリクエスト C が飛ぶ
  4. ネットワークの都合で、C → B → A の順にレスポンスが返ってくる

すると、最後に A の結果が画面に反映されてしまい、
「abc」と入力しているのに「a」の結果が表示される、というバグになります。

これが典型的な「非同期バグ」です。
同じコードでも、ネットワークのタイミング次第で出たり出なかったりします。


ステップ3:「タイミング」をコントロールして再現性を上げる

意図的に遅延を入れてみる

非同期バグを再現するために有効なのが
「わざと処理を遅くする」ことです。

先ほどの検索の例で、レスポンスをわざと遅らせてみます。

まず、フェイクの fetch を作ります。

function fakeFetch(url, delayMs, responseData) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        ok: true,
        json: async () => responseData,
      });
    }, delayMs);
  });
}
JavaScript

これを使って、順番が入れ替わる状況を人工的に作ります。

input.addEventListener("input", async () => {
  const keyword = input.value;

  let delay;
  if (keyword === "a") delay = 300;
  else if (keyword === "ab") delay = 600;
  else if (keyword === "abc") delay = 100;

  const res = await fakeFetch(`/api/search?q=${keyword}`, delay, {
    items: [keyword.toUpperCase()],
  });

  const data = await res.json();
  resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScript

こうすると、
「abc」の結果が一番早く返ってきて、
その後に「ab」「a」の結果が上書きする、という状況を確実に作れます。

ここで重要なのは
「バグが起きる状況を“運任せ”にしない」
ということです。

非同期バグを再現したいなら、
タイミングをコードでコントロールしてしまうのが近道です。


ステップ4:再現できたら「何が条件なのか」を切り出す

条件を言葉とコードでセットにする

さっきの例で言うと、
バグが起きる条件はこう言語化できます。

複数のリクエストをほぼ同時に投げる
後から投げたリクエストより、先に投げたリクエストのレスポンスが遅く返ってくる
レスポンスを受け取った順に、そのまま UI に反映している

これをコードで再現するために、
「遅延時間を意図的にずらす」フェイク fetch を使いました。

この「条件の言語化」と「コードでの再現」がセットになると、
バグの理解が一気に深まります。

ただ「なんかたまにおかしい」ではなく、
「こういう順番でこういうタイミングになると壊れる」
と説明できるようになるのが大事です。


ステップ5:再現用の「スイッチ」をコードに仕込む

本番コードに「デバッグモード」を持たせる発想

非同期バグは、本番環境でしか起きないことも多いです。
ネットワークが遅い、サーバーが重い、ユーザーの操作が激しい、など。

そういうときに効くのが
「再現用のスイッチをコードに仕込んでおく」
という発想です。

例えば、さっきの検索処理を少し設計し直して、
「遅延戦略」を外から渡せるようにします。

async function searchWithStrategy(keyword, { fetchImpl, delayStrategy }) {
  const delay = delayStrategy(keyword);
  const res = await fetchImpl(`/api/search?q=${encodeURIComponent(keyword)}`, delay);
  const data = await res.json();
  return data;
}
JavaScript

本番では、普通の fetch と「遅延なし」の戦略を渡します。

function noDelayStrategy() {
  return 0;
}

function realFetchWithDelay(url, delayMs) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      fetch(url).then(resolve).catch(reject);
    }, delayMs);
  });
}
JavaScript

デバッグ時には、
「特定のキーワードだけ遅延を変える戦略」を渡せます。

function debugDelayStrategy(keyword) {
  if (keyword === "a") return 500;
  if (keyword === "ab") return 800;
  if (keyword === "abc") return 100;
  return 0;
}
JavaScript

こうしておくと、
本番に近いコードのまま「バグが出やすい状況」をオン・オフできます。

ここが重要です。
非同期バグの再現性を上げるには、
「最初から“遅延や順番をいじれる設計”にしておく」
という発想が効いてきます。


ステップ6:ログで「時間」と「順番」を見える化する

ログに「いつ」「どのリクエスト」が動いたかを出す

非同期バグの再現で、ログはかなり強力な武器になります。
ただし、適当に console.log を増やすだけだとカオスになります。

大事なのは、
「時間」と「ID」をセットでログに出すことです。

例えば、検索リクエストに ID を振ってみます。

let requestIdCounter = 0;

async function search(keyword) {
  const id = ++requestIdCounter;
  console.log(`[${id}] start search: ${keyword}`);

  const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
  console.log(`[${id}] response received`);

  const data = await res.json();
  console.log(`[${id}] json parsed`);

  return { id, data };
}
JavaScript

UI 側では、こう使います。

input.addEventListener("input", async () => {
  const keyword = input.value;
  const { id, data } = await search(keyword);
  console.log(`[${id}] render result for: ${keyword}`);
  resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScript

これで、コンソールにはこういうログが並びます。

[1] start search: a
[2] start search: ab
[3] start search: abc
[3] response received
[3] json parsed
[3] render result for: abc
[1] response received
[1] json parsed
[1] render result for: a

このログを見れば、
「後から来た 1 の結果が abc の結果を上書きしている」
という事実が一目で分かります。

ここが重要です。
非同期バグの再現と理解には、
「時間と順番をログで可視化する」ことがほぼ必須です。


ステップ7:再現できたら「直し方」も非同期的に考える

最新のリクエストだけを有効にするガード

再現できたら、次は修正です。
検索の例なら、「最後に入力されたキーワードの結果だけを反映する」ようにします。

よく使うパターンは「最新リクエスト ID のチェック」です。

let latestRequestId = 0;

input.addEventListener("input", async () => {
  const keyword = input.value;
  const id = ++latestRequestId;

  const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
  const data = await res.json();

  if (id !== latestRequestId) {
    console.log(`[${id}] outdated result, ignore`);
    return;
  }

  console.log(`[${id}] apply result`);
  resultArea.textContent = `結果: ${data.items.join(", ")}`;
});
JavaScript

これで、
「古いリクエストの結果は無視する」というルールが入ります。

ここで大事なのは、
再現のときに使った「ID」と「順番」の考え方を、
そのまま修正にも活かしていることです。

非同期バグの再現は、
単に「バグを出すため」ではなく、
「どういう条件で壊れるのかを理解し、その条件をコードで潰すため」
にやるものです。


初心者として「非同期バグの再現」で本当に意識してほしいこと

最後に、あなたに持っていてほしい問いをまとめます。

このバグは「タイミング」「順番」「キャンセル」「複数同時実行」のどれに関係していそうか。
バグが起きる状況を、言葉で説明できるか(どういう操作・どういう遅さのときに起きるか)。
その状況を、コードで「意図的に」作り出せるか(遅延・フェイク fetch・ID など)。
ログに「いつ」「どのリクエスト」が動いたかを出して、順番を目で追えるようにしているか。
一度再現できたら、その条件を潰すためのルールをコードに落とし込めているか。

おすすめの練習は、
自分のコードの中から「たまに挙動が怪しい非同期処理」を一つ選んで、

わざと遅延を入れてみる
ID を振ってログを出してみる
複数回連続で呼び出してみる

という「再現のための実験」をしてみることです。

非同期バグを「怖いもの」ではなく、
「条件さえ分かれば再現できる現象」として扱えるようになったとき、
あなたはもう一段上のエンジニアになっています。

タイトルとURLをコピーしました