suspense-and-lazy

React Suspense와 Lazy: 스마트한 코드 분할로 성능 최적화하기

웹 애플리케이션이 점점 복잡해지면서 초기 로딩 시간이 길어지는 문제에 직면하게 됩니다. 사용자가 당장 필요하지 않은 코드까지 모두 다운로드해야 할까요? React의 SuspenseLazy를 활용하면 필요한 시점에만 코드를 로드하여 애플리케이션 성능을 크게 개선할 수 있습니다.

코드 분할이 필요한 이유

전통적인 번들링의 문제점

일반적인 React 애플리케이션은 모든 컴포넌트와 라이브러리를 하나의 큰 번들로 만듭니다:

javascript
// 모든 컴포넌트가 초기에 로드됨
import HomePage from './pages/HomePage';
import AboutPage from './pages/AboutPage';
import ContactPage from './pages/ContactPage';
import AdminDashboard from './pages/AdminDashboard'; // 관리자만 사용
import HeavyChart from './components/HeavyChart'; // 큰 차트 라이브러리

이런 방식의 문제점:

  • 초기 로딩 시간 증가: 사용하지 않는 코드도 모두 다운로드
  • 메모리 낭비: 불필요한 컴포넌트들이 메모리에 상주
  • 네트워크 비용: 모바일 환경에서 데이터 사용량 증가

코드 분할의 장점

javascript
// 필요할 때만 로드되는 컴포넌트들
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));

장점:

  • 빠른 초기 로딩: 필수 코드만 먼저 로드
  • 효율적인 리소스 사용: 필요한 시점에만 다운로드
  • 더 나은 사용자 경험: 페이지 전환이 더 빠르게 느껴짐

React.lazy() 기본 사용법

기본 문법

React.lazy()는 동적 import를 사용하여 컴포넌트를 지연 로딩합니다:

javascript
import React, { lazy, Suspense } from 'react';

// 지연 로딩할 컴포넌트 정의
const LazyComponent = lazy(() => import('./LazyComponent'));

