데이터 페칭 패턴 — useEffect fetch에서 Suspense까지
서버에서 데이터를 가져오는 가장 좋은 방법은 무엇일까요? useEffect 안에서 fetch하는 것이 정말 최선일까요, 아니면 더 나은 패턴이 있을까요?
개념 정의
데이터 페칭 패턴은 서버에서 비동기적으로 데이터를 가져와 UI에 표시하는 전략과 구현 방식 을 의미합니다. React에서는 useEffect fetch부터 Suspense까지 여러 접근법이 존재하며, 각각의 트레이드오프가 있습니다.
왜 필요한가
데이터 페칭은 거의 모든 웹 애플리케이션의 핵심 기능이지만, 올바르게 구현하기가 생각보다 까다롭습니다.
- 로딩/에러/성공 상태 관리
- Race condition 방지
- 중복 요청 제거
- 캐싱과 재검증
- 네트워크 워터폴 방지
패턴 1: Fetch-on-render (useEffect)
가장 기본적이고 직관적인 방식입니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error('요청 실패');
return res.json();
})
.then(data => {
if (!ignore) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (!ignore) {
setError(err);
setLoading(false);
}
});
return () => { ignore = true; };
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <div>{user.name}</div>;
}
문제점
1. Race condition
// userId가 1 → 2 → 3으로 빠르게 변경되면
// 요청 1 시작 → 요청 2 시작 → 요청 3 시작
// 응답 순서: 3 → 1 → 2 (네트워크 상황에 따라 다름)
// 최종 UI: 사용자 2의 정보 표시 (의도: 사용자 3)
2. 네트워크 워터폴
function ProfilePage({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <Spinner />;
// user를 가져온 후에야 posts 요청이 시작됨 → 순차적 워터폴
return (
<div>
<UserInfo user={user} />
<UserPosts userId={userId} /> {/* 여기서 또 fetch */}
</div>
);
}
// 타임라인:
// |--- user 요청 ---|
// |--- posts 요청 ---|
// 총 시간 = user 시간 + posts 시간 (순차)
3. AbortController로 race condition 해결
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err);
});
return () => controller.abort(); // 이전 요청 취소
}, [userId]);
패턴 2: Fetch-then-render
데이터를 모두 가져온 후에 렌더링을 시작합니다.
// 라우트 전환 시 모든 데이터를 먼저 가져옴
function ProfilePage({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
// 병렬로 요청
Promise.all([
fetchUser(userId),
fetchPosts(userId),
]).then(([user, posts]) => {
setData({ user, posts });
});
}, [userId]);
if (!data) return <Spinner />;
return (
<div>
<UserInfo user={data.user} />
<PostList posts={data.posts} />
</div>
);
}
// 타임라인:
// |--- user 요청 ---|
// |--- posts 요청 --|
// 총 시간 = max(user, posts) (병렬)
워터폴은 해결되지만, ** 모든 데이터가 준비될 때까지 아무것도 보여주지 않는** 단점이 있습니다.
패턴 3: Render-as-you-fetch (Suspense)
데이터 요청과 렌더링을 ** 동시에 시작 **합니다.
import { Suspense } from 'react';
// 라우트 전환과 동시에 데이터 요청 시작
function onNavigateToProfile(userId) {
// 렌더링 전에 데이터 요청을 시작
const userPromise = fetchUser(userId);
const postsPromise = fetchPosts(userId);
// 컴포넌트에 Promise를 전달
setProfileData({ userPromise, postsPromise });
}
function ProfilePage({ userPromise, postsPromise }) {
return (
<div>
<Suspense fallback={<UserSkeleton />}>
<UserInfo userPromise={userPromise} />
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<PostList postsPromise={postsPromise} />
</Suspense>
</div>
);
}
function UserInfo({ userPromise }) {
const user = use(userPromise); // React 19의 use()
return <div>{user.name}</div>;
}
타임라인:
네비게이션 시작 → user 요청 시작 + posts 요청 시작
|--- user 응답 → UserInfo 렌더링
|--- posts 응답 → PostList 렌더링
각 섹션이 준비되는 대로 점진적으로 표시
Suspense의 동작 원리
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
AsyncComponent가 데이터를 기다리는 Promise를 throw합니다- Suspense가 이를 catch하고
fallback을 표시합니다 - Promise가 resolve되면
AsyncComponent를 다시 렌더링합니다
ErrorBoundary와 함께 사용
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
중첩 Suspense
function Dashboard() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<div className="dashboard-grid">
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<ListSkeleton />}>
<TopProducts />
</Suspense>
</div>
</Suspense>
);
}
각 섹션이 ** 독립적으로** 로딩되므로, 하나가 느려도 나머지는 먼저 표시됩니다.
실전에서의 데이터 페칭
TanStack Query + Suspense
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// data가 항상 존재함이 보장 (loading/error 처리가 불필요)
return <div>{user.name}</div>;
}
// 사용
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile userId={1} />
</Suspense>
React Router loader + useLoaderData
const router = createBrowserRouter([
{
path: '/profile/:userId',
loader: ({ params }) => fetchUser(params.userId),
element: <ProfilePage />,
},
]);
function ProfilePage() {
const user = useLoaderData();
return <div>{user.name}</div>;
}
세 가지 패턴 비교
| 패턴 | 시작 시점 | 워터폴 | 점진적 표시 | 복잡도 |
|---|---|---|---|---|
| Fetch-on-render | 렌더링 후 | 있음 | 가능 | 낮음 |
| Fetch-then-render | 렌더링 전 | 없음 | 불가 | 중간 |
| Render-as-you-fetch | 네비게이션 시 | 없음 | 가능 | 높음 |
주의할 점
useEffect fetch에서 cleanup 없이 race condition 방치
빠른 입력이나 라우트 전환 시 이전 요청의 응답이 나중에 도착하면, 화면에 ** 이전 검색어의 결과 **가 표시됩니다. AbortController로 이전 요청을 취소하거나 boolean 플래그로 무시해야 합니다.
컴포넌트마다 loading/error state를 반복 구현
useEffect + useState로 매번 loading, error, data 세 가지 상태를 관리하면 보일러플레이트가 급증합니다. 커스텀 Hook으로 추출하거나 TanStack Query를 사용하는 것이 실용적입니다.
정리
| 패턴 | 시작 시점 | 워터폴 | 점진적 표시 | 복잡도 |
|---|---|---|---|---|
| Fetch-on-render | 렌더링 후 | 있음 | 가능 | 낮음 |
| Fetch-then-render | 렌더링 전 | 없음 | 불가 | 중간 |
| Render-as-you-fetch | 네비게이션 시 | 없음 | 가능 | 높음 |
데이터 페칭 패턴의 진화 방향을 이해하면, 새로운 API나 라이브러리가 나왔을 때 "왜 이렇게 설계했는지"가 바로 보입니다.