Next.js에서 데이터를 어디서 불러올까? Server Component vs TanStack Query

_sw_·2026년 3월 11일
post-thumbnail

Next.js 기반으로 서버 데이터를 활용하는 컴포넌트를 만들때 매번 고민되는 지점이 있다.

바로 데이터를 언제 어떻게 불러올 것 인가에 대해서 고민을 해야한다.

인턴 기간 중 Nuxt.js 로직을 Next.js로 마이그레이션 하는 과정에서도 고민을했던 영역이다.

Next.js에서 데이터 fetching 방식을 분류해 어떤 특징들이 있는지 알아보고, 그때의 기억을 되살려서 적절하게 의사결정을 했는지 한번 돌아보고자 한다.

Next.js 데이터 Fetching 옵션

  1. 서버 컴포넌트에서 fetching하기

    // app/posts/page.tsx
    export default async function PostsPage() {
      const posts = await fetch('/api/posts').then(res => res.json())
      return <PostList posts={posts} />
    }
    • 서버에서 실행, 클라이언트 번들에 포함되지 않는다.
    • 캐싱은 Next.js fetch 옵션으로 제어 (cache, revalidate) 할 수 있다.
    • 인터랙션 없는 정적 데이터에 적합하다.
  2. Server Component + Tanstack Query ( Hydration 활용 )

    // lib/getQueryClient.ts
    import { QueryClient } from '@tanstack/react-query'
    import { cache } from 'react'
    
    // React의 cache()로 감싸면 같은 요청 내에서는 동일한 인스턴스 반환
    // 요청이 다르면 새로 생성 → 요청 간 오염 없음
    const getQueryClient = cache(() => new QueryClient())
    export default getQueryClient
    // app/posts/page.tsx
    export default async function PostsPage() {
      const queryClient = getQueryClient() // 같은 요청 내에선 동일 인스턴스
      await queryClient.prefetchQuery(...)
    
      return (
        <HydrationBoundary state={dehydrate(queryClient)}>
          <PostList />
        </HydrationBoundary>
      )
    }
    • 초기 로딩은 SSR, 이후 클라이언트에서 refetch/캐싱 가능하다.
    • 구성이 복잡하지만 두 가지 장점을 다 챙길 수 있다.
  3. 클라이언트 컴포넌트 내부에서 Tanstack Query활용하기

    'use client'
    export default function PostList() {
      const { data, isLoading } = useQuery({
        queryKey: ['posts'],
        queryFn: fetchPosts,
      })
    }
    • 사용자 인터랙션에 반응하는 데이터에 적합하다.
    • Tanstack Query에서 제공하는 Caching, Refetching, 낙관적 업데이트 등 클라이언트 상태 관리에 용이하다.

언제 어떤 방식을 쓸까?

레퍼런스를 참고했을 때 대략 아래와 같은 케이스에서 각각의 방식이 적합하다고 볼 수 있다.

Server Component가 적합한 경우

  • SEO가 중요한 페이지 (게시글 목록, 상세)
  • 로그인 여부와 무관한 공개 데이터
  • 실시간 업데이트 불필요
  • 번들 사이즈를 줄이고 싶을 때

TanStack Query가 적합한 경우

  • 사용자 액션 후 데이터 갱신 (좋아요, 댓글, 필터링)
  • 낙관적 업데이트 필요
  • 무한 스크롤 / 페이지네이션
  • 여러 컴포넌트에서 같은 데이터 공유 (캐싱 활용)
  • 폴링(주기적 refetch) 필요

Hydration이 적합한 경우

  • SEO와 클라이언트 인터랙션 둘 다 필요한 경우
  • 서버에서 prefetch 후 클라이언트에서 실시간 갱신이 필요한 경우

정리하면 아래와 같이 보여줄 수 있다.

Server ComponentTanStack QueryHydration
SEO
인터랙션
번들 사이즈작음증가중간
구성 복잡도낮음낮음높음

Hydration은 SEO와 인터랙션의 장점을 모두 챙길 수 있지만, 구성 복잡도가 높고 번들 사이즈가 증가하는 트레이드오프가 있다. SEO와 클라이언트 인터랙션이 동시에 필요한 경우에만 선택하는 것이 좋다.


마이그레이션 당시 판단 기준

마이그레이션 과정에서 일부 API를 새로 작성해야 하는 경우가 있었고, 일부는 이미 특정 방식으로 구현된 로직을 그대로 활용할 수 있는 상황이었다. 이 두 가지 경우에 따라 데이터 페칭 위치를 어떻게 결정했는지 정리해보았다.

1. API를 새로 작성해야 하는 경우

새로 작성이 필요한 경우, 아래 세 가지 기준을 고려하여 호출 위치를 결정했다.

