서버 컴포넌트 vs TanStack Query

contability·2025년 11월 25일

목차

  1. Next.js 데이터 페칭 방식
  2. SEO와 데이터 페칭
  3. 크롤러 동작 방식
  4. Suspense와 Streaming
  5. 언제 무엇을 사용할까
  6. 실전 예시
  7. 로그인과 SEO

Next.js 데이터 페칭 방식

Next.js에서 데이터를 가져오는 주요 전략은 크게 2가지다.

1. 서버 컴포넌트에서 직접 fetch

// app/page.tsx
async function getData() {
  const res = await fetch('https://api.example.com/data')
  return res.json()
}

export default async function Page() {
  const data = await getData()
  return <div>{data.title}</div>
}
  • 서버에서 데이터를 가져와서 바로 렌더링
  • 클라이언트로 props 전달 불필요
  • SEO에 유리

2. TanStack Query 활용

// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

export function Providers({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient()
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

// app/components/MyComponent.tsx
'use client'
import { useQuery } from '@tanstack/react-query'

export function MyComponent() {
  const { data } = useQuery({
    queryKey: ['myData'],
    queryFn: () => fetch('/api/data').then(res => res.json())
  })
  return <div>{data?.title}</div>
}
  • 클라이언트에서 데이터 페칭
  • 상태 관리와 캐싱에 강점
  • SEO에 불리

SEO와 데이터 페칭

SEO란?

검색 엔진 최적화(Search Engine Optimization)의 약자로, 검색 엔진의 크롤러 봇이:
1. 웹사이트를 잘 찾고
2. 컨텐츠를 잘 이해하고
3. 검색 결과 상위에 노출되도록 만드는 것

TanStack Query가 SEO에 불리한 이유

1. 클라이언트 사이드 렌더링

'use client'
export function ProductList() {
  const { data } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts
  })
  
  return <div>{data?.map(...)}</div>
}
  • 브라우저에서 실행됨
  • 초기 HTML에 데이터가 없음
  • 크롤러가 빈 컨텐츠를 봄

2. 서버 컴포넌트와의 비교

클라이언트 사이드 (TanStack Query)

<!-- 크롤러가 보는 초기 HTML -->
<div id="root"></div>
<script src="app.js"></script>
<!-- 내용이 비어있음 -->

서버 사이드

<!-- 크롤러가 보는 초기 HTML -->
<article>
  <h1>Next.js 데이터 페칭 가이드</h1>
  <p>Next.js에서는 여러 방식으로...</p>
  <img src="..." alt="설명적인 alt 텍스트">
</article>
<!-- 완전한 컨텐츠가 이미 들어있음 -->

크롤러 동작 방식

검색 엔진 크롤링 과정

1. 크롤링(Crawling)
   → 봇이 웹페이지를 방문해서 HTML을 읽음

2. 인덱싱(Indexing)
   → 읽은 내용을 분석하고 데이터베이스에 저장

3. 랭킹(Ranking)
   → 검색어와 관련성, 품질 등을 평가해서 순위를 매김

블로그 예시: 크롤러는 모든 글을 확인한다

크롤링 과정

1. 블로그 메인 방문
   https://myblog.com/blog
   
   <a href="/blog/1">첫 번째 글</a>
   <a href="/blog/2">두 번째 글</a>
   <a href="/blog/3">세 번째 글</a>
   
2. 링크를 발견하고 큐에 추가

3. 각 링크를 방문
   https://myblog.com/blog/1 → 크롤링 → 인덱싱
   https://myblog.com/blog/2 → 크롤링 → 인덱싱
   https://myblog.com/blog/3 → 크롤링 → 인덱싱

리스트 페이지

// app/blog/page.tsx
export default async function BlogList() {
  const posts = await fetchPosts()
  
  return (
    <section>
      <h1>블로그 글 목록</h1>
      {posts.map(post => (
        <article key={post.id}>
          <h2>
            <a href={`/blog/${post.id}`}>
              {post.title}
            </a>
          </h2>
        </article>
      ))}
    </section>
  )
}

상세 페이지

// app/blog/[id]/page.tsx
export default async function BlogPost({ params }: { params: { id: string } }) {
  const post = await fetchPost(params.id)
  
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{post.date}</time>
      <p>{post.content}</p>
    </article>
  )
}

크롤러는 서버에서 렌더링된 완성된 HTML을 받기 때문에, 서버 컴포넌트로 구현하면 각 블로그 글의 전체 내용을 크롤러가 읽고 인덱싱할 수 있다.


