React 18: state 변경 시 이전 렌더 데이터와 꼬이는 경합(Race Condition) 방지

React 18 비동기 데이터 패칭 경합 상태 Race Condition 해결 가이드
3초 요약: 이렇게 고치세요
  • 원인: 나중에 보낸 요청보다 먼저 보낸 요청의 응답이 늦게 도착하면, 화면은 최신인데 데이터는 예전 걸로 덮어써짐
  • 해결: 상태 갱신만 막으면 될 때 → useEffect 클린업의 불리언 플래그 / 네트워크 요청 자체를 끊고 싶을 때 → AbortController
  • 주의: 의존성 배열만 맞춰놓고 클린업 함수를 안 넣으면 그대로 재발함
let active = true;
fetchData().then(data => { if (active) setData(data); });
return () => { active = false; };
↓ 왜 이런 에러가 나는지 원리 보기

검색 자동완성이나 탭 전환처럼 클릭할 때마다 API를 다시 호출하는 컴포넌트를 만들다 보면, 탭을 빠르게 연달아 누르거나 타이핑을 빠르게 했을 때 화면에 엉뚱한 이전 데이터가 떠 있는 상황을 한 번쯤 겪어보셨을 겁니다. 로컬이나 스테이징에서는 거의 재현이 안 되다가, 실제 사용자 네트워크 환경에서만 간헐적으로 터지는 탓에 원인 찾기도 까다로운 버그입니다.

원인은 생각보다 단순합니다. 나중에 보낸 요청의 응답이 먼저 보낸 요청의 응답보다 늦게 도착하는 순간이 있는데, 이때 컴포넌트는 이미 다음 상태로 넘어갔음에도 뒤늦게 도착한 예전 응답이 setState를 호출하면서 화면을 덮어써 버립니다. useEffect의 클린업 함수와 불리언 플래그, 혹은 AbortController로 이 흐름만 통제하면 해결됩니다.


왜 비동기 통신이 완료된 시점에 데이터가 꼬일까요

핵심은 네트워크 응답이 요청을 보낸 순서대로 돌아오지 않는다는 데 있습니다. 사용자가 1번 탭(공지사항)을 눌렀다가 곧바로 2번 탭(이벤트)을 눌렀다고 해봅시다.

브라우저는 1번 탭 요청을 먼저 보내고 2번 탭 요청을 뒤이어 보냅니다. 그런데 서버 부하나 네트워크 경로 차이로 2번 응답이 100ms 만에 오고, 1번 응답은 500ms 만에 뒤늦게 도착한다면 어떻게 될까요. 화면은 이미 2번 탭 데이터로 넘어갔는데, 나중에 도착한 1번 탭의 콜백(.then(data => setData(data)))이 실행되면서 화면을 다시 1번 데이터로 덮어써 버립니다. 사용자는 분명 2번 탭을 보고 있는데 내용은 1번 탭 그대로인, 앞뒤가 안 맞는 상태가 되는 겁니다.

경합 상태가 만들어지는 순서

취소 플래그가 없는 구조 (Race) 1번 요청 전송 -> 2번 요청 전송 -> 2번 응답 먼저 도착 (렌더링) -> 1번 응답 뒤늦게 도착 -> setData(1번데이터) 강제 덮어쓰기 완료 (에러).
클린업 활성 상태 (Safe) 1번 요청 전송 -> 2번 요청 시 1번의 active 플래그를 false로 전환 -> 2번 응답 도착 및 렌더링 -> 1번 응답 도착 -> 플래그가 false이므로 갱신 스킵.

실무에서 자주 나타나는 패턴

코인 목록에서 종목을 클릭하면 해당 코인의 분봉 차트를 그려주는 실시간 시세 대시보드 같은 컴포넌트에서 이 문제가 특히 잘 드러납니다.

목록을 빠르게 여러 번 클릭하며 테스트하다 보면, 화면 상단 탭 이름은 '이더리움'인데 차트는 뒤늦게 도착한 '비트코인' 데이터로 그려지는 상황을 마주하게 됩니다. 차트 라이브러리 쪽 버그로 오해하기 쉽지만, 요청 보내는 시점과 응답 오는 시점을 콘솔에 하나씩 찍어보면 원인이 API 응답 도착 순서에 있다는 게 드러납니다.

단순히 fetchuseState만 붙여둔 구조라면, useEffect 클린업 시점에 불리언 플래그로 걸러내는 패턴을 적용하는 것만으로 해결됩니다. coinId가 바뀌거나 컴포넌트가 언마운트될 때마다 이전 요청의 플래그를 꺼두는 방식이면 같은 문제가 재발하지 않습니다.


실전 해결 코드: useEffect 클린업 가드 & AbortController

불리언 플래그 방식과 웹 표준 AbortController 방식, 두 가지를 상황에 맞게 골라 쓰면 됩니다.

방법 A: 불리언 플래그 가드 (가장 가볍고 범용적인 기법)

