
팀 활동에서 한 팀원분께서 내가 useEffect에서 fetch하는 코드에 대해서 아주 중요한 피드백을 해주셨다.
코드 보러 가기
위 커스텀 훅의 useEffect에서 fetch 할 때 생기는 문제점들에 대해서다.
리액트 공식문서 중 ‘Effect으로 동기화하기’에서, ‘데이터 페칭’에 관해서 링크를 공유받아서 공식 문서를 정독하며 내 코드에서 어떤 점이 문제일지 알아보았다.
리액트의 렌더링 로직은 순수해야한다.
즉, 동일한 props와 state가 주어지면 항상 동일한 JSX를 반환해야 하며, 렌더링 과정에서 데이터를 가져오거나(fetch) state를 변경하는 등의 사이드 이펙트를 일으켜서는 안 된다.
데이터 fetching은 대표적인 사이드 이펙트다. 이는 외부 시스템(네트워크, API 서버)과 통신하는 작업은 리액트의 통제 하에 있지 않기 때문이다.
useEffect는 렌더링이 완료된 이후에 이러한 사이드 이펙트를 실행할 수 있게 해주는 장치이다.
따라서 컴포넌트가 렌더링되면 → 데이터를 API에서 가져와서 → 상태를 업데이트하고 → 재렌더링의 흐름을 useEffect로 구현할 수 있다.
아래 시나리오대로 레이스 컨디션이 발생할 수 있다.
A로 검색A의 fetch 시작 B로 다시 검색B의 fetch 시작 B의 검색 결과가 먼저 도착 → 상태 업데이트 → 요청 B 데이터 표시A의 검색 결과가 뒤늦게 도착 → 상태 업데이트 → 요청 A 데이터 표시최종 결과: 사용자는 분명 필터 B로 검색했지만, 화면에는 필터 A의 검색 결과 데이터가 표시된다. 프로덕션 환경에서 실제 사용자 인터랙션과 네트워크 속도에 따라 발생한다.
useEffect의 의존성 배열을 비워두면 컴포넌트가 한 번만 실행되는 줄 알았는데, 아니었다.
React의 개발 모드는 StrictMode로 동작한다. 이는 컴포넌트의 안정성을 검사하기 위한 방식이며 컴포넌트를 의도적으로 두 번 렌더링한다. (mount → unmount → mount)
아래 시나리오에서 문제가 발생할 수 있다.
useEffect 실행 → fetch (A) 요청 시작fetch (A)가 취소되지 않음useEffect 재실행 → fetch (B) 요청 시작최종 결과: fetch (A)와 (B)가 모두 실행 중이며, 만약 fetch (A)가 fetch (B)보다 늦게 완료되면, fetch(A)는 이미 사라진 첫 번째 마운트 시점의 컴포넌트의 상태를 업데이트(setState)하려고 시도한다.
그러나 이건 버그가 아니라, 의도된 동작이다.
이 동작은 개발 환경에서만 발생하며, 프로덕션 빌드에서는 발생하지 않는다.
위에서 언급한 두 문제가 기술적/구현 상의 문제점이라면,
이 문제는 useEffect + useState 패턴의 구조적/아키텍처적 한계점이다.
이 기본적인 fetching 패턴에는 컴포넌트 외부에 데이터를 저장하는 캐시 레이어가 없다.
컴포넌트가 언마운트되면 그 안의 useState로 관리되던 데이터도 함께 사라지는 것이다.
예를 들자면, 사용자가 A 페이지를 봤다가, B 페이지로 이동한 뒤, 다시 A 페이지로 돌아오면 컴포넌트가 다시 마운트되면서 훅이 다시 실행되고, useEffect 가 또 API를 호출한다.
데이터 캐싱이 되지 않아 불필요한 API 호출이 발생하는 것이다.
이는 매우 비효율적인 부분으로, React Query나 SWR 같은 라이브러리를 적용할 수 있다. 이 둘은 첫 번째 요청 결과를 메모리에 캐시해두었다가, 컴포넌트가 다시 마운트되면 API를 또 호출하는 대신 캐시된 데이터를 즉시 반환한다.
공식 문서에서는 useEffect의 클린업(cleanup) 함수를 활용하라고 강조한다.
useEffect는 함수를 반환할 수 있는데, 이 클린업 함수는 다음과 같은 두 가지 시점에 실행된다.
useEffect(() => {
(이펙트 함수)
return {
(클린업 함수)
};
}, [의존값]);
즉, StrictMode 에서는 첫 번째 마운트 → 클린업 → 두 번째 마운트 순서로 실행된다.
이 2번 특징을 이용해 레이스 컨디션과 StrictMode 에서의 마운트 해제된 컴포넌트의 상태를 업데이트하려는 문제를 해결한다.
fetch는 AbortController라는 API를 통해 요청을 취소할 수 있다. 이는 fetch 외에도 비동기 작업을 취소할 수 있는 기능을 제공한다.
AbortController는 AbortController 인스턴스와 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();
핵심 동작 원리
fetch 와 같은 API는 옵션으로 전달받은 signal 객체에 ‘abort’ 이벤트 리스너를 내부적으로 등록controller.abort()를 호출signal 객체에서 ‘abort’ 이벤트가 발생fetch의 내부 리스너가 이 이벤트를 감지하고, 진행 중이던 네트워크 요청을 즉시 중단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()를 호출하면, 컴포넌트가 언마운트되거나 의존성이 변경되어 재실행될 때 진행 중이던 네트워크 요청을 즉시 중단시킨다.
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 };
};
React Query(TanStack Query)나 SWR을 사용해 데이터를 컴포넌트의 생명주기에서 분리하여 전역적인 캐시에서 관리할 수 있다.
React Query는 useEffect와 useState를 직접 조합하는 대신 useQuery 훅 하나로 캐싱 문제를 해결한다.
useQuery로 같은 쿼리 키를 확인SWR은 React Query와 매우 유사한 방식으로 고유 키를 사용해 데이터를 캐시하고, 컴포넌트가 다시 마운트되면 캐시된 데이터를 먼저 보여준 뒤 백그라운드에서 데이터를 갱신한다.
이 둘은 위의 캐싱 문제 뿐만 아니라 레이스 컨디션과 StrictMode에서의 문제점 등 useEffect의 생명주기 의존성에서 발생하는 거의 모든 부작용을 해결하기 위해 설계됐다.
useEffect에서 fetch를 하는 것은 React에서 데이터를 가져오는 가장 기본적이고 핵심적인 방법이다.
다만 공식 문서에서 지적하는 것은 이 방식이 그저 나쁘다기보다는, 프로덕션 수준의 앱을 만들 때 고려해야 할 복잡한 문제들을 개발자가 모두 수동으로 처리해야 한다는 점이다.
정리하자면 useEffect + fetch 는 기본적인 방법이지만, 이 방법만으로는 레이스 컨디션, 캐싱, 중복 요청 제거 같은 문제를 해결하여 안정적인 앱을 만들기 매우 번거롭기 때문에 위에서 언급한 해결책을 수동으로 적용하거나 라이브러리를 적용해 번거로운 처리를 자동화하는 걸 권장하는 것이다.