React Server Components를 위한 컴포넌트 아키텍처

작성일:2026.06.01|조회수:189

React Server Components를 위한 컴포넌트 아키텍처

이 포스트는 Vercel의 Next.js 팀 소속 개발자 Aurora Scharff가 자신의 블로그에 올린 Component Architecture for React Server Components 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.

React의 역사 대부분에서 페이지의 데이터를 불러오는 관습적인 방식은 route의 가장 위에서 fetch를 하고, 그 데이터를 props를 통해 아래로 내려보내는 것이었다. Next.js App Router를 쓰고 있을 때조차 많은 React 개발자는 여전히 이 모델을 먼저 떠올린다.

이 글에서는 왜 그런 습관이 컴포넌트를 강하게 결합시키고 loading state를 다루기 어렵게 만드는지 살펴보고, React Server Components가 어떻게 페이지를 다르게 설계하게 해주는지 알아본다. useEffect에서 React Query, loader, RSC로 이어지는 흐름을 차례로 걸어본 뒤, 모든 데이터를 직접 관리하는 대신 loading experience를 설명하는 페이지를 만들어볼 것이다.

배경

서버에서 데이터를 가져오는 것은 클라이언트에서 가져오는 것보다 빠르다. 이유는 단순하다. 클라이언트에서 fetch를 하면 첫 요청을 시작하기 전부터 JavaScript bundle이 다운로드되고, parse되고, 실행될 때까지 기다려야 한다. UI가 렌더링되고 더 많은 컴포넌트가 mount되면, 각 컴포넌트가 자기 fetch를 트리거할 수 있다. 그러면 요청이 병렬이 아니라 순차적으로 일어나는 waterfall이 생긴다. 반면 서버는 데이터베이스 가까이에 있고, rendering과 병렬로 데이터를 가져온 뒤 결과를 HTML 안에 포함해 보낼 수 있다. 사용자는 추가 roundtrip 비용을 치르지 않고 데이터를 받는다.

그래서 예전 Remix(v1, v2), Next.js Pages Router, 그리고 최근의 React Router v7, TanStack Router 같은 프레임워크에서 loader가 널리 쓰여왔다. loader는 route boundary의 서버 쪽에 data fetching을 놓는다. 그리고 그 위치는 데이터 fetching을 두기에 꽤 알맞은 장소다. TanStack Router에서는 loader가 실제로 optional이고, 흔한 설정 중 하나는 이를 TanStack Query와 결합하는 것이다. 각 컴포넌트는 여전히 자기 useQuery로 데이터를 가져오고, loader는 route에서 그 데이터를 미리 prefetch하는 역할만 한다. 어쩌면 이 분리가 더 낫다고 볼 수도 있다. component-local fetching은 유지하면서도 route-level의 선행 fetch 이점을 얻을 수 있기 때문이다.

질문은 그 과정에서 우리가 무엇을 잃는가, 그리고 RSC가 서버 쪽 이점을 유지하면서도 그 trade-off를 줄여줄 수 있는가다. 이 주제의 performance 측면을 더 깊게 보고 싶다면 Nadia Makarevich의 글 React Server Components: Do They Really Improve Performance?를 함께 읽어보면 좋다. 그녀는 같은 앱을 CSR, loader 기반 SSR, RSC로 각각 측정했고, 실제 performance gain은 data fetching을 server-first 방식으로 다시 작성하고 의도적인 Suspense boundary를 추가했을 때에야 나타난다는 것을 보여준다.

사용 사례

우리가 social feed page를 만들고 있다고 상상해보자. UI에는 sidebar, post feed, follow할 만한 사용자 목록, trending tag 목록이 있다. plain JSX로 보면 페이지는 대략 이렇게 생겼다.

TSX
function HomePage() {
  return (
    <Layout>
      <Sidebar />
      <main>
        <PageHeader title="Home" />
        <Feed />
      </main>
      <aside>
        <TrendingTags />
        <WhoToFollow />
      </aside>
    </Layout>
  );
}

이 코드는 layout일 뿐이다. 아직 data도 없고, fetching도 없고, loading state도 없다. 여기 있는 모든 컴포넌트는 결국 데이터를 필요로 하겠지만, 지금 우리는 페이지가 어떻게 생겼는지만 설명하고 있다. 여기서부터 data fetching에 대한 서로 다른 접근이 이 페이지의 모양을 어떻게 바꾸는지 살펴볼 수 있다.

1. Local Data Fetching

React에서 데이터를 다루던 초기 방식은 useEffectuseState를 사용하는 것이었다. 각 컴포넌트는 자기 데이터를 fetch하고, 자기 loading flag를 소유하며, 다른 곳에서 알아야 할 상태가 생기면 state를 위로 끌어올린다.

