[React] useEffect로 fetch 할 때 마주할 수 있는 세 가지 함정

seungjun.dev·2025년 10월 22일
2

React

목록 보기
9/11

피드백을 받았어요

팀 활동에서 한 팀원분께서 내가 useEffect에서 fetch하는 코드에 대해서 아주 중요한 피드백을 해주셨다.
코드 보러 가기

위 커스텀 훅의 useEffect에서 fetch 할 때 생기는 문제점들에 대해서다.

리액트 공식문서 중 ‘Effect으로 동기화하기’에서, ‘데이터 페칭’에 관해서 링크를 공유받아서 공식 문서를 정독하며 내 코드에서 어떤 점이 문제일지 알아보았다.

왜 useEffect에서 fetch할까?

리액트의 렌더링 로직은 순수해야한다.

즉, 동일한 props와 state가 주어지면 항상 동일한 JSX를 반환해야 하며, 렌더링 과정에서 데이터를 가져오거나(fetch) state를 변경하는 등의 사이드 이펙트를 일으켜서는 안 된다.

데이터 fetching은 대표적인 사이드 이펙트다. 이는 외부 시스템(네트워크, API 서버)과 통신하는 작업은 리액트의 통제 하에 있지 않기 때문이다.

useEffect는 렌더링이 완료된 이후에 이러한 사이드 이펙트를 실행할 수 있게 해주는 장치이다.
따라서 컴포넌트가 렌더링되면 → 데이터를 API에서 가져와서 → 상태를 업데이트하고 → 재렌더링의 흐름을 useEffect로 구현할 수 있다.

useEffect에서 fetch 할 때의 문제점

1. 레이스 컨디션에 취약

아래 시나리오대로 레이스 컨디션이 발생할 수 있다.

  1. 사용자가 필터 A로 검색
  2. 필터 Afetch 시작
  3. 사용자가 바로 필터 B로 다시 검색
  4. 필터 Bfetch 시작
  5. 필터 B의 검색 결과가 먼저 도착 → 상태 업데이트 → 요청 B 데이터 표시
  6. 필터 A의 검색 결과가 뒤늦게 도착 → 상태 업데이트 → 요청 A 데이터 표시

최종 결과: 사용자는 분명 필터 B로 검색했지만, 화면에는 필터 A의 검색 결과 데이터가 표시된다. 프로덕션 환경에서 실제 사용자 인터랙션과 네트워크 속도에 따라 발생한다.

2. StrictMode로 인해 생기는 문제 두 가지

  1. 이미 사라진(언마운트된) 컴포넌트의 상태를 업데이트하려고 한다
  2. 불필요한 네트워크 요청이 두 번 발생한다

useEffect의 의존성 배열을 비워두면 컴포넌트가 한 번만 실행되는 줄 알았는데, 아니었다.

React의 개발 모드는 StrictMode로 동작한다. 이는 컴포넌트의 안정성을 검사하기 위한 방식이며 컴포넌트를 의도적으로 두 번 렌더링한다. (mountunmountmount)

아래 시나리오에서 문제가 발생할 수 있다.

  1. 첫 번째 마운트: useEffect 실행 → fetch (A) 요청 시작
  2. 즉시 언마운트: 별도의 클린업 함수가 없으므로 fetch (A)가 취소되지 않음
  3. 두 번째 마운트: useEffect 재실행 → fetch (B) 요청 시작

최종 결과: fetch (A)와 (B)가 모두 실행 중이며, 만약 fetch (A)가 fetch (B)보다 늦게 완료되면, fetch(A)는 이미 사라진 첫 번째 마운트 시점의 컴포넌트의 상태를 업데이트(setState)하려고 시도한다.

그러나 이건 버그가 아니라, 의도된 동작이다.
이 동작은 개발 환경에서만 발생하며, 프로덕션 빌드에서는 발생하지 않는다.

3. 데이터 캐싱이 되지 않음

위에서 언급한 두 문제가 기술적/구현 상의 문제점이라면,
이 문제는 useEffect + useState 패턴의 구조적/아키텍처적 한계점이다.

이 기본적인 fetching 패턴에는 컴포넌트 외부에 데이터를 저장하는 캐시 레이어가 없다.

컴포넌트가 언마운트되면 그 안의 useState로 관리되던 데이터도 함께 사라지는 것이다.

예를 들자면, 사용자가 A 페이지를 봤다가, B 페이지로 이동한 뒤, 다시 A 페이지로 돌아오면 컴포넌트가 다시 마운트되면서 훅이 다시 실행되고, useEffect 가 또 API를 호출한다.

데이터 캐싱이 되지 않아 불필요한 API 호출이 발생하는 것이다.

이는 매우 비효율적인 부분으로, React Query나 SWR 같은 라이브러리를 적용할 수 있다. 이 둘은 첫 번째 요청 결과를 메모리에 캐시해두었다가, 컴포넌트가 다시 마운트되면 API를 또 호출하는 대신 캐시된 데이터를 즉시 반환한다.

