본문으로 건너뛰기
Tech Blog

useEffect 페칭이 꼬이는 순간

글 복사 완료!

탭을 빨리 바꿨더니 직전 사람 정보가 남는 그 버그를 풀어요.

·18분·

검색창에 글자를 빠르게 칠 때 잠깐 엉뚱한 결과가 떴다가 사라진 적 있죠. 프로필 탭을 빨리 바꿨더니 직전에 고른 사람 정보가 그대로 남아 있던 적도요. ReactuseEffect 안에서 fetch 를 하면 effect 가 다시 실행될 때 이전 요청의 응답이 늦게 도착해 화면을 덮어쓰는 race condition 이 생기는데, cleanup 에서 늦은 결과를 무시하거나 요청 자체를 끊으면 막을 수 있어요.

빠르게 탭을 바꾸면 엉뚱한 사람이 남아요

상황을 그대로 재현해볼게요. 사람을 고르면 useEffect 가 그 사람의 소개를 가져와 화면에 뿌리는 컴포넌트예요. 한 가지 장치만 넣었어요. Bob 의 응답은 느리게, Alice 의 응답은 빠르게 오도록요.

아래 데모에서 Bob 을 누르고 곧바로 Alice 를 눌러보세요. Alice 소개가 먼저 떴다가, 잠시 뒤 느리게 도착한 Bob 의 응답이 화면을 덮어써요. 선택한 사람은 Alice 인데 표시되는 소개는 Bob 인 상태가 되거든요.

왜 이렇게 될까요. person 이 바뀌면 effect 가 다시 돌면서 새 fetch 를 시작하는데, 먼저 시작한 fetch 를 멈추는 장치가 없어요. 그래서 두 요청이 동시에 떠 있다가 도착하는 순서가 시작한 순서와 달라지면, 마지막에 도착한 응답이 setBio 를 호출하면서 이깁니다. React 공식 문서도 검색창 예시로 같은 장면을 짚어요.

"the "hell" response may arrive after the "hello" response. Since it will call setResults() last, you will be displaying the wrong search results. This is called a "race condition": two different requests "raced" against each other and came in a different order than you expected." - React, You Might Not Need an Effect

hello 를 칠 때 hell 까지의 요청이 먼저 나가는데, 그 응답이 hello 응답보다 늦게 도착하면 마지막 setResults 가 옛 결과로 화면을 덮어써요. 두 요청이 서로 경주하다 예상과 다른 순서로 들어온 거예요.

여기서 핵심은 응답이 도착하는 순서를 우리가 보장할 수 없다는 거예요. 네트워크 상황에 따라 빠른 요청이 먼저 올 수도, 느린 요청이 먼저 올 수도 있거든요. 그러니 도착 순서에 기대지 말고, 오래된 응답을 골라내는 장치를 effect 안에 직접 넣어야 해요.

ignore 플래그로 늦은 응답을 버려요

가장 단순한 장치는 cleanup 함수예요. cleanup 은 effect 가 다음에 다시 실행되기 직전이나 컴포넌트가 사라질 때 React 가 불러주는 정리 함수인데, 여기서 "이 응답은 이제 낡았다" 는 표시를 남기면 돼요.

방법은 이래요. effect 가 실행될 때마다 ignore 라는 지역 변수를 false 로 새로 만들어요. 그리고 cleanup 에서 그 변수를 true 로 바꿔요. fetch 가 끝나고 setBio 를 부르기 전에 if (!ignore) 로 막아두면, 낡은 effect 의 응답은 통과하지 못해요. 렌더마다 자기만의 ignore 를 들고 있어서, 가장 최근 effect 의 응답만 false 인 채로 살아남거든요.

이번엔 Bob 을 누르고 바로 Alice 를 눌러도 Alice 소개가 그대로 유지돼요. Bob 의 느린 응답이 도착하긴 하는데, 그 시점엔 Bob 의 effect cleanup 이 이미 ignoretrue 로 바꿔둬서 setBio 까지 가지 못하거든요. React 문서는 이 정리를 두 갈래로 정리해요.

"If your Effect fetches something, the cleanup function should either abort the fetch or ignore its result." - React, Synchronizing with Effects

effect 가 무언가를 가져온다면, cleanup 은 요청을 끊거나 결과를 무시하거나 둘 중 하나를 해야 한다는 거예요. 방금 본 게 후자, 결과를 무시하는 쪽이고요.

ignore 플래그는 코드가 짧고 의존성도 없어서 가장 먼저 손이 가요. 다만 한 가지는 알고 써야 해요. 이 방식은 응답을 받아놓고 결과만 버리는 거라서, Bob 의 요청 자체는 네트워크에서 끝까지 진행돼요. 빠르게 여러 번 전환하면 그만큼 헛된 요청이 계속 날아가는 셈이죠.

AbortController 로 요청 자체를 끊어요

낭비되는 요청까지 줄이고 싶으면 AbortController 를 써요. 이름 그대로 진행 중인 요청에 중단 신호를 보내는 컨트롤러예요. controller.signalfetch(url, { signal }) 에 넘겨두고, cleanup 에서 controller.abort() 를 부르면 그 요청이 네트워크 레벨에서 취소돼요.

"Aborts an asynchronous operation before it has completed. This is able to abort fetch requests, consumption of any response bodies, and streams." - MDN, AbortController.abort()

완료되기 전의 비동기 작업을 중단한다는 거예요. fetch 요청은 물론 응답 본문을 읽는 중이거나 스트림을 받는 중이어도 끊을 수 있고요.

