본문 바로가기

Suspense와 Lazy Loading

4. Suspense와 Lazy Loading

React의 Suspense는 컴포넌트가 렌더링할 준비가 완료될 때까지 대기할 수 있게 해주는 기능입니다. 주로 코드 분할(code splitting)과 데이터 로딩에 사용됩니다.

React.lazy를 이용한 코드 분할

기본 사용법

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

// lazy로 컴포넌트를 동적 로딩
const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  const [showLazy, setShowLazy] = useState(false);
  
  return (
    <div>
      <h1>Suspense 예제</h1>
      
      <button onClick={() => setShowLazy(!showLazy)}>
        {showLazy ? '컴포넌트 숨기기' : '컴포넌트 보기'}
      </button>
      
      {showLazy && (
        <Suspense fallback={<div>로딩 중...</div>}>
          <LazyComponent />
        </Suspense>
      )}
    </div>
  );
}

별도 파일로 분리된 컴포넌트

// LazyComponent.js
import { useState } from 'react';

function LazyComponent() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <h2>지연 로딩된 컴포넌트</h2>
      <p>이 컴포넌트는 필요할 때만 로드됩니다.</p>
      <button onClick={() => setCount(count + 1)}>
        카운트: {count}
      </button>
    </div>
  );
}

export default LazyComponent;

다중 컴포넌트 lazy loading

여러 컴포넌트를 조건부로 로딩

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

// 여러 컴포넌트를 lazy loading
const Dashboard = lazy(() => import('./Dashboard'));
const Profile = lazy(() => import('./Profile'));
const Settings = lazy(() => import('./Settings'));
const Reports = lazy(() => import('./Reports'));

function MultiPageApp() {
  const [currentPage, setCurrentPage] = useState('home');
  
  const renderPage = () => {
    switch (currentPage) {
      case 'dashboard':
        return <Dashboard />;
      case 'profile':
        return <Profile />;
      case 'settings':
        return <Settings />;
      case 'reports':
        return <Reports />;
      default:
        return (
          <div>
            <h2>홈페이지</h2>
            <p>원하는 페이지를 선택하세요.</p>
          </div>
        );
    }
  };
  
  return (
    <div>
      <nav>
        <button 
          onClick={() => setCurrentPage('home')}
        >
          홈
        </button>
        <button 
          onClick={() => setCurrentPage('dashboard')}
        >
          대시보드
        </button>
        <button 
          onClick={() => setCurrentPage('profile')}
        >
          프로필
        </button>
        <button 
          onClick={() => setCurrentPage('settings')}
        >
          설정
        </button>
        <button onClick={() => setCurrentPage('reports')}>
          리포트
        </button>
      </nav>
      
      <main>
        <Suspense fallback={<div>페이지를 로딩하는 중...</div>}>
          {renderPage()}
        </Suspense>
      </main>
    </div>
  );
}

각 페이지 컴포넌트 예제

// Dashboard.js
function Dashboard() {
  return (
    <div>
      <h2>대시보드</h2>
      <div>
        <div>
          <h3>매출 현황</h3>
          <p>이번 달 매출: 1,500,000원</p>
        </div>
        <div>
          <h3>사용자 통계</h3>
          <p>활성 사용자: 1,234명</p>
        </div>
      </div>
    </div>
  );
}

export default Dashboard;
// Profile.js
import { useState } from 'react';

function Profile() {
  const [user, setUser] = useState({
    name: '김개발',
    email: 'kim@example.com',
    bio: 'React 개발자입니다.'
  });
  
  const [isEditing, setIsEditing] = useState(false);
  
  return (
    <div>
      <h2>프로필</h2>
      {isEditing ? (
        <div>
          <input
            value={user.name}
            onChange={(e) => setUser({ ...user, name: e.target.value })}
            placeholder="이름"
          />
          <input
            value={user.email}
            onChange={(e) => setUser({ ...user, email: e.target.value })}
            placeholder="이메일"
          />
          <textarea
            value={user.bio}
            onChange={(e) => setUser({ ...user, bio: e.target.value })}
            placeholder="자기소개"
          />
          <div>
            <button onClick={() => setIsEditing(false)}>저장</button>
            <button onClick={() => setIsEditing(false)}>취소</button>
          </div>
        </div>
      ) : (
        <div>
          <p>이름: {user.name}</p>
          <p>이메일: {user.email}</p>
          <p>소개: {user.bio}</p>
          <button onClick={() => setIsEditing(true)}>편집</button>
        </div>
      )}
    </div>
  );
}