Suspense와 Streaming

문제: 페이지 전환 시 답답한 경험

Suspense 없이 서버 컴포넌트

// app/page-b/page.tsx
export default async function PageB() {
  const data = await fetchHeavyData() // 5초 걸림
  
  return <div>{data.content}</div>
}

사용자 경험:

A 페이지에서 링크 클릭
↓
5초 동안 A 페이지에 그대로 머물러 있음 (아무 반응 없음)
↓
갑자기 B 페이지가 나타남

해결: Suspense 사용

// app/page-b/page.tsx
export default function PageB() {
  return (
    <div>
      <h1>B 페이지</h1>
      <nav>메뉴</nav>
      
      <Suspense fallback={<div>데이터 로딩 중...</div>}>
        <HeavyContent />
      </Suspense>
      
      <footer>푸터</footer>
    </div>
  )
}

async function HeavyContent() {
  const data = await fetchHeavyData() // 5초 걸림
  return <div>{data.content}</div>
}

사용자 경험:

A 페이지에서 링크 클릭
↓
즉시 B 페이지로 전환 (레이아웃, 헤더, 메뉴 보임)
↓
"데이터 로딩 중..." 표시
↓
5초 후 실제 컨텐츠로 교체

Streaming의 장점

병렬 데이터 로딩

export default function Dashboard() {
  return (
    <div>
      <h1>대시보드</h1>
      
      <Suspense fallback={<ProfileSkeleton />}>
        <UserProfile />
      </Suspense>
      
      <Suspense fallback={<AnalyticsSkeleton />}>
        <Analytics />
      </Suspense>
      
      <Suspense fallback={<ReportsSkeleton />}>
        <Reports />
      </Suspense>
    </div>
  )
}

async function UserProfile() {
  const user = await fetchUser() // 1초
  return <div>{user.name}</div>
}

async function Analytics() {
  const analytics = await fetchAnalytics() // 3초
  return <div>{analytics.views}</div>
}

async function Reports() {
  const reports = await fetchReports() // 2초
  return <div>{reports.count}</div>
}

타임라인:

0초: 페이지 전환 + 레이아웃 + 3개 스켈레톤 표시
1초: UserProfile 데이터 도착 → 실제 컨텐츠
2초: Reports 데이터 도착 → 실제 컨텐츠
3초: Analytics 데이터 도착 → 실제 컨텐츠

각각 준비되는 대로 표시되어 훨씬 빠르게 느껴진다.


언제 무엇을 사용할까

핵심 판단 기준

TanStack Query
→ 클라이언트에서 데이터 캐싱, 갱신, 동기화가 필요
→ 사용자 인터랙션이 많음
→ SEO 불필요

서버 컴포넌트
→ 데이터 한 번 가져와서 표시
→ 읽기 위주
→ SEO 중요

TanStack Query를 써야 하는 경우

1. 실시간 refetch가 필요한 경우

'use client'
export function StockPrice() {
  const { data } = useQuery({
    queryKey: ['stock'],
    queryFn: fetchStock,
    refetchInterval: 5000 // 5초마다 자동 갱신
  })
}

2. 사용자 인터랙션으로 데이터 변경이 많은 경우

'use client'
export function TodoList() {
  const queryClient = useQueryClient()
  
  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] })
    }
  })
}

3. 낙관적 업데이트가 필요한 경우

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // 즉시 UI 업데이트 (서버 응답 전)
    await queryClient.cancelQueries({ queryKey: ['todos'] })
    const previous = queryClient.getQueryData(['todos'])
    queryClient.setQueryData(['todos'], old => [...old, newTodo])
    return { previous }
  }
})

서버 컴포넌트 + Suspense를 써야 하는 경우

// SEO가 중요하거나, 단순 데이터 표시
export default function BlogPost() {
  return (
    <Suspense fallback={<Skeleton />}>
      <PostContent />
    </Suspense>
  )
}

async function PostContent() {
  const post = await fetchPost()
  return <article>{post.title}</article>
}

요약 표

특징TanStack Query서버 컴포넌트 + Suspense
SEO✗ 불리✓ 유리
실시간 갱신✓ 쉬움✗ 어려움
복잡한 캐싱✓ 강력함△ 기본 제공
낙관적 업데이트✓ 지원✗ 어려움
코드 복잡도△ 높음✓ 간단함
초기 로딩 속도△ 느림✓ 빠름
무한 스크롤✓ 쉬움△ 가능

실전 예시

1. 블로그 리스트 페이지 (filter bar 존재)

