React 18 Concurrent 렌더링에서 발생한 useEffect 중복 호출 이슈

문제 상황

레거시 프로젝트를 React 18로 업그레이드한 뒤 개발 환경에서 API 호출이 두 번씩 발생하는 이슈를 발견했다. 특히 컴포넌트 마운트 시 실행되는 useEffect 내부의 fetch 요청이 중복으로 실행되고 있었다.

useEffect(() => {
  fetchUserData(userId).then(setUser);
}, [userId]);

원인 분석

React 18의 Strict Mode는 향후 도입될 기능을 대비해 컴포넌트를 의도적으로 mount → unmount → mount 시킨다. Concurrent 기능과 Offscreen API를 준비하기 위한 것으로, 컴포넌트가 재마운트되어도 정상 작동하는지 검증하는 목적이다.

개발 환경에서만 발생하며 프로덕션에는 영향이 없지만, 부수효과를 제대로 정리하지 않으면 메모리 누수나 불필요한 요청이 발생할 수 있다.

해결 방법

클린업 함수를 통해 AbortController로 요청을 취소하도록 수정했다.

useEffect(() => {
  const controller = new AbortController();
  
  fetchUserData(userId, { signal: controller.signal })
    .then(setUser)
    .catch(err => {
      if (err.name !== 'AbortError') {
        console.error(err);
      }
    });
  
  return () => controller.abort();
}, [userId]);

데이터 페칭 라이브러리를 사용하는 경우 React Query나 SWR이 이런 케이스를 자동으로 처리해준다. 다만 직접 useEffect로 관리하는 레거시 코드가 많아 패턴을 통일하는 작업이 필요했다.

결론

React 18의 Strict Mode 변경사항은 기존 코드의 취약점을 드러내는 좋은 기회였다. 클린업 함수를 제대로 작성하는 습관이 더 중요해졌다.