React環境における重複APIリクエストの改善

React環境における重複APIリクエストの改善

プロジェクトの規模が大きくなるほど、単に機能を実装することよりも、状態管理と非同期リクエストを効率的に処理することが重要になります。特に React ベースのアプリケーションでは、複数のコンポーネントが同じデータを共有し、さまざまな状態変化に応じて API を呼び出しますが、この構造が複雑になるほど、予期しない重複リクエストの問題が発生する可能性があります。

今回のプロジェクトでは、ログイン後のユーザー情報、権限情報、アクセス可能な業務データなどをグローバル Context に保存し、複数の画面で共通して使用する構造を適用していました。初期段階では正常に動作しているように見えましたが、機能が継続的に追加されるにつれて、同じ API が複数回呼び出されたり、以前のリクエストのレスポンスが最新のデータを上書きしたりする現象が発生しました。

最初はサーバーのレスポンス速度の問題だと考えていました。しかし、実際の原因を分析してみると、フロントエンド内部の状態管理構造と非同期処理方式に問題がありました。

この記事では、重複 API リクエストが発生した背景と原因の分析過程、そして AbortController とリクエスト識別子(runId)を活用して問題を解決した経験を共有します。

1. 問題の状況

プロジェクトには、ログイン後にユーザー関連データを取得する処理が存在していました。

初期ローディング時には、次のようなデータをまとめて取得していました。

1. ユーザー基本情報

2. ユーザー権限情報

3. ユーザーがアクセス可能な業務情報

4. プロジェクト関連情報

取得した情報はグローバル Context に保存され、複数の画面で共通して使用されました。

全体の流れは次のとおりでした。

ログイン 完了 -> ユーザー 情報 取得 -> 権限 情報 取得 -> プロジェクト 情報 取得 -> Context 保存 -> 画面 レンダリング

初期段階では問題がないように見えました。

しかし、プロジェクトが大きくなるにつれて Context に保存されるデータが増加し、それを使用するコンポーネントも増えました。

特に認証状態、権限状態、Context の状態が変化する過程で、同じ取得ロジックが繰り返し実行される現象が発生しました。

例えば、ユーザーがログインした直後に、次のような流れが発生する可能性がありました。

認証 状態 変更 -> ユーザー 情報 取得 -> Context 更新 -> Context 購読 コンポーネント 再レンダリング -> 追加 取得 実行

実際に Network タブを確認すると、同じ API が短時間のうちに複数回呼び出されていました。

開発環境ではあまり実感できませんでしたが、本番環境ではサーバー負荷の増加やネットワーク資源の浪費につながる可能性がある構造でした。

2. 原因分析

問題を解決するため、まずリクエストの流れを追跡しました。

React DevTools と Network タブを使って分析した結果、問題はフロントエンド内部にありました。

問題の原因は、状態間の複雑な依存関係にありました。

認証 状態 変更 -> ユーザー 情報 照会 -> Contextの保存 -> Contextの値 変更 -> Consumerの再レンダリング -> 別の useEffectの実行 -> 追加 API呼び出し

1つの状態変更が別の状態変更を引き起こし、その結果、複数のuseEffectが連鎖的に実行される可能性がありました。

問題は、このフローがプロジェクトの規模が大きくなるほど、さらに複雑になるという点でした。

実際に、同一のAPIがごくわずかな違いで複数回呼び出される現象を確認できました。

これにより、単に機能が動作することと、実際の運用環境で効率的に動作することは、まったく別の問題であるということを改めて実感しました。

3. 初期検討:Debounceの適用可能性

重複呼び出しの問題を確認した後、最初に検討した方法はDebounceでした。

Debounceは、一定時間内に追加のイベントが発生しなかった場合にのみ関数を実行する手法です。

一般的には、検索ボックスの最適化によく使用されます。

 const debouncedValue =   useDebounce(value, 300); 

当初は、状態変更が発生しても一定時間待機した後にAPIを呼び出すよう実装する方法を検討しました。

実際に、Debounceはユーザーの入力イベントが繰り返し発生する状況では非常に効果的な方法です。

しかし今回の問題は、入力イベントではなく、Contextの初期化過程と認証状態の変更過程で発生する問題でした。

つまり、リクエスト回数を単純に遅延させるよりも、すでに実行中のリクエストを安全にキャンセルすることが重要だと判断しました。

その結果、AbortControllerを適用することに決定しました。

4. AbortControllerの適用

AbortControllerは、進行中のリクエストをキャンセルできるように提供されているブラウザAPIです。

AbortControllerは非同期処理を進めるためのsignalオブジェクトを提供し、
controller.abort()を呼び出すと、そのsignalを検知しているすべてのリクエストが即座にキャンセルされます。

同じsignalを複数のリクエストに渡すことで、複数のリクエストを同時にキャンセルすることもできます。

AbortControllerを使用し、新しいリクエストが発生した場合に既存のリクエストをキャンセルするよう実装しました。

const controllerRef = useRef<AbortController | null>( null );

useEffect(()=>{
 	. . .
	controllerRef.current?.abort();
	const controller = new AbortController();
	controllerRef.current = controller;
	. . .
	// 언마운트/재실행 시 abort
	return () => {
		controller.abort();
	};
},[ctxData, auth])

実際のAPIリクエスト時には、signalも併せて渡しました。

const response =   await axios.get(url, {  signal:
controller.signal, }); 

この方法の利点は、もはや必要のないリクエストを即座に中断できる点です。