URL 기반 필터링 (추천)

// app/blog/list/page.tsx
export default async function BlogList({
  searchParams
}: {
  searchParams: { category?: string; search?: string }
}) {
  const posts = await fetchPosts({
    category: searchParams.category,
    search: searchParams.search
  })
  
  return (
    <div>
      <FilterBar />
      <Suspense fallback={<ListSkeleton />}>
        <PostList posts={posts} />
      </Suspense>
    </div>
  )
}

'use client'
function FilterBar() {
  const router = useRouter()
  const searchParams = useSearchParams()
  
  const handleFilter = (category: string) => {
    const params = new URLSearchParams(searchParams)
    params.set('category', category)
    router.push(`/blog/list?${params.toString()}`)
  }
  
  return <select onChange={(e) => handleFilter(e.target.value)}>...</select>
}

장점:

  • SEO 유지 (각 필터 상태가 고유 URL)
  • 브라우저 뒤로가기 동작
  • URL 공유 가능

2. 블로그 글 상세 페이지

// app/blog/[id]/page.tsx
export default async function BlogPost({ params }: { params: { id: string } }) {
  const post = await fetchPost(params.id)
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <ActionButtons postId={params.id} />
    </article>
  )
}

서버 사이드 fetch 사용

3. 블로그 글 수정 페이지

단순 수정 폼: 서버 컴포넌트로 충분

// app/blog/[id]/modify/page.tsx
export default async function ModifyPage({ params }: { params: { id: string } }) {
  const post = await fetchPost(params.id)
  
  return <ModifyForm post={post} />
}

'use client'
function ModifyForm({ post }: { post: Post }) {
  const [title, setTitle] = useState(post.title)
  const [content, setContent] = useState(post.content)
  
  const handleSubmit = async () => {
    await updatePost(post.id, { title, content })
    router.push(`/blog/${post.id}`)
  }
  
  return <form onSubmit={handleSubmit}>...</form>
}

자동 저장 필요 시: TanStack Query

'use client'
function ModifyForm({ postId }: { postId: string }) {
  const { data: post } = useQuery({
    queryKey: ['post', postId],
    queryFn: () => fetchPost(postId)
  })
  
  const mutation = useMutation({
    mutationFn: updatePost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['post', postId] })
    }
  })
  
  // 자동 저장 로직...
}

4. 정적 페이지

// app/prompt/page.tsx
export default function PromptPage() {
  return (
    <div>
      <h1>우리 서비스 소개</h1>
      <p>최고의 프롬프트 서비스...</p>
    </div>
  )
}

서버 사이드 fetch 사용


로그인과 SEO

핵심 개념

검색 엔진 크롤러
→ 로그인 불가능
→ 인증이 필요한 페이지 접근 불가능
→ 컨텐츠를 읽을 수 없음
→ 인덱싱 불가능

SEO 필요 (로그인 불필요)

✓ 블로그 글 목록: /blog
✓ 블로그 글 상세: /blog/123
✓ 제품 목록: /products
✓ 제품 상세: /products/iphone-15
✓ 회사 소개: /about
✓ 랜딩 페이지: /

서버 컴포넌트 + Suspense 사용

SEO 불필요 (로그인 필요)

✓ 대시보드: /dashboard
✓ 마이페이지: /mypage
✓ 장바구니: /cart
✓ 주문 내역: /orders
✓ 설정: /settings
✓ 관리자 페이지: /admin

TanStack Query 자유롭게 사용

혼합 케이스: 제품 상세 + 리뷰

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id)
  
  return (
    <div>
      {/* SEO 중요: 제품 정보 */}
      <article>
        <h1>{product.name}</h1>
        <p>{product.description}</p>
      </article>
      
      {/* SEO 중요: 기존 리뷰 목록 */}
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>
      
      {/* SEO 불필요: 리뷰 작성 폼 (로그인 필요) */}
      <ReviewForm productId={params.id} />
    </div>
  )
}

async function Reviews({ productId }: { productId: string }) {
  const reviews = await fetchReviews(productId)
  return <div>{reviews.map(...)}</div>
}

'use client'
function ReviewForm({ productId }: { productId: string }) {
  const mutation = useMutation({
    mutationFn: submitReview
  })
  // 로그인 필요한 기능
}

TanStack Query가 빛나는 실전 케이스

1. 대시보드 / 관리자 페이지