해결책

1. 클린업 함수

공식 문서에서는 useEffect의 클린업(cleanup) 함수를 활용하라고 강조한다.

useEffect는 함수를 반환할 수 있는데, 이 클린업 함수는 다음과 같은 두 가지 시점에 실행된다.

useEffect(() => {
    (이펙트 함수)
    return {
        (클린업 함수)
    };
}, [의존값]);
  1. 컴포넌트가 언마운트될 때
  2. 다음 Effect가 실행되기 직전 (의존성이 변경되어 리렌더링될 때)

즉, StrictMode 에서는 첫 번째 마운트 → 클린업 → 두 번째 마운트 순서로 실행된다.

이 2번 특징을 이용해 레이스 컨디션과 StrictMode 에서의 마운트 해제된 컴포넌트의 상태를 업데이트하려는 문제를 해결한다.

해결 방식 1: Abort Controller (권장 방식)

fetchAbortController라는 API를 통해 요청을 취소할 수 있다. 이는 fetch 외에도 비동기 작업을 취소할 수 있는 기능을 제공한다.

AbortControllerAbortController 인스턴스와 AbortSignal 두 가지 핵심 부분으로 구성된다.

  • AbortController 인스턴스는 취소를 실행하고 신호를 생성하는 역할을 한다.
    • signal 속성과 abort() 메서드를 가진다.
// 1. 컨트롤러 인스턴스 생성
const controller = new AbortController();
  • AbortSignal 객체는 비동기 작업에 전달되는 객체다.
    • 현재 취소 상태(aborted 속성, boolean)를 가지며, ‘abort’ 이벤트를 발생시킬 수 있는 EventTarget이다.
    • signal 객체를 취소하려는 비동기 함수의 옵션으로 넘겨준다.
const signal = controller.signal;

// 2. 비동기 작업(fetch)에 signal을 전달
fetch(url, { signal: signal });
  • abort()는 취소를 실행하는 메서드이다.
    • 호출되면 연결된 signal 객체의 aborted 속성이 true로 변경된다.
    • 동시에 signal 객체는 ‘abort’ 이벤트를 발생시킨다.
// 3. 원하는 시점에 취소 실행
controller.abort();

핵심 동작 원리

  1. fetch 와 같은 API는 옵션으로 전달받은 signal 객체에 ‘abort’ 이벤트 리스너를 내부적으로 등록
  2. 개발자가 controller.abort()를 호출
  3. signal 객체에서 ‘abort’ 이벤트가 발생
  4. fetch의 내부 리스너가 이 이벤트를 감지하고, 진행 중이던 네트워크 요청을 즉시 중단
  5. fetch는 프로미스를 reject하며 AbortError라는 이름의 DOMException을 발생
const getAccommodationList = async (signal?: AbortSignal): Promise<AccommodationResponse> => {
  // fetch의 두 번째 인자로 signal 객체를 전달
  const response = await fetch('/api/accommodations', { signal });

  if (!response.ok) {
    throw new HttpError(response.status); 
  }
  return response.json();
};
const useAccommodationList = () => {
  const [accommodations, setAccommodations] = useState<AccommodationResponse>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // 1. AbortController 인스턴스 생성
    const controller = new AbortController();

    const fetchData = async () => {
      try {
        setIsLoading(true);
        setError(null);
        
        // 2. getAccommodationList 호출 시 signal 전달
        const data = await getAccommodationList(controller.signal);
        
        // fetch가 abort되면 이 코드는 실행되지 않고 catch로 이동
        setAccommodations(data);
      } catch (err) {
        // 3. Abort로 인한 에러인지 확인
        if (err instanceof Error && err.name === 'AbortError') {
          // 요청이 중단된 것은 실제 에러가 아니므로 상태 업데이트 스킵
          return;
        }

        // 그 외 실제 에러 처리
        if (err instanceof Error) setError(err);
        else setError(new Error('알 수 없는 오류가 발생했습니다.'));
      } finally {
        // 4. Abort되지 않은 경우에만 로딩 상태 변경
        // (Abort된 경우 컴포넌트가 이미 unmount되었을 가능성이 높음)
        // 의도적으로 abort 됐을 때는 이미 사라진 컴포넌트일 수 있으므로 상태 변경을 차단
        if (!controller.signal.aborted) {
          setIsLoading(false);
        }
      }
    };

    fetchData();

    // 5. useEffect 클린업: 컴포넌트 unmount 시 fetch 요청 취소
    return () => {
      controller.abort();
    };
  }, []); // 의존성 배열이 비어있으므로 마운트/언마운트 시 각 1회 실행

  return { accommodations, isLoading, error };
};

useEffect의 클린업 함수에서 abort()를 호출하면, 컴포넌트가 언마운트되거나 의존성이 변경되어 재실행될 때 진행 중이던 네트워크 요청을 즉시 중단시킨다.

해결 방식 2: boolean 플래그 (구식)

