서버에서 데이터를 가져오는 가장 좋은 방법은 무엇일까요? useEffect 안에서 fetch하는 것이 정말 최선일까요, 아니면 더 나은 패턴이 있을까요?

개념 정의

데이터 페칭 패턴은 서버에서 비동기적으로 데이터를 가져와 UI에 표시하는 전략과 구현 방식 을 의미합니다. React에서는 useEffect fetch부터 Suspense까지 여러 접근법이 존재하며, 각각의 트레이드오프가 있습니다.

왜 필요한가

데이터 페칭은 거의 모든 웹 애플리케이션의 핵심 기능이지만, 올바르게 구현하기가 생각보다 까다롭습니다.

  • 로딩/에러/성공 상태 관리
  • Race condition 방지
  • 중복 요청 제거
  • 캐싱과 재검증
  • 네트워크 워터폴 방지

패턴 1: Fetch-on-render (useEffect)

가장 기본적이고 직관적인 방식입니다.

JSX
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

JSX
// userId가 1 → 2 → 3으로 빠르게 변경되면
// 요청 1 시작 → 요청 2 시작 → 요청 3 시작
// 응답 순서: 3 → 1 → 2 (네트워크 상황에 따라 다름)
// 최종 UI: 사용자 2의 정보 표시 (의도: 사용자 3)

2. 네트워크 워터폴

JSX
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 해결

JSX
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

데이터를 모두 가져온 후에 렌더링을 시작합니다.

JSX
// 라우트 전환 시 모든 데이터를 먼저 가져옴
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)

데이터 요청과 렌더링을 ** 동시에 시작 **합니다.

JSX
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>;
}
PLAINTEXT
타임라인:
네비게이션 시작 → user 요청 시작 + posts 요청 시작
                  |--- user 응답 → UserInfo 렌더링
                  |--- posts 응답 → PostList 렌더링
각 섹션이 준비되는 대로 점진적으로 표시

Suspense의 동작 원리

JSX
<Suspense fallback={<Loading />}>
  <AsyncComponent />
</Suspense>
  1. AsyncComponent가 데이터를 기다리는 Promise를 throw합니다
  2. Suspense가 이를 catch하고 fallback을 표시합니다
  3. Promise가 resolve되면 AsyncComponent를 다시 렌더링합니다

ErrorBoundary와 함께 사용

JSX
<ErrorBoundary fallback={<ErrorUI />}>
  <Suspense fallback={<Loading />}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

중첩 Suspense

JSX
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

JSX
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

JSX
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나 라이브러리가 나왔을 때 "왜 이렇게 설계했는지"가 바로 보입니다.

댓글 로딩 중...