
Next.js에서 데이터를 가져오는 주요 전략은 크게 2가지다.
// 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>
}
// 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>
}
검색 엔진 최적화(Search Engine Optimization)의 약자로, 검색 엔진의 크롤러 봇이:
1. 웹사이트를 잘 찾고
2. 컨텐츠를 잘 이해하고
3. 검색 결과 상위에 노출되도록 만드는 것
'use client'
export function ProductList() {
const { data } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts
})
return <div>{data?.map(...)}</div>
}
클라이언트 사이드 (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 없이 서버 컴포넌트
// app/page-b/page.tsx
export default async function PageB() {
const data = await fetchHeavyData() // 5초 걸림
return <div>{data.content}</div>
}
사용자 경험:
A 페이지에서 링크 클릭
↓
5초 동안 A 페이지에 그대로 머물러 있음 (아무 반응 없음)
↓
갑자기 B 페이지가 나타남
// 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초 후 실제 컨텐츠로 교체
병렬 데이터 로딩
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 중요
'use client'
export function StockPrice() {
const { data } = useQuery({
queryKey: ['stock'],
queryFn: fetchStock,
refetchInterval: 5000 // 5초마다 자동 갱신
})
}
'use client'
export function TodoList() {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
}
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 }
}
})
// 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 | ✗ 불리 | ✓ 유리 |
| 실시간 갱신 | ✓ 쉬움 | ✗ 어려움 |
| 복잡한 캐싱 | ✓ 강력함 | △ 기본 제공 |
| 낙관적 업데이트 | ✓ 지원 | ✗ 어려움 |
| 코드 복잡도 | △ 높음 | ✓ 간단함 |
| 초기 로딩 속도 | △ 느림 | ✓ 빠름 |
| 무한 스크롤 | ✓ 쉬움 | △ 가능 |
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>
}
장점:
// 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 사용
단순 수정 폼: 서버 컴포넌트로 충분
// 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] })
}
})
// 자동 저장 로직...
}
// app/prompt/page.tsx
export default function PromptPage() {
return (
<div>
<h1>우리 서비스 소개</h1>
<p>최고의 프롬프트 서비스...</p>
</div>
)
}
서버 사이드 fetch 사용
검색 엔진 크롤러
→ 로그인 불가능
→ 인증이 필요한 페이지 접근 불가능
→ 컨텐츠를 읽을 수 없음
→ 인덱싱 불가능
✓ 블로그 글 목록: /blog
✓ 블로그 글 상세: /blog/123
✓ 제품 목록: /products
✓ 제품 상세: /products/iphone-15
✓ 회사 소개: /about
✓ 랜딩 페이지: /
→ 서버 컴포넌트 + Suspense 사용
✓ 대시보드: /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
})
// 로그인 필요한 기능
}
'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?
'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로 무한 스크롤 쉽게 구현'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?
서버 컴포넌트 + 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 |
| 블로그 상세 | 서버 컴포넌트 + Suspense | SEO, 크롤러 접근 |
| 수정 폼 (단순) | 서버 컴포넌트 | 간단한 코드 |
| 수정 폼 (자동저장) | TanStack Query | 실시간 동기화 |
| 대시보드 | TanStack Query | 실시간 갱신, 로그인 필요 |
| 무한 스크롤 피드 | TanStack Query | useInfiniteQuery, 로그인 필요 |
| 장바구니 | TanStack Query | 낙관적 업데이트 |
| 정적 페이지 | 서버 컴포넌트 | SEO, 간단함 |