TSX
function Feed() {
  const [posts, setPosts] = useState<PostT[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    fetchFeed().then(p => {
      setPosts(p);
      setIsLoading(false);
    });
  }, []);

  if (isLoading) return <FeedSkeleton />;
  return <ul>{posts.map(post => <Post key={post.id} post={post} />)}</ul>;
}

이 방식은 하나의 컴포넌트 안에서는 동작한다. 하지만 tree의 다른 부분에서 같은 데이터가 필요해지는 순간, 우리는 postssetPosts를 공통 ancestor로 끌어올리고 아래로 내려보내야 한다. mutation도 같은 패턴을 따른다. Post가 자기 자신을 like하고, 그 count가 다른 곳에서도 갱신되어야 한다면, like handler는 두 컴포넌트가 모두 접근할 수 있는 어딘가에 있어야 한다. 보통 그 위치는 두 컴포넌트가 실제로 필요로 하는 위치보다 훨씬 위쪽이다. 결국 UI 구조와는 별 상관없는 이유로 state를 끌어올리게 된다.

TSX
function HomePage() {
  const [posts, setPosts] = useState<PostT[]>([]);
  // ...fetch logic...

  function handleLike(postId: string) {
    likePost(postId).then(() => {
      fetchFeed().then(setPosts);
    });
  }

  return (
    <Feed posts={posts} onLike={handleLike} />
  );
}

function Feed({ posts, onLike }: Props) {
  return (
    <ul>
      {posts.map(post => (
        <Post key={post.id} post={post}>
          <LikeButton onClick={() => onLike(post.id)} />
        </Post>
      ))}
    </ul>
  );
}

like handler는 posts를 갱신해야 하기 때문에 HomePage에 있다. Feed는 data와 callback을 모두 받는다. LikeButton은 handler가 어디에서 오는지 알지 못한다. 모든 것이 props를 통해 흐른다.

React Query와 비슷한 라이브러리들은 이 문제를 크게 정리해주었다. 데이터는 key를 기준으로 중앙 cache에 저장되므로 어떤 컴포넌트든 prop drilling 없이 데이터를 요청할 수 있고, mutation은 어디서든 entry를 invalidate하거나 update할 수 있다.

TSX
function Feed() {
  const { data, isLoading } = useQuery({ queryKey: ["feed"], queryFn: fetchFeed });
  if (isLoading) return <FeedSkeleton />;
  return <ul>{data.map(post => <Post key={post.id} post={post} />)}</ul>;
}

function LikeButton({ postId }: { postId: string }) {
  const qc = useQueryClient();
  const mutation = useMutation({
    mutationFn: () => likePost(postId),
    onSuccess: () => qc.invalidateQueries({ queryKey: ["feed"] }),
  });
  return <button onClick={() => mutation.mutate()}>Like</button>;
}

갑자기 state가 그것을 소유한 컴포넌트보다 더 위에 있을 필요가 없어졌다. LikeButton은 tree 깊은 곳에 있어도 mutation을 실행할 수 있고, Feed query는 두 컴포넌트 위쪽의 어떤 것도 알 필요 없이 refetch된다. 이것은 정말로 더 나은 방식이고, React Query가 지금의 위치를 갖게 된 큰 이유이기도 하다.

다만 두 경우 모두 단점은 남아 있다. 각 컴포넌트가 자기 준비 여부를 독립적으로 결정하기 때문에 페이지 안의 것들이 network가 반환되는 순서대로 하나씩 튀어나오는 popcorn UI가 되기 쉽다. 우리는 loading sequence를 설계한 것이 아니라 network에 맡긴 것이다. 게다가 모든 fetching은 클라이언트에서 일어나므로, 사용자는 데이터 요청이 시작되기 전부터 JavaScript가 다운로드되고 실행될 때까지 기다려야 한다. loader는 이 문제를 해결하려는 시도였다.

2. Route-Level Loaders

client-fetching 문제를 해결하기 위해 data fetching을 route-level loader를 통해 서버로 옮길 수 있다. 각 컴포넌트가 자기 데이터를 따로 fetch하는 대신, 하나의 함수가 페이지에 필요한 모든 것을 미리 fetch하고, 그 결과를 page component로 내려준다. React Router에서는 이렇게 보인다.

TSX
// React Router / Remix style
export async function loader() {
  const user = await getCurrentUser();
  const [feed, whoToFollow, trendingTags] = await Promise.all([
    getFeed(user.handle),
    getWhoToFollow(user.handle),
    getTrendingTags(),
  ]);
  return { user, feed, whoToFollow, trendingTags };
}

export default function HomePage() {
  const { user, feed, whoToFollow, trendingTags } = useLoaderData<typeof loader>();

  return (
    <Layout>
      <Sidebar user={user} />
      <Feed posts={feed.posts} currentUser={user} />
      <aside>
        <TrendingTags tags={trendingTags} />
        <WhoToFollow users={whoToFollow} currentUser={user} />
      </aside>
    </Layout>
  );
}