'use client'
export default function AdminDashboard() {
  const { data: stats } = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
    refetchInterval: 10000 // 10초마다 자동 갱신
  })
  
  const { data: recentOrders } = useQuery({
    queryKey: ['orders', 'recent'],
    queryFn: fetchRecentOrders,
    refetchInterval: 5000
  })
  
  const { data: alerts } = useQuery({
    queryKey: ['alerts'],
    queryFn: fetchAlerts,
    refetchInterval: 3000
  })
  
  return (
    <div>
      <StatsCard data={stats} />
      <OrderList orders={recentOrders} />
      <AlertPanel alerts={alerts} />
    </div>
  )
}

왜 TanStack Query?

  • 실시간 데이터 갱신 필요
  • SEO 불필요 (로그인 필요)
  • 여러 API를 독립적으로 관리
  • 각각 다른 갱신 주기

2. 무한 스크롤 피드

'use client'
export default function Feed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) => fetchPosts({ page: pageParam }),
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
    initialPageParam: 0
  })
  
  const { ref } = useInView({
    onChange: (inView) => {
      if (inView && hasNextPage) {
        fetchNextPage()
      }
    }
  })
  
  return (
    <div>
      {data?.pages.map((page) => (
        page.posts.map(post => <PostCard key={post.id} post={post} />)
      ))}
      
      <div ref={ref}>
        {isFetchingNextPage ? '로딩 중...' : null}
      </div>
    </div>
  )
}

왜 TanStack Query?

  • useInfiniteQuery로 무한 스크롤 쉽게 구현
  • 이전 페이지 캐싱
  • SEO 불필요
  • 복잡한 페이지네이션 상태 관리

3. 장바구니

'use client'
export default function Cart() {
  const queryClient = useQueryClient()
  
  const { data: cart } = useQuery({
    queryKey: ['cart'],
    queryFn: fetchCart
  })
  
  const updateQuantity = useMutation({
    mutationFn: ({ itemId, quantity }: { itemId: string; quantity: number }) =>
      updateCartItem(itemId, quantity),
    onMutate: async ({ itemId, quantity }) => {
      // 낙관적 업데이트: 즉시 UI 반영
      await queryClient.cancelQueries({ queryKey: ['cart'] })
      const previous = queryClient.getQueryData(['cart'])
      
      queryClient.setQueryData(['cart'], (old: Cart) => ({
        ...old,
        items: old.items.map(item =>
          item.id === itemId ? { ...item, quantity } : item
        )
      }))
      
      return { previous }
    },
    onError: (err, variables, context) => {
      // 실패 시 롤백
      queryClient.setQueryData(['cart'], context?.previous)
    }
  })
  
  return (
    <div>
      {cart?.items.map(item => (
        <div key={item.id}>
          <span>{item.name}</span>
          <button onClick={() => updateQuantity.mutate({
            itemId: item.id,
            quantity: item.quantity + 1
          })}>
            +
          </button>
        </div>
      ))}
    </div>
  )
}

왜 TanStack Query?

  • 낙관적 업데이트 (즉각적인 UI 반응)
  • 여러 페이지에서 장바구니 상태 공유
  • 자동 동기화

최종 결론

간단 정리

서버 컴포넌트 + Suspense
→ SEO가 중요한 페이지
→ 읽기 위주의 컨텐츠
→ 로그인 불필요

TanStack Query
→ 실시간 데이터 갱신
→ 복잡한 클라이언트 상태 관리
→ 로그인 필요한 페이지
→ 대시보드, 무한 스크롤, 장바구니 등

의사결정 트리

1. 로그인이 필요한가?
   YES → TanStack Query 고려
   NO → 2번으로

2. SEO가 중요한가?
   YES → 서버 컴포넌트 + Suspense
   NO → TanStack Query 고려

3. 실시간 갱신이 필요한가?
   YES → TanStack Query
   NO → 서버 컴포넌트 + Suspense

4. 낙관적 업데이트가 필요한가?
   YES → TanStack Query
   NO → 서버 컴포넌트 + Suspense

페이지별 전략 요약

페이지 유형전략이유
블로그 리스트서버 컴포넌트 + URL 파라미터SEO, 공유 가능한 URL
블로그 상세서버 컴포넌트 + SuspenseSEO, 크롤러 접근
수정 폼 (단순)서버 컴포넌트간단한 코드
수정 폼 (자동저장)TanStack Query실시간 동기화
대시보드TanStack Query실시간 갱신, 로그인 필요
무한 스크롤 피드TanStack QueryuseInfiniteQuery, 로그인 필요
장바구니TanStack Query낙관적 업데이트
정적 페이지서버 컴포넌트SEO, 간단함

0개의 댓글