Menu

리액트 useEffect로 데이터 가져오기: 로딩, 오류, 경쟁 상태

React에서는 useEffect에서 요청을 시작하고, 로딩, 오류, 데이터를 state에 보관하고, 입력이 바뀐 뒤에 도착한 응답은 무시하는 방식으로 데이터를 가져옵니다. 이펙트 안의 async/await, AbortController, 경쟁 상태, 그리고 라이브러리를 써야 할 때를 다룹니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

React에서 데이터를 가져오려면 useEffect 안에서 요청을 시작하고, 응답을 state에 저장하고, 도착할 때까지 로딩 메시지를 렌더링하세요. id처럼 요청이 의존하는 값을 의존성 배열에 넣으면, 그 값이 바뀔 때 이펙트가 다시 가져옵니다.

이 페이지의 예제는 네트워크에 접근할 수 없으므로 fetchUser는 가짜 API입니다. fetch처럼 일정 시간 뒤에 완료되는 프로미스입니다. 800을 3000으로 바꾸면 로딩 텍스트가 더 오래 보입니다.

실제 API라면 이펙트 본문은 다음과 같습니다.

useEffect(() => {
    fetch('https://api.example.com/users/1')
        .then((res) => res.json())
        .then((data) => setUser(data));
}, []);

로딩, 오류, 데이터

요청에는 화면이 보여 줘야 할 세 가지 결과가 있습니다. 아직 로딩 중, 실패, 완료입니다. 각각을 state에 보관하고, 이펙트가 새 요청을 시작할 때 초기화하세요.

User 3을 클릭해 보세요. 가짜 API가 거부하고, 이름 대신 오류 메시지가 나타나며, 콘솔에 오류가 표시됩니다. finally 블록이 두 경로 모두에서 loading을 해제하므로, 실패한 요청이 스피너를 켜 둔 채로 남기지 않습니다.

fetch가 실제 서버와 통신할 때 404나 500 응답은 프로미스를 거부하지 않습니다. res.ok를 확인하고 직접 오류를 던지세요.

const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

이펙트 안의 async/await

useEffect에 넘기는 함수는 async일 수 없습니다. React는 그 함수가 아무것도 반환하지 않거나 클린업 함수를 반환하기를 기대하는데, async 함수는 항상 프로미스를 반환합니다.

// Wrong: the effect returns a promise
useEffect(async () => {
    const data = await fetchUser(id);
    setUser(data);
}, [id]);

// Right: define an async function inside and call it
useEffect(() => {
    async function load() {
        const data = await fetchUser(id);
        setUser(data);
    }
    load();
}, [id]);

경쟁 상태

id가 빠르게 바뀌면 두 요청이 동시에 진행됩니다. 순서대로 응답한다는 보장은 없습니다. 오래된 요청이 더 느리면 마지막에 도착해서, 사용자가 가장 최근에 고른 id의 데이터를 덮어씁니다.

해결 방법은 ignore 플래그입니다. 이펙트의 각 실행은 자기만의 플래그를 가지고, 클린업이 그 플래그를 true로 설정합니다. React는 다음 실행 전에 클린업을 실행하므로, 낡은 실행에서 온 응답은 ignore === true를 보고 버려집니다. 이 블록은 같은 프로필을 플래그 없이, 그리고 플래그와 함께 두 번 렌더링합니다.

버튼을 클릭하고 기다려 보세요. user 2의 빠른 응답이 먼저 도착하므로 두 프로필 모두 로그를 남기고 Grace를 유지합니다. 클릭하고 약 1.5초 뒤 user 1의 느린 응답이 도착합니다. 플래그가 없는 프로필은 user 2가 선택되어 있는데도 Ada로 바뀌고, 플래그가 있는 프로필은 dropped stale Ada를 기록하고 Grace를 유지합니다.

같은 버그가 검색창, 탭, 서버에서 필터링하는 모든 목록에서 나타납니다. 위의 로딩 예제는 짧게 유지하려고 플래그를 뺐지만, 이펙트 안의 모든 fetch에는 이 플래그나 아래의 abort가 있어야 합니다.

AbortController로 취소하기

ignore 플래그는 낡은 응답을 버리지만, 요청 자체는 끝까지 실행됩니다. 실제 fetch라면 취소할 수 있습니다. 이펙트에서 AbortController를 만들고, 그 signal을 fetch에 넘기고, 클린업에서 abort()를 호출하세요.

useEffect(() => {
    const controller = new AbortController();

    async function load() {
        try {
            const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            setUser(await res.json());
        } catch (err) {
            if (err.name === 'AbortError') return; // cancelled on purpose
            setError(err);
        }
    }

    load();
    return () => controller.abort();
}, [id]);

취소된 fetch는 AbortError로 거부되므로, catch 블록은 그것을 실패로 보여 주지 않고 건너뜁니다. abort는 언마운트도 처리합니다. 컴포넌트가 제거되면 클린업이 요청을 취소합니다.

커스텀 훅으로 옮기기

로딩, 오류, 경쟁 상태 방지는 데이터를 가져오는 모든 컴포넌트에서 똑같습니다. 이것들을 커스텀 훅에 넣으면 각 컴포넌트는 한 줄로 데이터를 요청할 수 있습니다.

fetchPosts는 컴포넌트 바깥에 정의되어 있으므로 동일성이 절대 바뀌지 않고 의존성 배열에 넣어도 안전합니다. App 안에 정의했다면 렌더링마다 새 함수가 되어 이펙트가 렌더링마다 실행되고, 이펙트가 state를 설정하므로 데이터 가져오기가 절대 멈추지 않을 것입니다.

500을 2000으로 바꾸고 버튼을 연달아 두 번 클릭해 보세요. 콘솔에는 요청 두 개가 표시되고, 마지막으로 고른 주제의 게시물만 나타납니다.