예전 Next.js Pages Router에서 이에 해당하는 것은 getServerSideProps였다. 이 함수는 데이터를 props로 page에 전달한다. 어느 쪽이든 loader는 route boundary에 있고, 그 아래의 컴포넌트는 구체적인 data shape을 받는다. 앞에서 본 LikeButton 같은 mutation은 더 이상 page level에서 보이지 않는다는 점도 주목할 만하다. loader는 read만 처리하고, write는 보통 별도의 API call이나 page reload 혹은 revalidation을 트리거하는 form submission을 통해 처리된다.

같은 사고방식은 Next.js App Router의 page component level에서도 쉽게 재현된다. page 자체를 async로 만들고 맨 위에서 모든 것을 await하면 된다.

TSX
// Next.js App Router, loader mindset
export default async function HomePage() {
  const user = await getCurrentUser();
  const [feed, whoToFollow, trendingTags] = await Promise.all([
    getFeed(user.handle),
    getWhoToFollow(user.handle),
    getTrendingTags(),
  ]);

  return (
    <Layout>
      <Sidebar user={user} />
      <Feed posts={feed.posts} currentUser={user} />
      <aside>
        <TrendingTags tags={trendingTags} />
        <WhoToFollow users={whoToFollow} currentUser={user} />
      </aside>
    </Layout>
  );
}

framework는 다르지만 shape은 같다. page는 여전히 data owner이고, 컴포넌트는 page가 fetch하기로 선택한 데이터를 받는 view다.

이 구조는 정돈되어 보이지만, 이제 컴포넌트는 page가 자신을 위해 fetch해준 데이터에 결합된다. WhoToFollow 컴포넌트는 받은 것을 렌더링할 뿐이다.

TSX
function WhoToFollow({ users, currentUser }: Props) {
  return (
    <ul>
      {users.map(user => (
        <UserRow key={user.handle} user={user} currentUser={currentUser} />
      ))}
    </ul>
  );
}

home page에서는 page가 이미 whoToFollow를 fetch하기 때문에 문제가 없다. 그런데 이제 profile page에서도 이 컴포넌트를 재사용하고 싶다고 해보자.

TSX
// home page
const [user, feed, whoToFollow] = await Promise.all([
  getCurrentUser(),
  getFeed(/* ... */),
  getWhoToFollow(/* ... */),
]);

// profile page (now needs the same thing)
const [user, profile, whoToFollow] = await Promise.all([
  getCurrentUser(),
  getProfile(handle),
  getWhoToFollow(/* ... */), // duplicated
]);

<WhoToFollow users={whoToFollow} currentUser={user} />;

컴포넌트 자체는 바뀌지 않았다. 하지만 이 컴포넌트를 쓰려는 모든 route는 같은 데이터를 같은 shape으로 fetch하고, 그 props를 위의 모든 wrapper를 거쳐 내려보내야 한다. 컴포넌트는 사실상 자기 데이터를 fetch하는 loader에 용접되어 있다. 이것은 어떤 framework를 쓰든 loader pattern에 내재된 성질이다. data는 route boundary에 있고, 그 아래의 모든 것은 props를 받는 view가 된다.

3. Async Server Components

각 컴포넌트가 loader에게 데이터를 받아올 필요 없이, 서버에서 자기 데이터를 직접 fetch할 수 있다면 어떨까? 바로 이것이 React Server Components가 가능하게 하는 일이다. Server Component는 async일 수 있고, 서버에서 실행되며, database를 직접 읽을 수 있고, 브라우저에서 실행되지 않는다. 이 덕분에 useEffect 접근이 가진 composability를 유지하면서도 loader처럼 서버에서 데이터를 가져올 수 있다. 각 컴포넌트는 자기 데이터를 소유하지만, fetch는 server rendering 중에 일어나고 결과는 rendered HTML로 client에 전송된다.

Next.js App Router는 오늘날 대부분의 개발자가 RSC를 만나는 장소이며, 이 모델을 기본값으로 만든다. 모든 컴포넌트는 명시적으로 "use client"를 붙이지 않는 한 server component다.

page가 모든 것을 fetch해서 내려주는 대신, 각 컴포넌트는 최소한의 props, 보통 identifier만을 기준으로 자신에게 필요한 것을 fetch한다. 컴포넌트는 자기완결적이다. consumer는 컴포넌트가 알아야 할 최소한의 정보, 보통 id나 handle 정도만 전달하고, 컴포넌트는 내부에서 나머지를 해결한다. 앞선 loader 예시WhoToFollow를 보자. server component가 되면 이 컴포넌트는 page가 넘겨주던 userscurrentUser props를 필요로 하지 않는다. 현재 사용자를 resolve하고 목록을 직접 fetch할 수 있다.

TSX
export async function WhoToFollow() {
  const handle = await getCurrentUserHandle();
  const users = await getWhoToFollow(handle);
  return (
    <ul>
      {users.map(user => (
        <UserRow key={user.handle} handle={user.handle} />
      ))}
    </ul>
  );
}

