프론트엔드

dynamic import를 이용한 lazy loading, code splitting 개념 정리

yoxxin 2024. 11. 8. 03:23

들어가기

웹페이지를 만들 때 랜딩 페이지의 로딩 시간은 중요하다.

페이지 로드 속도가 느려지면 그만큼 사용자 이탈률도 높아진다.

이때 문제를 해결하기 위해서 CDN 캐싱 등 여러 방법을 이용할 수 있지만,

코드 레벨에서의 가장 대표적인 방법은 dynamic import이다.

용어 정리

용어부터 짚고 넘어가면 좋을 것 같다.

초기 로딩 시간 줄이기에 대해 검색하면 code splitting, dynamic import, lazy loading 키워드들을 보게된다.

이를 정리하면 다음과 같다:

dynamic import는 lazy loading을 하기 위한 수단이고, lazy loading을 하기 위해서는 code splitting이 선행되어야 한다.

코드 레벨에서 dynamic import를 사용하면 번들링 때 code splitting이 되고, 런타임 때 lazy loading이 된다.

 

1. 번들링 시: import() 구문을 만나면 번들러(Webpack 등)는 이 모듈을 별도의 청크로 분리하여(Code Splitting) 저장한다.

2. 런타임 시: 실제 코드가 실행될 때, Dynamic Import가 해당 청크를 필요한 시점에 불러오면서(Lazy Loading) 초기 로딩 부담을 줄인다.

Nextjs, React에서의 lazy loading

Nextjs를 사용한다면 next/dynamic 에서 제공하는 dynamic 함수를 이용해 lazy loading 할 수 있다.

dynamic 함수는 구현 코드를 뜯어보면 React.lazy()와 Suspense를 이용하고 있음을 알 수 있다.(내부 Loadable 함수 참고)

또한 옵션으로 클라이언트 컴포넌트의 `ssr`(클라이언트 컴포넌트는 ssr과 csr 두 곳에서 렌더링 되므로), Suspense fallback을 편하게 이용할 수 있는 `loading` 옵션을 제공한다.

// dynamic 함수 사용 예시
'use client'
 
import { useState } from 'react'
import dynamic from 'next/dynamic'
 
// Client Components:
const ComponentA = dynamic(() => import('../components/A'))
const ComponentB = dynamic(() => import('../components/B'))
const ComponentC = dynamic(() => import('../components/C'), { ssr: false })
 
export default function ClientComponentExample() {
  const [showMore, setShowMore] = useState(false)
 
  return (
    <div>
      {/* Load immediately, but in a separate client bundle */}
      <ComponentA />
 
      {/* Load on demand, only when/if the condition is met */}
      {showMore && <ComponentB />}
      <button onClick={() => setShowMore(!showMore)}>Toggle</button>
 
      {/* Load only on the client side */}
      <ComponentC />
    </div>
  )
}

React를 사용한다면 직접 lazy 함수와 Suspense를 이용해 구현할 수 있다. 공식문서

import { lazy } from 'react';

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

...
<Suspense fallback={<Loading />}>
  <h2>Preview</h2>
  <MarkdownPreview />
</Suspense>

 

위 두 사용 예시를 보면 사용 방식이 동일하다는 것을 알 수 있다.

const ComponentA = dynamic(() => import('../components/A'))

const MarkdownPreview = lazy(() => import('./MarkdownPreview.js'));

 

이때 () => import('파일경로') 구문이 바로 dynamic import에 해당한다.

dynamic import와 번들러의 역할

동적 import 키워드는 필요한 파일을 메모리에 올리고, 해당 파일이 없다면 서버에 요청하는 역할을 한다.

 

예를 들어 런타임 때 코드를 한 줄씩 읽어가면서 동적 import 키워드를 만나게 될 텐데, 만약 번들러가 빌드 시 파일들을 청크로 분리하지 않는다면 초기 로딩 시 브라우저는 결국 한 덩어리로 된 빌드 파일을 받아올 수밖에 없다. 이때 동적 import()의 장점은 고작 메모리에 올리는 시간을 절약하는 데 그친다.

 

하지만 실제로 번들러는 빌드 시 동적 import() 구문을 만나면 해당 파일을 청크로 분리한다(code splitting).

그 결과 빌드 파일이 여러 개의 청크로 나눠지게 된다.

이제 브라우저가 동적 import 키워드를 만나면 그 청크가 필요할 때만 서버에 요청하여 가져오게 된다.

즉, 초기 JS 로딩 파일 크기가 줄어들어 초기 로딩 시간을 줄일 수 있다.

사용 예시

무거운 모달 컴포넌트클릭 시 dynamic import 하여 초기 로딩 속도를 최적화할 수 있다.

초기 로딩 시점에 반드시 필요하지 않은,

예를 들어 모달, 팝업 등 무거운 UI 요소 등을 Dynamic Import로 로드 시점을 조절하여 성능을 최적화해 보자.

 

파일 구조

components/
 ├── HeavyModal.js           // 모달 컴포넌트 (첫 화면 로딩 후에 필요)
 └── LightComponent.js       // 첫 화면에서 항상 필요한 컴포넌트
pages/
 └── index.js                // 메인 페이지
// components/HeavyModal.js
export default function HeavyModal() {
  return (
    <div style={{ padding: '20px', background: 'lightgray' }}>
      <h2>난 엄청 무거운 모달 컴포넌트</h2>
    </div>
  );
}

index.js에서 동적 import로 모달 로드 설정

// pages/index.js
import { useState } from 'react';
import dynamic from 'next/dynamic';
import LightComponent from '@/components/LightComponent';

// `HeavyModal`는 필요할 때만 로드되도록 설정
const HeavyModal = dynamic(() => import('@/components/HeavyModal'), {
  loading: () => <p>Loading modal...</p>, // 로딩 중에 표시할 UI
  ssr: false,                             // 서버사이드 렌더링 비활성화
});

export default function Home() {
  const [showModal, setShowModal] = useState(false);

  return (
    <div>
      <h1>Welcome to Next.js!</h1>
      <LightComponent />
      <button onClick={() => setShowModal(true)}>
        Open Heavy Modal
      </button>

      {/* 모달이 필요한 경우에만 로드 */}
      {showModal && <HeavyModal />}
    </div>
  );
}

서버 컴포넌트 내에서 Dynamic Import로 클라이언트 컴포넌트 로드

서버 컴포넌트 내에 포함된 클라이언트 컴포넌트가 초기 시점에 필요하긴 하지만 크기가 커서 초기 로딩에 부담을 줄 때,

dynamic import를 이용해 초기 로딩 속도를 개선할 수 있다.

// ServerComponent.js (서버 컴포넌트)
import dynamic from 'next/dynamic';

// 무거운 클라이언트 컴포넌트를 동적 로드
const HeavyClientComponent = dynamic(() => import('./HeavyClientComponent'), {
  ssr: false,
});

export default function ServerComponent() {
  return (
    <div>
      <h1>Welcome to the Server Component!</h1>
      <HeavyClientComponent />
    </div>
  );
}