[ 데이터가 재사용되어야 하는가? ]

TanStack Query가 제공하는 캐싱 기능을 고려한 판단이었다. 동일한 요청이 반복될 가능성이 있는 데이터라면 TanStack Query를 활용하는 것이 더 적합하다고 생각했다.

[ 데이터가 props에 따라 달라지는가? ]

props에 의존하는 데이터는 그만큼 요청 빈도가 높고, 같은 데이터를 다시 조회할 가능성도 높다고 생각했다. 재사용성을 고려한 맥락과 동일하게 TanStack Query가 더 나은 선택이라고 판단했다.

'use client'

import { useInfiniteQuery } from '@tanstack/react-query'

type Item = {
  id: number
  title: string
}

async function fetchItems({ pageParam = 1 }: { pageParam?: number }) {
  const res = await fetch(`/api/items?page=${pageParam}`)
  if (!res.ok) {
    throw new Error('Failed to fetch items')
  }

  return res.json() as Promise<{
    items: Item[]
    nextPage?: number
  }>
}

export default function ItemList() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery({
      queryKey: ['items'],
      queryFn: ({ pageParam }) => fetchItems({ pageParam }),
      initialPageParam: 1,
      getNextPageParam: (lastPage) => lastPage.nextPage,
    })

  const items = data?.pages.flatMap((page) => page.items) ?? []

  return (
    <div>
      <ul>
        {items.map((item) => (
          <li key={item.id}>{item.title}</li>
        ))}
      </ul>

      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '불러오는 중...' : '더 보기'}
        </button>
      )}
    </div>
  )
}

페이지네이션이 필요한 데이터도 사용자의 액션에 따라 다음 데이터를 계속 불러와야 했기 때문에 TanStack Query를 활용했다. 특히 무한 스크롤을 적용하려면 “다음 페이지를 불러오는 상태”, “기존 목록 유지”, “추가 요청 제어”가 중요했는데, 이런 점에서 서버 컴포넌트만으로 처리하는 것보다 클라이언트에서 쿼리 상태를 관리하는 편이 더 자연스럽다고 생각했다.

[ 빠른 사용자 피드백이 중요한가? ]

화면 깜빡임 없이 데이터를 최대한 빠르게 보여줘야 하는 경우, 서버 컴포넌트에서 데이터를 페칭해 하위 컴포넌트로 내려주는 방식을 선택했다. 페이지 진입 시점에 데이터가 반드시 존재해야 하고, 이후 클라이언트에서 상태로 관리할 필요가 없는 데이터라면 서버 컴포넌트를 선택했다.

2. 기존 API 로직을 재사용하는 경우

[ 기존 로직이 TanStack Query 훅으로 구현된 경우 ]

훅으로 만들어진 이유가 재사용성을 고려한 것이라고 판단했다. 따라서 해당 훅이 호출되는 시점을 먼저 확인했다. 마이그레이션 대상 페이지 진입 전에 이미 훅이 호출된 적 있다면, 이후에는 캐싱된 데이터를 활용할 가능성이 높기 때문에 훅을 그대로 사용했다. 그렇지 않은 경우에는 처음부터 다시 판단해 적절한 방식을 결정했다.

export default function UserProfile() {
  // 기존 구현된 유저 정보 조회 커스텀 훅을 그대로 활용
  const { data: user, isLoading } = useUserInfo()

  if (isLoading) return <div>로딩 중...</div>

  return (
    <div>
      <p>{user.name}</p>
      <p>{user.email}</p>
    </div>
  )
}

[ 기존 로직이 서버 컴포넌트에서 구현된 경우 ]

데이터의 재사용 가능성이 이미 고려된 시점이라고 봤다. 기존에 서버 컴포넌트로 구현된 이유를 먼저 파악하고, 마이그레이션 이후에도 동일한 방식이 적합한지, 혹은 로직 수정이 필요한지를 함께 검토했다.


마치며

마이그레이션 당시에도 헷갈리기도 했고, 이렇게 기준을 잡아도 되나 싶었던 부분이었는데 이번에 확실히 정리를 하게 되어 다행이라 생각한다.

당시에 하이드레이션할 생각까지는 못했던 것 같아 조금 더 알아보고 작업을 했다면 조금 더 확장된 사고로 의사결정을 할 수 있지 않았을까하는 아쉬움도 남았다.

그리고 각 방식에서 디테일하게 놓치고 있던 부분들도 이번에 확실히 알아가는 것 같아서 정리하길 잘한 것 같다.


Reference

https://nextjs.org/docs/app/getting-started/fetching-data

https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr

https://ko.react.dev/reference/rsc/server-components

0개의 댓글