Next.js App Router loading.tsx와 Suspense 차이
Next.js App Router에서 느린 route에 로딩 화면을 붙이는 방법은 크게 두 가지다.
route 폴더에 loading.tsx를 만들거나 페이지 안에서 <Suspense>를 직접 배치할 수 있다.
두 기능은 서로 다른 로딩 시스템이 아니다.
loading.tsx는 Next.js가 route segment에 자동으로 만드는 Suspense 경계이고, 직접 작성한 Suspense는 그 안을 더 작은 단위로 나누는 경계다.
이 글은 Next.js 16 App Router를 기준으로 한다.
loading.tsx의 자동 경계
route 폴더에 loading.tsx를 두면 해당 segment의 page와 아래 children이 준비되는 동안 fallback을 보여준다.
예를 들어 app/dashboard/ 안에 layout.tsx, loading.tsx, page.tsx를 나란히 둔다.
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}Next.js는 loading 파일을 같은 폴더의 layout 안쪽에 배치하고 page를 Suspense 경계로 자동 감싼다.
그래서 공유 layout은 유지된 채 dashboard page의 fallback과 실제 내용이 교체된다.
fallback은 미리 가져올 수 있어 client-side navigation을 빠르게 시작하는 데 도움이 된다.
사용자는 route의 렌더링이 끝날 때까지 빈 화면을 보지 않고 다른 링크로 이동할 수도 있다.
loading component는 기본적으로 Server Component이며 props를 받지 않는다.
같은 segment의 layout 자체에서 기다리는 비동기 작업은 이 loading 경계에 포함되지 않는다는 점이 중요하다.
Suspense의 세밀한 경계
직접 배치한 Suspense는 페이지의 일부만 기다리게 한다.
빠른 제목과 안내문은 먼저 보내고 느린 목록과 통계는 각자 준비되는 순서대로 streaming할 수 있다.
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<main>
<h1>대시보드</h1>
<p>오늘의 상태를 확인합니다.</p>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</main>
)
}Stats가 끝날 때까지 RecentActivity가 반드시 기다릴 필요는 없다.
독립적인 데이터를 별도 경계에 두면 준비된 영역부터 사용자에게 보여줄 수 있다.
route 전체 구조를 모르는 재사용 component에도 Suspense를 가까이 둘 수 있다.
어떤 데이터가 느린지 알고 있는 위치에서 구체적인 skeleton을 제공하기 좋다.
경계에 걸리는 비동기 작업
Suspense를 JSX에 추가했다고 모든 이전 await가 그 경계에 걸리는 것은 아니다.
page가 return하기 전에 데이터를 먼저 기다리면 내부 Suspense가 렌더링될 기회가 없다.
// 내부 Suspense가 getPosts의 대기를 나누지 못하는 구조
import { Suspense } from 'react'
export default async function PostsPage() {
const posts = await getPosts()
return (
<Suspense fallback={<PostsSkeleton />}>
<PostList posts={posts} />
</Suspense>
)
}느린 작업을 경계 아래의 async Server Component로 옮기면 fallback을 먼저 보낼 수 있다.
import { Suspense } from 'react'
export default function PostsPage() {
return (
<Suspense fallback={<PostsSkeleton />}>
<PostList />
</Suspense>
)
}
async function PostList() {
const posts = await getPosts()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}layout에서 cookies(), headers(), uncached 요청처럼 runtime data를 기다리는 경우도 같은 기준으로 봐야 한다.
같은 segment의 loading.tsx는 layout을 감싸지 않으므로 느린 작업을 page로 옮기거나 layout 안에 별도 Suspense 경계를 둔다.
중첩 구성과 이동 경험
실무에서는 loading.tsx와 Suspense를 함께 사용하는 구성이 자연스럽다.
loading.tsx는 route로 들어오는 순간의 전체 골격을 담당하고, page 안의 Suspense는 느린 영역을 독립적으로 교체한다.
Link의 prefetch가 완료됐다면 동적 route의 layout과 loading fallback을 미리 받을 수 있다.
느린 네트워크에서 prefetch가 끝나기 전에 사용자가 클릭하면 fallback도 서버 응답을 기다린 뒤 보일 수 있다.
Link와 프로그램 이동의 차이는 Next.js App Router Link와 router.push 차이에서 정리했다.
Next.js 16에서 정적 shell과 동적 영역을 나누는 캐시 기준은 Next.js 16 Cache Components와 use cache 정리를 함께 보면 좋다.
loading UI는 실제 화면 크기와 비슷한 skeleton으로 만드는 편이 좋다.
너무 빠른 요청마다 큰 spinner를 깜빡이게 하기보다 사용자가 기다리는 영역과 남아 있는 상호작용을 구분해서 보여준다.
스트리밍의 상태 코드와 배포 조건
Suspense fallback이 렌더링되면 서버는 응답 본문 streaming을 시작할 수 있다.
응답 header가 이미 전송된 뒤에는 HTTP status code를 바꿀 수 없다.
그래서 streaming 도중 notFound()가 실행되면 HTML 안에서 noindex를 전달할 수는 있어도 이미 시작한 응답은 200일 수 있다.
분석이나 계약상 실제 404 응답이 필요하다면 fallback이 렌더링되기 전에 리소스 존재 여부를 빠르게 확인해야 한다.
Next.js 공식 문서 기준으로 Node.js server와 Docker는 streaming을 지원하고 static export는 지원하지 않는다.
일부 브라우저는 작은 streaming 응답을 1024바이트까지 buffering하므로 아주 작은 예제에서는 차이가 보이지 않을 수 있다.
fetch 캐시 기본값과 Cache Components 규칙은 Next.js 버전에 따라 달라졌다.
loading.tsx와 Suspense의 범위 설명에 과거 버전의 캐시 전제를 섞지 않고 설치된 Next.js 문서를 기준으로 확인해야 한다.
참고 자료







