스트리밍 – Suspense와 loading.tsx
공식문서 기반의 Next.js 입문기
들어가며
3-3편에서 데이터마다 force-cache, revalidate, no-store를 조합해 정적/ISR/동적 경로를 어우르는 모습을 봤습니다. 이 정도만 해도 서버 효율성과 최신성을 점진적으로 제어할 수 있지만, 여전히 “가장 느린 데이터 하나가 화면 전체를 지연시키는” 장면이 반복됩니다. 요약 정도만 먼저 보여주고, 목록은 나중에 채워주는 스크롤 경험을 만들고 싶다면, 스트리밍 경계가 다음 단계입니다.
이번에는 “인보이스 목록” 시나리오를 통해 요약·필터는 먼저, 표 데이터는 뒤에 도착하는 구조를 보여드립니다. 리액트(CSR)에서 로딩 상태를 직접 관리하는 한계부터 모아보고, Next.js에서는 loading.tsx와 Suspense 경계로 어떻게 점진적 렌더링을 만드는지를 짚어보겠습니다.
스트리밍 경계와 loading.tsx
왜 스트리밍 경계가 필요할까?
서버에서 여러 데이터를 준비할 때 가장 오래 걸리는 API 하나 때문에 전체 HTML 전송이 늦어지는 경우가 많습니다. 클라이언트는 모든 청크(HTML 조각)를 기다렸다가 조립하므로, "요약은 보이는데 목록은 껌벅이는" 사용자 경험을 피하기 어렵습니다. Next.js 스트리밍은 HTML을 청크 단위로 전달하며, 준비된 것부터 먼저 뿌려주는 방식입니다. 중요한 건 “경계”를 어디에 두느냐입니다.
Suspense와 loading.tsx의 역할
Suspense는 클라이언트 컴포넌트에서 비동기 작업을 감싸는 경계입니다. fallback을 지정하면 해당 작업이 끝날 때까지 어떤 UI를 보여줄지 선언적으로 정할 수 있고, React Query나 use를 호출하는 컴포넌트를 감쌀 때 유용합니다. Next.js에서는 서버 컴포넌트에서 async 함수를 호출하거나 동적 import로 컴포넌트를 로딩할 때도 Suspense를 활용할 수 있습니다.
// components/InvoiceTable.jsx (CSR 예시)
import { Suspense } from "react";
import { InvoiceTableInner } from "./InvoiceTableInner";
export function InvoiceTable() {
return (
<Suspense fallback={<div>목록을 불러오는 중...</div>}>
<InvoiceTableInner />
</Suspense>
);
}loading.tsx는 App Router에서 라우트 또는 세그먼트 수준 로딩 상태를 정의하는 파일입니다. 서버가 그 세그먼트를 렌더링하는 동안 해당 UI가 HTML 스트리밍 청크로 먼저 전송됩니다.
app/
├── invoices/
│ ├── loading.tsx // invoices 세그먼트 전체
│ │ └── default export 로딩 UI
│ └── page.tsx
│ ├── components/
│ │ ├── Summary.tsx // 서버 컴포넌트
│ │ └── Table.tsx // 클라이언트 Suspense 진입
└── api/
└── invoices/
└── route.tsSuspense로 컴포넌트 단위 경계를 잡고, loading.tsx로 더 넓은 세그먼트를 감싸면 서버/클라이언트가 각자 준비된 영역을 순차적으로 전달하게 됩니다。
스트리밍 경계는 데이터 준비 시간 차이를 반영합니다. 빠르게 준비되는 요약·헤더·필터는 서버 컴포넌트로 즉시 렌더링하고, 늦게 오는 표는 Suspense를 통해 추가 경계를 둡니다. loading.tsx가 있는 세그먼트는 서버가 HTML을 청크로 쪼개 전송하므로 “요약 먼저 → 목록 순차적” 체감이 가능합니다.
기능 구현 및 비교
리액트(CSR)에서의 구조
useSuspenseQuery(React Query의 Suspense 전용 훅)를 활용해 데이터를 읽으면 Suspense 경계 안에서 로딩 상태를 간결하게 처리할 수 있습니다. 하지만 모든 데이터 패칭과 로딩 상태 관리가 브라우저에서 벌어지고, 요약과 목록이 준비되는 시점이 뒤섞이기 때문에 초기 화면이 지연되거나 인터랙션이 제한될 수 있습니다.
src/
├── components/
│ ├── InvoiceSummary.jsx
│ ├── InvoiceFilters.jsx
│ └── InvoiceTable.jsx
├── queries/invoices.js
└── App.jsx// queries/invoices.js
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
export function useInvoiceSummary() {
return useQuery({
queryKey: ["invoice-summary"],
queryFn: () => fetch("/api/invoices/summary").then((res) => res.json()),
staleTime: 5 * 60 * 1000,
});
}
export function useInvoices(filters) {
return useSuspenseQuery({
queryKey: ["invoices", filters],
queryFn: () => fetch(`/api/invoices?${new URLSearchParams(filters)}`).then((res) => res.json()),
});
}// App.jsx
import { Suspense } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { InvoiceSummary } from "./components/InvoiceSummary";
import { InvoiceFilters } from "./components/InvoiceFilters";
import { InvoiceTable } from "./components/InvoiceTable";
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<InvoiceSummary />
<InvoiceFilters />
<Suspense fallback={<div>인보이스 목록을 불러오는 중...</div>}>
<InvoiceTable />
</Suspense>
</QueryClientProvider>
);
}React Query를 쓰면 캐시/재요청/에러 처리를 어느 정도 제공하지만, 서버에서 HTML을 준비하는 데 비해 초기 FCP가 느릴 수밖에 없습니다. 때문에 동일한 인보이스 화면에서는 초기 요약이 늦게 나타나고, 목록 로딩까지 전체 화면이 멈춥니다.
Next.js로 스트리밍 경계 구성
Next.js에서는 서버 컴포넌트에서 요약 데이터를 먼저 준비하고, Suspense와 loading.tsx를 쓰면 목록은 Suspense 깊이에서 비동기 로딩됩니다. 클라이언트 사이드 컴포넌트는 use client를 달고 fetch를 호출하며, 브라우저는 서버가 보낸 HTML 청크를 받는 동안에도 이미 렌더링된 요약을 보여줍니다.
// app/invoices/page.tsx
import { Suspense } from "react";
import { InvoiceSummary } from "./components/InvoiceSummary";
import { InvoiceFilters } from "./components/InvoiceFilters";
import { InvoiceTable } from "./components/InvoiceTable";
async function getSummary() {
const res = await fetch("http://localhost:3000/api/invoices/summary", {
revalidate: 300,
});
if (!res.ok) throw new Error("요약 정보를 불러오지 못했습니다.");
return res.json();
}
export default async function InvoicesPage() {
const summary = await getSummary();
return (
<div>
<InvoiceSummary summary={summary} />
<InvoiceFilters />
<Suspense fallback={<div>인보이스 목록을 불러오는 중...</div>}>
<InvoiceTable />
</Suspense>
</div>
);
}InvoiceTable은 클라이언트 컴포넌트로 useEffect에서 /api/invoices를 호출하고 준비되는 대로 UI를 채웁니다. loading.tsx가 밖에서 이 세그먼트를 감싸므로 요약은 이미 보이는 상태에서 목록만 따로 로딩 피드백을 줄 수 있습니다.
리액트 vs Next.js 비교표
| 구분 | 리액트 (CSR 중심) | Next.js (서버/스트리밍) |
|---|---|---|
| 실행 환경 | 브라우저에서 모든 캐시/로딩 관리 | 서버 컴포넌트가 먼저 렌더링하고 필요한 부분만 스트리밍 |
| 데이터 접근 | React Query로 클라이언트 데이터를 읽음 | 서버 fetch + 클라이언트 컴포넌트의 useEffect 혼합 |
| 번들 | React Query/캐시 로직 포함 | 서버 데이터는 번들 제외 → 번들 가벼움 |
| 로딩 경계 | Suspense로만 처리 | loading.tsx 라우트 경계 + Suspense 컴포넌트 경계 |
| UX 제약 | 섹션 간 준비 시점이 뒤섞임 | 중요 섹션부터 점진적 스트리밍 가능 |
스트리밍 경계의 트레이드오프
장점
- 체감 성능 향상: HTML을 청크로 나누어 전송하여 사용자가 콘텐츠가 "채워지는" 듯한 빠른 체감을 경험하고, 특히 데이터 소스가 많은 대시보드에서 효과적입니다.
- SEO 유지: 서버에서 HTML을 점진적으로 전송하므로 검색 엔진이 전체 콘텐츠를 색인할 수 있고, 초기 HTML에 중요한 콘텐츠를 포함시킬 수 있습니다.
- 유연한 로딩 제어:
loading.tsx로 라우트 단위,Suspense로 컴포넌트 단위 로딩 상태를 세밀하게 제어할 수 있습니다.
단점
- 경계 설정 복잡성:
Suspense를 너무 세밀하게 나누면 네트워크 요청 증가와 브라우저 병합 오버헤드 발생, 너무 크게 나누면 스트리밍 이점 상실 - 캐시 전략 혼동: 스트리밍이 캐시 전략을 무시한다고 착각하기 쉬움 (실제로는
Suspense안에서도fetch캐시 옵션이 적용됨) - 불필요한 로딩 화면: 모든 라우트에
loading.tsx를 적용하면 빠른 페이지에서도 로딩 화면이 표시되어 UX 저하
균형 맞추기 팁
사용자가 "이 섹션이 로딩 중"이라고 체감할 수 있는 단위로 경계를 설정하세요. 대시보드처럼 여러 데이터 소스가 있는 페이지에서는 핵심 콘텐츠(요약 정보)를 먼저 보여주고, 부가 정보(상세 목록, 추천 상품)를 Suspense로 감싸는 전략을 사용하세요.
실무 적용 시 체크사항:
loading.tsx는 페이지 전체가 실제로 느릴 때만 추가. 빠른 페이지는 불필요한 로딩 화면을 피하기 위해 제외Suspense경계를 너무 잘게 나누면 네트워크/렌더링 오버헤드가 늘어나므로, 목록/차트 단위로 유지use client컴포넌트는 서버에서 받은 props 외에는 최대한 의존성을 줄이고,fetch캐시 옵션을 다시 확인- Lighthouse나 실제 네트워크 타이밍으로
loading.tsx추가 전후를 비교. 100ms 미만 개선이면 경계 재검토
실제 로딩 시간을 측정해서 경계를 결정하는 것이 중요합니다.
예상 질문
Q1. loading.tsx와 Suspense의 차이점은?
loading.tsx는 세그먼트 전체, Suspense는 컴포넌트 단위입니다. loading.tsx는 서버에서 작동하고, Suspense는 클라이언트에서 추가 경계를 만들 때 주로 씁니다.
Q2. 모든 페이지에 스트리밍 경계를 두는 게 좋은가요?
아니요. 경계를 지나치게 넣으면 오히려 로딩 상태만 잔뜩 보이므로, 실제로 느린 데이터가 있을 때만 loading.tsx 또는 Suspense를 적용하세요.
Q3. 스트리밍이 SEO에 불리하지 않나요?
초기 HTML에는 핵심 콘텐츠를 넣고, 부가 정보만 스트리밍하면 큰 문제가 없습니다. 중요한 콘텐츠는 서버 컴포넌트에서 먼저 렌더링하면 검색 엔진 색인에 포함됩니다.
요약
3-3편에서는 캐시 옵션으로 정적/동적 전략을 혼합하는 판단 기준을 다뤘다면, 3-4편에서는 "시간축을 나누는 경계"로 사용자의 체감 속도를 관리합니다. loading.tsx는 라우트 단위 경계, Suspense는 컴포넌트 단위 경계입니다. 인보이스 시나리오처럼 요약은 서버에서 준비하고, 목록은 Suspense 안에서 나중에 채워 넣으면 전체 화면이 멈추지 않는 체감이 가능합니다.
참조
- Next.js 공식문서