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 호출

하나의 상태 변경이 또 다른 상태 변경을 유발하고, 그 결과 여러 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(); 

이 경우 첫 번째 요청이 완료되어야 두 번째 요청이 시작됩니다.

만약 각각 500ms가 소요된다면 총 1초 이상의 시간이 필요합니다.

하지만 두 API는 서로 의존 관계가 없었습니다.

따라서 Promise.all을 활용하여 병렬 처리하도록 변경하였습니다.

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

이를 통해 전체 초기 로딩 시간을 줄일 수 있었으며 사용자 입장에서도 더 빠른 화면 진입 경험을 제공할 수 있었습니다.

8. 개선 결과

개선 작업 이후 요청 흐름을 다시 분석하였습니다.

기존에는 동일 API가 반복적으로 호출되는 현상이 자주 발생하였지만 개선 이후에는 대부분 한 번의 요청만 발생하였습니다.

또한 이전 응답이 최신 데이터를 덮어쓰는 현상과 화면이 여러 번 깜빡이는 현상도 나타나지 않았습니다.

개선 효과를 정리하면 다음과 같습니다.

- 동일 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