コードレビューで並列フェッチのレースコンディションを指摘された話

エンジニアの記録
コードレビュー・実体験

「このコード、並列リクエストが両方通ってしまうリスクがあります」とコードレビューで指摘を受けました。

「レースコンディション」という概念を理解できていなかった自分が、指摘を受けて理解するまでの話を書きます。

対象読者

Reactの非同期処理・useEffectでのデータ取得・レースコンディションが何かわからないエンジニア向けです。

PROFILE 20代ITエンジニア|チーム開発でPRレビューを日々やっている|Raw Ambition 運営
キラ
キラ
「動いてるのになんで問題なの?」が最初の反応だった。でも理解したら「これは確かに直すべき」となった。
Contents
  1. レースコンディションとは何か
  2. 指摘を受けたコードの問題点
  3. なぜ「動いているのに問題か」
  4. 解決策:useRefでガードする
  5. まとめ:「動く」と「正しい」は違う

レースコンディションとは何か

レースコンディション(Race Condition)とは、複数の処理が同時に走って、順番によって結果が変わってしまう問題です。

わかりやすい例で説明します。

# レースコンディションのイメージ

# 状況:画面を開くと transcript と summary を並列で取得する

1. useEffectが実行される
2. loadTranscript() が実行される(処理A)
3. loadSummary() が実行される(処理B)

# 問題:
# 処理Aと処理Bが両方「loadedRef.current = false」の状態で
# 同時に走ってしまうと、ガード処理が機能しない

# 期待した動き:
# 最初のリクエストが完了したら、2回目はスキップする

# 実際の動き(問題あり):
# 両方のリクエストが先に走り始めてから、後からガードをセットする
# → 両方のリクエストが通ってしまう
スカイ
スカイ
「ガードをセットする前に両方が走り始める」というタイミングの問題なんだね。普段は問題にならないけど、特定の条件下でバグになる。

指摘を受けたコードの問題点

// ❌ 問題のあったコード
const loadedTranscriptPathRef = useRef<string | null>(null);

const loadTranscript = async () => {
  // この時点でloadedTranscriptPathRef.currentはnull
  // → 2つのリクエストが同時に走るとここを両方通過してしまう
  if (loadedTranscriptPathRef.current === transcriptPath) return;

  const result = await fetchTranscript(transcriptPath);

  // fetchが完了してからrefをセット
  // → 両方のリクエストが fetchTranscript を実行してしまう
  loadedTranscriptPathRef.current = transcriptPath;

  setTranscript(result);
};

// useEffectで呼ばれる
useEffect(() => {
  loadTranscript(); // 1回目
  loadTranscript(); // 2回目(条件次第で呼ばれる)
}, [transcriptPath]);

問題の核心はloadedTranscriptPathRef.currentがセットされるのはfetchTranscriptが完了した後」という点です。両方のリクエストがfetchTranscriptを実行し始めてから、後からガードをセットしても手遅れです。

⚠️ なぜ「普段は気づかない」か

ほとんどの場合、この問題は顕在化しません。でも「ネットワークが遅い」「重い処理と同時に走る」「特定のタイミングでレンダリングが走る」という条件が重なると、二重リクエストが発生してデータが壊れたりエラーになります。

なぜ「動いているのに問題か」

指摘を受けたとき最初は「でも普通に動いてるし…」と思いました。でもレビュアーの説明を聞いて理解しました。

「普段は動く」と「必ず正しく動く」は違います。

普段は問題ないのは「タイミングがたまたまずれていないから」です。ネットワーク遅延・CPUの負荷・他の処理との競合など、さまざまな要因でタイミングが変わったとき、初めてバグとして現れます。本番環境・ユーザーの端末・特定の条件下では再現する可能性があります。

キラ
キラ
「ローカルでは動く、本番で稀にバグる」という最悪のパターン。コードレビューでこういうリスクを事前に発見できるのがレビューの価値。

解決策:useRefでガードする

// ✅ 修正後のコード
const loadedTranscriptPathRef = useRef<string | null>(null);

const loadTranscript = async () => {
  // fetchを実行する前にガードをセットする(順番が重要)
  if (loadedTranscriptPathRef.current === transcriptPath) return;

  // 先にrefをセット → 2回目のリクエストはここでreturnされる
  loadedTranscriptPathRef.current = transcriptPath;

  const result = await fetchTranscript(transcriptPath);
  setTranscript(result);
};

修正のポイントはfetchTranscriptを呼ぶ前にloadedTranscriptPathRef.currentをセットする」ことです。こうすることで、2回目のリクエストが来たときにすでにrefがセットされているため、確実にスキップされます。

? useRefを使う理由

stateではなくrefを使うのは、refの変更はレンダリングを引き起こさないためです。ガード用のフラグをstateで管理すると、セットするたびに再レンダリングが走ってしまいます。「レンダリングに関係ない値の保持」にはrefが適しています。
スカイ
スカイ
「fetchの前にガードをセット」というたった一行の順番の違いで安全になる。シンプルだけど重要な修正。

? この記事のまとめ
  • レースコンディションとは複数の処理が同時に走って結果が変わる問題
  • 「普段は動く」と「必ず正しく動く」は違う。本番の特定条件下でバグになる可能性がある
  • 解決策:非同期処理の前にガードをセットする(fetchの後ではなく前)
  • ガード用フラグはstateではなくuseRefを使う(不要なレンダリングを防ぐため)

コードレビューで「動いているのに指摘された」経験は、「正しさ」の基準を上げてくれます。「動く」から「正しく動く」へのレベルアップは、レビューを通じてしか学べないことだと思っています。

Recommended
ブログを始めるならConoHa WINGが最速
エンジニアの技術メモをブログで発信して収益化。月1,000円以下で始められます。
▶ ConoHa WINGを見てみる
※ アフィリエイトリンクです
Next
次回:「やりたいことが多すぎて、結局何もできていない話」
Raw Ambition 始動から23日目

コメント

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