export default Profile;

중첩된 Suspense

계층적 로딩 구조

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

const UserList = lazy(() => import('./UserList'));
const UserDetail = lazy(() => import('./UserDetail'));

// UserList.js의 내용
const LazyUserList = lazy(() => 
  new Promise(resolve => {
    setTimeout(() => {
      resolve({
        default: function UserList({ onSelectUser }) {
          const users = [
            { id: 1, name: '김개발', email: 'kim@example.com' },
            { id: 2, name: '이디자인', email: 'lee@example.com' },
            { id: 3, name: '박기획', email: 'park@example.com' }
          ];
          
          return (
            <div>
              <h3>사용자 목록</h3>
              {users.map(user => (
                <div key={user.id}>
                  <p>{user.name} ({user.email})</p>
                  <button onClick={() => onSelectUser(user)}>
                    상세보기
                  </button>
                </div>
              ))}
            </div>
          );
        }
      });
    }, 1000); // 1초 지연
  })
);

const LazyUserDetail = lazy(() =>
  new Promise(resolve => {
    setTimeout(() => {
      resolve({
        default: function UserDetail({ user, onBack }) {
          if (!user) return null;
          
          return (
            <div>
              <button onClick={onBack}>← 뒤로가기</button>
              <h3>{user.name}의 상세 정보</h3>
              <p>이메일: {user.email}</p>
              <p>가입일: 2024-01-15</p>
              <p>마지막 로그인: 2024-12-20</p>
              
              <Suspense fallback={<div>추가 정보 로딩 중...</div>}>
                <LazyUserStats userId={user.id} />
              </Suspense>
            </div>
          );
        }
      });
    }, 800); // 0.8초 지연
  })
);

const LazyUserStats = lazy(() =>
  new Promise(resolve => {
    setTimeout(() => {
      resolve({
        default: function UserStats({ userId }) {
          const stats = {
            posts: 42,
            comments: 156,
            likes: 89
          };
          
          return (
            <div>
              <h4>활동 통계</h4>
              <p>작성한 게시글: {stats.posts}개</p>
              <p>댓글: {stats.comments}개</p>
              <p>받은 좋아요: {stats.likes}개</p>
            </div>
          );
        }
      });
    }, 500); // 0.5초 지연
  })
);

function NestedSuspenseExample() {
  const [selectedUser, setSelectedUser] = useState(null);
  const [showUsers, setShowUsers] = useState(false);
  
  if (selectedUser) {
    return (
      <div>
        <h2>사용자 관리 시스템</h2>
        <Suspense fallback={<div>사용자 상세 정보 로딩 중...</div>}>
          <LazyUserDetail 
            user={selectedUser} 
            onBack={() => setSelectedUser(null)}
          />
        </Suspense>
      </div>
    );
  }
  
  return (
    <div>
      <h2>사용자 관리 시스템</h2>
      
      <button onClick={() => setShowUsers(!showUsers)}>
        {showUsers ? '사용자 목록 숨기기' : '사용자 목록 보기'}
      </button>
      
      {showUsers && (
        <Suspense fallback={<div>사용자 목록 로딩 중...</div>}>
          <LazyUserList onSelectUser={setSelectedUser} />
        </Suspense>
      )}
    </div>
  );
}

에러 경계와 함께 사용하기

Suspense와 ErrorBoundary 조합

import { lazy, Suspense, Component } from 'react';

class SuspenseErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  componentDidCatch(error, errorInfo) {
    console.error('Suspense 로딩 중 오류 발생:', error, errorInfo);
  }
  
  render() {
    if (this.state.hasError) {
      return (
        <div>
          <h3>컴포넌트를 불러오는 중 오류가 발생했습니다</h3>
          <button onClick={() => this.setState({ hasError: false })}>
            다시 시도
          </button>
        </div>
      );
    }
    
    return this.props.children;
  }
}

// 로딩에 실패할 수 있는 컴포넌트
const ProblematicComponent = lazy(() => 
  new Promise((resolve, reject) => {
    setTimeout(() => {
      // 50% 확률로 로딩 실패
      if (Math.random() > 0.5) {
        resolve({
          default: function ProblematicComponent() {
            return <div>성공적으로 로드된 컴포넌트!</div>;
          }
        });
      } else {
        reject(new Error('컴포넌트 로딩 실패'));
      }
    }, 2000);
  })
);

function ErrorHandlingExample() {
  const [showComponent, setShowComponent] = useState(false);
  
  return (
    <div>
      <h2>에러 처리가 포함된 Suspense</h2>
      
      <button onClick={() => setShowComponent(!showComponent)}>
        {showComponent ? '컴포넌트 숨기기' : '컴포넌트 로드하기'}
      </button>
      
      {showComponent && (
        <SuspenseErrorBoundary>
          <Suspense fallback={<div>컴포넌트 로딩 중... (실패할 수 있음)</div>}>
            <ProblematicComponent />
          </Suspense>
        </SuspenseErrorBoundary>
      )}
    </div>
  );
}

실습: 이미지 갤러리 애플리케이션

이미지를 lazy loading으로 로드하는 갤러리

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

// 이미지 컴포넌트를 동적으로 생성
const createLazyImageComponent = (imageSrc, delay = 1000) => {
  return lazy(() =>
    new Promise(resolve => {
      const img = new Image();
      img.onload = () => {
        setTimeout(() => {
          resolve({
            default: function LazyImage() {
              return (
                <div>
                  <img
                    src={imageSrc}
                    alt="갤러리 이미지"
                  />
                </div>
              );
            }
          });
        }, delay);
      };
      img.onerror = () => {
        resolve({
          default: function ErrorImage() {
            return (
              <div>
                <p>이미지 로드 실패</p>
              </div>
            );
          }
        });
      };
      img.src = imageSrc;
    })
  );
};