function App() {
  return (
    <div>
      <h1>메인 애플리케이션</h1>
      <Suspense fallback={<div>로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}

조건부 렌더링과 함께 사용

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

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

function App() {
  const [showHeavy, setShowHeavy] = useState(false);

  return (
    <div>
      <button onClick={() => setShowHeavy(!showHeavy)}>
        {showHeavy ? '숨기기' : '무거운 컴포넌트 보기'}
      </button>
      
      {showHeavy && (
        <Suspense fallback={<div>컴포넌트 로딩 중...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
}

Suspense 심화 활용

중첩된 Suspense 경계

여러 레벨의 Suspense를 사용하여 세밀한 로딩 제어가 가능합니다:

javascript
import React, { lazy, Suspense } from 'react';

const Header = lazy(() => import('./Header'));
const Sidebar = lazy(() => import('./Sidebar'));
const MainContent = lazy(() => import('./MainContent'));

function Dashboard() {
  return (
    <div className="dashboard">
      {/* 헤더는 빠르게 로드 */}
      <Suspense fallback={<div className="header-skeleton">헤더 로딩...</div>}>
        <Header />
      </Suspense>
      
      <div className="dashboard-body">
        {/* 사이드바와 메인 콘텐츠는 별도로 로드 */}
        <Suspense fallback={<div className="sidebar-skeleton">사이드바 로딩...</div>}>
          <Sidebar />
        </Suspense>
        
        <Suspense fallback={<div className="content-skeleton">콘텐츠 로딩...</div>}>
          <MainContent />
        </Suspense>
      </div>
    </div>
  );
}

에러 경계와 함께 사용

javascript
import React, { lazy, Suspense } from 'react';

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

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Lazy loading error:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <div>컴포넌트 로딩에 실패했습니다. 페이지를 새로고침해주세요.</div>;
    }

    return this.props.children;
  }
}

function App() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<div>로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

실제 사용 패턴과 예시

1. 라우트 기반 코드 분할

가장 일반적인 패턴으로, 페이지별로 코드를 분할합니다:

javascript
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 페이지별 지연 로딩
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ProductPage = lazy(() => import('./pages/ProductPage'));
const AdminPage = lazy(() => import('./pages/AdminPage'));

function App() {
  return (
    <BrowserRouter>
      <div className="app">
        <nav>
          {/* 네비게이션은 즉시 로드 */}
        </nav>
        
        <main>
          <Suspense fallback={<PageLoader />}>
            <Routes>
              <Route path="/" element={<HomePage />} />
              <Route path="/about" element={<AboutPage />} />
              <Route path="/products" element={<ProductPage />} />
              <Route path="/admin" element={<AdminPage />} />
            </Routes>
          </Suspense>
        </main>
      </div>
    </BrowserRouter>
  );
}

// 페이지 로딩 컴포넌트
function PageLoader() {
  return (
    <div className="page-loader">
      <div className="spinner"></div>
      <p>페이지를 불러오는 중...</p>
    </div>
  );
}

2. 기능별 코드 분할

특정 기능이나 모듈별로 분할하는 방법:

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

// 기능별 지연 로딩
const ChartModule = lazy(() => import('./modules/ChartModule'));
const TableModule = lazy(() => import('./modules/TableModule'));
const MapModule = lazy(() => import('./modules/MapModule'));

function Dashboard() {
  const [activeTab, setActiveTab] = useState('chart');

  const renderContent = () => {
    switch (activeTab) {
      case 'chart':
        return (
          <Suspense fallback={<ModuleLoader />}>
            <ChartModule />
          </Suspense>
        );
      case 'table':
        return (
          <Suspense fallback={<ModuleLoader />}>
            <TableModule />
          </Suspense>
        );
      case 'map':
        return (
          <Suspense fallback={<ModuleLoader />}>
            <MapModule />
          </Suspense>
        );
      default:
        return <div>탭을 선택해주세요.</div>;
    }
  };

  return (
    <div className="dashboard">
      <div className="tabs">
        <button 
          className={activeTab === 'chart' ? 'active' : ''}
          onClick={() => setActiveTab('chart')}
        >
          차트
        </button>
        <button 
          className={activeTab === 'table' ? 'active' : ''}
          onClick={() => setActiveTab('table')}
        >
          테이블
        </button>
        <button 
          className={activeTab === 'map' ? 'active' : ''}
          onClick={() => setActiveTab('map')}
        >
          지도
        </button>
      </div>
      
      <div className="content">
        {renderContent()}
      </div>
    </div>
  );
}

function ModuleLoader() {
  return (
    <div className="module-loader">
      <div className="skeleton-box"></div>
      <div className="skeleton-text"></div>
    </div>
  );
}

3. 조건부 기능 로딩

사용자 권한이나 특정 조건에 따라 로딩하는 패턴:

javascript
import React, { lazy, Suspense } from 'react';

// 관리자 전용 컴포넌트
const AdminPanel = lazy(() => import('./AdminPanel'));
const UserSettings = lazy(() => import('./UserSettings'));

function UserDashboard({ user }) {
  return (
    <div className="user-dashboard">
      <h1>안녕하세요, {user.name}님!</h1>
      
      {/* 모든 사용자가 볼 수 있는 설정 */}
      <Suspense fallback={<div>설정 로딩 중...</div>}>
        <UserSettings user={user} />
      </Suspense>
      
      {/* 관리자만 볼 수 있는 패널 */}
      {user.role === 'admin' && (
        <Suspense fallback={<div>관리자 패널 로딩 중...</div>}>
          <AdminPanel />
        </Suspense>
      )}
    </div>
  );
}

성능 최적화 기법

1. 프리로딩 (Preloading)

사용자가 필요로 할 가능성이 높은 컴포넌트를 미리 로드합니다:

javascript
import React, { lazy, Suspense, useEffect } from 'react';

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

// 프리로딩 함수
const preloadHeavyComponent = () => {
  import('./HeavyComponent');
};

function App() {
  useEffect(() => {
    // 컴포넌트 마운트 후 3초 뒤에 프리로드
    const timer = setTimeout(preloadHeavyComponent, 3000);
    return () => clearTimeout(timer);
  }, []);

  const [showHeavy, setShowHeavy] = useState(false);

  return (
    <div>
      <button 
        onMouseEnter={preloadHeavyComponent} // 마우스 호버 시 프리로드
        onClick={() => setShowHeavy(true)}
      >
        무거운 컴포넌트 보기
      </button>
      
      {showHeavy && (
        <Suspense fallback={<div>로딩 중...</div>}>
          <HeavyComponent />
        </Suspense>
      )}
    </div>
  );
}

2. 청크 이름 지정

Webpack의 매직 코멘트를 사용하여 청크 이름을 지정합니다:

javascript
// 청크 이름을 명시적으로 지정
const HomePage = lazy(() => 
  import(/* webpackChunkName: "home-page" */ './pages/HomePage')
);

const AdminDashboard = lazy(() => 
  import(/* webpackChunkName: "admin-dashboard" */ './pages/AdminDashboard')
);

// 관련된 컴포넌트들을 같은 청크로 그룹화
const ChartComponents = lazy(() => 
  import(/* webpackChunkName: "chart-components" */ './components/Charts')
);

3. 리소스 힌트 활용

javascript
// 중요한 청크는 prefetch로 미리 로드
const CriticalComponent = lazy(() => 
  import(/* webpackChunkName: "critical", webpackPrefetch: true */ './CriticalComponent')
);

// 다음 네비게이션에서 필요할 가능성이 높은 청크는 preload
const NextPageComponent = lazy(() => 
  import(/* webpackChunkName: "next-page", webpackPreload: true */ './NextPageComponent')
);

로딩 상태 개선하기

스켈레톤 UI 구현

javascript
import React, { lazy, Suspense } from 'react';

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

function ProductListSkeleton() {
  return (
    <div className="product-list-skeleton">
      {Array.from({ length: 6 }).map((_, index) => (
        <div key={index} className="product-card-skeleton">
          <div className="skeleton-image"></div>
          <div className="skeleton-title"></div>
          <div className="skeleton-price"></div>
        </div>
      ))}
    </div>
  );
}

function ProductPage() {
  return (
    <div className="product-page">
      <h1>상품 목록</h1>
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
    </div>
  );
}

점진적 로딩

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

const BasicContent = lazy(() => import('./BasicContent'));
const EnhancedContent = lazy(() => import('./EnhancedContent'));

function ProgressiveLoadingPage() {
  const [loadEnhanced, setLoadEnhanced] = useState(false);

  useEffect(() => {
    // 기본 콘텐츠 로드 후 향상된 기능 로드
    const timer = setTimeout(() => {
      setLoadEnhanced(true);
    }, 2000);

    return () => clearTimeout(timer);
  }, []);

  return (
    <div className="progressive-loading-page">
      <Suspense fallback={<div>기본 콘텐츠 로딩 중...</div>}>
        <BasicContent />
      </Suspense>
      
      {loadEnhanced && (
        <Suspense fallback={<div>향상된 기능 로딩 중...</div>}>
          <EnhancedContent />
        </Suspense>
      )}
    </div>
  );
}

에러 처리 및 재시도 로직

네트워크 오류 처리

javascript
import React, { lazy, Suspense } from 'react';

// 재시도 로직이 포함된 지연 로딩
const createRetryableLazy = (importFunc, retries = 3) => {
  return lazy(() => {
    return new Promise((resolve, reject) => {
      const attemptImport = (remainingRetries) => {
        importFunc()
          .then(resolve)
          .catch((error) => {
            if (remainingRetries > 0) {
              console.warn(`Import failed, retrying... (${remainingRetries} attempts left)`);
              setTimeout(() => attemptImport(remainingRetries - 1), 1000);
            } else {
              reject(error);
            }
          });
      };
      attemptImport(retries);
    });
  });
};

const ReliableComponent = createRetryableLazy(
  () => import('./ReliableComponent'),
  3 // 3번까지 재시도
);

function App() {
  return (
    <div>
      <Suspense fallback={<div>로딩 중...</div>}>
        <ReliableComponent />
      </Suspense>
    </div>
  );
}

사용자 친화적인 에러 처리

javascript
import React, { lazy, Suspense } from 'react';

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

class LazyLoadErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  handleRetry = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div className="lazy-load-error">
          <h3>컴포넌트 로딩에 실패했습니다</h3>
          <p>네트워크 연결을 확인하고 다시 시도해주세요.</p>
          <button onClick={this.handleRetry}>다시 시도</button>
          <details>
            <summary>오류 세부사항</summary>
            <pre>{this.state.error?.message}</pre>
          </details>
        </div>
      );
    }

    return this.props.children;
  }
}

