한우의 개발일기

lazy() 알아보기 본문

React

lazy() 알아보기

한우코딩 2025. 3. 28. 15:29

React의 lazy() 함수 이해하기: 동작 원리와 활용법

React로 대규모 애플리케이션을 개발할 때 마주치는 가장 큰 문제 중 하나는 초기 로딩 시간입니다. 사용자가 애플리케이션에 처음 접속할 때 모든 코드를 한 번에 다운로드하면 초기 로딩 시간이 길어져 사용자 경험이 저하됩니다. 이런 문제를 해결하기 위해 React는 lazy() 함수를 제공합니다.

React.lazy()란?

React.lazy()는 코드 분할(Code Splitting)을 쉽게 구현할 수 있게 해주는 React의 내장 기능입니다. 코드 분할은 애플리케이션을 여러 개의 작은 번들로 나누고, 필요한 시점에만 해당 번들을 로드하는 기술입니다.

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

// 일반적인 임포트
// import MyComponent from './MyComponent';

// lazy()를 사용한 동적 임포트
const MyComponent = lazy(() => import('./MyComponent'));

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

위 코드에서 MyComponent는 애플리케이션이 시작될 때 바로 로드되지 않고, 실제로 렌더링이 필요한 시점에만 로드됩니다. 로딩이 완료되기 전까지는 fallback으로 지정한 컴포넌트가 표시됩니다.

lazy()의 동작 원리

1. 기본 동작 흐름

lazy()의 동작 흐름은 다음과 같습니다:

  1. lazy()는 동적 import()를 인자로 받아 특별한 React 컴포넌트를 반환합니다.
  2. 이 컴포넌트가 처음 렌더링될 때, 지정한 모듈을 비동기적으로 로드합니다.
  3. 모듈 로딩 중에는 React의 Suspense 메커니즘을 통해 렌더링을 일시 중단합니다.
  4. 모듈 로딩이 완료되면 내보낸 컴포넌트를 렌더링합니다.

2. Suspense와의 연동 - 왜 반드시 함께 써야 하는가?

lazy()는 반드시 Suspense 컴포넌트와 함께 사용해야 합니다. 이는 단순한 권장사항이 아니라 기술적 필수사항입니다. 그 이유는 lazy()의 내부 동작 메커니즘에 있습니다.

Suspense와 lazy()의 동작 원리

  1. Promise 던지기 메커니즘

    • lazy()로 생성된 컴포넌트가 렌더링될 때, 해당 모듈이 아직 로드되지 않았다면 Promise 객체를 던집니다(throw).
    • 일반적인 JavaScript에서 throw는 에러를 발생시키는 데 사용되지만, React의 Suspense 시스템에서는 이 특별한 패턴을 사용하여 "지금은 렌더링할 수 없으니 나중에 다시 시도해달라"는 신호로 활용합니다.
  2. Suspense의 Promise 캐치

    • Suspense 컴포넌트는 자식 컴포넌트에서 던져진 Promise를 캐치합니다.
    • Promise가 캐치되면 Suspense는 자신의 fallback prop에 지정된 컴포넌트를 대신 렌더링합니다.
  3. Promise 해결 후 재시도

    • 던져진 Promise가 해결(resolve)되면, React는 자동으로 렌더링을 다시 시도합니다.
    • 이번에는 모듈이 로드되었으므로 실제 컴포넌트가 렌더링됩니다.

이 전체 과정을 코드로 표현하면 다음과 같습니다:

<Suspense fallback={<Spinner />}>
  <LazyComponent />  {/* 아직 로드되지 않았다면 Promise를 throw */}
</Suspense>

LazyComponent가 로딩 중일 때 Spinner 컴포넌트가 대신 표시되고, 로딩이 완료되면 자동으로 LazyComponent가 렌더링됩니다.

Suspense 없이 lazy()를 사용하면 어떻게 될까?

만약 Suspense 없이 lazy()를 사용하면 다음과 같은 상황이 발생합니다:

  1. lazy() 컴포넌트가 렌더링될 때 Promise를 throw합니다.
  2. 이 Promise를 캐치할 Suspense가 없으므로 일반적인 예외처리와 동일하게 처리됩니다.
  3. 결과적으로 "[object Promise] was thrown. React doesn't know how to handle this type of thrown value."와 같은 에러가 발생하고 애플리케이션이 중단됩니다.