たとえば、ユーザーが画面を素早く移動したり、状態が連続して変更されたりする場合、以前のリクエストは実質的に意味を失います。

AbortControllerを適用した後は、このような不要なリクエストを排除でき、ネットワーク使用量も削減できました。

5. AbortControllerだけでは解決できなかった問題

AbortControllerを適用した後、重複リクエストの大部分の問題は解決されました。

しかし、テストの過程で別の問題を発見しました。

次のような状況を例に挙げてみましょう。

リクエスト Aの実行 -> リクエスト Bの実行 -> リクエスト B完了 -> 最新 データ 表示 -> リクエスト A 完了 -> 以前の データ 表示

ユーザーはリクエスト A をキャンセルしたと思っていても、実際にはレスポンスの返却時点とキャンセル時点が重なる場合があります。

この場合、古いレスポンスが最新の状態を上書きしてしまいます。

これは非同期処理の過程でよく発生する Race Condition(レースコンディション)の問題です。

AbortControllerだけでは、このような状況を完全に防ぐことはできませんでした。

そのため、追加の安全策が必要だと判断しました。

6. リクエスト識別子(runId)の管理

Race Conditionの問題を解決するため、リクエスト識別子(runId)も併せて管理しました。

新しいリクエストが発生するたびに番号を増加させ、レスポンス処理時に現在のリクエストが最新のリクエストかどうかを検証するように実装しました。

const runIdRef = useRef(0);
//요청이 바뀔 때 마다 번호 증가
const currentRunId =   ++runIdRef.current; 

レスポンスを処理する前に、次のように確認しました。

if ( currentRunId !== runIdRef.current ) {
	//최신 요청이 아닐 시 return
         return; 
    } 

動作の流れは次のとおりです。

リクエスト A runId = 1 -> リクエスト B runId = 2 -> リクエスト A レスポンス 1 !== 2 無視 -> リクエスト B レスポンス 2 === 2 反映

これにより、古いリクエストのレスポンスはすべて無視し、最後のリクエストだけを画面に反映できるようになりました。

個人的に、今回の作業で最も意義深かった部分も、まさにこのプロセスでした。

最初は API 呼び出し回数を減らすことが目標でしたが、実際にはどのレスポンスを信頼するかを管理することのほうが重要な課題でした。

7. Promise.allを活用した並列処理

さらに、API 呼び出しの構造も改善しました。

初期段階では、次のように順番にリクエストを処理していました。

const userInfo = await findUserInfo();
const projectInfo = await findProjectInfo(); 

この場合、1つ目のリクエストが完了してから2つ目のリクエストが開始されます。

それぞれに500msかかる場合、合計で1秒以上の時間が必要になります。

しかし、2つの API には相互依存関係がありませんでした。

そこで、Promise.allを活用して並列処理するように変更しました。

const [ userInfo, projectInfo ] = await Promise.all([ 
                                    findUserInfo(),
                                    findProjectInfo(),
                                 ]); 

これにより、初期ローディング時間を短縮でき、ユーザーにとってもより速く画面にアクセスできる体験を提供できました。

8. 改善結果

改善作業後、リクエストの流れを再度分析しました。

以前は同じ API が繰り返し呼び出される現象が頻繁に発生していましたが、改善後はほとんどの場合、1回のリクエストだけが発生するようになりました。

また、以前のレスポンスが最新のデータを上書きする現象や、画面が何度もちらつく現象も発生しなくなりました。

改善効果をまとめると、次のとおりです。

- 同じ API の重複呼び出しを削減

- ネットワーク使用量を削減

- 不要なサーバーリクエストを削減

- Race Conditionの問題を解決

- 初期ローディング速度を改善

- データの一貫性向上

- 保守性向上

特に、運用環境で発生する可能性のあるデータ不整合の問題を事前に排除できた点が、最大の成果だと考えています。

9. 振り返り

今回の作業を進める中で、単に機能が正常に動作することと、効率的に動作することはまったく別の問題であるという点を、改めて実感しました。

当初は、ユーザー情報が正常に取得され、画面にも表示されていたため、大きな問題はないと考えていました。

しかし、プロジェクトの規模が大きくなり、状態管理が複雑になるにつれて、同じ API が繰り返し呼び出されたり、古いレスポンスが最新のデータを上書きしたりする問題が発生しました。その分析過程で、React のレンダリング構造と非同期処理の仕組みについて、より深く理解することができました。

特に今回の作業では、単に API の呼び出し回数を減らすことよりも、リクエストのライフサイクルを管理することが重要であると学びました。

AbortController を活用して不要なリクエストをキャンセルし、リクエスト識別子(runId)によって最新のリクエストだけを反映するように実装することで、安定したデータ処理の構造を構築できました。

また、問題を解決する過程で、普段はあまり意識していなかった重複リクエストや Race Condition などの問題が、実際のサービス環境ではパフォーマンスの低下やデータ不整合につながる可能性があることを実感しました。

今回の経験を通じて、React アプリケーションにおける状態管理と非同期処理は、単なる実装領域ではなく、サービス品質に直結する重要な要素であることを改めて確認できました。これからも機能の実装だけに集中するのではなく、リクエストの流れや状態の変化まで考慮し、より安定性と効率性に優れたサービスを開発できるよう、継続的に考え、学び続けていきたいと思います。

            
[参考]
https://developer.mozilla.org/ko/docs/Web/API/AbortController

https://okayoon.tistory.com/entry/AbortController#google_vignette

Kancho

Site footer