function ImageGallery() {
  const [visibleImages, setVisibleImages] = useState(new Set());
  
  // 샘플 이미지 URL들 (실제로는 API에서 받아올 수 있음)
  const imageUrls = [
    'https://picsum.photos/400/300?random=1',
    'https://picsum.photos/400/300?random=2',
    'https://picsum.photos/400/300?random=3',
    'https://picsum.photos/400/300?random=4',
    'https://picsum.photos/400/300?random=5',
    'https://picsum.photos/400/300?random=6'
  ];
  
  const toggleImage = (index) => {
    setVisibleImages(prev => {
      const newSet = new Set(prev);
      if (newSet.has(index)) {
        newSet.delete(index);
      } else {
        newSet.add(index);
      }
      return newSet;
    });
  };
  
  const showAllImages = () => {
    setVisibleImages(new Set(imageUrls.map((_, index) => index)));
  };
  
  const hideAllImages = () => {
    setVisibleImages(new Set());
  };
  
  return (
    <div>
      <h2>이미지 갤러리</h2>
      
      <div>
        <button onClick={showAllImages}>
          모든 이미지 보기
        </button>
        <button onClick={hideAllImages}>
          모든 이미지 숨기기
        </button>
      </div>
      
      <div>
        {imageUrls.map((url, index) => (
          <div key={index}>
            <button
              onClick={() => toggleImage(index)}
            >
              이미지 {index + 1} {visibleImages.has(index) ? '숨기기' : '보기'}
            </button>
            
            {visibleImages.has(index) && (
              <Suspense fallback={
                <div>
                  <p>이미지 로딩 중...</p>
                </div>
              }>
                {(() => {
                  const LazyImage = createLazyImageComponent(url, Math.random() * 2000 + 500);
                  return <LazyImage />;
                })()}
              </Suspense>
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

라우터와 함께 사용하기

페이지별 코드 분할

import { lazy, Suspense } from 'react';

// 각 페이지를 별도 파일로 분리하여 lazy loading
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));
const ProductsPage = lazy(() => import('./pages/ProductsPage'));

function App() {
  const [currentRoute, setCurrentRoute] = useState('home');
  
  const renderRoute = () => {
    switch (currentRoute) {
      case 'home':
        return <HomePage />;
      case 'about':
        return <AboutPage />;
      case 'contact':
        return <ContactPage />;
      case 'products':
        return <ProductsPage />;
      default:
        return <HomePage />;
    }
  };
  
  const routes = [
    { key: 'home', label: '홈' },
    { key: 'about', label: '소개' },
    { key: 'contact', label: '연락처' },
    { key: 'products', label: '제품' }
  ];
  
  return (
    <div>
      <nav>
        <h1>My Website</h1>
        <div>
          {routes.map(route => (
            <button
              key={route.key}
              onClick={() => setCurrentRoute(route.key)}
            >
              {route.label}
            </button>
          ))}
        </div>
      </nav>
      
      <main>
        <Suspense fallback={
          <div>
            <div>페이지를 불러오는 중...</div>
          </div>
        }>
          {renderRoute()}
        </Suspense>
      </main>
    </div>
  );
}

성능 최적화 팁

프리로딩(Preloading) 기법

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

// 컴포넌트를 미리 로드하는 함수
const preloadComponent = (componentImport) => {
  componentImport();
};

const HeavyComponent = lazy(() => import('./HeavyComponent'));

// 홈 화면에서 마우스를 올리면 미리 로드
function PreloadingExample() {
  const [showHeavy, setShowHeavy] = useState(false);
  const [isPreloaded, setIsPreloaded] = useState(false);
  
  const handleMouseEnter = () => {
    if (!isPreloaded) {
      console.log('컴포넌트 프리로딩 시작');
      preloadComponent(() => import('./HeavyComponent'));
      setIsPreloaded(true);
    }
  };
  
  return (
    <div>
      <h2>프리로딩 예제</h2>
      <p>버튼에 마우스를 올리면 컴포넌트가 미리 로드됩니다.</p>
      
      <button
        onMouseEnter={handleMouseEnter}
        onClick={() => setShowHeavy(!showHeavy)}
      >
        {showHeavy ? '무거운 컴포넌트 숨기기' : '무거운 컴포넌트 보기'}
      </button>
      
      {showHeavy && (
        <Suspense fallback={<div>로딩 중...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
}

정리

Suspense와 Lazy Loading의 핵심 개념을 학습했습니다:

  1. React.lazy: 컴포넌트를 동적으로 로딩하여 번들 크기 최적화
  2. Suspense: 비동기 로딩 중 fallback UI 표시
  3. 코드 분할: 페이지별, 기능별로 코드를 나누어 초기 로딩 시간 단축
  4. 중첩 Suspense: 계층적 로딩 구조로 세밀한 로딩 상태 관리
  5. 에러 처리: ErrorBoundary와 함께 사용하여 로딩 실패 처리
  6. 성능 최적화: 프리로딩 기법으로 사용자 경험 개선
  7. 실전 활용: 라우터, 이미지 갤러리 등에서의 활용법

Suspense와 lazy loading은 대규모 애플리케이션에서 성능을 크게 개선할 수 있는 중요한 기능입니다. 사용자가 실제로 필요로 하는 코드만 로드하여 초기 로딩 시간을 단축하고, 네트워크 사용량을 줄일 수 있습니다.

다음 장에서는 React의 고급 Hook 패턴에 대해 알아보겠습니다.