따라서 lazy()는 반드시 Suspense와 함께 사용해야 정상적으로 동작합니다.

3. 내부 동작 방식 간략 설명

lazy()의 내부 동작은 간략히 다음과 같습니다:

function lazy(importFn) {
  // 로딩 상태를 관리하는 객체
  const cache = {
    status: 'pending',  // 'pending', 'fulfilled', 'rejected' 중 하나
    result: null        // 로드된 컴포넌트 또는 에러
  };

  // Promise 기반 로딩 시작
  importFn().then(
    module => {
      cache.status = 'fulfilled';
      cache.result = module.default;  // ES 모듈의 기본 내보내기
    },
    error => {
      cache.status = 'rejected';
      cache.result = error;
    }
  );

  // 실제 렌더링 시 호출되는 컴포넌트
  return function LazyComponent(props) {
    // 상태에 따른 처리
    if (cache.status === 'fulfilled') {
      // 로딩 완료 - 실제 컴포넌트 렌더링
      const Component = cache.result;
      return <Component {...props} />;
    } else if (cache.status === 'rejected') {
      // 로딩 실패 - 에러 발생
      throw cache.result;
    } else {
      // 로딩 중 - Promise를 throw하여 Suspense 발동
      throw importFn();
    }
  };
}

이 구현은 실제 React 내부 구현과 조금 다르지만, 기본 원리는 동일합니다:

  1. 모듈 로딩 상태 관리
  2. 로딩 중일 때 Promise를 throw하여 Suspense 활성화
  3. 로딩 완료 시 실제 컴포넌트 렌더링

React의 실제 구현은 더 복잡하지만, 이 간략화된 버전으로도 핵심 개념을 이해할 수 있습니다.

lazy()의 실제 사용 사례

1. 라우트 기반 코드 분할

가장 일반적인 사용 사례는 라우트 기반 코드 분할입니다

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

const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const Contact = lazy(() => import('./routes/Contact'));
const Dashboard = lazy(() => import('./routes/Dashboard'));

function App() {
  return (
    <Routes>
      <Route path="/" element={
        <Suspense fallback={<div>로딩 중...</div>}>
          <Home />
        </Suspense>
      } />
      <Route path="/about" element={
        <Suspense fallback={<div>로딩 중...</div>}>
          <About />
        </Suspense>
      } />
      <Route path="/contact" element={
        <Suspense fallback={<div>로딩 중...</div>}>
          <Contact />
        </Suspense>
      } />
      <Route path="/dashboard" element={
        <Suspense fallback={<div>로딩 중...</div>}>
          <Dashboard />
        </Suspense>
      } />
    </Routes>
  );
}

이렇게 하면 사용자가 특정 페이지에 접근할 때만 해당 페이지의 코드가 로드됩니다.

2. 조건부 컴포넌트 로딩

특정 조건에서만 필요한 무거운 컴포넌트는 조건부로 로딩할 수 있습니다

function AdminPanel({ isAdmin }) {
  // 관리자만 볼 수 있는 무거운 컴포넌트
  const AdminDashboard = lazy(() => import('./AdminDashboard'));

  return (
    <div>
      {isAdmin ? (
        <Suspense fallback={<div>관리자 대시보드 로딩 중...</div>}>
          <AdminDashboard />
        </Suspense>
      ) : (
        <p>관리자 권한이 필요합니다.</p>
      )}
    </div>
  );
}

3. 사용자 상호작용에 따른 지연 로딩

버튼 클릭과 같은 사용자 상호작용에 따라 컴포넌트를 지연 로딩할 수 있습니다

function App() {
  const [showChart, setShowChart] = useState(false);
  const Chart = lazy(() => import('./Chart'));

  return (
    <div>
      <button onClick={() => setShowChart(true)}>
        차트 보기
      </button>

      {showChart && (
        <Suspense fallback={<div>차트 로딩 중...</div>}>
          <Chart />
        </Suspense>
      )}
    </div>
  );
}

lazy()의 고급 활용법

1. 사전 로딩 (Preloading)

사용자 경험을 더 향상시키기 위해 미리 컴포넌트를 로드할 수 있습니다. 이는 Suspense와 lazy()의 동작 원리를 활용한 최적화 기법입니다

// 컴포넌트 정의
const DetailView = lazy(() => import('./DetailView'));

// 사전 로딩 함수
const preloadDetailView = () => import('./DetailView');

