Next.js Dynamic Import와 SSR 제어: lazy vs dynamic 완벽 비교
React lazy와 Next.js dynamic의 SSR 동작 차이와 최적의 코드 스플리팅 전략
Next.js는 공식 문서에 명시되지 않은 수많은 내부 최적화를 수행합니다. 그 중 하나가 렌더링 전 이벤트 루프를 지연시켜 동적 임포트를 분석하고, Link Preload 헤더를 자동으로 생성하는 메커니즘입니다.
이 글에서는 MSW(Mock Service Worker)와 App Router 통합 과정에서 우연히 발견한 Next.js의 렌더링 최적화 전략을 살펴봅니다. 특히 JavaScript 이벤트 루프를 활용한 동적 임포트 수집과 리소스 preload 자동화 과정을 깊이 있게 분석합니다.
Next.js App Router에서 MSW를 통합할 때 다음과 같은 타이밍 문제가 발생했습니다:
요구사항:
기존 방식의 한계:
// [주의] 문제: 컴포넌트 렌더링 후 MSW 초기화
export default function RootLayout({ children }) {
useEffect(() => {
// 렌더링 이후에 실행됨 - 너무 늦음
initMSW();
}, []);
return <html>{children}</html>;
}MSW는 Node.js 환경용 setupServer와 브라우저 환경용 setupWorker를 제공하지만, Next.js의 SSR 라이프사이클에 정확히 통합할 표준 방법이 부재했습니다.
MSW 예제 저장소에서 App Router 통합 PR을 발견했습니다. 이 방식은 React의 use hook과 Promise를 활용하여 렌더링 전 MSW 초기화를 보장합니다.
'use client';
import { PropsWithChildren, use } from 'react';
const shouldMockRequest = (url: string) => {
return !url.includes('_rsc') && !url.includes('/_next/') && url.includes('api');
};
// 모듈 레벨에서 Promise 생성 (최초 1회만 실행)
const mockingEnabledPromise =
typeof window !== 'undefined'
? // 브라우저 환경: Service Worker 초기화
import('./browser').then(async ({ worker }) => {
await worker.start({
onUnhandledRequest: (request, print) => {
if (shouldMockRequest(request.url)) {
print.warning();
}
},
});
})
: // Node.js 환경: MSW 서버 초기화
import('./mocks/server').then(async ({ server }) => {
await server.listen({
onUnhandledRequest: (request, print) => {
if (shouldMockRequest(request.url)) {
print.warning();
}
},
});
});
export function MSWProvider({ children }: PropsWithChildren) {
// use()가 Promise가 완료될 때까지 렌더링 중단
use(mockingEnabledPromise);
return <>{children}</>;
}1. 모듈 레벨 Promise 실행
// 모듈 import 시 즉시 평가됨
const mockingEnabledPromise = typeof window !== 'undefined'
? import('./browser').then(...)
: import('./server').then(...);2. React use Hook의 Suspend 동작
export function MSWProvider({ children }) {
use(mockingEnabledPromise); // Promise가 완료될 때까지 렌더링 중단
return <>{children}</>;
}3. SSR 라이프사이클
1. 서버에서 MSWProvider 렌더링 시도
2. use(mockingEnabledPromise) 실행
3. Promise 완료 대기 (MSW 서버 초기화 완료)
4. children 렌더링 시작
5. API 호출 → MSW 인터셉트 [권장]
위 MSW 통합 코드가 동작하는 원리를 분석하던 중, Next.js 소스 코드에서 흥미로운 패턴을 발견했습니다.
Next.js SSR 렌더링 파이프라인 내부:
// packages/next/src/server/render.tsx (simplified)
export async function renderToHTML(...) {
// 렌더링 전 의도적으로 이벤트 루프 지연
await new Promise((resolve) => setTimeout(resolve, 0));
// 이후 실제 렌더링 시작
const stream = await renderToReadableStream(<App />);
return stream;
}주석을 확인한 결과, 다음과 같은 내용이 있었습니다:
"We don't want synchronous rendering, but we need to wait for dynamic imports to be analyzed for preload optimization."
setTimeout(fn, 0)의 의미:
await new Promise((resolve) => setTimeout(resolve, 0));이 코드는 JavaScript 이벤트 루프를 1틱(tick) 지연시킵니다:
[현재 틱]
1. 렌더링 준비
2. 동적 임포트 Promise 생성 (마이크로태스크 큐에 추가)
3. setTimeout으로 다음 틱까지 대기
[다음 틱]
4. 마이크로태스크 큐의 모든 Promise 완료
5. Next.js가 수집한 동적 임포트 목록 확인
6. 실제 렌더링 시작
1. 개발자가 작성한 코드:
// app/dashboard/page.tsx
import dynamic from 'next/dynamic';
const Chart = dynamic(() => import('./Chart'));
const Analytics = dynamic(() => import('./Analytics'));
export default function Dashboard() {
return (
<div>
<Chart />
<Analytics />
</div>
);
}2. Next.js 내부 추적:
// Next.js 내부 (simplified)
const dynamicImports = new Set<string>();
function dynamic(loader: () => Promise<any>) {
const importPromise = loader();
// 임포트 경로 수집
importPromise.then((module) => {
dynamicImports.add(module.__esModule);
});
return importPromise;
}3. 이벤트 루프 대기 후 수집 완료:
await new Promise((resolve) => setTimeout(resolve, 0));
// 이 시점에 dynamicImports에는:
// ['./Chart.js', './Analytics.js']Next.js는 수집한 동적 임포트 정보를 기반으로 HTTP Link Preload 헤더를 자동 생성합니다.
생성되는 HTTP 응답 헤더:
HTTP/1.1 200 OK
Content-Type: text/html
Link: </chunks/Chart.js>; rel=preload; as=script
Link: </chunks/Analytics.js>; rel=preload; as=script
<!DOCTYPE html>
<html>...</html>브라우저 동작:
1. HTML 응답 수신
2. Link 헤더 파싱 (HTML 파싱 전!)
3. Chart.js, Analytics.js 병렬 다운로드 시작
4. HTML 파싱 시작
5. <script> 태그 만나도 이미 다운로드 완료 → 즉시 실행
Link Preload가 없는 경우:
0ms: HTML 요청
100ms: HTML 수신 시작
200ms: HTML 파싱 중 <script src="Chart.js"> 발견
201ms: Chart.js 요청 시작 ← 100ms 손실!
301ms: Chart.js 다운로드 완료
Link Preload가 있는 경우:
0ms: HTML 요청
100ms: HTML + Link 헤더 수신
101ms: Chart.js 병렬 다운로드 시작 ← 즉시!
200ms: HTML 파싱 시작
201ms: <script src="Chart.js"> 발견 → 이미 다운로드 완료!
결과: ~100ms 절감
브라우저 개발자 도구에서 Response Headers를 확인하여 Next.js가 자동 생성한 preload를 볼 수 있습니다:
curl -I https://your-app.com/dashboard출력 예시:
HTTP/2 200
link: </_next/static/chunks/123.js>; rel=preload; as=script
link: </_next/static/chunks/456.js>; rel=preload; as=script
link: </_next/static/css/app.css>; rel=preload; as=styleChrome DevTools의 Network 탭에서 확인:
Before (preload 없음):
HTML: 0ms ~ 100ms
└─ Waiting: 100ms ~ 200ms (HTML 파싱)
└─ Chart.js: 200ms ~ 300ms ← Waterfall 발생
After (preload 있음):
HTML: 0ms ~ 100ms
Chart.js: 100ms ~ 200ms ← HTML과 병렬 다운로드
Next.js의 자동 preload 외에 수동으로 추가할 수도 있습니다:
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="preload" href="/fonts/custom.woff2" as="font" crossOrigin="anonymous" />
<link rel="preload" href="/critical-image.webp" as="image" />
</head>
<body>{children}</body>
</html>
);
}Remix는 loader 단계에서 명시적으로 preload합니다:
// routes/dashboard.tsx
export const links = () => [
{ rel: 'preload', href: '/chunks/Chart.js', as: 'script' },
];특징:
SvelteKit은 빌드 타임에 정적 분석으로 preload 생성:
// +page.svelte
<script>
import Chart from './Chart.svelte'; // 빌드 시 분석
</script>특징:
// [주의] 너무 많은 동적 임포트
const A = dynamic(() => import('./A'));
const B = dynamic(() => import('./B'));
const C = dynamic(() => import('./C'));
// ... 20개 이상
// [권장] 적절한 청크 분할
const DashboardWidgets = dynamic(() => import('./DashboardWidgets'));이유:
// [참고] 주의: 조건부 렌더링은 항상 preload됨
const Admin = dynamic(() => import('./Admin'));
export default function Dashboard({ isAdmin }) {
return (
<div>
{isAdmin && <Admin />} {/* isAdmin=false여도 Admin.js preload */}
</div>
);
}해결책:
// [권장] 조건부 동적 임포트
export default function Dashboard({ isAdmin }) {
const [AdminComponent, setAdmin] = useState(null);
useEffect(() => {
if (isAdmin) {
import('./Admin').then(mod => setAdmin(() => mod.default));
}
}, [isAdmin]);
return <div>{AdminComponent && <AdminComponent />}</div>;
}Next.js의 자동 최적화 효과를 측정하려면:
// middleware.ts
export function middleware(request: NextRequest) {
const start = Date.now();
return NextResponse.next({
headers: {
'Server-Timing': `render;dur=${Date.now() - start}`,
},
});
}브라우저 DevTools → Network → Timing 탭에서 확인 가능합니다.
Next.js는 이벤트 루프를 1틱 지연시켜 동적 임포트를 수집하고, Link Preload 헤더를 자동 생성하는 숨겨진 최적화를 수행합니다. 이는 공식 문서에 명시되지 않았지만, 실제 프로덕션 환경에서 상당한 성능 향상을 제공합니다.
핵심 권장사항:
이처럼 프레임워크는 표면적으로 드러나지 않는 최적화를 내부적으로 수행합니다. 소스 코드를 직접 탐색하면 이러한 메커니즘을 이해하고 더 효과적으로 활용할 수 있습니다.
React lazy와 Next.js dynamic의 SSR 동작 차이와 최적의 코드 스플리팅 전략
Next.js의 장단점과 실무 도입 시 고려해야 할 핵심 요소
React Server Components를 활용한 제로 클라이언트 번들 렌더링과 서버-클라이언트 조합 패턴