Next.js 교재 · 8편 · 데이터 fetching

서버에서 데이터 가져오기 — fetch와 async

useEffect 안녕. 컴포넌트 함수 자체가 async — 데이터 가져오는 코드가 짧아진다.

서버 컴포넌트가 데이터를 가져와 페이지로 렌더링되는 컨셉 아이소메트릭 일러스트

옛 React 에선 데이터 가져오기가 이런 패턴이었다 — useState 로 빈 상태 만들고, useEffect 안에서 fetch 호출하고, 로딩 분기 처리하고, 에러 분기 처리하고… 코드 30줄이 데이터 가져오기에만 들어갔다.

App Router 의 답은 컴포넌트 함수 자체를 async 로. async function Page() { const data = await fetch(...) } 가 끝. 로딩 UI 는 loading.tsx, 에러는 error.tsx — 별도 파일이 분담한다.

1. 가장 단순한 fetching — async + await

서버 컴포넌트는 함수 시그니처에 async 를 붙일 수 있다. 안에서 await 자유롭게.

// app/blog/page.tsx (서버 컴포넌트) type Post = { id: number; title: string }; export default async function BlogIndex() { const res = await fetch('https://api.example.com/posts'); const posts: Post[] = await res.json(); return ( <ul> {posts.map(p => ( <li key={p.id}>{p.title}</li> ))} </ul> ); }

이 한 함수가 서버에서 실행되고, 결과 HTML 만 브라우저로 전송된다. 클라이언트엔 데이터 fetching JS 가 일절 안 들어가니까 번들이 가벼워진다.

useState·useEffect 없는 데이터 fetching — Server Component 의 가장 큰 변화. 그 둘은 클라이언트 컴포넌트(상호작용 처리)에서만 쓰인다. 옛 패턴(useEffect 안 fetch + setState) 은 더 이상 표준이 아니다.

2. DB 직접 호출 — fetch 가 아니어도 OK

서버 컴포넌트는 어차피 서버에서 도니까 fetch HTTP 만 쓸 이유가 없다. DB 클라이언트를 직접 import 해도 된다.

// app/blog/page.tsx import { db } from '@/lib/db'; // Drizzle·Prisma·node-postgres 등 export default async function BlogIndex() { const posts = await db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' }, limit: 20, }); return …; }

네트워크 한 단계가 빠지므로 더 빠르다. 외부 마이크로서비스가 없는 한 DB 직호출이 권장. 빌드된 JS 가 클라이언트로 안 가므로 DB 자격증명 노출 위험도 없다.

3. 병렬 vs 순차 — Promise.all 활용

두 데이터를 같이 가져올 때 — 그냥 await 두 번 하면 순차다. 둘이 독립적이면 시간 낭비.

// ❌ 순차 (400ms + 600ms = 1초) const user = await fetch('/api/user'); const posts = await fetch('/api/posts'); // ✅ 병렬 (max(400, 600) = 600ms) const [user, posts] = await Promise.all([ fetch('/api/user').then(r => r.json()), fetch('/api/posts').then(r => r.json()), ]);

독립적인 두 fetch 는 무조건 Promise.all. 페이지 첫 로딩 시간을 결정하는 가장 흔한 차이.

예외 — 두 번째 fetch 가 첫 번째 결과에 의존하면 (waterfall) 어쩔 수 없이 순차.

// 의존적이라 순차가 맞음 const user = await fetch(`/api/user/${userId}`).then(r => r.json()); const posts = await fetch(`/api/posts?author=${user.username}`).then(r => r.json());

4. 캐시 옵션 — 3가지 모드

Next.js 의 fetch 는 일반 브라우저 fetch 가 아니다. Next 가 감싼 버전으로, 빌드 캐시·요청 캐시를 추가로 처리한다.

옵션의미실전 사용
cache: 'force-cache'빌드 타임 캐시 (SSG 동작)변하지 않는 콘텐츠 (블로그·문서)
cache: 'no-store'매 요청 새로 (SSR)실시간 데이터 (대시보드·재고)
next: { revalidate: 60 }60초마다 갱신 (ISR)자주 안 바뀌는 동적 (인기 글)

Next 14 까지는 force-cache 가 기본이었는데, Next 15 부터 no-store 가 기본으로 바뀌었다. 옛 코드 마이그레이션할 때 가장 자주 부딪치는 변화. 빌드 시 캐시 받고 싶으면 명시적으로 cache: 'force-cache'.

// Next 15 — 명시적 캐시 const posts = await fetch('https://api.example.com/posts', { cache: 'force-cache', // 또는 next: { revalidate: 3600 } // 한 시간마다 갱신 }).then(r => r.json());

5. revalidatePath·revalidateTag — 캐시 무효화

캐시는 좋지만 — 글을 새로 썼는데 옛 캐시가 남아있으면? 손으로 무효화한다.

// app/admin/post-form.tsx (또는 Server Action) import { revalidatePath, revalidateTag } from 'next/cache'; export async function publishPost(formData) { await db.posts.create({ ... }); revalidatePath('/blog'); // /blog 경로 캐시 폐기 revalidateTag('posts'); // 'posts' 태그 모든 캐시 폐기 }

revalidateTagfetch 호출 시 next: { tags: ['posts'] } 로 태그 붙여둔 모든 캐시를 한 번에 폐기. 글 발행할 때 인덱스·태그 페이지·연관 글이 다 같이 갱신되게 하는 표준 패턴.

흔한 함정 — 클라이언트 컴포넌트에서 async 함수 export 하면 에러. 클라이언트 컴포넌트의 데이터 fetching 은 여전히 useEffect 또는 TanStack Query 같은 라이브러리 필요. 서버 컴포넌트만 async OK.

요약 — 8편 좌표

여기까지 정리. 서버 컴포넌트는 async function + await 만으로 데이터 가져오기 — useState·useEffect 불필요. fetch 가 아니어도 DB 직호출 가능. 독립적인 fetch 는 Promise.all 로 병렬. 캐시 모드 3가지 — force-cache(SSG) · no-store(SSR, Next 15 기본) · revalidate: N(ISR). 무효화는 revalidatePath·revalidateTag. 다음 편에선 데이터 쓰기 방향 — Server Actions.

다음 편 예고 — Server Actions

서버 함수를 폼·버튼에서 직접 호출. 9편.

© 2026 주나이테크(주) @JUNAITECH