function ProductList() {
  return (
    <div>
      {products.map(product => (
        <ProductCard 
          key={product.id} 
          product={product}
          onMouseEnter={preloadDetailView} // 마우스를 올렸을 때 미리 로드
        />
      ))}

      {selectedProduct && (
        <Suspense fallback={<div>상세 정보 로딩 중...</div>}>
          <DetailView product={selectedProduct} />
        </Suspense>
      )}
    </div>
  );
}

이 방식이 작동하는 이유는 dynamic import(import())가 모듈을 한 번만 로드하기 때문입니다.
그리고 이 모듈은 브라우저에 의해 캐시됩니다

  1. onMouseEnter에서 preloadDetailView가 호출되면 모듈 로딩이 시작됩니다.
  2. 사용자가 실제로 컴포넌트를 보기로 결정하면, 이미 모듈 로딩이 시작되었거나 완료되었기 때문에 기다리는 시간이 줄어듭니다.
  3. 모듈이 이미 완전히 로드되었다면, Suspense의 fallback이 전혀 표시되지 않고 즉시 컴포넌트가 렌더링됩니다.

2. 다중 Suspense 경계와 로딩 계층화

복잡한 UI의 경우 여러 개의 Suspense 경계를 사용하여 부분적으로 로딩 상태를 표시할 수 있습니다. 이는 Suspense의 중첩 사용을 통해 세밀한 로딩 제어가 가능함을 보여줍니다

function ProfilePage() {
  return (
    <div>
      <Suspense fallback={<UserHeaderSkeleton />}>
        <UserHeader />
      </Suspense>

      <Suspense fallback={<PostsSkeleton />}>
        <UserPosts />
      </Suspense>

      <Suspense fallback={<FriendsSkeleton />}>
        <UserFriends />
      </Suspense>
    </div>
  );
}

이 패턴이 작동하는 방식

  1. 독립적인 로딩 상태: 각 Suspense 경계는 독립적으로 작동하여, 한 컴포넌트의 로딩이 다른 컴포넌트의 렌더링을 차단하지 않습니다.

  2. 점진적 UI 렌더링: 준비된 컴포넌트부터 순차적으로 화면에 표시되므로, 사용자는 전체 페이지가 로드될 때까지 기다릴 필요가 없습니다.

  3. 컨텍스트별 로딩 UI: 각 컴포넌트에 맞는 스켈레톤 UI를 보여줌으로써 사용자 경험을 향상시킵니다.

중첩된 Suspense도 사용할 수 있습니다

function UserPosts() {
  return (
    <div>
      <h2>사용자 게시물</h2>
      <Suspense fallback={<PostListSkeleton />}>
        <PostList />
        <Suspense fallback={<LoadMoreButtonSkeleton />}>
          <LoadMoreButton />
        </Suspense>
      </Suspense>
    </div>
  );
}

이 경우 외부 Suspense가 활성화되면 내부 Suspense는 무시됩니다. 내부 Suspense는 외부 컴포넌트가 로드된 후에만 고려됩니다.

3. 로딩 실패 처리

ErrorBoundary를 사용하여 로딩 실패를 처리할 수 있습니다

import { ErrorBoundary } from 'react-error-boundary';

function MyComponent() {
  const LazyComponent = lazy(() => import('./HeavyComponent'));

  return (
    <ErrorBoundary
      fallback={<div>컴포넌트를 로드하는 중 오류가 발생했습니다.</div>}
    >
      <Suspense fallback={<div>로딩 중...</div>}>
        <LazyComponent />
      </Suspense>
    </ErrorBoundary>
  );
}

lazy()를 사용할 때 주의할 점

  1. 서버 사이드 렌더링: lazy()는 기본적으로 서버 사이드 렌더링을 지원하지 않습니다. SSR에서는 @loadable/component와 같은 대안을 고려하세요.

  2. 네트워크 환경: 불안정한 네트워크 환경에서는 로딩이 오래 걸릴 수 있습니다. 적절한 로딩 UI와 타임아웃 처리를 고려하세요.

  3. 번들 크기 모니터링: Webpack Bundle Analyzer와 같은 도구를 사용하여 분할된 번들의 크기를 모니터링하세요.

  4. 중첩된 lazy 컴포넌트: 중첩된 lazy 컴포넌트는 중첩된 Suspense 없이 사용하면 예상치 못한 동작을 보일 수 있습니다.

  5. 개발 모드와 프로덕션 모드: 개발 모드에서는 코드 분할의 효과를 제대로 체감하기 어렵습니다. 프로덕션 빌드에서 성능을 확인하세요.