이제 <WhoToFollow />는 어떤 page에서도 위에서 데이터를 wiring하지 않고 사용할 수 있다. 앞에서는 두 개의 별도 loader가 필요했던 같은 컴포넌트가 이제 그냥 동작한다.

Feed도 마찬가지다. loader 버전에서는 postscurrentUser를 props로 받았다. server component가 되면 자기 데이터를 직접 fetch하고 post list를 바로 렌더링한다.

TSX
export async function Feed() {
  const handle = await getCurrentUserHandle();
  const { posts } = await getFeed(handle);
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <Post post={post} />
        </li>
      ))}
    </ul>
  );
}

page는 그냥 <Feed />를 렌더링한다. 여기서 Feed가 전체 post 객체를 Post로 넘긴다는 점을 보자. feed query에서 이미 데이터를 가지고 있으므로, 각 Post가 id로 자기 row를 다시 fetch할 이유는 없다.

모든 컴포넌트가 자기 데이터를 fetch하게 되면 page는 다시 이렇게 보인다.

TSX
export default function HomePage() {
  return (
    <Layout>
      <Sidebar />
      <main>
        <PageHeader title="Home" />
        <Feed />
      </main>
      <aside>
        <TrendingTags />
        <WhoToFollow />
      </aside>
    </Layout>
  );
}

구조는 사용 사례에서 본 것과 같다. 차이는 이 tree의 모든 컴포넌트가 이제 서버에서 자기 데이터를 fetch한다는 점이다.

이쯤에서 중복 fetch가 걱정될 수 있다. 각 컴포넌트가 자기 데이터를 가져오면 같은 getCurrentUserHandle이 하나의 render 안에서 여러 곳에서 호출될 수 있다. React의 cache() 함수는 request 단위로 이를 deduplicate한다. 같은 render 안에서 열 번 호출해도 source에는 한 번만 접근한다. 이는 client의 React Query 중앙 cache와 비슷하지만, server render 자체에 내장되어 있다. Aurora Scharff는 이전 글 Avoiding Server Component Waterfall Fetching with React 19 cache()에서 이 내용을 더 깊게 다루었다.

https://x.com/_mjmeyer/status/2057861625382842383

이 composability는 AI coding agent가 React와 잘 맞는 이유이기도 하며, RSC는 그 composability model을 서버까지 확장한다. 자기완결적인 컴포넌트는 새 page로 옮기거나, 다른 layout에서 재사용하거나, 자기 파일 바깥을 건드리지 않고 refactor할 수 있다. agent도 컴포넌트가 무엇을 필요로 하는지 이해하기 위해 loader나 prop chain을 추적할 필요가 없다.

Next.js에서 앱 만들기

이제 자기 데이터를 fetch하고 loader 없이 여러 page에서 재사용할 수 있는 컴포넌트를 갖게 되었다. 다음 질문은 이것으로 실제 app을 어떻게 만들 것인가다. social feed app에 page가 하나 이상 있다고 해보자.

TXT
app/
  layout.tsx            // root shell: nav, sidebar
  page.tsx              // home feed
  explore/
    page.tsx            // discover feed
  post/
    [id]/
      page.tsx          // single post with replies

<WhoToFollow /> 같은 컴포넌트는 page가 자신을 위해 아무것도 fetch하지 않아도 이 page들 어디에서나 동작한다. page는 사용자가 실제로 무엇을 보게 되는지와 loading 중에 무엇이 보일지에 집중할 수 있다.

이 시점부터 layout markup과 sidebar는 app/layout.tsx에 있어 모든 page를 자동으로 감싼다.

Blocking Render 피하기

Server component는 서버에서 stream으로 렌더링된다. 즉 React는 모든 async component의 fetch가 끝나기 전에 HTML을 client로 보내기 시작할 수 있다. Suspense는 이것을 가능하게 하는 도구다. async component를 fallback이 있는 Suspense boundary로 감싸면, React는 컴포넌트가 background에서 resolve되는 동안 fallback을 즉시 보낸다. 준비가 끝나면 React는 실제 content를 stream으로 보내고 그 자리에 교체한다.

TSX
<Suspense fallback={<FeedSkeleton />}>
  <Feed />
</Suspense>

Suspense가 없으면 page는 모든 async component가 끝날 때까지 아무것도 보내지 못하고 기다린다. boundary를 추가하는 것이 이 blocking을 피하는 방법이며, Nadia의 글에서 측정한 실제 performance gain을 가능하게 하는 것도 바로 이 부분이다.

Making Skeletons That Stay in Sync

Suspense에 넘기는 fallback은 async component가 fetching 중일 때 사용자가 보는 것이다. 보통 이것은 skeleton이다. skeleton은 실제 content와 같은 shape, 같은 dimension, 같은 layout을 가지지만 실제 데이터는 없는 가벼운 placeholder다. 어떤 경우에는 spinner만으로 충분할 수도 있다. 어느 쪽이든 이것은 HTML과 CSS일 뿐이며, 목표는 실제 content가 도착했을 때 layout shift를 피하는 것이다.

