App Router 데이터 패칭과 스트리밍 실전: 캐시, 워터폴, Suspense 단위 설계

Frontend

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가 아닌 접근은 reactcache()로 감싸 같은 효과를 냅니다.

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>
  );
}

FeedRecommendations는 서로를 기다리지 않고 각자 준비되는 대로 화면에 채워집니다. 컴포넌트를 데이터 단위로 나눠 두면, 병렬 요청과 독립 스트리밍이 자연스럽게 따라옵니다.

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는 기본 미캐시다. 필요한 곳만 revalidateforce-cache로 켠다.
  • 독립적인 요청은 Promise.all이나 컴포넌트 분리로 병렬화해 워터폴을 없앤다.
  • 같은 요청은 자동 메모이제이션과 cache()로 한 번만 나가게 한다.
  • 스트리밍은 loading.tsx로 시작하고, 느린 영역만 골라 <Suspense>로 세분화한다.

읽기를 단순하게 만든 다음에 남는 일은 결국, 무엇을 캐시하고 무엇을 먼저 보여 줄지를 의도적으로 정하는 작업이라는 점이 가장 크게 남습니다.

캐시 기본값과 옵션은 버전에 민감합니다. 이 글은 Next.js 15(App Router) 기준이며, 더 최신 버전에서 도입되는 캐시 방식(예: 'use cache' 계열)은 도입 전에 해당 버전 문서로 확인하기를 권합니다.

같이 보면 좋은 글