Next.js 로딩 상태 관리: loading.tsx와 Suspense 실전 가이드

이런 상황을 겪어 본 적이 있을 겁니다. 사용자가 링크를 눌렀는데 페이지가 3초 동안 하얗게 비어 있고 아무런 반응도 없습니다. 사용자는 ‘멈춘 건가?’ 하고 불안해져 F5를 연달아 누릅니다. 그러면 막 로드된 페이지가 다시 새로고침되어 사라집니다.
예전에는 저도 로딩을 이렇게 처리했습니다. 새 페이지를 만들 때마다 컴포넌트에 다음 코드를 넣었습니다.
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
setLoading(true);
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
코드는 길고 번거로운 데다 페이지마다 똑같이 작성해야 했습니다. 더 큰 문제는 팀원마다 로딩 로직이 달랐다는 점입니다. 누군가는 전역 상태를, 누군가는 Context를 사용해서 유지보수가 악몽처럼 느껴졌습니다.
그러던 어느 날 Next.js 공식 문서를 읽다가 알게 됐습니다. Next.js에는 이미 더 우아한 로딩 관리 방식, 즉 loading.tsx와 Suspense가 내장되어 있었습니다.
직접 써 보니 로딩 상태 관리가 이렇게 간단할 수 있다는 사실이 놀라웠습니다. 코드량이 절반으로 줄었을 뿐 아니라 사용자 경험도 한 단계 좋아졌습니다. 이 글에서는 이 방식을 프로젝트에서 활용하며 얻은 경험을 소개하겠습니다.
loading.tsx와 Suspense를 사용해야 하는 이유
기존 방식의 문제점
먼저 실제 예시를 보겠습니다. 블로그 목록 페이지를 만든다고 가정하면 기존 방식은 대략 다음과 같습니다.
// app/blog/page.tsx
'use client';
import { useState, useEffect } from 'react';
export default function BlogPage() {
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
겉보기에는 괜찮아 보이지만 다음과 같은 문제가 있습니다.
- 코드 중복: 페이지마다 상태 관리 코드를 한 덩어리씩 작성해야 합니다.
- 분산된 상태: loading, data, error가 세 개의 state로 나뉘어 있어 상태가 어긋나는 버그가 생기기 쉽습니다.
- Client Component 강제: useState와 useEffect를 사용하므로 컴포넌트 전체가 클라이언트에서만 실행되어 서버 렌더링의 장점을 잃습니다.
- 나쁜 사용자 경험: 클릭한 뒤 loading이 표시되기 전까지 눈에 띄는 흰 화면과 멈춤이 발생합니다.
Code Review를 해 본 적이 있다면 개발자마다 로딩을 제각각 처리한다는 것도 알 겁니다. 누군가는 loading 상태를 Context로 끌어올리고, 누군가는 Zustand로 전역 관리하며, 누군가는 컴포넌트마다 따로 작성합니다. 프로젝트가 커지면 유지보수가 매우 어려워집니다.
Next.js의 해결책
Next.js App Router는 이런 문제를 해결할 세 가지 핵심 기능을 제공합니다.
1. loading.tsx - 설정보다 규칙
라우트 폴더에 loading.tsx 파일 하나만 만들면 Next.js가 해당 라우트의 로딩 상태 UI로 자동 사용합니다. useState를 직접 작성하거나 상태를 관리할 필요가 없고, Suspense도 직접 감쌀 필요가 없습니다.
2. Suspense - React 18의 기본 지원
React 18의 Suspense를 사용하면 컴포넌트 수준에서 로딩 상태를 세밀하게 제어할 수 있습니다. 데이터가 느린 부분만 Suspense 경계로 감싸면 나머지 부분은 먼저 표시되므로 전체 페이지를 기다릴 필요가 없습니다.
3. Streaming - 불러오면서 표시하기
Next.js의 스트리밍 렌더링을 함께 사용하면 페이지를 부분별로 표시할 수 있습니다. 헤더가 먼저 나타나고, 그다음 사이드바가, 마지막에 느린 데이터 영역이 표시됩니다. 사용자가 빈 화면을 바라보며 기다릴 필요가 없어 경험이 크게 좋아집니다.
참고할 만한 지표도 있습니다. 스켈레톤 UI와 Streaming을 사용하면 FCP(First Contentful Paint)와 LCP(Largest Contentful Paint) 시간을 눈에 띄게 줄이고 Google PageSpeed Insights 점수를 몇 점 높일 수 있습니다.
loading.tsx 기본 사용법
빠른 시작: 첫 loading.tsx 만들기
이제 가장 간단한 loading.tsx부터 바로 만들어 보겠습니다.
다음과 같은 디렉터리 구조가 있다고 가정합니다.
app/
blog/
page.tsx
blog 폴더에 loading.tsx를 하나 추가하면 됩니다.
app/
blog/
loading.tsx ← 새로 추가
page.tsx
그리고 loading.tsx에 간단한 로딩 UI를 작성합니다.
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="ml-4">로딩 중...</p>
</div>
);
}
이게 전부입니다. 10줄이면 끝납니다. 이제 사용자가 /blog에 접속하면 page.tsx가 로드되기 전까지 Next.js가 이 loading 컴포넌트를 자동으로 표시합니다.
중요한 점은 Suspense를 직접 감쌀 필요가 전혀 없다는 것입니다. Next.js가 자동으로 처리합니다. 실제 렌더링에서는 page.tsx를 <Suspense fallback={<Loading />}>로 감쌉니다.
처음 봤을 때는 저도 ‘이렇게 간단해도 정말 되는 건가?’ 싶었습니다. 직접 시험해 보니 정말 됐고, 페이지마다 useState 코드를 한가득 넣지 않아도 되어 훨씬 깔끔했습니다.
loading.tsx의 적용 범위
loading.tsx에는 **라우트 세그먼트(Route Segment)**라는 중요한 개념이 있습니다. 간단히 말하면 같은 폴더의 page.tsx와 모든 하위 라우트에 적용됩니다.
예를 들어 다음과 같습니다.
app/
blog/
loading.tsx ← /blog와 /blog/[id]에 적용
page.tsx ← /blog 목록 페이지
[id]/
page.tsx ← /blog/123 상세 페이지
이 loading.tsx는 다음 상황에서 표시됩니다.
- 사용자가
/blog에 접속할 때(목록 페이지 로딩 중) - 사용자가 목록에서
/blog/123으로 이동할 때(상세 페이지 로딩 중)
하지만 layout에는 영향을 주지 않습니다. blog/layout.tsx에 내비게이션 바가 있다면 그 바는 계속 표시되고 page.tsx 영역만 loading으로 교체됩니다.
Next.js 공식 문서에서 말하는 ‘공유 레이아웃이 상호작용 가능한 상태로 유지된다’는 것이 바로 이 뜻입니다. 새 페이지를 기다리는 동안에도 사용자는 내비게이션 바를 눌러 다른 페이지로 이동할 수 있고, 화면 전체가 멈추지 않습니다.
다음처럼 시각화하면 더 이해하기 쉽습니다.
Layout(계속 표시)
├─ 내비게이션 바
└─ Suspense Boundary
├─ Loading UI(데이터 로딩 중 표시)
└─ Page(데이터 로딩 완료 후 표시)
Server Component와 Client Component
loading.tsx는 기본적으로 Server Component입니다. 대부분의 경우에는 JSX를 바로 반환하는 것만으로 충분합니다.
그러나 Framer Motion으로 페이드 인·아웃을 넣거나 클라이언트 JavaScript가 필요한 라이브러리를 사용하는 등 애니메이션 효과를 추가하고 싶다면 'use client'를 넣어야 합니다.
// app/blog/loading.tsx
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center min-h-screen"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</motion.div>
);
}
제 원칙은 가능하면 Server Component를 사용하고, 실제로 클라이언트 상호작용이 필요할 때만 'use client'를 추가하는 것입니다. Server Component는 클라이언트 bundle에 포함되지 않아 페이지가 더 빠르게 로드되기 때문입니다.
스켈레톤 UI 실전
스켈레톤 UI가 spinner보다 나은 이유
빙글빙글 도는 loading spinner를 본 적이 있을 겁니다. 사용자 경험 관점에서는 스켈레톤 UI(Skeleton Screen)가 spinner보다 훨씬 낫습니다.
그 이유는 무엇일까요? 사용자 심리에 관한 한 연구에서는 사용자가 스켈레톤 UI를 보면 머릿속으로 ‘콘텐츠가 곧 나타나겠다’고 예상해 체감 대기 시간이 짧아진다고 합니다. spinner를 볼 때는 ‘로드 중’이라는 사실만 알 뿐 무엇을 불러오는지, 얼마나 기다려야 하는지 몰라 불안감이 더 커집니다.
스켈레톤 UI에는 또 다른 장점도 있습니다. 페이지가 어떤 레이아웃으로 나타날지 미리 알려 줍니다. 예를 들어 가로 막대 세 개로 된 스켈레톤을 보면 이곳에 글 세 개가 표시될 것이라고 예상할 수 있습니다. 무엇이 나올지 알면 기다림이 덜 답답합니다.
세 가지 구현 방법
스켈레톤 UI를 구현하는 방법은 많습니다. 여기서는 가장 많이 쓰는 세 가지를 소개하니 프로젝트 상황에 맞게 선택하면 됩니다.
방법 1: 순수 CSS 구현(가장 가벼움)
프로젝트에 추가 의존성을 넣고 싶지 않다면 순수 CSS만으로 구현할 수 있습니다.
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8 animate-pulse">
{/* 제목 스켈레톤 */}
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
{/* 요약 스켈레톤 */}
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
{/* 메타 정보 스켈레톤 */}
<div className="flex gap-4 mt-4">
<div className="h-3 bg-gray-200 rounded w-20"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
))}
</div>
);
}
장점은 의존성이 없고 성능이 가장 좋다는 것입니다. 단점은 스타일을 직접 작성해야 해 조금 번거롭다는 점입니다.
방법 2: react-loading-skeleton 라이브러리(가장 빠름)
스타일을 많이 쓰지 않고 빠르게 구현하려면 react-loading-skeleton을 사용할 수 있습니다.
npm install react-loading-skeleton
// app/blog/loading.tsx
'use client';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton height={32} width="75%" className="mb-4" />
<Skeleton count={2} />
<div className="flex gap-4 mt-4">
<Skeleton width={80} />
<Skeleton width={100} />
</div>
</div>
))}
</div>
);
}
이 라이브러리는 사용하기 편하고 애니메이션 효과도 잘 만들어져 있습니다. 저도 소규모 프로젝트에서 자주 사용합니다.
방법 3: shadcn/ui(가장 전문적)
프로젝트에서 이미 shadcn/ui를 사용한다면 그 Skeleton 컴포넌트를 바로 쓰는 것이 가장 편합니다.
npx shadcn-ui@latest add skeleton
// app/blog/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton className="h-8 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-5/6 mb-4" />
<div className="flex gap-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
);
}
이 방식은 스타일이 디자인 시스템과 완전히 일치하므로 별도로 조정할 필요가 없다는 장점이 있습니다.
스켈레톤 UI 디자인 원칙
어떤 방법을 사용하든 몇 가지 원칙을 지켜야 합니다.
-
실제 레이아웃과 일치시키기: 스켈레톤 구조는 실제 콘텐츠 레이아웃과 같아야 합니다. 예를 들어 글 목록에 제목, 요약, 태그가 있다면 스켈레톤에도 각각 대응하는 세 영역이 있어야 합니다.
-
은은한 애니메이션: 애니메이션은 과하지 않게 가볍게 깜빡이는 정도가 좋습니다. 너무 화려하면 사용자의 주의를 분산하고 오히려 기다림을 더 길게 느끼게 합니다.
-
적절한 개수: 일반적으로 스켈레톤 항목은 3~5개면 충분하며 화면 전체를 채울 필요는 없습니다. 너무 많으면 오히려 복잡해 보입니다.
실제 사례: 블로그 목록 페이지 전체 구현
이제 앞에서 본 내용을 연결해 완전한 블로그 목록 페이지를 만들어 보겠습니다.
먼저 loading.tsx입니다.
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="h-12 bg-gray-200 rounded w-1/3 mb-8 animate-pulse"></div>
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<article key={i} className="border-b pb-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-11/12"></div>
<div className="h-4 bg-gray-200 rounded w-4/5"></div>
</div>
<div className="flex gap-3">
<div className="h-6 bg-gray-200 rounded-full w-16"></div>
<div className="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</article>
))}
</div>
</div>
);
}
다음은 실제 page.tsx입니다(Server Component 사용).
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store' // 매번 새로 가져오도록 보장
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">블로그 글</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.id} className="border-b pb-8">
<h2 className="text-2xl font-semibold mb-3">
<a href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</a>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex gap-3">
{post.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{tag}
</span>
))}
</div>
</article>
))}
</div>
</div>
);
}
보셨나요? page.tsx가 async 함수가 되어 컴포넌트 안에서 데이터를 직접 await합니다. useState나 useEffect가 필요 없어 코드가 훨씬 깔끔해졌습니다.
Server Component이므로 이 코드는 모두 서버에서 실행되고 클라이언트 bundle 크기를 늘리지 않습니다. 첫 화면도 더 빠르게 로드됩니다.
디버깅 팁: React DevTools로 테스트하기
개발 중에 loading 효과를 확인하고 싶어도 데이터가 너무 빨리 로드되어 화면이 순식간에 지나갈 때가 있습니다.
이럴 때는 React DevTools에서 Suspense 경계를 직접 전환할 수 있습니다.
- React DevTools 브라우저 확장 프로그램을 설치합니다.
- 개발자 도구를 열고 Components 탭으로 이동합니다.
<Suspense>컴포넌트를 찾습니다.- 마우스 오른쪽 버튼으로 클릭하고 ‘Suspend this Suspense boundary’를 선택합니다.
그러면 loading UI가 계속 표시되어 천천히 스타일을 조정할 수 있습니다. 작업을 마치면 suspend를 해제하면 됩니다.
솔직히 이 기능은 여러 번 시행착오를 겪은 뒤에야 발견했습니다. 진작 알았다면 시간을 많이 아꼈을 겁니다.
Suspense 고급 활용법
Suspense 경계 직접 설정하기
loading.tsx는 편리하지만 더 세밀한 제어가 필요할 때도 있습니다. 예를 들어 한 페이지에 독립적인 데이터 소스가 여러 개 있고, 모든 데이터가 준비될 때까지 기다리지 않고 각각 loading을 표시하고 싶을 수 있습니다.
이때는 Suspense 경계를 직접 설정해야 합니다.
먼저 흔히 하는 실수를 살펴보겠습니다. 저도 처음에는 아래처럼 데이터를 가져오는 컴포넌트 내부에 Suspense를 넣었습니다.
// ❌ 잘못된 예시 - Suspense가 너무 아래에 있음
async function PostList() {
const posts = await fetchPosts();
return (
<Suspense fallback={<Loading />}> {/* 이렇게 하면 동작하지 않습니다! */}
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
</Suspense>
);
}
이렇게 하면 효과가 없습니다. Suspense는 컴포넌트 트리의 더 높은 위치에 있어야 아래 컴포넌트의 비동기 작업을 포착할 수 있기 때문입니다.
올바른 방법은 Suspense를 상위 컴포넌트에 두는 것입니다.
// ✅ 올바른 예시 - Suspense가 상위 컴포넌트에 있음
export default function BlogPage() {
return (
<div>
<h1>블로그 글</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}
// 하위 컴포넌트에서 데이터 가져오기
async function PostList() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
);
}
Suspense를 하나의 수문이라고 생각하면 됩니다. 컴포넌트 트리의 한 위치에서 아래에 있는 모든 비동기 작업을 감시합니다. 하위 컴포넌트가 데이터를 기다리는 동안에는 수문을 닫고 fallback을 표시합니다. 데이터가 모두 도착하면 수문을 열어 실제 콘텐츠를 보여 줍니다.
동적 라우트의 특별한 처리
이 문제는 저도 크게 헤맸기 때문에 꼭 강조하고 싶습니다.
제품 상세 페이지 /products/[id]가 있고, 사용자가 제품 A(id=1)에서 제품 B(id=2)로 전환한다고 가정해 봅시다. 그런데 loading.tsx가 표시되지 않는 현상을 볼 수 있습니다.
중간에 아무런 loading 전환 없이 제품 A의 콘텐츠가 곧바로 제품 B로 바뀌어 경험이 어색합니다.
React의 최적화 방식 때문입니다. 컴포넌트 유형이 둘 다 ProductPage로 같으면 React는 컴포넌트 인스턴스를 재사용하고 props만 갱신합니다. 그래서 Suspense는 ‘컴포넌트가 바뀌지 않았으니 다시 suspend할 필요가 없다’고 판단합니다.
해결 방법은 Suspense에 key 속성을 추가해 React에 ‘새 컴포넌트이므로 다시 렌더링해야 한다’고 알려 주는 것입니다.
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<Suspense key={params.id} fallback={<ProductSkeleton />}>
<ProductDetail id={params.id} />
</Suspense>
);
}
async function ProductDetail({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}
이 줄을 눈여겨보세요. <Suspense key={params.id} ...>
이제 id가 바뀌면 React가 기존 Suspense 인스턴스를 제거하고 새 인스턴스를 만듭니다. 새 인스턴스는 다시 suspend 상태로 들어가므로 loading UI가 정상적으로 표시됩니다.
저는 이 문제로 반나절을 헤맸습니다. 결국 GitHub issue에서 key 방법을 언급한 글을 보고 적용하자 바로 해결됐습니다. 해결책은 간단해도 알기 전까지는 찾기 어려운 경우가 있습니다.
여러 로딩 상태 조율하기
마지막으로 조금 더 복잡한 상황을 살펴보겠습니다. 한 페이지에서 여러 데이터 소스를 동시에 불러오는 경우입니다.
예를 들어 대시보드 페이지에 사용자 정보, 통계 데이터, 최근 활동이라는 세 영역이 있고 각각 API를 호출한다고 해 봅시다. 두 가지 전략을 사용할 수 있습니다.
전략 1: 모두 로드된 뒤 표시하기(Suspense 하나로 전체 감싸기)
export default function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserInfo /> {/* API 1 호출 */}
<Statistics /> {/* API 2 호출 */}
<RecentActivity /> {/* API 3 호출 */}
</Suspense>
);
}
장점: 구현이 간단하고 완성된 콘텐츠를 한 번에 표시합니다.
단점: 가장 느린 API가 전체를 지연시키며 사용자의 대기 시간은 가장 느린 API의 시간과 같습니다.
전략 2: 점진적으로 표시하기(여러 Suspense 경계)
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Statistics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
장점: 빠른 영역부터 표시되어 사용자가 체감하는 대기 시간이 짧아집니다.
단점: 콘텐츠가 로드될 때마다 레이아웃이 바뀌어 페이지가 ‘튀는’ 것처럼 보일 수 있습니다.
저는 보통 데이터의 중요도에 따라 선택합니다.
- 사용자 정보처럼 핵심 데이터는 Suspense 하나로 묶어 함께 표시합니다.
- 추천 콘텐츠나 광고처럼 부차적인 데이터는 별도의 Suspense로 비동기 로드합니다.
이렇게 하면 핵심 경험을 보장하면서도 사용자가 모든 데이터가 준비될 때까지 기다리지 않게 할 수 있습니다.
자주 발생하는 문제와 해결 방법
Suspense가 동작하지 않을 때
Suspense가 동작하지 않는다면 다음 항목을 확인하세요.
1. 데이터를 가져오는 방식이 올바른가요?
Suspense는 ‘Suspense와 호환되는 데이터 로딩 방식’에서만 작동합니다. Next.js App Router에서는 다음과 같습니다.
- ✅ Server Component에서 직접 await 사용(권장)
- ✅ Suspense를 지원하는 라이브러리 사용(SWR, React Query 등)
- ❌ useEffect 안에서 fetch(지원하지 않음)
- ❌ 기존 Promise.then 방식(지원하지 않음)
2. 컴포넌트 위치가 올바른가요?
Suspense는 데이터를 가져오는 컴포넌트의 상위에 있어야 하며, 같은 컴포넌트나 하위에 두면 안 됩니다.
3. 버전이 호환되나요?
다음 버전 이상인지 확인하세요.
- React 18+
- Next.js 13+(App Router)
4. 디버깅 방법
React DevTools에서 Suspense 경계를 직접 전환해 보세요. 직접 전환해도 반응이 없다면 Suspense 자체가 적용되지 않은 것이므로 앞의 항목을 다시 확인해야 합니다.
useFormStatus hook의 주의점
Server Actions로 폼을 제출할 때 제출 상태를 표시하려고 useFormStatus hook을 사용할 수 있습니다.
여기에는 주의할 점이 있습니다. useFormStatus는 Client Component에서만 작동합니다.
하지만 Server Action을 바인딩하려면 form 자체는 Server Component에서 렌더링해야 합니다.
따라서 올바른 방식은 Server Component에서 form을 렌더링하고 Client Component에서 상태를 표시하는 것입니다.
// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
// 폼 처리...
await saveToDatabase(formData);
}
// app/page.tsx (Server Component)
import { submitForm } from './actions';
import { SubmitButton } from './submit-button';
export default function Page() {
return (
<form action={submitForm}>
<input name="email" type="email" />
<SubmitButton />
</form>
);
}
// app/submit-button.tsx (Client Component)
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '제출 중...' : '제출'}
</button>
);
}
form은 Server Component에 있고 button은 Client Component에 있다는 점에 주의하세요. 이렇게 해야 Server Action과 loading 상태가 모두 정상적으로 작동합니다.
미리 가져오기(Prefetch)가 loading에 미치는 영향
Next.js의 <Link> 컴포넌트는 링크가 뷰포트에 들어오면 기본적으로 해당 페이지를 미리 가져옵니다.
그래서 링크를 눌렀을 때 loading이 순식간에 지나가거나 아예 표시되지 않을 때가 있습니다. 이미 데이터를 가져와 loading이 필요 없기 때문입니다.
loading 효과를 테스트하려면 미리 가져오기를 잠시 끌 수 있습니다.
<Link href="/blog" prefetch={false}>
블로그
</Link>
하지만 프로덕션에서는 미리 가져오기를 켜 두는 편이 사용자 경험에 더 좋습니다. loading 표시가 너무 짧아 사용자가 알아차리지 못할까 걱정된다면 최소 표시 시간(예: 300ms)을 주거나 spinner 대신 스켈레톤 UI를 사용할 수 있습니다.
정리
핵심 내용을 빠르게 되짚어 보겠습니다.
-
loading.tsx는 라우트 수준 loading의 모범 사례입니다: 파일을 라우트 폴더에 두면 Next.js가 모든 것을 자동으로 처리합니다. 직접 작성한 useState를 없애 코드량을 절반으로 줄일 수 있습니다.
-
스켈레톤 UI가 spinner보다 경험이 좋습니다: 레이아웃을 미리 보여 주어 사용자의 불안을 줄입니다. 프로젝트 요구 사항에 따라 순수 CSS, react-loading-skeleton 또는 UI 라이브러리로 구현할 수 있습니다.
-
Suspense는 컴포넌트 트리의 상위에 둬야 합니다: 하위의 모든 비동기 작업을 감시하는 수문과 같습니다. 위치가 잘못되면 동작하지 않습니다.
-
동적 라우트에는 key를 잊지 마세요: 그렇지 않으면 ID가 바뀔 때 loading이 표시되지 않습니다.
<Suspense key={params.id}>한 줄을 꼭 기억하세요. -
데이터 소스가 여러 개라면 필요에 따라 Suspense를 나누세요: 핵심 데이터는 함께 표시하고 부차적인 데이터는 비동기로 불러와 경험과 성능의 균형을 맞춥니다.
솔직히 말해 직접 useState를 작성하다 loading.tsx로 바꾸는 것은 일을 늘리는 게 아니라 더 영리하게 일하는 방법입니다. 코드는 줄고 버그도 줄며 사용자 경험까지 좋아지니 사용하지 않을 이유가 없습니다.
다음 단계
지금 바로 시험해 보고 싶다면 다음과 같이 시작해 보세요.
바로 실천하기: 기존 프로젝트에서 간단한 목록 페이지 하나를 골라 로딩 처리를 loading.tsx로 바꿔 보세요. 글 열 편을 읽는 것보다 직접 한 번 해 보는 것이 더 도움이 됩니다.
심화 학습: loading을 익혔다면 다음에는 Error Boundaries를 공부해 보세요. 하나는 로딩 상태를, 다른 하나는 오류 상태를 담당하므로 한 세트처럼 사용할 수 있습니다. 다음에는 Error Boundaries 실전 글로 이어가겠습니다.
경험 공유하기: 프로젝트에서는 loading을 어떻게 처리하고 있나요? 어떤 방식을 썼고 어떤 문제를 겪었나요? 댓글로 경험을 나누며 함께 이야기해 봅시다.
참고 자료:
Next.js 로딩 상태 관리 전체 과정
loading.tsx와 Suspense로 전문적인 로딩 경험을 구현하고 직접 작성한 useState 로딩 코드를 없앱니다.
⏱️ Estimated time: 1 hr
- 1
Step 1: loading.tsx 파일 만들기
라우트 디렉터리에 loading.tsx를 만듭니다:
• app/dashboard/loading.tsx: dashboard 라우트의 로딩 상태
• app/products/[id]/loading.tsx: 동적 라우트의 로딩 상태
파일 내용:
export default function Loading() {
return <div>로딩 중...</div>
}
Next.js가 페이지를 불러오는 동안 이 컴포넌트를 자동으로 표시합니다. - 2
Step 2: 스켈레톤 UI 구현하기
더 전문적인 로딩 UI를 만듭니다:
• Skeleton 컴포넌트로 콘텐츠 레이아웃을 모방합니다.
• 실제 콘텐츠와 비슷한 레이아웃을 유지합니다.
• 애니메이션으로 경험을 개선합니다.
예시:
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
)
} - 3
Step 3: Suspense로 비동기 컴포넌트 감싸기
컴포넌트에서 Suspense를 사용합니다:
• 비동기 데이터를 가져오는 컴포넌트를 감쌉니다.
• fallback에 표시할 로딩 상태를 설정합니다.
• 중첩 Suspense로 세밀한 로딩을 구현합니다.
예시:
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
여러 컴포넌트를 각각 Suspense로 감쌀 수도 있습니다:
• 각 컴포넌트가 독립적으로 로드됩니다.
• 빠른 컴포넌트가 먼저, 느린 컴포넌트가 나중에 표시됩니다.
• 사용자 경험이 향상됩니다. - 4
Step 4: 동적 라우트의 로딩 처리하기
동적 라우트 로딩:
• 동적 라우트 디렉터리에 loading.tsx를 만듭니다.
• Next.js가 매개변수 변경 시의 로딩을 자동으로 처리합니다.
• 로딩 상태를 직접 관리할 필요가 없습니다.
예시:
app/products/[id]/
├── loading.tsx # 매개변수가 바뀔 때 자동 표시
└── page.tsx
/products/1에서 /products/2로 이동하면
loading.tsx가 자동으로 표시됩니다. - 5
Step 5: 로딩 경험 최적화하기
최적화 팁:
• 단순한 Spinner 대신 스켈레톤 UI를 사용합니다.
• 로딩 UI와 실제 콘텐츠의 레이아웃을 일치시킵니다.
• 애니메이션(animate-pulse)으로 경험을 개선합니다.
• Suspense를 적절히 사용해 스트리밍 렌더링을 구현합니다.
피해야 할 점:
• 모든 곳에 loading.tsx를 사용하지 마세요.
• 지나치게 복잡한 로딩 UI를 만들지 마세요.
• 오류 처리를 빠뜨리지 마세요(error.tsx와 함께 사용). - 6
Step 6: 테스트하고 검증하기
테스트 항목:
• 페이지 이동 중 로딩 상태를 테스트합니다.
• 동적 라우트 매개변수가 바뀔 때의 로딩을 테스트합니다.
• 느린 네트워크에서 로딩 경험을 테스트합니다.
• 로딩 UI가 자연스럽게 동작하는지 확인합니다.
체크리스트:
• 모든 라우트에 적절한 로딩 상태가 있습니다.
• 로딩 UI와 실제 콘텐츠의 레이아웃이 일치합니다.
• 깜빡임이나 레이아웃 이동이 없습니다.
• 사용자 경험이 매끄럽습니다.
FAQ
loading.tsx와 직접 작성한 useState는 무엇이 다른가요?
loading.tsx는 언제 표시되나요?
Suspense와 loading.tsx는 무엇이 다른가요?
스켈레톤 UI는 어떻게 구현하나요?
동적 라우트의 로딩은 어떻게 처리하나요?
로딩 스타일을 사용자 정의할 수 있나요?
loading.tsx가 성능에 영향을 주나요?
6분 읽기 · 게시일: 2026년 1월 5일 · 수정일: 2026년 9월 8일
Next.js 완전 가이드
검색으로 들어왔다면 같은 시리즈의 이전 글이나 다음 글로 이동하는 것이 가장 빠릅니다.
이전
Next.js 엔지니어링 설정: ESLint + Prettier + Husky 한 번에 구축하기
금요일 저녁, 서식 문제로 PR이 반려되었나요? 팀의 코드 스타일이 제각각이라 불필요한 충돌이 생기나요? 이 글에서는 ESLint, Prettier, Husky를 설정해 코드 검사와 서식 지정을 자동화하고 팀 협업 효율을 높이는 방법을 단계별로 설명합니다.
45편 중 27편
다음
Next.js 404·500 페이지 커스터마이징 완벽 가이드: 구현부터 디자인 최적화까지
not-found.tsx, error.tsx, global-error.tsx의 전체 코드 예제와 디자인 모범 사례, 자주 발생하는 문제 해결법을 통해 Next.js 오류 페이지를 직접 커스터마이징하고 사용자 경험을 높여 이탈률을 낮추는 방법을 안내합니다.
45편 중 29편



댓글
GitHub로 로그인하여 댓글을 남기세요