skeleton을 component와 계속 맞춰두기 위해, Aurora Scharff는 둘을 같은 파일에서 export하는 방식을 선호한다고 한다.

TSX
// features/post/components/feed.tsx
export async function Feed() {
  const handle = await getCurrentUserHandle();
  const { posts } = await getFeed(handle);
  return (
    <ul className="flex flex-col gap-4">
      {posts.map(post => (
        <li key={post.id}>
          <Post post={post} />
        </li>
      ))}
    </ul>
  );
}

export function FeedSkeleton({ count = 5 }: { count?: number }) {
  return (
    <ul className="flex flex-col gap-4">
      {Array.from({ length: count }).map((_, i) => (
        <li key={i}>
          <PostSkeleton />
        </li>
      ))}
    </ul>
  );
}

PostSkeletonPost와 같은 outer class를 사용해 같은 공간을 예약할 수 있고, text를 대신하는 pulsing rectangle을 넣을 수 있다.

TSX
// features/post/components/post.tsx
export function Post({ post }: { post: PostT }) {
  return (
    <article className="h-24 rounded-lg border p-4">
      <p>{post.body}</p>
    </article>
  );
}

export function PostSkeleton() {
  return (
    <article className="h-24 rounded-lg border p-4">
      <div className="h-4 w-32 animate-pulse rounded bg-muted" />
    </article>
  );
}

FeedSkeletonFeedPost로 구성되는 것과 같은 방식으로 PostSkeleton으로 구성된다. skeleton은 component tree를 mirror한다. Post를 수정해 metadata line을 추가하거나 avatar size를 바꾸면, PostSkeleton은 같은 파일 안에 있다. loading state와 rendered state 사이의 drift, 즉 layout jank의 가장 흔한 원인은 변경이 이루어지는 시점에 잡히게 된다. 나중에 QA pass에서 발견되는 것이 아니다. AI coding agent가 component를 수정할 때도 이 skeleton을 같이 보게 되고, 맞춰 업데이트해야 한다는 것을 기억하기 쉬워진다. page를 compose할 때도 각 component에 맞는 fallback shape을 어디서 찾아야 하는지 알 수 있다.

Loading Experience 설계하기

Suspense와 skeleton을 갖추면 다음 질문은 이것이다. page가 어떻게 load되기를 원하는가? 전체 content area를 하나의 boundary로 감쌀 수도 있다.

TSX
// app/page.tsx
export default function HomePage() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <main>
        <PageHeader title="Home" />
        <Feed />
      </main>
      <aside>
        <TrendingTags />
        <WhoToFollow />
      </aside>
    </Suspense>
  );
}

sidebar는 즉시 나타난다. 그 외 모든 것은 하나의 boundary 뒤에서 기다렸다가 한 번에 나타난다. 단순하지만 사용자는 가장 느린 컴포넌트가 끝날 때까지 하나의 skeleton만 바라보게 된다.

반대로 boundary를 나누어 각 section이 독립적으로 stream되게 만들 수도 있다.

TSX
// app/page.tsx
export default function HomePage() {
  return (
    <>
      <main>
        <PageHeader title="Home" />
        <Suspense fallback={<FeedSkeleton />}>
          <Feed />
        </Suspense>
      </main>
      <aside>
        <Suspense fallback={<TrendingTagsSkeleton />}>
          <TrendingTags />
        </Suspense>
        <Suspense fallback={<WhoToFollowSkeleton />}>
          <WhoToFollow />
        </Suspense>
      </aside>
    </>
  );
}

이제 layout shell은 static하게 유지되고, page header는 어떤 boundary 밖에 있으며, feed, follow suggestions, trending tags는 각자 resolve된다. suggestions가 빠르고 feed가 느리면 사용자는 suggestions를 먼저 본다. 다만 이것 역시 조각난 느낌을 줄 수 있다. 세 개의 서로 다른 영역이 서로 다른 시점에 튀어나오는 것이 항상 더 나은 경험은 아니다.

aside를 하나의 boundary 뒤에 묶을 수도 있다.

TSX
// app/page.tsx
export default function HomePage() {
  return (
    <>
      <main>
        <PageHeader title="Home" />
        <Suspense fallback={<FeedSkeleton />}>
          <Feed />
        </Suspense>
      </main>
      <Suspense fallback={<TrendingTagsSkeleton />}>
        <aside>
          <TrendingTags />
          <WhoToFollow />
        </aside>
      </Suspense>
    </>
  );
}