이벤트 핸들러에서 데이터 가져오기

이펙트는 컴포넌트가 화면에 있기 때문에 필요한 데이터를 위한 것입니다. 프로필 페이지는 프로필을 불러옵니다. Search나 Save를 누르는 것처럼 사용자가 무언가를 했기 때문에 일어나는 요청은 이벤트 핸들러에서 하세요. 무엇이 요청을 일으켰는지 정확히 알 수 있고, 관련 없는 state가 바뀌어도 아무것도 다시 실행되지 않습니다.

async function handleSubmit(e) {
    e.preventDefault();
    setStatus('saving');
    const res = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
    setStatus(res.ok ? 'saved' : 'error');
}

자주 하는 실수

의존성 누락. id를 읽으면서 []를 가진 이펙트는 영원히 첫 번째 사용자만 가져옵니다. 요청이 사용하는 모든 값을 나열하세요.

컴포넌트 본문에서 데이터 가져오기. 이펙트 바깥의 fetch는 렌더링마다 실행되고, state를 설정한다면 루프를 시작합니다.

fetch가 오류 시 거부할 것이라고 믿기. 네트워크가 실패할 때만 거부합니다. res.ok를 확인하세요.

로딩 초기화를 잊기. id가 바뀌면 loading을 다시 true로 설정하세요. 그렇지 않으면 새 데이터가 오고 있다는 표시 없이 이전 데이터가 화면에 남습니다.

라이브러리나 프레임워크를 써야 할 때

요청이 몇 개뿐이라면 이펙트에서 가져와도 괜찮습니다. 하지만 캐시가 없습니다. 같은 프로필을 두 번 열면 두 번 가져옵니다. 컴포넌트 간에 데이터를 공유하지 않고, 실패 시 재시도하지 않으며, 탭이 다시 포커스를 얻을 때 다시 가져오지도 않습니다. 라이브러리가 이런 일을 대신해 줍니다.

import { useQuery } from '@tanstack/react-query';

function Profile({ id }) {
    const { data, error, isPending } = useQuery({
        queryKey: ['user', id],
        queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
    });

    if (isPending) return <p>Loading...</p>;
    if (error) return <p>{error.message}</p>;
    return <p>{data.name}</p>;
}

클라이언트 사이드 데이터에는 TanStack Query와 SWR이 흔히 쓰입니다. 프레임워크는 더 나아가 페이지가 브라우저에 도달하기 전에 서버에서 데이터를 불러옵니다. 서버 컴포넌트를 쓰는 Next.js, 로더를 쓰는 React Router가 그렇습니다. 덕분에 로딩 깜빡임을 피하고, 부모의 데이터가 도착해야 자식이 비로소 가져오기를 시작하는 요청의 연쇄도 피할 수 있습니다.

React 19: Suspense와 함께 쓰는 use()

React 19는 렌더링 중에 프로미스를 읽는 use를 추가합니다. 컴포넌트는 프로미스가 완료될 때까지 일시 중단되고, 그동안 가장 가까운 <Suspense> 경계가 대체 UI를 보여 주므로, 컴포넌트 자체에는 로딩 state가 없습니다.

import { use, Suspense } from 'react';

function Profile({ userPromise }) {
    const user = use(userPromise);
    return <p>{user.name}</p>;
}

<Suspense fallback={<p>Loading...</p>}>
    <Profile userPromise={userPromise} />
</Suspense>

프로미스는 그것을 읽는 렌더링 안이 아니라 컴포넌트 바깥(프레임워크, 캐시, 부모)에서 만들어야 합니다. 그렇지 않으면 렌더링할 때마다 새 요청이 시작됩니다. use 훅 페이지에서 실행 가능한 예제로 다룹니다.

자주 묻는 질문

React 컴포넌트가 로드될 때 데이터를 가져오려면 어떻게 하나요?

요청이 의존하는 값을 의존성 배열에 넣고(없으면 []) useEffect에서 요청을 시작한 다음, 결과를 useState로 state에 저장하세요. 데이터가 도착할 때까지 로딩 메시지를 렌더링합니다.

useEffect 콜백을 async로 만들 수 없는 이유는 무엇인가요?

async 함수는 항상 프로미스를 반환하는데, React는 이펙트가 아무것도 반환하지 않거나 클린업 함수를 반환하기를 기대합니다. 이펙트 안에 async 함수를 작성하고 바로 호출하세요.

React 데이터 가져오기에서 경쟁 상태란 무엇인가요?

입력이 빠르게 바뀌면 두 요청이 동시에 진행되고, 오래된 요청이 마지막에 응답해서 새 데이터를 덮어쓸 수 있습니다. 이펙트의 클린업에서 ignore 플래그를 설정하고 플래그가 설정되어 있으면 setState를 건너뛰거나, AbortController로 요청을 취소하세요.

데이터를 가져올 때 useEffect를 써야 하나요, 라이브러리를 써야 하나요?

useEffect는 작은 앱에서 잘 동작하며 이해해 둘 가치가 있습니다. 캐싱, 중복 제거, 재시도, 다시 가져오기가 필요하다면 TanStack Query나 SWR 같은 라이브러리, 또는 Next.js 같은 프레임워크에 내장된 데이터 로딩을 쓰면 그 로직을 직접 작성하지 않아도 됩니다.

데이터를 불러오는 동안 로딩 스피너는 어떻게 보여 주나요?

loading 불리언(또는 status 문자열)을 state에 두고, 요청을 시작하기 전에 설정하고, 성공과 오류 두 경로 모두에서 요청이 끝나면 해제하세요. 값이 true인 동안 스피너를 렌더링합니다.

Coddy 프로그래밍 언어 일러스트

Coddy로 코딩 배우기

시작하기