deep-dive2025-06-07·11 min·43/348

Next.js 14 Suspense를 활용한 로딩 UI 구현

React Suspense와 Next.js의 로딩 컨벤션을 활용하여 사용자 경험을 향상시키는 방법을 알아봅니다.

Next.js 14 Suspense를 활용한 로딩 UI 구현

Introduction

Suspense는 비동기 작업이 완료될 때까지 로딩 상태를 표시하는 React 컴포넌트입니다. Next.js 14에서는 Suspense와 결합된 스트리밍 서버 렌더링으로 사용자 경험을 크게 향상시킬 수 있습니다. 이 글에서는 Suspense 활용법과 로딩 UI 패턴을 다룹니다.

Environment

# 프로젝트 구조
my-app/
├── app/
│   ├── layout.tsx
│   ├── page.tsx
│   ├── loading.tsx
│   ├── dashboard/
│   │   ├── page.tsx
│   │   └── loading.tsx
│   └── components/
│       ├── ProductList.tsx
│       └── UserProfile.tsx
└── package.json

# 기술 스택
Next.js: 14.2.5
React: 18.3.1

Problem

전체 페이지 로딩은 사용자 경험을 저해합니다:

// ❌ 전체 페이지가 로드될 때까지 빈 화면
export default async function DashboardPage() {
  // 느린 데이터 페칭
  const stats = await fetchStats();
  const notifications = await fetchNotifications();
  const userData = await fetchUserData();
  
  return (
    
); }

Analysis

Suspense의 장점:

  1. 점진적 로딩: 컴포넌트별로 독립적으로 로드
  2. 스트리밍: HTML을 chunks로 전송
  3. 用户体验: 로딩 상태를 즉시 표시
  4. 에러 격리: 개별 컴포넌트 실패가 전체에 영향 안 함

Solution

1. 기본 Suspense 사용

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Stats } from '@/components/Stats';
import { Notifications } from '@/components/Notifications';
import { UserProfile } from '@/components/UserProfile';

export default function DashboardPage() {
  return (
    

대시보드

통계 로딩 중...
}> 알림 로딩 중...
}> 프로필 로딩 중...
}>
); }

2. 파일 기반 로딩 UI

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    
); }
// app/dashboard/page.tsx
import { Stats } from '@/components/Stats';

export default function DashboardPage() {
  return (
    

대시보드

); }

3. 스트리밍과 Suspense 조합

// app/page.tsx
import { Suspense } from 'react';
import { Headers } from '@/components/Headers';
import { MainContent } from '@/components/MainContent';
import { Sidebar } from '@/components/Sidebar';

export default function HomePage() {
  return (
    
{/* 즉시 렌더링 (동기) */}
{/* 스트리밍으로 로드 */} }> {/* 스트리밍으로 로드 */} }>
); }

4. Suspense를 이용한 에러 격리

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { CriticalStats } from '@/components/CriticalStats';
import { OptionalFeatures } from '@/components/OptionalFeatures';

export default function DashboardPage() {
  return (
    

대시보드

{/* 에러 바운더리로 격리 */} 통계 로딩 실패
}> 로딩 중...
}> {/* 선택적 기능 (실패해도 전체에 영향 없음) */} 추가 정보 로딩 중...}> ); }

5. 조건부 Suspense

// components/ConditionalSuspense.tsx
'use client';

import { Suspense, useState, useEffect } from 'react';

interface ConditionalSuspenseProps {
  children: React.ReactNode;
  fallback: React.ReactNode;
  condition: boolean;
}

export function ConditionalSuspense({
  children,
  fallback,
  condition,
}: ConditionalSuspenseProps) {
  if (!condition) {
    return <>{children};
  }
  
  return (
    
      {children}
    
  );
}
// 사용 예시
export function Dashboard() {
  const [showAdvanced, setShowAdvanced] = useState(false);
  
  return (
    
} condition={showAdvanced} >
); }

6. Suspense 커스텀 훅

// hooks/useSuspenseQuery.ts
'use client';

import { useState, useEffect, useTransition } from 'react';

export function useSuspenseQuery(
  queryFn: () => Promise,
  deps: any[]
): { data: T | null; isLoading: boolean; error: Error | null } {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();
  
  useEffect(() => {
    let cancelled = false;
    
    startTransition(async () => {
      try {
        const result = await queryFn();
        if (!cancelled) {
          setData(result);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
        }
      }
    });
    
    return () => {
      cancelled = true;
    };
  }, deps);
  
  return { data, isLoading: isPending, error };
}

7. Suspense와 레이아웃 조합

// app/dashboard/layout.tsx
import { Suspense } from 'react';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    
{/* 네비게이션은 즉시 로드 */} {/* 메인 콘텐츠 */}
{children}
); }

8. Suspense 성능 최적화

// components/OptimizedSuspense.tsx
'use client';

import { Suspense, lazy, useState, useEffect } from 'react';

// 지연 로딩 컴포넌트
const HeavyComponent = lazy(() => import('@/components/HeavyComponent'));

export function OptimizedSuspense() {
  const [showHeavy, setShowHeavy] = useState(false);
  
  // 사용자 인터랙션 후에만 로드
  useEffect(() => {
    const handleInteraction = () => {
      setShowHeavy(true);
    };
    
    window.addEventListener('scroll', handleInteraction, { once: true });
    return () => window.removeEventListener('scroll', handleInteraction);
  }, []);
  
  if (!showHeavy) {
    return 
스크롤하여 더 보기
; } return ( 무거운 컴포넌트 로딩 중...}> ); }

Lessons Learned

  1. 점진적 로딩: 전체 페이지보다 컴포넌트별 Suspense 사용
  2. 의미 있는 fallback: 사용자에게 유용한 로딩 상태 표시
  3. 에러 격리: ErrorBoundary와 조합하여 에러 전파 방지
  4. 성능 고려: 불필요한 Suspense 중첩 지양

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.

Previous
Recovering SSH After ufw Firewall Misconfiguration
Next
next.config.js 실험적 기능 활성화 방법
Back to Blog