function App() {
  return (
    <LazyLoadErrorBoundary>
      <Suspense fallback={<div>로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </LazyLoadErrorBoundary>
  );
}

Next.js에서의 활용

동적 import와 함께 사용

javascript
import dynamic from 'next/dynamic';
import { Suspense } from 'react';

// Next.js의 dynamic import
const DynamicComponent = dynamic(
  () => import('../components/HeavyComponent'),
  {
    loading: () => <p>로딩 중...</p>,
    ssr: false // 클라이언트 사이드에서만 로드
  }
);

// React의 lazy와 Suspense 조합
const LazyComponent = lazy(() => import('../components/LazyComponent'));

function NextJsPage() {
  return (
    <div>
      <h1>Next.js 페이지</h1>
      
      {/* Next.js 방식 */}
      <DynamicComponent />
      
      {/* React 방식 */}
      <Suspense fallback={<div>React Lazy 로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </div>
  );
}

export default NextJsPage;

성능 측정 및 모니터링

로딩 시간 측정

javascript
import React, { lazy, Suspense, useEffect } from 'react';

const MeasuredComponent = lazy(() => {
  const startTime = performance.now();
  
  return import('./HeavyComponent').then((module) => {
    const endTime = performance.now();
    console.log(`Component loaded in ${endTime - startTime}ms`);
    
    // 성능 데이터를 분석 도구로 전송
    if (window.gtag) {
      window.gtag('event', 'lazy_component_load', {
        event_category: 'performance',
        event_label: 'HeavyComponent',
        value: Math.round(endTime - startTime)
      });
    }
    
    return module;
  });
});

function App() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <MeasuredComponent />
    </Suspense>
  );
}