이제 page는 세 그룹이 아니라 두 그룹으로 load된다. 여기서 fallback이 <TrendingTagsSkeleton /> 하나뿐이라는 점을 보자. TrendingTags는 variable number of items를 반환할 수 있으므로 얼마나 높아질지 알 수 없다. 그 아래에 <WhoToFollowSkeleton />까지 보여준다면, 실제 trending tags가 resolve된 뒤 skeleton이 잘못된 vertical position에 있을 가능성이 크다. trending tags skeleton만 보여주면 이 mismatch를 피할 수 있다. aside 전체는 두 component가 모두 준비되었을 때 한 번에 나타난다.

각 컴포넌트가 client에서 자기 loading state를 관리하면, page는 무엇이 언제 나타나는지 결정할 수 없다. Suspense가 있으면 page가 사용자가 어디에서 기다릴지 결정한다. 완벽한 boundary placement를 위한 공식은 없다. 여러 grouping을 시도하고, 어떻게 느껴지는지 보고, 반복하는 수밖에 없다.

이 시점의 page가 얼마나 읽기 쉬운지도 보자. JSX만 보아도 무엇이 렌더링되는지, 무엇이 skeleton을 보여주는지, 무엇이 static shell의 일부인지 알 수 있다.

현대적인 loader도 stream할 수 있다. React Router v7에서는 loader에서 promise를 반환하면 route의 나머지가 렌더링되는 동안 그 data를 Suspense boundary 뒤에서 resolve할 수 있다. 다만 page는 여전히 useLoaderData를 통해 data를 props로 받는다. 그러면 다시 route boundary에서 data를 내려보내는 구조로 돌아가게 되며, 이것이 여기서 피하려는 부분이다.

Parameterized Page 만들기

우리 route tree에는 post/[id]/page.tsx라는 parameterized route도 있다. 이 page는 하나의 post와 그 아래 replies를 렌더링한다. PostDetailid를 받아 post를 직접 fetch하고, feed의 Post list item과 같은 building block을 사용할 수 있다.

Next.js App Router에서 params는 Next.js 15부터 Promise다. 그래서 이를 resolve해야 한다. page level에서 await할 수 있다.

TSX
// app/post/[id]/page.tsx
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <div>
      <PageHeader title="Post" />
      <Suspense fallback={<PostDetailSkeleton />}>
        <PostDetail id={id} />
        <section>
          <SectionHeader>Replies</SectionHeader>
          <Suspense fallback={<RepliesSkeleton />}>
            <Replies postId={id} />
          </Suspense>
        </section>
      </Suspense>
    </div>
  );
}

이 방식은 동작한다. 하지만 page가 async가 되고, page는 무엇이든 렌더링하기 전에 params가 resolve될 때까지 기다려야 한다. 한 가지 방법은 params를 읽는 일만 맡는 작은 async component를 추출하는 것이다.

TSX
async function PostContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <>
      <PostDetail id={id} />
      <section>
        <SectionHeader>Replies</SectionHeader>
        <Suspense fallback={<RepliesSkeleton />}>
          <Replies postId={id} />
        </Suspense>
      </section>
    </>
  );
}

이렇게 하면 page는 synchronous하게 유지되지만, Promise를 unwrap하기 위해 wrapper component가 하나 생긴다. 대신 .then()을 직접 사용할 수 있다.

TSX
// app/post/[id]/page.tsx
export default function PostPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <div>
      <PageHeader title="Post" />
      <Suspense fallback={<PostDetailSkeleton />}>
        {params.then(({ id }) => (
          <>
            <PostDetail id={id} />
            <section>
              <SectionHeader>Replies</SectionHeader>
              <Suspense fallback={<RepliesSkeleton />}>
                <Replies postId={id} />
              </Suspense>
            </section>
          </>
        ))}
      </Suspense>
    </div>
  );
}

