React 18 Server Components에서 데이터 페칭 패턴 정리

배경

회사 대시보드 프로젝트를 Next.js 14로 마이그레이션하면서 Server Components를 본격적으로 도입했다. 기존에는 클라이언트 컴포넌트에서 useEffect로 데이터를 페칭했지만, 이제는 서버에서 직접 데이터를 가져올 수 있게 되었다.

기존 패턴의 문제점

// 기존 방식
function Dashboard() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/dashboard')
      .then(res => res.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <Spinner />;
  return <div>{data.title}</div>;
}

이 패턴은 클라이언트에서 추가 요청이 발생하고, waterfall 문제가 있었다.

Server Components 적용

// app/dashboard/page.tsx
async function Dashboard() {
  const data = await fetch('https://api.example.com/dashboard', {
    next: { revalidate: 60 }
  }).then(res => res.json());

  return <div>{data.title}</div>;
}

서버 컴포넌트에서는 async/await를 직접 사용할 수 있다. 컴포넌트가 비동기 함수가 되는 것이 처음엔 낯설었지만, 코드가 훨씬 간결해졌다.

병렬 데이터 페칭

여러 API를 호출할 때는 Promise.all을 활용했다.

async function Page() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ]);

  return (
    <>
      <UserProfile user={user} />
      <PostList posts={posts} />
      <CommentSection comments={comments} />
    </>
  );
}

클라이언트 인터랙션 분리

서버 컴포넌트는 상태를 가질 수 없으므로, 인터랙션이 필요한 부분만 클라이언트 컴포넌트로 분리했다.

// Server Component
async function PostList() {
  const posts = await fetchPosts();
  return posts.map(post => <PostCard key={post.id} post={post} />);
}

// Client Component
'use client';
function PostCard({ post }) {
  const [liked, setLiked] = useState(false);
  return (
    <div>
      <h3>{post.title}</h3>
      <button onClick={() => setLiked(!liked)}>좋아요</button>
    </div>
  );
}

결과

초기 로딩 속도가 약 40% 개선되었고, 클라이언트 번들 크기도 줄었다. 다만 서버와 클라이언트 컴포넌트의 경계를 명확히 하는 것이 초반에는 혼란스러웠다. 팀 내 컨벤션을 정하고 파일명에 .client.tsx, .server.tsx 같은 접미사를 붙여 구분하기로 했다.