import { useState, useEffect } from 'react';
export default function TabDetail({ tabId }: { tabId: string }) {
  const [data, setData] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  useEffect(() => {
    // 1. 컴포넌트 렌더링 범위 내 활성화 불리언 변수 선언
    let active = true;

    async function fetchData() {
      setLoading(true);
      const response = await fetch(`https://api.example.com/tab/${tabId}`);
      const result = await response.json();

      // 2. 오직 active 플래그가 true(아직 의존성이 바뀌지 않은 상태)일 때만 상태 변경
      if (active) {
        setData(result.message);
        setLoading(false);
      }
    }
    fetchData();
    // 3. 의존성(tabId)이 변경되거나 언마운트 시 자동 발동하는 클린업 함수
    return () => {
      // 다음 렌더가 실행되기 직전에 기존 active 변수를 차단 상태로 변경
      active = false;
    };
  }, [tabId]); // tabId가 바뀔 때마다 기존 클린업 함수가 실행됨
  if (loading) return <p>데이터 로딩 중...</p>;
  return <div>상세 정보: {data}</div>;
}

방법 B: AbortController를 활용한 실제 네트워크 전송 강제 취소

import { useState, useEffect } from 'react';
export default function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<string[]>([]);
  useEffect(() => {
    // 1. 웹 표준 AbortController 인스턴스 생성
    const controller = new AbortController();
    const signal = controller.signal;
    async function search() {
      try {
        // 2. fetch 요청에 signal 바인딩 전달
        const res = await fetch(`https://api.example.com/search?q=${query}`, { signal });
        const data = await res.json();
        setResults(data);
      } catch (err: any) {
        if (err.name === 'AbortError') {
          console.log('[네트워크] 이전 요청 취소 완료:', query);
        } else {
          console.error('검색 실패', err);
        }
      }
    }
    if (query) {
      search();
    }
    // 3. 검색어가 바뀌거나 언마운트 시 네트워크 송수신 자체를 강제 드롭
    return () => {
      controller.abort();
    };
  }, [query]);
  return (
    <ul>
      {results.map((item, index) => <li key={index}>{item}</li>)}
    </ul>
  );
}

해결 코드의 라인별 동작 해설

방법 A의 핵심은 useEffect 클린업이 실행되는 순서를 이용하는 것입니다. useEffect 안에서 선언한 active 변수는 클로저로 그 렌더링 시점에 묶여 있습니다. tabId가 바뀌어 effect가 다시 실행되기 직전, 리액트는 이전 렌더링에 연결된 클린업 함수(return () => { active = false; })를 먼저 실행해서 이전 스코프의 activefalse로 만들어 둡니다. 그래서 뒤늦게 이전 요청의 응답이 오더라도 if (active) 조건에서 걸러져 상태가 갱신되지 않습니다.

AbortController는 한 걸음 더 나아가 상태 갱신뿐 아니라 진행 중인 네트워크 요청 자체를 중단시킵니다. controller.abort()가 호출되면 fetchAbortError를 던지며 중단되고, 브라우저도 더 이상 응답을 기다리지 않습니다. 어차피 버릴 응답을 끝까지 받는 것보다, 애초에 요청을 취소하는 쪽이 네트워크 낭비를 줄이는 방법입니다.

참고로 개발 모드에서 useEffect가 두 번 실행되는 것처럼 보이는 것과는 다른 문제입니다. React 18의 StrictMode는 클린업이 제대로 되어 있는지 확인시키려고 개발 모드에서만 마운트 시점 effect를 일부러 한 번 더 실행하는데, 이건 프로덕션 빌드에서는 일어나지 않습니다. 오히려 이 글에서 다루는 불리언 플래그·AbortController 패턴을 넣어두면 StrictMode의 이중 실행도 자연스럽게 통과합니다.


정리하면 간단한 탭 클릭 정도는 불리언 가드로 충분하고, 자동완성 검색창처럼 트래픽 자체를 줄여야 하면 AbortController가 낫습니다. 프로젝트 규모가 커져서 여러 컴포넌트가 같은 데이터를 공유한다면 SWR이나 React Query 같은 라이브러리가 이 문제를 아예 대신 처리해 줍니다.

추가 추천 자료 및 공식 레퍼런스

MDN 공식 AbortController 사양서와 웹 비동기 취소 인터페이스 명세는 아래에서 확인할 수 있습니다.

호마다의 웹개발 팁

useEffect 클린업과 AbortController로 경합 상태를 막는 패턴은 프론트엔드에서 한 번은 꼭 짚고 넘어가야 하는 주제입니다. 다만 이걸 제대로 이해하려면 자바스크립트 이벤트 루프와 마이크로태스크 큐가 비동기 작업을 어떤 순서로 처리하는지 먼저 알아두면 훨씬 수월합니다. 관련 기초 개념을 좀 더 쉽게 풀어 쓴 글은 [호마다의 IT 개발 입문 블로그] 에서 확인하실 수 있습니다.

댓글

이 블로그의 인기 게시물

TypeScript: Unknown vs Any 타입 차이점과 안전한 타입 가드(Type Guard)

Next.js 하이드레이션 오류 원인과 해결법 완벽 정리

Next.js App Router에서 getServerSideProps 대체 및 데이터 페칭법