.then()params를 resolve해 PostDetailReplies가 여전히 plain id string을 prop으로 받게 해주고, page는 synchronous하고 readable하게 유지된다. 같은 trick은 [searchParams](https://nextjs.org/docs/app/api-reference/file-conventions/page#searchparams-optional)에도 적용된다. searchParams 역시 Next.js 15+에서는 Promise다. 이 패턴은 나중에 cache components를 사용할 때도 잘 맞는다. 그때는 page를 synchronous하게 유지하는 일이 더 중요해진다.

loading sequence도 home feed에서와 같은 사고방식을 따른다. header는 static shell의 일부이고, post detail은 Suspense boundary 뒤에서 stream되며, Replies는 그 안에 자기 boundary를 가져 post와 독립적으로 resolve될 수 있다.

Interactivity 추가하기

feed 자체에는 interactive한 부분이 있을 수 있다. 각 post의 like button은 client에서 JavaScript가 필요하다. Client component도 같은 방식으로 compose할 수 있다. 다음은 form action을 사용해 Server Function(likePost)을 호출하고, 즉각적인 feedback을 위해 useOptimistic을 사용하는 LikeButton이다.

TSX
'use client';

export function LikeButton({ postId, liked, count }: Props) {
  const [optimistic, setOptimistic] = useOptimistic({ liked, count });

  const likeAction = async () => {
    setOptimistic({
      liked: !optimistic.liked,
      count: optimistic.count + (optimistic.liked ? -1 : 1),
    });
    await likePost(postId);
  };

  return (
    <form action={likeAction}>
      <Button>{optimistic.liked ? "♥" : "♡"} {optimistic.count}</Button>
    </form>
  );
}

form은 server boundary를 가로질러 likePost를 직접 호출하고, useOptimistic은 서버 응답이 오기 전에 UI를 업데이트한다.

useOptimistic은 그것을 사용하는 컴포넌트에 local이다. update가 local component에만 영향을 준다면 이것으로 충분하다. 같은 update에 page의 다른 부분, 예를 들어 follower count나 notification badge가 반응해야 한다면 optimistic state를 context로 끌어올리거나 framework의 revalidation에 맡길 수 있다.

feed의 각 Post는 서버에서 렌더링되는 나머지 content와 함께 이 버튼을 compose한다.

TSX
// features/post/components/post.tsx
export async function Post({ post }: { post: PostT }) {
  const userState = await getPostUserState(post.id);
  return (
    <article>
      <PostAuthor handle={post.authorHandle} />
      <PostBody body={post.body} />
      <LikeButton postId={post.id} liked={userState.liked} count={post.likes} />
    </article>
  );
}

Aurora Scharff의 이전 글 server and client component composition in practicebuilding design components with action props using async React는 이 주제의 client side를 더 깊게 다룬다. 용어를 하나 덧붙이면, React 문서에서는 현재 Server Functions라는 표현을 더 명확하게 사용하고, Next.js 생태계에서는 Server Actions라는 표현도 널리 쓰인다. 또한 'use server'는 server component를 표시하는 지시어가 아니라 서버에서 호출될 async function을 표시하는 지시어다. 이 둘을 섞어 말하면 RSC 경계를 설명할 때 쉽게 헷갈린다.

코드베이스 구성하기

컴포넌트가 이렇게 자기완결적이면 feature별로 묶는 것이 자연스러워진다. feature folder structure는 이 방식과 잘 맞는다.

TXT
features/
  post/
    components/
      post.tsx                   // server component + skeleton
      post-detail.tsx            // server component + skeleton
      feed.tsx                   // server component + skeleton
      like-button.tsx            // client component
      replies.tsx                // server component + skeleton
  user/
    components/
      user-avatar.tsx            // server component + skeleton
      who-to-follow.tsx          // server component + skeleton

컴포넌트가 handle 같은 최소 props만 받고 자기 데이터를 직접 fetch하면, 어떤 page에서든 집어 들어 compose할 수 있다. <UserAvatar handle={handle} />가 좋은 예다. 같은 컴포넌트는 feed, post author row, 각 reply 옆, follow suggestions, sidebar에서 모두 렌더링될 수 있고, 필요한 것은 handle 하나뿐이다. 컴포넌트를 새 page로 refactor해도 feature folder 밖을 건드리지 않는다.

Feature slicing은 이 구조를 구성하는 한 가지 방법일 뿐이다. 컴포넌트가 자기완결적이라면 어떤 구조든 가능하지만, reusable model은 feature folder와 특히 잘 맞는다.

같은 맥락에서 어떤 region에 error handling이나 animation을 추가하고 싶다면 React ErrorBoundary로 감쌀 수 있다. Next.js에서는 catchError가 retry button까지 제공하는데, Aurora Scharff는 Error Handling in Next.js with catchError에서 이를 다뤘다고 한다. 또는 content가 stream될 때 animation을 주기 위해 ViewTransition으로 감쌀 수도 있다. page는 async component 주변에 이런 것들을 compose한다.

이 글의 내용을 한곳에 모으면 home feed page는 대략 이렇게 생길 수 있다.

TSX
// app/page.tsx
export default function HomePage() {
  return (
    <>
      <main>
        <PageHeader title="Home" />
        <ErrorBoundary title="Failed to load feed">
          <Suspense fallback={<FeedSkeleton />}>
            <ViewTransition>
              <Feed />
            </ViewTransition>
          </Suspense>
        </ErrorBoundary>
      </main>
      <ErrorBoundary title="Failed to load suggestions">
        <Suspense fallback={<TrendingTagsSkeleton />}>
          <aside>
            <TrendingTags />
            <WhoToFollow />
          </aside>
        </Suspense>
      </ErrorBoundary>
    </>
  );
}

각 region에는 자기 error boundary가 있으므로 page의 한 부분에서 실패가 나도 나머지 전체를 무너뜨리지 않는다. feed 주변의 ViewTransition은 content가 stream될 때 부드럽게 들어오도록 해 skeleton에서 실제 post로 바뀌는 순간이 갑작스럽게 느껴지지 않게 한다.

post detail page도 같은 pattern을 따를 수 있다. replies 주변에 view transition과 내부 error boundary를 두면 replies는 post와 독립적으로 실패할 수 있다. 바깥쪽 route-level error는 Next.js의 error.tsx로 처리할 수 있으므로 page 전체를 직접 감쌀 필요는 없다.

TSX
// app/post/[id]/page.tsx
export default function PostPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <div>
      <PageHeader title="Post" />
      <Suspense fallback={<PostDetailSkeleton />}>
        <ViewTransition>
          {params.then(({ id }) => (
            <>
              <PostDetail id={id} />
              <section>
                <SectionHeader>Replies</SectionHeader>
                <ErrorBoundary title="Failed to load replies">
                  <Suspense fallback={<RepliesSkeleton />}>
                    <Replies postId={id} />
                  </Suspense>
                </ErrorBoundary>
              </section>
            </>
          ))}
        </ViewTransition>
      </Suspense>
    </div>
  );
}

