Next.js Server Components 데이터 가져오기 완벽 가이드: fetch, 데이터베이스 쿼리와 모범 사례

Next.js App Router에서 처음 컴포넌트를 작성했을 때 다음과 같은 코드를 봤습니다.
async function Page() {
const data = await fetch('...')
return <div>{data}</div>
}
이게 전부라고요? 곧바로 await해도 된다고요? useEffect도, useState도 필요 없고 경쟁 상태까지 걱정하지 않아도 될까요?
React 클라이언트 방식에 익숙해진 상태에서 갑자기 “컴포넌트가 비동기일 수 있다”는 말을 들으니 규칙이 달라진 듯했습니다. 더 고민스러웠던 점은 fetch API를 써야 하는지, 데이터베이스를 직접 조회해야 하는지였습니다. fetch를 쓰면 API 호출 단계가 하나 더 생기는 것 같고, 데이터베이스를 직접 조회하면 비밀 키가 클라이언트에 노출될까 걱정됐습니다.
비슷한 고민이 있다면 이 글이 도움이 될 것입니다. Next.js Server Components 데이터 가져오기의 올바른 방법을 살펴보겠습니다. 언제 fetch를 사용하고 언제 데이터베이스를 조회하는지, async 컴포넌트는 어떻게 작성하는지, 캐시는 어떻게 제어하는지, 어떤 함정을 조심해야 하는지 차례대로 설명합니다.
Server Components 데이터 가져오기 기초
Server Components에서는 왜 곧바로 await할 수 있을까요?
답부터 말하면 Server Components는 브라우저가 아니라 서버에서 실행되기 때문입니다.
당연한 말처럼 들리지만 이 차이가 핵심입니다. 기존 React 컴포넌트는 브라우저에서 렌더링되므로 데이터베이스나 파일 시스템에 직접 접근할 수 없습니다. 반면 Server Components는 서버에서 실행되기 때문에 예전에는 API 라우트에서만 하던 여러 작업을 처리할 수 있습니다.
- 데이터베이스에 직접 연결(Prisma, Drizzle, 네이티브 SQL)
- 파일 시스템 읽기(예: markdown 파일)
- 교차 출처 문제 없이 내부 서비스 호출
- 클라이언트에 노출하지 않고 환경 변수와 비밀 키에 접근
따라서 다음 코드는 완전히 안전합니다.
// app/posts/page.tsx
import { db } from '@/lib/db'
async function PostsPage() {
// 데이터베이스를 직접 조회하며 비밀 키는 브라우저로 전송되지 않음
const posts = await db.post.findMany()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default PostsPage
몇 가지 핵심을 기억하세요.
- 컴포넌트를
async function으로 선언합니다. - 데이터베이스 쿼리를 곧바로
await할 수 있습니다. - React hooks(
useState,useEffect등)는 사용할 수 없습니다. - 기본적으로 이 컴포넌트는 서버에서 렌더링되며, 클라이언트는 HTML만 받습니다.
세 가지 주요 데이터 가져오기 방식
Server Components에서는 세 가지 방법을 선택할 수 있습니다.
1. fetch API
가장 익숙한 방식으로, 외부 API나 자체 Route Handler를 호출합니다.
async function Page() {
const res = await fetch('https://api.example.com/data')
const data = await res.json()
return <div>{data.title}</div>
}
2. 데이터베이스 직접 조회
ORM이나 데이터베이스 클라이언트로 곧바로 조회합니다.
import { db } from '@/lib/db'
async function Page() {
const data = await db.posts.findFirst()
return <div>{data.title}</div>
}
3. Server Actions
폼 제출이나 삭제 같은 데이터 변경에 사용합니다. 데이터를 가져올 뿐 아니라 수정할 수도 있습니다.
async function createPost(formData: FormData) {
'use server'
const title = formData.get('title')
await db.post.create({ data: { title } })
}
그렇다면 어떤 방법을 골라야 할까요? 이어서 살펴보겠습니다.
fetch와 데이터베이스 쿼리, 어떻게 선택할까요?
App Router를 처음 사용할 때 가장 고민했던 부분입니다. 지금 돌아보면 답은 꽤 명확합니다.
의사결정 트리: 5초 만에 결정하기
먼저 세 가지 질문을 해보세요.
- Server Component인가요? → 그렇다면 계속 진행하고, 아니라면(Client Component라면) 3번으로 이동합니다.
- 데이터는 어디에서 오나요?
- 자체 데이터베이스 → 데이터베이스 직접 조회
- 서드파티 API → fetch 사용
- Client Component에서 데이터를 가져와야 하나요? → API 라우트를 만들고 fetch를 사용합니다.
이것이 전부입니다.
왜 데이터베이스 직접 조회를 우선할까요?
Next.js의 공식 권장 사항은 분명합니다. Server Component에서 API 라우트를 우회 호출하지 말고 데이터베이스를 직접 조회하면 됩니다.
이유도 현실적입니다.
1. HTTP 왕복 한 번을 줄입니다
차이를 비교해 보세요.
// ❌ 우회: Server Component → API Route → Database
async function Page() {
const res = await fetch('/api/posts') // HTTP 호출
const posts = await res.json()
return <PostList posts={posts} />
}
// ✅ 직행: Server Component → Database
async function Page() {
const posts = await db.post.findMany() // 직접 조회
return <PostList posts={posts} />
}
두 번째 방식은 단계가 하나 적어 응답이 더 빠릅니다. “얼마나 차이가 나겠어?”라고 생각할 수도 있지만, 이런 차이가 쌓이면 페이지 로딩이 100~200ms 빨라지는 것도 분명히 체감됩니다.
2. 타입 안전성이 더 좋습니다
TypeScript와 Prisma 또는 Drizzle을 함께 사용하면 데이터베이스 직접 조회에서 완전한 타입 추론을 얻을 수 있습니다.
// 타입이 자동 추론되어 편집기 자동 완성이 정확함
const post = await db.post.findFirst({
include: { author: true, comments: true }
})
// post.author.name ← 타입 힌트 제공
// post.comments[0].content ← 이 항목도 제공
fetch를 사용하면 타입을 직접 정의하거나 as 단언을 사용해야 하므로 오류가 생기기 쉽습니다.
3. 코드가 더 간결합니다
별도의 API 파일을 만들 필요도 없고 HTTP 상태 코드와 오류를 처리할 필요도 없어 코드가 절반으로 줄어듭니다.
언제 API/fetch를 반드시 사용해야 할까요?
API 라우트가 완전히 필요 없는 것은 아닙니다. 다음 세 가지 상황에서는 여전히 사용해야 합니다.
상황 1: Client Component에 데이터가 필요할 때
클라이언트 컴포넌트는 브라우저에서 실행되므로 데이터베이스를 직접 조회할 수 없습니다. 이때는 API 엔드포인트가 필요합니다.
// app/api/posts/route.ts
export async function GET() {
const posts = await db.post.findMany()
return Response.json(posts)
}
// components/client-posts.tsx
'use client'
export function ClientPosts() {
const [posts, setPosts] = useState([])
useEffect(() => {
fetch('/api/posts')
.then(res => res.json())
.then(setPosts)
}, [])
return <div>{/* posts 렌더링 */}</div>
}
상황 2: 외부에 API를 공개해야 할 때
Next.js 애플리케이션이 모바일 앱이나 서드파티 같은 다른 서비스에 데이터를 제공해야 한다면 공개 API 엔드포인트를 만들어야 합니다.
상황 3: 서드파티 서비스와 연동할 때
GitHub API나 OpenAI API 등을 호출한다면 망설일 필요 없이 fetch를 사용합니다.
async function Page() {
const res = await fetch('https://api.github.com/users/vercel')
const user = await res.json()
return <div>Followers: {user.followers}</div>
}
async/await 컴포넌트의 올바른 작성법
무엇을 선택할지 정했으니 이제 작성 방법을 알아보겠습니다.
기본 패턴: 놀라울 만큼 단순합니다
가장 기본적인 async 컴포넌트는 다음과 같습니다.
async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({
where: { id: params.id }
})
if (!product) {
return <div>Product not found</div>
}
return (
<div>
<h1>{product.name}</h1>
<p>{product.price}</p>
</div>
)
}
loading 상태도, useEffect도 없습니다. 데이터가 돌아오기를 기다린 뒤 렌더링하면 됩니다. 깔끔합니다.
병렬과 직렬: 성능 차이가 큽니다
여기에는 저도 직접 겪었던 함정이 하나 있습니다.
다음 두 코드 중 어느 쪽이 더 빠를까요?
// ❌ 직렬: 느림
async function Page() {
const user = await db.user.findFirst() // 200ms 대기
const posts = await db.post.findMany() // 다시 150ms 대기
const comments = await db.comment.findMany() // 다시 100ms 대기
// 총 450ms
return <Dashboard user={user} posts={posts} comments={comments} />
}
// ✅ 병렬: 빠름
async function Page() {
const [user, posts, comments] = await Promise.all([
db.user.findFirst(), // 동시에 시작
db.post.findMany(), // 동시에 시작
db.comment.findMany(), // 동시에 시작
])
// 총 200ms(가장 느린 요청 기준)
return <Dashboard user={user} posts={posts} comments={comments} />
}
두 배가 넘는 차이입니다. 데이터 사이에 의존 관계가 없다면 반드시 Promise.all로 병렬로 가져오세요.
물론 의존 관계가 있다면 이야기가 달라집니다.
// 반드시 직렬 처리: 뒤의 쿼리가 앞의 결과에 의존함
async function Page({ params }) {
const user = await db.user.findUnique({ where: { id: params.id } })
// user를 먼저 가져와야 해당 사용자의 posts를 조회할 수 있음
const posts = await db.post.findMany({ where: { authorId: user.id } })
return <Profile user={user} posts={posts} />
}
Suspense 경계: 로딩 경험 제어하기
“서버에서 데이터를 가져온다면 사용자는 빈 화면만 보게 되는 것 아닐까?”라고 생각할 수 있습니다.
맞습니다. 하지만 Next.js는 loading.js와 Suspense로 이 문제를 개선합니다.
방법 1: loading.js 파일
라우트 폴더에 loading.tsx를 만들면 자동으로 적용됩니다.
// app/posts/loading.tsx
export default function Loading() {
return <div>Loading posts...</div>
}
// app/posts/page.tsx
async function PostsPage() {
const posts = await db.post.findMany() // 느린 쿼리
return <PostList posts={posts} />
}
사용자는 먼저 “Loading posts…”를 보고, 데이터가 돌아오면 실제 콘텐츠로 교체됩니다.
방법 2: Suspense 직접 사용
더 세밀하게 제어하고 싶다면 Suspense를 직접 감쌀 수 있습니다.
import { Suspense } from 'react'
async function SlowComponent() {
const data = await slowQuery() // 3초
return <div>{data}</div>
}
async function FastComponent() {
const data = await fastQuery() // 0.5초
return <div>{data}</div>
}
export default function Page() {
return (
<div>
<FastComponent /> {/* 빠른 컴포넌트를 먼저 표시 */}
<Suspense fallback={<div>Loading...</div>}>
<SlowComponent /> {/* 느린 컴포넌트는 기다리되 위 콘텐츠를 막지 않음 */}
</Suspense>
</div>
)
}
흔한 실수: Suspense 위치가 잘못된 경우
저도 이 실수를 한 적이 있습니다.
// ❌ 잘못된 예: Suspense가 async 컴포넌트 내부에 있어 동작하지 않음
async function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
{await slowQuery()} {/* Suspense가 잡을 수 없음 */}
</Suspense>
)
}
// ✅ 올바른 예: Suspense가 async 컴포넌트 바깥을 감쌈
export default function Layout() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SlowPage /> {/* async 컴포넌트 */}
</Suspense>
)
}
Suspense가 promise를 잡으려면 async 컴포넌트의 바깥쪽에 있어야 합니다.
요청 자동 중복 제거: 반복 호출을 걱정하지 마세요
또 하나 멋진 기능이 있습니다. 같은 렌더링 중 동일한 요청을 여러 번 호출하면 Next.js가 자동으로 중복을 제거합니다.
async function Header() {
const user = await db.user.findFirst() // 쿼리 1
return <div>{user.name}</div>
}
async function Sidebar() {
const user = await db.user.findFirst() // 쿼리 2지만 실제로 실행되지 않음
return <div>{user.name}</div>
}
export default function Page() {
return (
<div>
<Header />
<Sidebar />
{/* 실제 데이터베이스 쿼리는 한 번만 실행됨 */}
</div>
)
}
Next.js는 첫 번째 결과를 기억하고 이후 호출에는 캐시된 결과를 반환합니다. 성능을 걱정하지 않고 여러 컴포넌트에서 같은 데이터 소스를 호출할 수 있습니다.
캐시 전략과 데이터 재검증
캐시 이야기가 나온 김에, 많은 사람이 놓친 Next.js 15의 큰 변화를 살펴보겠습니다.
Next.js 15에서 캐시 기본값이 바뀌었습니다
이전(Next.js 14): fetch의 기본값은 cache: 'force-cache'였으며 계속 캐시했습니다.
현재(Next.js 15): fetch의 기본값은 cache: 'no-store'이며 캐시하지 않고 매번 새로 가져옵니다.
왜 바뀌었을까요? 공식 설명에 따르면 사용자가 캐시 때문에 자주 곤란을 겪었기 때문입니다. 데이터가 실시간으로 갱신될 것으로 생각했지만 이전 값이 계속 남아 있었습니다. 이제 기본적으로 캐시하지 않는 방식이 직관에 더 잘 맞습니다.
이것이 의미하는 바는 무엇일까요? 14에서 15로 업그레이드한 뒤 페이지가 느려졌다고 느낄 수 있습니다. 예전에는 캐시되던 인터페이스를 이제 매번 호출하기 때문입니다.
세 가지 캐시 전략
데이터 특성에 따라 선택하세요.
1. 완전 캐시(정적 사이트에 적합)
async function BlogPost({ slug }) {
const post = await fetch(`https://api.example.com/posts/${slug}`, {
cache: 'force-cache' // 다시 빌드할 때까지 영구 캐시
})
return <article>{post.content}</article>
}
블로그 글, 제품 페이지, 문서처럼 내용이 거의 바뀌지 않는 데이터에 적합합니다.
2. 캐시하지 않음(실시간 데이터)
async function StockPrice() {
const price = await fetch('https://api.example.com/stock', {
cache: 'no-store' // 매번 새로 가져오기
})
return <div>Current price: {price}</div>
}
주가, 실시간 댓글, 사용자 상태처럼 반드시 최신이어야 하는 데이터에 적합합니다.
3. 주기적 재검증(ISR)
async function ProductList() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 } // 60초 뒤 만료되고 다시 가져오기
})
return <div>{products.map(p => <Card key={p.id} {...p} />)}</div>
}
몇십 초 정도의 지연은 허용하지만 너무 오래된 상태여서는 안 되는 제품 목록이나 뉴스 홈페이지에 적합합니다.
수동 재검증: 데이터가 바뀌면 즉시 갱신하기
사용자가 글을 작성한 경우처럼 데이터를 변경한 뒤 캐시를 즉시 갱신해야 할 때가 있습니다. Next.js는 두 가지 API를 제공합니다.
1. revalidatePath(페이지 전체 갱신)
'use server'
import { revalidatePath } from 'next/cache'
async function createPost(formData: FormData) {
await db.post.create({ data: {...} })
revalidatePath('/posts') // /posts 페이지 캐시 갱신
}
2. revalidateTag(특정 태그 갱신)
더 세밀하게 제어할 수 있습니다.
// 데이터를 가져올 때 태그 지정
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] } // 'posts' 태그 지정
})
return res.json()
}
// 필요할 때 이 태그 갱신
'use server'
import { revalidateTag } from 'next/cache'
async function createPost() {
await db.post.create({ data: {...} })
revalidateTag('posts') // 'posts' 태그가 있는 캐시만 갱신
}
오류 처리와 성능 최적화
오류 처리: 페이지 전체가 망가지지 않게 하기
Server Components에서 데이터 가져오기가 실패하면 기본적으로 페이지 전체가 오류를 일으킵니다. 따라서 오류를 제대로 처리해야 합니다.
방법 1: try/catch
async function Page() {
try {
const data = await fetch('https://api.example.com/data')
if (!data.ok) throw new Error('Failed to fetch')
return <div>{data.title}</div>
} catch (error) {
return <div>Something went wrong. Please try again.</div>
}
}
방법 2: error.js 파일
라우트 폴더에 error.tsx를 만들면 해당 라우트와 하위 라우트의 오류를 자동으로 잡습니다.
// app/posts/error.tsx
'use client' // 오류 경계는 반드시 클라이언트 컴포넌트여야 함
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
한 가지 함정: try/catch 안의 redirect는 가로채집니다
// ❌ 잘못된 예: redirect가 던진 오류를 catch가 잡음
async function Page() {
try {
const user = await getUser()
if (!user) redirect('/login') // 여기서 던진 오류를 아래 catch가 잡음
} catch (error) {
return <div>Error</div> // redirect가 동작하지 않음!
}
}
// ✅ 올바른 예: redirect를 try/catch 밖에 둠
async function Page() {
let user
try {
user = await getUser()
} catch (error) {
return <div>Error</div>
}
if (!user) redirect('/login') // 이제 정상적으로 이동함
}
흔한 오류와 해결 방법
제가 직접 겪은 문제를 정리해 보겠습니다.
오류 1: 서버 측 fetch에 상대 경로 사용
// ❌ 잘못된 예: 서버에는 base URL이 없음
async function Page() {
const data = await fetch('/api/posts') // 오류!
}
// ✅ 올바른 예: 절대 경로 사용
async function Page() {
const data = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/posts`)
}
// ✅ 더 나은 방법: fetch 대신 데이터베이스 직접 조회
async function Page() {
const posts = await db.post.findMany()
}
오류 2: response.ok 확인 누락
// ❌ 잘못된 예: fetch는 자동으로 오류를 던지지 않음
async function Page() {
const res = await fetch('https://api.example.com/data')
const data = await res.json() // 404라면 여기서 문제가 생김
return <div>{data.title}</div>
}
// ✅ 올바른 예: 상태 확인
async function Page() {
const res = await fetch('https://api.example.com/data')
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
const data = await res.json()
return <div>{data.title}</div>
}
오류 3: Server Component에서 Route Handler 호출
// ❌ 권장하지 않음: 불필요한 우회
async function Page() {
const res = await fetch('/api/posts') // 왜 굳이 돌아가야 할까요?
const posts = await res.json()
return <PostList posts={posts} />
}
// ✅ 권장: 직접 조회
async function Page() {
const posts = await db.post.findMany()
return <PostList posts={posts} />
}
실전 사례: 블로그 페이지 만들기
이론을 충분히 살펴봤으니 완성된 예제를 만들어 보겠습니다.
다음 요소가 필요한 블로그 글 상세 페이지를 만든다고 가정합니다.
- 글 내용 표시
- 작성자 정보 표시
- 관련 글 추천 표시
파일 구조
app/
posts/
[slug]/
page.tsx ← 글 상세 페이지
loading.tsx ← 로딩 상태
error.tsx ← 오류 처리
구현 코드
// app/posts/[slug]/page.tsx
import { db } from '@/lib/prisma'
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
// 메인 페이지 컴포넌트
export default async function PostPage({
params,
}: {
params: { slug: string }
}) {
// 글과 작성자 정보를 병렬로 가져오기
const [post, author] = await Promise.all([
db.post.findUnique({
where: { slug: params.slug },
}),
db.user.findFirst(),
])
if (!post) {
notFound() // 404 페이지 표시
}
return (
<article>
<h1>{post.title}</h1>
<AuthorCard author={author} />
<div>{post.content}</div>
{/* 관련 글 추천은 늦게 불러와도 되므로 메인 콘텐츠를 막지 않음 */}
<Suspense fallback={<div>Loading recommendations...</div>}>
<RecommendedPosts currentPostId={post.id} />
</Suspense>
</article>
)
}
// 작성자 카드(데이터가 이미 있으므로 바로 렌더링)
function AuthorCard({ author }) {
return (
<div>
<img src={author.avatar} alt={author.name} />
<span>{author.name}</span>
</div>
)
}
// 추천 글(독립적으로 불러오는 비동기 컴포넌트)
async function RecommendedPosts({ currentPostId }: { currentPostId: string }) {
const recommended = await db.post.findMany({
where: {
id: { not: currentPostId },
published: true,
},
take: 3,
})
return (
<div>
<h3>You might also like</h3>
{recommended.map((post) => (
<a key={post.id} href={`/posts/${post.slug}`}>
{post.title}
</a>
))}
</div>
)
}
// 캐시 전략: 글 콘텐츠를 1시간마다 재검증
export const revalidate = 3600
// 정적 매개변수 생성(선택 사항, 정적 생성에 사용)
export async function generateStaticParams() {
const posts = await db.post.findMany({
select: { slug: true },
})
return posts.map((post) => ({
slug: post.slug,
}))
}
// app/posts/[slug]/loading.tsx
export default function Loading() {
return (
<div>
<div className="skeleton h-12 w-3/4" />
<div className="skeleton h-4 w-1/4 mt-4" />
<div className="skeleton h-64 mt-8" />
</div>
)
}
// app/posts/[slug]/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Failed to load post</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
핵심 결정 설명
- 왜 데이터베이스를 직접 조회하나요? → Server Component이므로 API 라우트를 우회할 필요가 없습니다.
- 왜 글과 작성자 정보를 병렬로 가져오나요? → 두 쿼리에 의존 관계가 없으므로 병렬 처리가 더 빠릅니다.
- 왜 추천 글에 Suspense를 사용하나요? → 추천은 중요도가 낮아 조금 늦게 불러와도 되고, 메인 콘텐츠를 막지 않아야 합니다.
- 왜 revalidate: 3600을 사용하나요? → 글 내용은 자주 바뀌지 않으므로 1시간 캐시면 충분하며 데이터베이스 부하도 줄일 수 있습니다.
결론
내용이 많았지만 핵심은 몇 가지입니다.
- Server Component에서는 데이터베이스 직접 조회를 우선하세요. Client Component에서 가져와야 하거나 서드파티 API와 연동해야 할 때는 예외입니다.
- async/await 컴포넌트는 단순합니다. 다만 Promise.all로 병렬 처리하고 Suspense로 로딩 경험을 개선하세요.
- Next.js 15는 기본적으로 캐시하지 않습니다. 데이터 특성에 따라
force-cache,no-store,revalidate중 하나를 선택하세요. - 오류를 제대로 처리하세요.
response.ok를 확인하고error.tsx를 안전망으로 사용하며,redirect는 try/catch 안에 두지 마세요.
Server Components의 데이터 가져오기는 클라이언트보다 훨씬 단순합니다. loading 상태, 경쟁 상태, 요청 취소를 직접 관리할 필요가 없어 작성하기도 훨씬 편합니다. 아직 App Router로 마이그레이션할지 고민하고 있다면 이 장점 하나만으로도 시도해 볼 가치가 있습니다.
다음 프로젝트에서 과감히 사용해 보세요. 문제가 생기면 이 글을 다시 참고하면 됩니다.
Next.js Server Components 데이터 가져오기 전체 과정
fetch와 데이터베이스 쿼리 선택부터 async 컴포넌트 작성법, 캐시 전략, 오류 처리까지의 전체 단계
⏱️ Estimated time: 1 hr
- 1
Step 1: fetch와 데이터베이스 쿼리 선택
데이터베이스 직접 조회(권장):
• 더 빠름: API 호출 단계가 하나 줄어 지연 시간이 짧음
• 더 안전함: 데이터베이스 비밀 키가 클라이언트에 노출되지 않음
• 적합한 경우: 서버에서 데이터베이스에 접근할 수 있음
코드 예시:
```tsx
import { db } from '@/lib/db'
export default async function Page() {
const users = await db.user.findMany()
return <div>{users.map(u => <div key={u.id}>{u.name}</div>)}</div>
}
```
fetch 사용:
• 적합한 경우: 서드파티 API 연동
• 적합한 경우: 교차 출처 요청이 필요한 상황
• 주의: Next.js 15는 기본적으로 캐시하지 않음
코드 예시:
```tsx
export default async function Page() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache' // Next.js 15에서는 명시적 설정 필요
})
const data = await res.json()
return <div>{data}</div>
}
```
선택 기준: 데이터베이스 직접 조회를 우선하고, 서드파티 API를 연동해야 할 때 fetch를 사용합니다. - 2
Step 2: async/await 컴포넌트 작성
컴포넌트를 곧바로 async로 선언합니다:
```tsx
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
```
병렬 가져오기(Promise.all):
```tsx
export default async function Page() {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
])
return <div>...</div>
}
```
Suspense로 로딩 경험 개선:
```tsx
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserList />
</Suspense>
)
}
async function UserList() {
const users = await fetchUsers()
return <div>{users.map(...)}</div>
}
```
핵심:
• Server Components는 곧바로 async로 선언할 수 있음
• Promise.all로 병렬 처리
• Suspense로 로딩 경험 개선 - 3
Step 3: 캐시 전략 설정
Next.js 15는 기본적으로 캐시하지 않으므로 명시적으로 설정해야 합니다.
캐시하지 않기(실시간 데이터):
```tsx
fetch(url, { cache: 'no-store' })
```
영구 캐시(정적 데이터):
```tsx
fetch(url, { cache: 'force-cache' })
```
주기적 갱신(ISR):
```tsx
fetch(url, { next: { revalidate: 3600 } })
```
선택 기준:
• 실시간 데이터 → cache: 'no-store'
• 정적 데이터 → cache: 'force-cache'
• 자주 바뀌지만 실시간일 필요는 없음 → revalidate
주의: Next.js 15의 철학은 ‘암시보다 명시’이므로 어떤 데이터를 캐시해야 하는지 직접 판단해야 합니다. - 4
Step 4: 오류 처리
response.ok 확인:
```tsx
const res = await fetch(url)
if (!res.ok) {
throw new Error('Failed to fetch')
}
const data = await res.json()
```
error.tsx를 안전망으로 사용:
```tsx
// app/page/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>오류가 발생했습니다: {error.message}</h2>
<button onClick={reset}>다시 시도</button>
</div>
)
}
```
redirect 주의:
```tsx
// ❌ 잘못된 예: redirect가 try/catch 안에 있음
try {
if (!user) redirect('/login')
} catch (e) {
// redirect가 던진 오류까지 catch가 잡음
}
// ✅ 올바른 예: redirect가 try/catch 밖에 있음
if (!user) redirect('/login')
try {
// 기타 로직
} catch (e) {
// 오류 처리
}
```
핵심:
• response.ok 확인
• error.tsx를 안전망으로 사용
• redirect를 try/catch 안에 넣지 않기
FAQ
Server Components에서는 왜 곧바로 await할 수 있나요?
Server Components에서는 다음 작업을 할 수 있습니다.
• 데이터베이스에 직접 연결(Prisma, Drizzle, 네이티브 SQL)
• 파일 시스템 읽기(예: markdown 파일)
• 교차 출처 문제 없이 내부 서비스 호출
• 클라이언트에 노출하지 않고 환경 변수와 비밀 키에 접근
따라서 다음 코드는 완전히 안전합니다.
```tsx
import { db } from '@/lib/db'
export default async function Page() {
const users = await db.user.findMany() // 데이터베이스 비밀 키는 노출되지 않음
return <div>{users.map(...)}</div>
}
```
장점:
• useEffect와 useState가 필요 없음
• 경쟁 상태를 걱정할 필요가 없음
• loading 상태를 처리할 필요가 없음
• 클라이언트보다 데이터 가져오기가 훨씬 단순함
언제 fetch를 사용하고 언제 데이터베이스를 직접 조회해야 하나요?
• 더 빠름: API 호출 단계가 하나 줄어 지연 시간이 짧음(서버에서 데이터베이스까지는 보통 <10ms, 클라이언트에서 서버까지는 100ms 이상일 수 있음)
• 더 안전함: 데이터베이스 비밀 키가 클라이언트에 노출되지 않음
• 적합한 경우: 서버에서 데이터베이스에 접근할 수 있음
fetch 사용:
• 적합한 경우: 서드파티 API 연동
• 적합한 경우: 교차 출처 요청이 필요한 상황
• 적합한 경우: 기존 API가 있어 아키텍처를 바꾸고 싶지 않음
선택 기준:
• 데이터베이스 직접 조회를 우선
• 서드파티 API를 연동해야 할 때만 fetch 사용
• Server Component에서 자체 API를 fetch하는 불필요한 우회는 피하기
코드 비교:
```tsx
// ❌ 안티 패턴: Server Component에서 자체 API를 fetch
const res = await fetch('/api/users')
const users = await res.json()
// ✅ 올바른 방법: 데이터베이스 직접 조회
const users = await db.user.findMany()
```
async 컴포넌트는 어떻게 작성하나요?
```tsx
export default async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
```
병렬 가져오기(Promise.all):
```tsx
export default async function Page() {
const [users, posts] = await Promise.all([
fetchUsers(),
fetchPosts()
])
return <div>...</div>
}
```
Suspense로 로딩 경험 개선:
```tsx
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserList />
</Suspense>
)
}
async function UserList() {
const users = await fetchUsers()
return <div>{users.map(...)}</div>
}
```
핵심:
• Server Components는 곧바로 async로 선언할 수 있음
• Promise.all로 병렬 처리
• Suspense로 로딩 경험 개선
Next.js 15의 캐시 전략은 어떻게 설정하나요?
캐시하지 않기(실시간 데이터):
```tsx
fetch(url, { cache: 'no-store' })
```
영구 캐시(정적 데이터):
```tsx
fetch(url, { cache: 'force-cache' })
```
주기적 갱신(ISR):
```tsx
fetch(url, { next: { revalidate: 3600 } })
```
선택 기준:
• 실시간 데이터 → cache: 'no-store'
• 정적 데이터 → cache: 'force-cache'
• 자주 바뀌지만 실시간일 필요는 없음 → revalidate
주의: Next.js 15의 철학은 ‘암시보다 명시’입니다. 어떤 데이터를 캐시해야 하는지 직접 판단해야 합니다. 이는 호환성을 깨뜨리는 변경이므로 마이그레이션할 때 주의해야 합니다.
Server Components의 오류는 어떻게 처리하나요?
```tsx
const res = await fetch(url)
if (!res.ok) {
throw new Error('Failed to fetch')
}
const data = await res.json()
```
error.tsx를 안전망으로 사용:
```tsx
// app/page/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>오류가 발생했습니다: {error.message}</h2>
<button onClick={reset}>다시 시도</button>
</div>
)
}
```
redirect 주의:
```tsx
// ❌ 잘못된 예: redirect가 try/catch 안에 있음
try {
if (!user) redirect('/login')
} catch (e) {
// redirect가 던진 오류까지 catch가 잡음
}
// ✅ 올바른 예: redirect가 try/catch 밖에 있음
if (!user) redirect('/login')
try {
// 기타 로직
} catch (e) {
// 오류 처리
}
```
핵심:
• response.ok 확인
• error.tsx를 안전망으로 사용
• redirect를 try/catch 안에 넣지 않기(redirect는 오류를 던짐)
7분 읽기 · 게시일: 2025년 12월 19일 · 수정일: 2026년 9월 8일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js 동적 라우팅과 매개변수 처리 완벽 가이드: 기초부터 타입 안전성까지
Next.js 14+의 동적 라우팅 시스템을 단계별로 익혀 봅니다. 동적 매개변수, catch-all 라우트, 선택적 매개변수, generateStaticParams를 사용해야 하는 시점, TypeScript 타입 안전성 실전까지 다룹니다. 라우트 매개변수 조회 방식의 변화로 생기는 혼란을 해결할 수 있도록 다양한 실전 코드 예제를 함께 제공합니다.
45편 중 5편
다음
Next.js SSR vs SSG vs ISR: 렌더링 전략 선택 가이드
Next.js에서 언제 SSR, SSG, ISR을 써야 할지 고민되시나요? 실전 사례 비교와 의사결정 트리를 통해 적합한 렌더링 전략을 빠르게 선택하고, ISR 설정이 적용되지 않거나 초기 화면 로딩이 느린 흔한 문제를 해결하는 방법을 알아봅니다.
45편 중 7편



댓글
GitHub로 로그인하여 댓글을 남기세요