실제 작동 방식 더 자세히 알아보기

Suspense와 lazy()의 내부 메커니즘

React의 lazy()와 Suspense 메커니즘은 "throw promise" 패턴을 기반으로 합니다. 이 패턴의 핵심은 다음과 같습니다:

  1. 렌더링 중단: 컴포넌트가 아직 준비되지 않았을 때 Promise 객체를 throw합니다.
  2. React의 캐치: React는 이 Promise를 캐치하고 가장 가까운 Suspense 경계를 활성화합니다.
  3. Promise 해결 감지: Promise가 해결되면 React는 자동으로 다시 렌더링을 시도합니다.
  4. 완료된 컴포넌트 렌더링: 컴포넌트가 준비되면 정상적으로 렌더링합니다.

Suspense의 렌더링 프로세스 이해하기

Suspense가 내부적으로 어떻게 작동하는지 더 구체적으로 살펴보겠습니다:

  1. 첫 번째 렌더링 시도:

    • React는 컴포넌트 트리를 렌더링합니다.
    • lazy() 컴포넌트를 만나면 모듈 로딩 상태를 확인합니다.
    • 모듈이 아직 로드되지 않았다면 Promise를 throw합니다.
  2. Suspense 대응:

    • Suspense는 자식 컴포넌트에서 던져진 Promise를 감지합니다.
    • 자식 컴포넌트의 렌더링을 중단하고 fallback UI를 렌더링합니다.
    • Promise를 구독하여 완료 시점을 모니터링합니다.
  3. 모듈 로딩 완료 후:

    • Promise가 해결되면 Suspense는 이를 감지합니다.
    • React에게 해당 부분을 다시 렌더링하도록 요청합니다.
    • 이번에는 모듈이 로드되었으므로 실제 컴포넌트가 렌더링됩니다.

Suspense의 동시 모드(Concurrent Mode) 최적화

React 18에서 도입된 동시 모드에서는 Suspense와 lazy()의 통합이 더욱 최적화되었습니다:

  1. 우선순위 기반 렌더링:

    • 동시 모드에서는 UI 업데이트에 우선순위를 부여할 수 있습니다.
    • Suspense가 활성화된 컴포넌트는 낮은 우선순위로 처리될 수 있어, 중요한 UI 업데이트가 차단되지 않습니다.
  2. 자동 중단 및 재시작:

    • 더 중요한 업데이트가 발생하면 현재 진행 중인 Suspense 렌더링을 중단하고 나중에 재개할 수 있습니다.
    • 이를 통해 애플리케이션의 반응성을 유지할 수 있습니다.
  3. 스트리밍 렌더링:

    • 서버 사이드 렌더링에서 Suspense를 사용하면 컴포넌트가 준비되는 대로 HTML을 스트리밍할 수 있습니다.
    • 이는 초기 로딩 성능을 크게 향상시킬 수 있습니다.

이 메커니즘을 이해하면 lazy()뿐만 아니라 데이터 패칭과 같은 다른 비동기 작업도 Suspense와 통합할 수 있습니다. React의 Future 기능인 use() 훅은 이런 패턴을 더욱 확장하여 Promise나 Context의 값을 직접 컴포넌트 내에서 사용할 수 있게 합니다.

결론

React의 lazy() 함수는 코드 분할을 통해 애플리케이션 성능을 크게 향상시킬 수 있는 강력한 도구입니다. 초기에는 필요한 코드만 로드하고, 나머지는 필요할 때 로드함으로써 초기 로딩 시간을 단축하고 사용자 경험을 개선할 수 있습니다.

lazy()의 동작 원리를 이해하면 더 효과적으로 활용할 수 있으며, 다양한 패턴과 기법을 통해 애플리케이션의 성능과 사용자 경험을 최적화할 수 있습니다

'React' 카테고리의 다른 글

hydration알아보기  (0) 2025.04.01
Suspense 알아보기  (1) 2025.03.24
Errorboundary 내부동작 이해하기  (1) 2025.03.23
useEffect vs useLayoutEffect  (0) 2025.03.21
useAsync 파해치기  (0) 2025.03.18