번들 크기 분석

bash
# 번들 분석기를 사용하여 코드 분할 효과 확인
npm install --save-dev webpack-bundle-analyzer

# package.json에 스크립트 추가
{
  "scripts": {
    "analyze": "npm run build && npx webpack-bundle-analyzer build/static/js/*.js"
  }
}

모범 사례 및 주의사항

권장사항

  1. 적절한 분할 단위: 너무 작게 나누면 오히려 성능이 저하될 수 있습니다
  2. 의미있는 로딩 상태: 사용자에게 명확한 피드백을 제공하세요
  3. 에러 처리: 네트워크 오류나 로딩 실패에 대비하세요
  4. 프리로딩 활용: 사용자 행동을 예측하여 미리 로드하세요

피해야 할 패턴

javascript
// ❌ 너무 작은 컴포넌트 분할
const TinyComponent = lazy(() => import('./TinyComponent')); // 1KB 미만

// ❌ 즉시 필요한 컴포넌트 분할
const CriticalHeader = lazy(() => import('./Header')); // 항상 표시되는 헤더

// ❌ 에러 처리 없음
function BadExample() {
  return (
    <Suspense fallback={<div>로딩 중...</div>}>
      <LazyComponent /> {/* 에러 경계 없음 */}
    </Suspense>
  );
}

// ✅ 적절한 크기와 에러 처리
const FeatureModule = lazy(() => import('./FeatureModule')); // 의미있는 크기

function GoodExample() {
  return (
    <ErrorBoundary>
      <Suspense fallback={<FeatureModuleSkeleton />}>
        <FeatureModule />
      </Suspense>
    </ErrorBoundary>
  );
}

마무리

React의 Suspense와 Lazy를 활용한 코드 분할은 현대 웹 애플리케이션의 성능 최적화에 필수적인 기법입니다. 적절히 활용하면:

  • 초기 로딩 시간 단축: 필요한 코드만 먼저 로드
  • 사용자 경험 개선: 빠른 페이지 전환과 반응성
  • 리소스 효율성: 네트워크와 메모리 사용량 최적화
  • 확장성: 애플리케이션이 커져도 성능 유지

하지만 무분별한 코드 분할은 오히려 성능을 저하시킬 수 있으므로, 사용자의 행동 패턴과 애플리케이션의 특성을 고려하여 전략적으로 적용하는 것이 중요합니다.

성능 측정 도구를 활용하여 분할 효과를 지속적으로 모니터링하고, 사용자 피드백을 바탕으로 최적화를 개선해 나가시기 바랍니다.