App Router 데이터 패칭과 스트리밍 실전: 캐시, 워터폴, Suspense 단위 설계
async 서버 컴포넌트에서 await로 데이터를 읽는 법은 지난 글에서 다뤘습니다. 문법만 보면 끝난 것 같지만, 막상 실제 화면을 만들면 다른 고민이 시작됩니다. 이 데이터는 캐시해도 되는 건가, 요청 세 개가 줄줄이 기다리고 있는데 괜찮은 건가, 화면을 어디서 끊어 먼저 보여 줘야 하나 같은 질문입니다.
이 글은 그 실전 영역을 정리합니다. App Router에서 캐시를 언제 켜고 끄는지, 요청 워터폴을 어떻게 없애는지, 스트리밍 단위를 어디서 잡는지를 차례로 보겠습니다.
한눈에 보면
- Next.js 15부터
fetch는 기본적으로 캐시하지 않습니다. 캐시가 필요하면 명시적으로 켜야 합니다. - 캐시 옵션은 셋으로 정리됩니다. 매번 새로(
no-store, 기본값), 주기적 재검증(next: { revalidate }), 강제 캐시(force-cache). - 서로 독립적인 요청을 순차
await로 세우면 응답 시간이 그대로 더해지는 워터폴이 됩니다.Promise.all이나 컴포넌트 분리로 병렬화합니다. - 같은 렌더 안의 동일한
fetch는 자동으로 한 번만 나갑니다.fetch가 아닌 데이터 접근은cache()로 중복을 막습니다. loading.tsx는 해당 라우트를 자동으로 Suspense로 감싸 스트리밍을 켭니다. 더 잘게 끊고 싶으면<Suspense>를 직접 둡니다.- 빠른 데이터까지 Suspense로 감싸면 오히려 셸이 늦어집니다. 스트리밍 단위는 "느린 부분"을 기준으로 잡습니다.
Next.js 15에서 fetch 캐시는 기본이 꺼져 있다
가장 먼저 짚어야 할 변화입니다. Next.js 14까지는 서버에서의 fetch가 기본으로 캐시됐습니다. 그래서 "분명 데이터를 바꿨는데 화면이 안 바뀐다"는 혼란이 잦았습니다. Next.js 15부터는 이 기본값이 뒤집혀, fetch는 별도 설정이 없으면 매 요청마다 새로 가져옵니다.
덕분에 동작은 예측하기 쉬워졌지만, 캐시가 필요한 곳에서는 이제 직접 켜 줘야 합니다. 옵션은 의도별로 나뉩니다.
// 매 요청마다 새로 가져옴 (Next.js 15 기본값)
await fetch('https://api.example.com/posts');
// 일정 시간마다 재검증하며 캐시 (예: 60초)
await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
});
// 변하지 않는 데이터는 강제로 캐시
await fetch('https://api.example.com/config', {
cache: 'force-cache',
});
// 태그를 달아 두고 나중에 revalidateTag로 무효화
await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});기준은 단순합니다. 자주 바뀌는 데이터는 기본값 그대로 두고, 거의 안 바뀌는 데이터만 골라 캐시를 켜는 쪽이 사고가 적습니다. 뮤테이션 이후 갱신이 필요하면 태그를 달아 두고 Server Action에서 revalidateTag로 무효화하는 흐름과 자연스럽게 이어집니다.
요청 워터폴을 피하는 법
await가 편하다 보니 무심코 줄을 세우게 됩니다.
async function Dashboard({ userId }: { userId: string }) {
const user = await getUser(userId);
const posts = await getPosts(userId); // user를 안 기다려도 되는데 기다림
const stats = await getStats(userId);
// ...
}세 요청 사이에 의존 관계가 없는데도, 코드 순서 때문에 하나씩 끝나야 다음이 출발합니다. 응답 시간이 그대로 합산되는 워터폴입니다. 서로 독립적인 요청은 동시에 출발시키는 게 맞습니다.
async function Dashboard({ userId }: { userId: string }) {
const [user, posts, stats] = await Promise.all([
getUser(userId),
getPosts(userId),
getStats(userId),
]);
// ...
}뒤 요청이 앞 결과에 진짜로 의존할 때만 순차로 두면 됩니다. 한 컴포넌트 안에서 묶기 애매하다면, 데이터를 쓰는 단위로 컴포넌트를 나눠 각자 await하게 하는 방법도 있습니다. 이렇게 나누면 다음 절의 스트리밍과도 잘 맞물립니다.
요청 중복 제거: 자동 메모이제이션과 cache()
같은 렌더 과정에서 똑같은 fetch(같은 URL·옵션)를 여러 컴포넌트가 호출하면, React가 이를 알아서 한 번만 내보냅니다. 헤더와 본문에서 같은 사용자 정보를 각각 불러도 네트워크 요청은 한 번이라는 뜻입니다. props로 데이터를 길게 내려보내지 않아도 되는 이유가 여기 있습니다.
다만 이 자동 중복 제거는 fetch에만 적용됩니다. ORM이나 DB 클라이언트처럼 fetch가 아닌 접근은 react의 cache()로 감싸 같은 효과를 냅니다.
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});이렇게 감싸 두면 한 요청 안에서 getUser('1')을 몇 번 부르든 실제 쿼리는 한 번만 나갑니다.
스트리밍의 단위: loading.tsx와 Suspense
데이터를 기다리는 동안 화면 전체가 멈추지 않게 하는 게 스트리밍입니다. App Router는 두 가지 방식을 제공합니다.
가장 간단한 건 loading.tsx입니다. 라우트 폴더에 이 파일을 두면, 해당 세그먼트의 page.tsx가 자동으로 Suspense 경계로 감싸집니다. 데이터를 기다리는 동안 loading.tsx가 먼저 그려지고, 준비되면 실제 페이지로 교체됩니다.
// app/posts/loading.tsx
export default function Loading() {
return <PostListSkeleton />;
}페이지 단위보다 더 잘게 끊고 싶을 때는 <Suspense>를 직접 둡니다. 빠른 영역은 바로 보여 주고, 느린 영역만 fallback으로 자리를 잡아 두는 식입니다.
import { Suspense } from 'react';
export default function Page() {
return (
<section>
{/* 헤더는 즉시 표시 */}
<ProfileHeader />
<Suspense fallback={<FeedSkeleton />}>
<Feed /> {/* 안에서 await — 느린 영역 */}
</Suspense>
<Suspense fallback={<RecommendSkeleton />}>
<Recommendations /> {/* 또 다른 느린 영역, 독립적으로 스트리밍 */}
</Suspense>
</section>
);
}Feed와 Recommendations는 서로를 기다리지 않고 각자 준비되는 대로 화면에 채워집니다. 컴포넌트를 데이터 단위로 나눠 두면, 병렬 요청과 독립 스트리밍이 자연스럽게 따라옵니다.
sequenceDiagram
participant B as 브라우저
participant S as 서버(Next.js)
B->>S: 페이지 요청
S-->>B: 셸 + ProfileHeader 즉시 전송
S-->>B: Feed/Recommendations 자리에 스켈레톤
Note over S: 두 영역 데이터 병렬 진행
S-->>B: 준비된 영역부터 조각으로 스트리밍
B->>B: 스켈레톤을 실제 UI로 교체어디까지 스트리밍할까
스트리밍이 좋다고 모든 걸 Suspense로 감싸면 역효과가 납니다. 빠르게 끝나는 데이터까지 경계를 두면, 그 영역을 스켈레톤으로 한 번 보여 줬다가 바로 교체하면서 깜빡임만 늘어납니다. 셸이 나가는 시점도 그만큼 늦어집니다.
실무에서 기준으로 삼을 만한 점들을 추려 봤습니다.
- 느린 데이터만 경계로 감쌉니다. 외부 API, 무거운 집계 쿼리처럼 시간이 걸리는 부분이 후보입니다.
- 레이아웃이 흔들리지 않게 fallback의 크기를 실제 콘텐츠와 맞춥니다. 스켈레톤 높이가 실제와 다르면 콘텐츠가 들어올 때 화면이 밀립니다.
- 첫 화면에 꼭 보여야 하는(특히 SEO에 중요한) 콘텐츠는 스트리밍 뒤로 미루지 않습니다. 스트리밍은 보조적인 영역에 더 잘 맞습니다.
- 캐시와 스트리밍을 같이 봅니다. 캐시된 데이터는 애초에 빠르므로 굳이 감쌀 이유가 줄어듭니다.
정리하면
App Router의 데이터 패칭은 await 문법보다 "캐시·병렬·스트리밍 단위를 어떻게 잡느냐"가 실전의 핵심입니다.
- Next.js 15에서
fetch는 기본 미캐시다. 필요한 곳만revalidate나force-cache로 켠다. - 독립적인 요청은
Promise.all이나 컴포넌트 분리로 병렬화해 워터폴을 없앤다. - 같은 요청은 자동 메모이제이션과
cache()로 한 번만 나가게 한다. - 스트리밍은
loading.tsx로 시작하고, 느린 영역만 골라<Suspense>로 세분화한다.
읽기를 단순하게 만든 다음에 남는 일은 결국, 무엇을 캐시하고 무엇을 먼저 보여 줄지를 의도적으로 정하는 작업이라는 점이 가장 크게 남습니다.
캐시 기본값과 옵션은 버전에 민감합니다. 이 글은 Next.js 15(App Router) 기준이며, 더 최신 버전에서 도입되는 캐시 방식(예:
'use cache'계열)은 도입 전에 해당 버전 문서로 확인하기를 권합니다.
