Next.js dynamic import
React 컴포넌트를 렌더링할 때 모든 컴포넌트를 한 번에 번들로 묶으면 초기 로딩이 느려진다. 특히 PDF 뷰어, 차트 라이브러리, 코드 에디터처럼 무거운 라이브러리에 의존하는 컴포넌트가 있으면 사용자가 해당 기능을 사용하지 않더라도 전체 번들 크기가 커진다.
React에서는 React.lazy()와 Suspense로 코드 스플리팅을 할 수 있다. 하지만 Next.js에서는 서버 사이드 렌더링(SSR)이라는 추가적인 문제가 있다. 브라우저 전용 API(window, document, canvas 등)에 의존하는 라이브러리는 서버에서 import만 해도 에러가 발생한다. React.lazy()는 SSR을 비활성화하는 옵션이 없기 때문에 이런 상황을 처리할 수 없다.
next/dynamic은 React.lazy() + Suspense를 감싼 래퍼로, SSR 제어와 로딩 UI를 하나의 API로 해결한다.
기본 사용법
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/HeavyChart'));
이렇게 하면 HeavyChart 컴포넌트는 실제로 렌더링될 때 비동기로 로드된다. 번들러가 자동으로 별도 청크로 분리하기 때문에 초기 번들에 포함되지 않는다.
내부적으로 next/dynamic은 React.lazy()를 호출하고, 반환된 컴포넌트를 Suspense로 감싼다. 결과적으로 React의 코드 스플리팅 메커니즘을 그대로 활용하면서 Next.js 환경에 맞는 추가 기능을 제공하는 것이다.
SSR 비활성화
next/dynamic을 쓰는 가장 큰 이유다. ssr: false 옵션을 설정하면 해당 컴포넌트는 서버에서 렌더링되지 않고, 클라이언트에서만 로드된다.
const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((mod) => ({ default: mod.PDFViewer })),
{ ssr: false }
);
이 옵션이 필요한 대표적인 경우:
- PDF 렌더링 라이브러리 (
pdfjs-dist): Web Worker API와 Canvas API에 의존 - Canvas 기반 라이브러리 (
konva,fabric.js):window와document가 필요 - 브라우저 전용 라이브러리 (
chart.js등): DOM 측정이 필수
ssr: false를 설정하면 서버에서는 해당 컴포넌트 위치에 아무것도 렌더링하지 않거나(또는 loading 컴포넌트를 렌더링) 클라이언트 하이드레이션 이후에 비동기로 모듈을 로드한다. 이렇게 하면 서버 환경에서 브라우저 API에 접근하려는 시도 자체가 발생하지 않는다.
typeof window 체크와의 차이
SSR을 피하는 다른 방법으로 typeof window !== 'undefined' 체크가 있다.
// 방법 1: 조건부 렌더링
function Page() {
if (typeof window === 'undefined') return null;
const PDFViewer = require('@/components/PDFViewer');
return <PDFViewer />;
}
이 방식의 문제는 모듈 자체는 서버에서도 번들에 포함된다는 점이다. require()가 빌드 타임에 해석되기 때문에 해당 모듈의 최상위 코드가 서버에서 실행될 수 있다. dynamic의 ssr: false는 빌드 레벨에서 서버 번들 자체에 해당 모듈을 포함시키지 않기 때문에 더 안전하다.
loading 옵션
모듈이 로드되는 동안 보여줄 컴포넌트를 지정할 수 있다.
const Editor = dynamic(() => import('@/components/Editor'), {
loading: () => <Skeleton className="h-[400px] w-full" />,
ssr: false,
});
loading 컴포넌트는 동적 컴포넌트가 로드 완료될 때까지 대신 렌더링된다. 네트워크 속도가 느리거나 모듈이 무거울 때 빈 화면 대신 스켈레톤이나 스피너를 보여주면 UX가 훨씬 자연스러워진다.
참고로 loading은 React Suspense의 fallback과 같은 역할을 한다. next/dynamic이 내부적으로 Suspense를 사용하기 때문이다.
Named Export 처리
모듈이 default export가 아닌 named export를 사용하는 경우, then()으로 원하는 export를 선택해야 한다.
// PDFViewer.tsx
export function PDFViewer() { ... } // named export
// 사용하는 곳
const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((mod) => ({ default: mod.PDFViewer })),
{ ssr: false }
);
dynamic()은 default export를 기대하기 때문에, named export를 { default: mod.NamedExport } 형태로 감싸서 반환해야 한다. 이걸 빠뜨리면 컴포넌트가 렌더링되지 않거나 에러가 발생한다.
React.lazy()와 비교
React.lazy() | next/dynamic | |
|---|---|---|
| SSR 제어 | 불가능 | ssr: false 옵션 |
| 로딩 UI | Suspense의 fallback 직접 설정 | loading 옵션으로 간편 설정 |
| Named export | then()으로 직접 처리 | 동일 |
| 서버 컴포넌트 | 지원하지 않음 | 지원 (App Router) |
Next.js 13+ App Router에서 서버 컴포넌트를 사용한다면, React.lazy()는 서버 컴포넌트 내에서 사용할 수 없다. next/dynamic은 서버 컴포넌트에서도 동작하며, import된 컴포넌트가 클라이언트 컴포넌트로 취급되게 해준다.
순수 CSR(Create React App 등) 환경에서는 React.lazy()로 충분하다. Next.js를 쓴다면 SSR 제어가 필요한 순간이 반드시 오기 때문에 next/dynamic을 쓰는 게 자연스럽다.
실전 패턴
조건부 렌더링과 결합
동적 import는 컴포넌트가 실제로 렌더링될 때 모듈을 로드한다. 조건부 렌더링과 결합하면 특정 조건에서만 모듈을 로드할 수 있다.
const Editor = dynamic(() => import('@/components/Editor'), { ssr: false });
function Page() {
const [editing, setEditing] = useState(false);
return (
<div>
<button onClick={() => setEditing(true)}>편집</button>
{editing && <Editor />}
</div>
);
}
editing이 true가 되기 전까지 Editor 모듈은 로드되지 않는다. 사용자가 편집 버튼을 누르는 순간에만 네트워크 요청이 발생한다.
미디어 타입별 뷰어 분기
실제 프로젝트에서 흔한 패턴으로, 미디어 타입에 따라 다른 뷰어를 렌더링하되, 무거운 뷰어만 동적 import하는 방식이다.
import { ImageViewer } from '@/components/ImageViewer'; // 가벼움 → static
import { VideoPlayer } from '@/components/VideoPlayer'; // 가벼움 → static
const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((mod) => ({ default: mod.PDFViewer })),
{ ssr: false }
);
function MediaViewer({ type, src }: Props) {
switch (type) {
case 'image': return <ImageViewer src={src} />;
case 'video': return <VideoPlayer src={src} />;
case 'pdf': return <PDFViewer src={src} />;
}
}
모든 뷰어를 동적 import할 필요는 없다. 가벼운 컴포넌트는 정적 import로 두고, 무거운 것만 동적으로 분리하는 게 효과적이다. 코드 스플리팅은 "모든 곳에 적용하면 좋다"가 아니라 "무거운 곳에만 적용하면 된다"가 맞다.
useMemo와 함께 사용
동적 컴포넌트를 조건에 따라 선택할 때 useMemo로 감싸면 불필요한 재생성을 방지할 수 있다.
const PDFViewer = dynamic(
() => import('@/components/PDFViewer').then((mod) => ({ default: mod.PDFViewer })),
{ ssr: false }
);
function MediaViewer({ type, src }: Props) {
const ViewerComponent = useMemo(() => {
switch (type) {
case 'pdf': return PDFViewer;
case 'image': return ImageViewer;
default: return null;
}
}, [type]);
if (!ViewerComponent) return null;
return <ViewerComponent src={src} />;
}
주의할 점
동적 import 경로는 정적이어야 한다
// ❌ 변수를 사용한 동적 경로 — 번들러가 분석할 수 없음
const Component = dynamic(() => import(`@/components/${name}`));
// ✅ 정적 경로
const ComponentA = dynamic(() => import('@/components/ComponentA'));
const ComponentB = dynamic(() => import('@/components/ComponentB'));
Webpack/Turbopack은 빌드 타임에 import 경로를 분석해서 청크를 생성한다. 경로에 변수가 들어가면 어떤 모듈이 필요한지 알 수 없어서 코드 스플리팅이 제대로 동작하지 않는다.
과도한 사용은 오히려 성능 저하
모든 컴포넌트를 동적 import하면 HTTP 요청 수가 늘어나고, 각 청크를 로드할 때마다 네트워크 라운드트립이 발생한다. 작은 컴포넌트를 동적 import하면 메인 번들 크기 절감보다 추가 요청의 오버헤드가 더 클 수 있다. 기준은 "이 컴포넌트가 초기 렌더링에 필요한가?"와 "번들 크기에 유의미한 영향을 주는가?"다.
서버 컴포넌트에서의 동작
App Router의 서버 컴포넌트에서 next/dynamic을 사용하면, import된 컴포넌트는 자동으로 클라이언트 컴포넌트로 취급된다. 하지만 서버 컴포넌트 자체에서는 ssr: false가 의미가 없다 — 서버 컴포넌트는 항상 서버에서 렌더링되기 때문이다. ssr: false는 클라이언트 컴포넌트에서만 의미 있는 옵션이다.
정리
- next/dynamic은 React.lazy + Suspense 래퍼로, ssr: false 옵션이 핵심 차별점이다
- 브라우저 전용 API에 의존하는 무거운 컴포넌트만 선택적으로 적용하고, 가벼운 컴포넌트는 정적 import로 둔다
- import 경로는 반드시 정적 문자열이어야 하며, 변수를 사용하면 번들러가 청크를 생성할 수 없다