nextjs-loading-and-suspense
Next.js App Router: loading.js와 Suspense로 완벽한 로딩 경험 만들기
Next.js 13의 App Router는 React의 Suspense를 기반으로 한 혁신적인 로딩 시스템을 제공합니다. 기존 React 애플리케이션과 달리, Next.js는 loading.js 파일과 Streaming을 통해 더욱 세밀하고 사용자 친화적인 로딩 경험을 구현할 수 있습니다.
Next.js App Router의 로딩 시스템 이해하기
기존 Pages Router vs App Router
Pages Router (기존 방식)
javascript
// pages/dashboard.js
import { useState, useEffect } from 'react';
export default function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData().then(data => {
setData(data);
setLoading(false);
});
}, []);
if (loading) return <div>로딩 중...</div>;
return <div>{/* 데이터 렌더링 */}</div>;
}
App Router (새로운 방식)
javascript
// app/dashboard/page.js
async function Dashboard() {
const data = await fetchData(); // 서버에서 직접 데이터 페치
return <div>{/* 데이터 렌더링 */}</div>;
}
export default Dashboard;
javascript
// app/dashboard/loading.js
export default function Loading() {
return <div>대시보드 로딩 중...</div>;
}
loading.js의 핵심 개념
loading.js
파일은 해당 경로와 하위 경로에서 자동으로 Suspense 경계를 생성합니다:
app/
├── dashboard/
│ ├── loading.js # /dashboard 경로의 로딩 UI
│ ├── page.js # 실제 페이지 컴포넌트
│ └── analytics/
│ ├── loading.js # /dashboard/analytics 경로의 로딩 UI
│ └── page.js
loading.js 파일 활용하기
1. 기본 loading.js 구현
javascript
// app/dashboard/loading.js
export default function DashboardLoading() {
return (
<div className="dashboard-loading">
<div className="loading-header">
<div className="skeleton-box h-8 w-48"></div>
<div className="skeleton-box h-6 w-32"></div>
</div>
<div className="loading-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="loading-card">
<div className="skeleton-box h-40 w-full"></div>
<div className="skeleton-box h-4 w-3/4 mt-4"></div>
<div className="skeleton-box h-4 w-1/2 mt-2"></div>
</div>
))}
</div>
</div>
);
}
2. 중첩된 로딩 상태
javascript
// app/dashboard/layout.js
export default function DashboardLayout({ children }) {
return (
<div className="dashboard-layout">
<nav className="dashboard-nav">
{/* 네비게이션은 즉시 표시 */}
</nav>
<main className="dashboard-main">
{children} {/* 여기서 loading.js가 적용됨 */}
</main>
</div>
);
}
javascript
// app/dashboard/loading.js
export default function DashboardLoading() {
return (
<div className="dashboard-content-loading">
{/* 네비게이션은 유지되고 메인 콘텐츠만 로딩 상태 */}
<div className="content-skeleton">
<div className="skeleton-header"></div>
<div className="skeleton-body"></div>
</div>
</div>
);
}
3. 동적 로딩 상태
javascript
// app/products/[category]/loading.js
export default function CategoryLoading() {
return (
<div className="category-loading">
<div className="breadcrumb-skeleton">
<div className="skeleton-box h-4 w-20"></div>
<span>/</span>
<div className="skeleton-box h-4 w-24"></div>
</div>
<div className="category-header-skeleton">
<div className="skeleton-box h-10 w-48"></div>
<div className="skeleton-box h-6 w-64"></div>
</div>
<div className="products-grid-skeleton">
{Array.from({ length: 12 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
</div>
);
}
function ProductCardSkeleton() {
return (
<div className="product-card-skeleton">
<div className="skeleton-box aspect-square w-full"></div>
<div className="skeleton-box h-4 w-3/4 mt-2"></div>
<div className="skeleton-box h-4 w-1/2 mt-1"></div>
<div className="skeleton-box h-6 w-16 mt-2"></div>
</div>
);
}
Server Components와 Suspense 조합
1. 부분적 렌더링 (Partial Rendering)
javascript
// app/dashboard/page.js
import { Suspense } from 'react';
import UserProfile from './components/UserProfile';
import RecentActivity from './components/RecentActivity';
import Analytics from './components/Analytics';
export default function Dashboard() {
return (
<div className="dashboard">
{/* 즉시 렌더링되는 정적 콘텐츠 */}
<h1>대시보드</h1>
{/* 각 섹션별로 독립적인 로딩 */}
<div className="dashboard-grid">
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</div>
</div>
);
}
javascript
// app/dashboard/components/UserProfile.js
async function UserProfile() {
// 빠른 API 호출
const user = await fetchUser();
return (
<div className="user-profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
javascript
// app/dashboard/components/Analytics.js
async function Analytics() {
// 느린 API 호출 (복잡한 계산 필요)
const analytics = await fetchAnalytics();
return (
<div className="analytics">
<h3>분석 데이터</h3>
<Chart data={analytics.chartData} />
<Metrics data={analytics.metrics} />
</div>
);
}
export default Analytics;
2. Streaming과 Progressive Enhancement
javascript
// app/blog/[slug]/page.js
import { Suspense } from 'react';
async function BlogPost({ params }) {
return (
<article className="blog-post">
{/* 즉시 표시되는 기본 구조 */}
<header className="blog-header">
<h1>블로그 포스트</h1>
<div className="blog-meta">
<span>작성 중...</span>
</div>
</header>
{/* 점진적으로 로드되는 콘텐츠 */}
<Suspense fallback={<ContentSkeleton />}>
<BlogContent slug={params.slug} />
</Suspense>
<aside className="blog-sidebar">
<Suspense fallback={<RelatedPostsSkeleton />}>
<RelatedPosts slug={params.slug} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments slug={params.slug} />
</Suspense>
</aside>
</article>
);
}
export default BlogPost;
실제 프로젝트 패턴
1. E-commerce 상품 페이지
javascript
// app/products/[id]/page.js
import { Suspense } from 'react';
export default async function ProductPage({ params }) {
return (
<div className="product-page">
{/* 즉시 표시되는 레이아웃 */}
<nav className="breadcrumb">
<Suspense fallback={<BreadcrumbSkeleton />}>
<ProductBreadcrumb productId={params.id} />
</Suspense>
</nav>
<div className="product-layout">
<div className="product-images">
<Suspense fallback={<ImageGallerySkeleton />}>
<ProductImages productId={params.id} />
</Suspense>
</div>
<div className="product-info">
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo productId={params.id} />
</Suspense>
</div>
</div>
<div className="product-details">
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<RelatedProducts productId={params.id} />
</Suspense>
</div>
</div>
);
}
javascript
// app/products/[id]/loading.js
export default function ProductLoading() {
return (
<div className="product-page-loading">
<div className="breadcrumb-skeleton">
<div className="skeleton-box h-4 w-16"></div>
<span>/</span>
<div className="skeleton-box h-4 w-20"></div>
<span>/</span>
<div className="skeleton-box h-4 w-24"></div>
</div>
<div className="product-layout-skeleton">
<div className="product-images-skeleton">
<div className="skeleton-box aspect-square w-full"></div>
<div className="thumbnail-grid">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="skeleton-box aspect-square"></div>
))}
</div>
</div>
<div className="product-info-skeleton">
<div className="skeleton-box h-8 w-3/4"></div>
<div className="skeleton-box h-6 w-1/2 mt-2"></div>
<div className="skeleton-box h-10 w-24 mt-4"></div>
<div className="skeleton-box h-12 w-32 mt-6"></div>
</div>
</div>
</div>
);
}
2. 대시보드 애플리케이션
javascript
// app/admin/dashboard/page.js
import { Suspense } from 'react';
export default function AdminDashboard() {
return (
<div className="admin-dashboard">
<header className="dashboard-header">
<h1>관리자 대시보드</h1>
<div className="header-actions">
{/* 즉시 표시되는 액션 버튼들 */}
<button>새로고침</button>
<button>설정</button>
</div>
</header>
{/* 핵심 지표는 빠르게 로드 */}
<div className="metrics-grid">
<Suspense fallback={<MetricCardSkeleton />}>
<TotalUsers />
</Suspense>
<Suspense fallback={<MetricCardSkeleton />}>
<Revenue />
</Suspense>
<Suspense fallback={<MetricCardSkeleton />}>
<Orders />
</Suspense>
<Suspense fallback={<MetricCardSkeleton />}>
<Conversion />
</Suspense>
</div>
{/* 복잡한 차트는 나중에 로드 */}
<div className="charts-section">
<Suspense fallback={<ChartSkeleton />}>
<SalesChart />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<UserActivityChart />
</Suspense>
</div>
{/* 테이블 데이터는 가장 나중에 로드 */}
<div className="tables-section">
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<TopProducts />
</Suspense>
</div>
</div>
);
}
에러 처리와 error.js 연동
1. 에러 경계와 로딩 상태
javascript
// app/dashboard/error.js
'use client';
export default function DashboardError({ error, reset }) {
return (
<div className="dashboard-error">
<h2>대시보드 로딩 중 오류가 발생했습니다</h2>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
);
}
javascript
// app/dashboard/loading.js
export default function DashboardLoading() {
return (
<div className="dashboard-loading">
<div className="loading-header">
<div className="skeleton-box h-8 w-48"></div>
</div>
<div className="loading-message">
<p>대시보드 데이터를 불러오는 중입니다...</p>
<div className="loading-spinner"></div>
</div>
</div>
);
}
2. 컴포넌트별 에러 처리
javascript
// app/dashboard/components/ErrorBoundary.js
'use client';
import { Component } from 'react';
class ComponentErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return (
<div className="component-error">
<p>이 섹션을 로드할 수 없습니다.</p>
<button onClick={() => this.setState({ hasError: false })}>
다시 시도
</button>
</div>
);
}
return this.props.children;
}
}
export default ComponentErrorBoundary;
javascript
// app/dashboard/page.js
import { Suspense } from 'react';
import ComponentErrorBoundary from './components/ErrorBoundary';
export default function Dashboard() {
return (
<div className="dashboard">
<ComponentErrorBoundary>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics />
</Suspense>
</ComponentErrorBoundary>
<ComponentErrorBoundary>
<Suspense fallback={<UserListSkeleton />}>
<UserList />
</Suspense>
</ComponentErrorBoundary>
</div>
);
}
성능 최적화 기법
1. 데이터 페칭 최적화
javascript
// lib/data-fetching.js
import { cache } from 'react';
// React의 cache를 사용하여 중복 요청 방지
export const getUser = cache(async (userId) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
});
export const getAnalytics = cache(async () => {
const response = await fetch('/api/analytics');
return response.json();
});
javascript
// app/dashboard/components/UserProfile.js
import { getUser } from '@/lib/data-fetching';
async function UserProfile({ userId }) {
const user = await getUser(userId); // 캐시된 요청
return (
<div className="user-profile">
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
2. 병렬 데이터 로딩
javascript
// app/dashboard/page.js
import { Suspense } from 'react';
// 데이터를 병렬로 시작
async function Dashboard() {
// 모든 데이터 페칭을 동시에 시작
const userPromise = getUser();
const analyticsPromise = getAnalytics();
const ordersPromise = getRecentOrders();
return (
<div className="dashboard">
<Suspense fallback={<UserSkeleton />}>
<UserSection userPromise={userPromise} />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsSection analyticsPromise={analyticsPromise} />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<OrdersSection ordersPromise={ordersPromise} />
</Suspense>
</div>
);
}
async function UserSection({ userPromise }) {
const user = await userPromise;
return <div>{/* 사용자 정보 렌더링 */}</div>;
}
async function AnalyticsSection({ analyticsPromise }) {
const analytics = await analyticsPromise;
return <div>{/* 분석 데이터 렌더링 */}</div>;
}
async function OrdersSection({ ordersPromise }) {
const orders = await ordersPromise;
return <div>{/* 주문 데이터 렌더링 */}</div>;
}
3. 조건부 로딩
javascript
// app/dashboard/page.js
import { Suspense } from 'react';
import { getCurrentUser } from '@/lib/auth';
export default async function Dashboard() {
const user = await getCurrentUser();
return (
<div className="dashboard">
<h1>안녕하세요, {user.name}님!</h1>
{/* 기본 콘텐츠는 모든 사용자에게 표시 */}
<Suspense fallback={<BasicDashboardSkeleton />}>
<BasicDashboard userId={user.id} />
</Suspense>
{/* 관리자 전용 콘텐츠 */}
{user.role === 'admin' && (
<Suspense fallback={<AdminPanelSkeleton />}>
<AdminPanel />
</Suspense>
)}
{/* 프리미엄 사용자 전용 기능 */}
{user.plan === 'premium' && (
<Suspense fallback={<PremiumFeaturesSkeleton />}>
<PremiumFeatures />
</Suspense>
)}
</div>
);
}
사용자 경험 개선
1. 스켈레톤 UI 디자인 시스템
javascript
// components/ui/Skeleton.js
export function Skeleton({ className, ...props }) {
return (
<div
className={`animate-pulse rounded-md bg-gray-200 ${className}`}
{...props}
/>
);
}
export function CardSkeleton() {
return (
<div className="card-skeleton">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2 mt-2" />
<Skeleton className="h-20 w-full mt-4" />
</div>
);
}
export function TableSkeleton({ rows = 5 }) {
return (
<div className="table-skeleton">
<div className="table-header">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-20" />
</div>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="table-row">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
);
}
2. 로딩 상태 피드백
javascript
// app/search/loading.js
export default function SearchLoading() {
return (
<div className="search-loading">
<div className="search-header">
<Skeleton className="h-8 w-48" />
<div className="search-filters">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
<div className="search-results">
<div className="results-info">
<Skeleton className="h-4 w-40" />
</div>
<div className="results-grid">
{Array.from({ length: 12 }).map((_, i) => (
<SearchResultSkeleton key={i} />
))}
</div>
</div>
{/* 로딩 중임을 명확히 알려주는 메시지 */}
<div className="loading-indicator">
<div className="spinner"></div>
<p>검색 결과를 찾고 있습니다...</p>
</div>
</div>
);
}
3. 점진적 향상
javascript
// app/blog/page.js
import { Suspense } from 'react';
export default function BlogPage() {
return (
<div className="blog-page">
{/* 즉시 표시되는 기본 구조 */}
<header className="blog-header">
<h1>블로그</h1>
<nav className="blog-nav">
<a href="/blog/tech">기술</a>
<a href="/blog/design">디자인</a>
<a href="/blog/business">비즈니스</a>
</nav>
</header>
{/* 핵심 콘텐츠 먼저 로드 */}
<main className="blog-main">
<Suspense fallback={<FeaturedPostsSkeleton />}>
<FeaturedPosts />
</Suspense>
<Suspense fallback={<RecentPostsSkeleton />}>
<RecentPosts />
</Suspense>
</main>
{/* 부가 기능은 나중에 로드 */}
<aside className="blog-sidebar">
<Suspense fallback={<NewsletterSkeleton />}>
<NewsletterSignup />
</Suspense>
<Suspense fallback={<PopularTagsSkeleton />}>
<PopularTags />
</Suspense>
<Suspense fallback={<ArchiveSkeleton />}>
<BlogArchive />
</Suspense>
</aside>
</div>
);
}
모니터링 및 성능 측정
1. 로딩 시간 추적
javascript
// lib/performance.js
export function trackLoadingTime(componentName, startTime) {
const endTime = performance.now();
const loadTime = endTime - startTime;
// 성능 데이터 전송
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', 'component_load_time', {
event_category: 'performance',
event_label: componentName,
value: Math.round(loadTime)
});
}
console.log(`${componentName} loaded in ${loadTime}ms`);
}
javascript
// app/dashboard/components/Analytics.js
import { trackLoadingTime } from '@/lib/performance';
async function Analytics() {
const startTime = performance.now();
try {
const data = await fetchAnalytics();
trackLoadingTime('Analytics', startTime);
return (
<div className="analytics">
{/* 분석 데이터 렌더링 */}
</div>
);
} catch (error) {
trackLoadingTime('Analytics_Error', startTime);
throw error;
}
}
export default Analytics;
2. 사용자 행동 분석
javascript
// hooks/useLoadingAnalytics.js
'use client';
import { useEffect } from 'react';
export function useLoadingAnalytics(componentName) {
useEffect(() => {
const startTime = Date.now();
// 컴포넌트가 마운트되면 로딩 완료로 간주
const endTime = Date.now();
const loadTime = endTime - startTime;
// 사용자가 로딩을 얼마나 기다렸는지 추적
if (window.gtag) {
window.gtag('event', 'loading_experience', {
event_category: 'ux',
event_label: componentName,
value: loadTime
});
}
}, [componentName]);
}
모범 사례 및 주의사항
권장사항
- 의미있는 로딩 상태: 단순한 스피너보다는 실제 콘텐츠 구조를 반영한 스켈레톤 UI 사용
- 계층적 로딩: 중요한 콘텐츠부터 순차적으로 로드
- 에러 처리: 로딩 실패 시 사용자에게 명확한 피드백 제공
- 성능 모니터링: 실제 로딩 시간을 측정하고 개선
피해야 할 패턴
javascript
// ❌ 너무 많은 중첩된 Suspense
function BadExample() {
return (
<Suspense fallback={<Loading />}>
<Suspense fallback={<Loading />}>
<Suspense fallback={<Loading />}>
<Component />
</Suspense>
</Suspense>
</Suspense>
);
}
// ❌ 의미없는 로딩 상태
function BadLoading() {
return <div>Loading...</div>; // 사용자에게 도움이 되지 않음
}
// ❌ 에러 처리 없음
function BadComponent() {
return (
<Suspense fallback={<Loading />}>
<DataComponent /> {/* 에러 발생 시 처리 방법 없음 */}
</Suspense>
);
}
javascript
// ✅ 적절한 구조와 에러 처리
function GoodExample() {
return (
<ErrorBoundary fallback={<ErrorComponent />}>
<Suspense fallback={<MeaningfulSkeleton />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
// ✅ 의미있는 로딩 상태
function GoodLoading() {
return (
<div className="product-loading">
<div className="product-image-skeleton"></div>
<div className="product-info-skeleton">
<div className="title-skeleton"></div>
<div className="price-skeleton"></div>
<div className="description-skeleton"></div>
</div>
</div>
);
}
마무리
Next.js App Router의 loading.js와 Suspense를 활용하면 기존 React 애플리케이션보다 훨씬 세밀하고 사용자 친화적인 로딩 경험을 구현할 수 있습니다.
핵심은:
- 파일 기반 라우팅으로 자동 Suspense 경계 생성
- Server Components와 Streaming을 통한 점진적 렌더링
- 의미있는 스켈레톤 UI로 사용자 경험 개선
- 적절한 에러 처리로 안정성 확보
이러한 기법들을 적절히 조합하면 사용자가 기다림을 느끼지 않는 매끄러운 웹 애플리케이션을 만들 수 있습니다. 무엇보다 실제 사용자의 행동 패턴을 분석하고 지속적으로 개선해 나가는 것이 중요합니다.