아래 데모는 가짜 fetch 가 signal 을 듣고 있다가, cleanup 의 abort() 가 불리면 진행 중이던 타이머를 즉시 끄도록 만들었어요. 끊긴 요청은 AbortError 라는 이름의 예외로 reject 되니까, catch 에서 그 이름만 따로 걸러주면 돼요.

두 방식은 비슷해 보이지만 맡는 일이 달라요. 헷갈리지 않게 갈라두면 이래요.

ignore 플래그 (정합성 가드)

setState 직전에 동기로 한 번 검사해요. 낡은 effect 의 응답은 if (!ignore) 에서 걸러지니 race condition 을 확실히 막아요. 대신 요청 자체는 끝까지 진행돼요.

AbortController (트래픽 최적화)

진행 중인 요청을 네트워크에서 끊어 헛된 트래픽을 줄여요. 보통의 전환에선 끊긴 요청이 reject 되며 낡은 setState 도 같이 막히지만, 응답이 이미 도착해 resolve 된 뒤라면 abort 는 그 결과를 되돌리지 못해요. 그래서 정합성까지 책임지는 가드라기보단, ignore 위에 얹는 최적화로 보는 게 정확해요.

abort 가 이미 도착한 응답을 못 막는다는 게 약점처럼 들리지만, 보통의 클릭 전환에선 cleanup 이 응답보다 먼저 돌아서 잘 막혀요. 그래도 빈틈을 아예 없애고 싶으면 둘을 같이 쓰면 돼요. abort 로 트래픽을 줄이고, ignore 로 setState 직전을 한 번 더 잠그는 거예요.

useEffect(() => {
  const controller = new AbortController();
  let ignore = false;
  setBio(null);
  fetchBio(person, controller.signal)
    .then((result) => {
      if (!ignore) setBio(result);
    })
    .catch((error) => {
      if (error.name !== "AbortError") throw error;
    });
  return () => {
    ignore = true;
    controller.abort();
  };
}, [person]);

AbortController 는 fetch 전용처럼 보이지만 사실 비동기 작업 일반에 쓰는 표준 신호예요. 같은 신호를 addEventListener 나 스트림에도 넘길 수 있는데, 이 얘기는 AbortSignal 이 fetch 만 보고 만든 게 아니라는 글에서 따로 풀었어요.

race condition 만 막아선 끝이 아니에요

cleanup 으로 race condition 은 막았지만, useEffect 에서 직접 fetch 하는 방식엔 cleanup 으로도 안 풀리는 숙제가 남아요. effect 는 서버에서 돌지 않으니 초기 HTML 에는 데이터가 비어 있고, 부모가 받아온 뒤에야 자식이 요청을 시작하는 network waterfall, 즉 직렬로 늘어지는 지연도 생겨요. 같은 데이터를 두 컴포넌트가 따로 받아오는 중복 요청이나 캐시 부재도 그대로고요.

"These issues apply to any UI library, not just React. Solving them is not trivial, which is why modern frameworks provide more efficient built-in data fetching mechanisms than fetching data in Effects." - React, You Might Not Need an Effect

이 문제들은 React 만의 일이 아니라 어느 UI 라이브러리에서나 똑같이 생긴다는 거예요. 풀기가 만만치 않아서 요즘 프레임워크는 effect 페칭의 이런 단점을 피하는 내장 페칭을 따로 둔 거고요.

그래서 React 공식 문서도 직접 페칭 대신 프레임워크 내장 페칭이나 TanStack Query, SWR 같은 클라이언트 캐시를 권해요. 이런 도구는 race condition 을 알아서 처리하는 데서 그치지 않고, 캐싱과 중복 요청 제거, 오래된 데이터의 백그라운드 갱신까지 함께 맡거든요. 서버에서 가져온 데이터는 클라이언트 상태와 성질이 다르다는 게 이 도구들의 출발점이에요. SWR 과 TanStack Query 가 같은 자리에서도 왜 다르게 생겼는지는 두 라이브러리의 철학을 비교한 글에서 다뤘어요.

정리하면 이래요. effect 안에서 fetch 를 한다면 cleanup 에서 결과를 무시하거나 요청을 끊어 race condition 만은 꼭 막으세요. 다만 캐싱이나 waterfall 까지 신경 쓰는 단계라면, 페칭을 데이터 도구에 맡기는 쪽이 결국 편해요. React 19 의 use 가 이 흐름에서 어디에 서는지는 use 는 상태 훅이 아니라는 글에서 이어 볼 수 있어요.

자주 묻는 질문

답을 펼치기 전에 스스로 답해보세요

기본은 ignore 플래그예요. 코드가 짧고 의존성도 없어서 race condition 만 막는 데는 충분하거든요. 응답이 크거나 전환이 잦아 헛된 요청을 줄이고 싶을 때 AbortController 로 올리면 돼요.
effect 가 실행될 때마다 그 렌더만의 ignore 를 새로 가져야, 가장 최근 effect 의 응답만 살아남고 이전 응답은 각자의 cleanup 이 막을 수 있어요. 바깥에 하나만 두면 어느 응답이 낡은 건지 구분하지 못해요.
race condition 하나만 보면 cleanup 으로 충분해요. 다만 캐싱, 중복 요청 제거, network waterfall 같은 문제까지 겹치면 직접 풀기가 까다로워서, 그때는 TanStack Query 나 SWR, 프레임워크 내장 페칭에 맡기는 편이 나아요.

참고 자료

관련 글