AbortController를 사용하기 어려운 환경이라면, boolean 플래그로 무시할 수 있다.

이 방식은 fetch 요청 자체를 취소하진 않지만, 요청이 뒤늦게 완료되더라도 여전히 컴포넌트가 마운트된 상태인지 확인하여 상태 업데이트를 하지 않도록 무시한다.

그러므로 네트워크 요청은 계속 진행되어 리소스를 낭비한다.

const useAccommodationList = () => {
  const [accommodations, setAccommodations] = useState<AccommodationResponse>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // 1. 마운트 상태를 추적하는 플래그
    let isMounted = true;

    const fetchData = async () => {
      try {
        setIsLoading(true);
        setError(null);
        const data = await getAccommodationList(); // 이 함수는 signal을 받지 않아도 됨

        // 2. 상태 업데이트 전 플래그 확인
        if (isMounted) {
          setAccommodations(data);
        }
      } catch (err) {
        // 2. 상태 업데이트 전 플래그 확인
        if (isMounted) {
          if (err instanceof Error) setError(err);
          else setError(new Error('알 수 없는 오류가 발생했습니다.'));
        }
      } finally {
        // 2. 상태 업데이트 전 플래그 확인
        if (isMounted) {
          setIsLoading(false);
        }
      }
    };

    fetchData();

    // 3. useEffect 클린업: unmount 시 플래그를 false로 설정
    return () => {
      isMounted = false;
    };
  }, []);

  return { accommodations, isLoading, error };
};

2. 라이브러리 사용

React Query(TanStack Query)SWR을 사용해 데이터를 컴포넌트의 생명주기에서 분리하여 전역적인 캐시에서 관리할 수 있다.

React QueryuseEffectuseState를 직접 조합하는 대신 useQuery 훅 하나로 캐싱 문제를 해결한다.

  1. 고유한 쿼리 키를 기반으로 데이터를 페칭하고 캐시
  2. 컴포넌트가 언마운트되어도 캐시된 데이터는 메모리에 유지된다.
  3. 다른 페이지로 이동했다가 돌아와서 컴포넌트가 재마운트되면, useQuery로 같은 쿼리 키를 확인
  4. 캐시에 데이터가 있으면 API를 기다리지 않고 즉시 캐시된 데이터를 반환한다.
  5. 동시에 백그라운드에서 조용히 API를 다시 호출하여 최신 데이터를 가져오고, 만약 데이터에 변경이 있다면 화면을 업데이트한다. (Stale-While-Revalidate 과정)

SWR은 React Query와 매우 유사한 방식으로 고유 키를 사용해 데이터를 캐시하고, 컴포넌트가 다시 마운트되면 캐시된 데이터를 먼저 보여준 뒤 백그라운드에서 데이터를 갱신한다.

이 둘은 위의 캐싱 문제 뿐만 아니라 레이스 컨디션StrictMode에서의 문제점 등 useEffect의 생명주기 의존성에서 발생하는 거의 모든 부작용을 해결하기 위해 설계됐다.

  • 레이스 컨디션 해결: 요청 자체에 대해 구독 방식을 사용해 오직 구독 중인 최신 요청의 응답만을 상태에 반영한다.
  • StrictMode 문제점 해결: 요청 중복 제거 기능으로 이미 진행 중인 API 호출을 감지하여 네트워크 요청을 한 번만 보낸다.

그럼 useEffect에서 fetch를 하는 게 마냥 안 좋은 걸까?

useEffect에서 fetch를 하는 것은 React에서 데이터를 가져오는 가장 기본적이고 핵심적인 방법이다.

다만 공식 문서에서 지적하는 것은 이 방식이 그저 나쁘다기보다는, 프로덕션 수준의 앱을 만들 때 고려해야 할 복잡한 문제들을 개발자가 모두 수동으로 처리해야 한다는 점이다.

정리하자면 useEffect + fetch 는 기본적인 방법이지만, 이 방법만으로는 레이스 컨디션, 캐싱, 중복 요청 제거 같은 문제를 해결하여 안정적인 앱을 만들기 매우 번거롭기 때문에 위에서 언급한 해결책을 수동으로 적용하거나 라이브러리를 적용해 번거로운 처리를 자동화하는 걸 권장하는 것이다.

이런 상황이면 useEffect로도 괜찮아요

  1. 학습 목적: 데이터 fetching의 전체 생명주기(로딩, 성공, 실패, 클린업)를 직접 구현
  2. 의존성이 없는 단순한 데이터: 앱에서 딱 한 번만 불러오고 다시는 불러올 일이 없는 데이터 (e.g. 앱 버전 정보, 고정된 설정값 등)
  3. 외부 라이브러리를 추가하고 싶지 않을 때: 프로젝트 규모가 매우 작아서 React Query 같은 라이브러리를 추가하는 것이 더 번거로울 때 (오버 엔지니어링)
profile
Web FE Dev | Microsoft Student Ambassadors Alumni

0개의 댓글