Cache Components에 대한 메모

Next.js 16에서 cacheComponents를 활성화하면, dynamic data를 fetch하는 모든 컴포넌트는 Suspense boundary 뒤에 있어야 한다. boundary 밖의 모든 것은 static shell의 일부가 되어 prerender되고 즉시 제공될 수 있다. 이를 통해 Partial Prerendering이 가능해진다. static part는 즉시 제공되고 dynamic part는 stream된다. 'use cache'를 사용하면 개별 component나 data fetch를 cache할 수도 있다. 그러면 이전에는 Suspense fallback이 필요했던 일부 region이 즉시 resolve되고 loading state가 완전히 사라질 수도 있다.

이 글에서 계속 만들어온 architecture는 이 model에 자연스럽게 맞는다. component는 자기 data를 fetch하고, page는 의도적인 boundary를 배치하며, 무엇이 즉시 나타나고 무엇이 stream될지 선택한다. parameterized page에서 사용한 .then() pattern도 여기서 더 중요해진다. page level에서 paramsawait하면 전체 page가 static shell에서 빠져나가고 error가 날 수 있기 때문이다.

이 방식으로 처음부터 만들면 cacheComponents를 켜기 전에도 이점이 있다. 그리고 실제로 켰을 때는 architecture가 이미 준비되어 있다.

결론

useEffect에서 React Query, loader, RSC로 이어지는 여정은 결국 data fetching을 서버로 옮기면서도 component를 composable하게 유지하려는 과정이었다. RSC가 여기에 도달하는 유일한 방법은 아니지만, React의 component model과 아름답게 compose되고, Suspense는 그 위에서 loading experience를 설계할 방법을 준다.

아직도 반사적으로 async function Page를 작성하고 맨 위에서 다섯 개의 query를 await하고 있다면, 그 방향을 한 번 뒤집어보자. 많은 개발자가 loader와 getServerSideProps에서 그 습관을 배웠고, AI coding agent도 같은 pattern으로 학습되어 있다. data fetch를 그것을 사용하는 component 안으로 밀어 넣고, orchestration은 Suspense가 맡게 하자. 결과는 읽기 쉽고, 옮기기 쉽고, 사람과 agent 모두가 작업하기 쉬운 codebase가 된다.

원칙을 요약하면 다음과 같다.

이 글의 demo app은 next16-social-media에 open source로 공개되어 있으니 전체 codebase를 살펴보고 싶다면 참고하면 된다.

이 글이 도움이 되었기를 바란다. RSC performance를 benchmark해준 Nadia Makarevich에게도 감사를 전한다. 덕분에 Aurora Scharff의 말이 맞는지 직접 확인해볼 수 있게 되었다. 질문이나 의견이 있다면 Aurora Scharff에게 알려주고, 더 많은 업데이트를 보고 싶다면 그녀의 BlueskyX를 팔로우하면 된다. 그럼 모두들 Happy coding! 🚀

더 읽어보기

  • 2026.04.11

    Trie 자료구조

    문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다. Trie는 무…

  • 2026.03.01

    렌더링 전략 정리

    리액트와 Next.js에서 렌더링 전략은 단순한 옵션 선택이 아니다. 이는 서비스의 초기 로딩 속도, 서버 비용, 캐싱 전략, SEO 노출, 개발 복잡도까지 동시에 좌우하는 아키텍처 결정이다. 프로젝트 규모가 커질수록 “어디에서 HTML을 생성하는가”, “언제 자바스크립트를 실행하는가”…

  • 2026.03.01

    함수, 펑터, 그리고 모나드

    복잡한 버그는 대개 거대한 기능이 아니라 사소한 데이터 변환 구간에서 시작된다. 문자열을 한 번 다듬고, 숫자를 한 번 바꾸고, 그 결과를 다음 단계로 넘기는 단순한 흐름이다. 그런데 조건이 조금씩 추가되는 순간 로직은 빠르게 복잡해진다. 값만 바꾸던 코드가 어느새 값의 부재, 비동기…

댓글

댓글을 불러오는 중...