코드 분할 및 지연 로딩
라우트 단위 코드 분할과 next/dynamic 지연 로딩으로 초기 JavaScript를 줄이고 무거운 UI를 필요할 때 불러옵니다.
웹 애플리케이션의 성능 최적화에 있어 이미지와 폰트 외에도, 페이지 로딩 시 전송되는 JavaScript 코드의 양을 줄이는 것이 매우 중요합니다.
애플리케이션의 규모가 커질수록 JavaScript 번들의 크기는 기하급수적으로 증가할 수 있으며, 이는 초기 로딩 시간을 지연시키고 사용자 경험을 저해하는 주된 원인이 됩니다.
코드 분할(Code Splitting)과 지연 로딩(Lazy Loading)은 이러한 문제를 해결하기 위한 핵심적인 기술입니다.
Next.js는 이러한 기법들을 자동으로 적용하거나, 개발자가 직접 제어할 수 있는 API를 제공합니다.
이 절에서는 코드 분할과 지연 로딩의 개념, Next.js App Router에서 이를 구현하는 방법, 그리고 관련 최적화 기법들을 상세히 알아보겠습니다.
코드 분할(Code Splitting)이란?
코드 분할은 애플리케이션의 전체 JavaScript 코드를 하나의 큰 번들 파일로 만드는 대신, 여러 개의 작은 파일(청크, chunks)로 나누는 기법입니다.
청크를 나누면 초기 페이지에 필요하지 않은 JavaScript의 다운로드와 파싱을 미룰 수 있습니다. 다만 코드가 나뉘었다고 해서 모든 청크가 사용자 행동 뒤에만 요청되는 것은 아닙니다.
코드 분할의 이점- 초기 로딩 시간 단축: 사용자가 처음 방문하는 페이지에 필요한 코드만 다운로드되므로, 페이지가 더 빠르게 표시됩니다.
- 리소스 효율성: 초기 화면의 필수 코드와 나중에 쓸 코드를 구분할 수 있습니다. Next.js의 경로 prefetch는 방문 전에도 일부 자원을 가져올 수 있습니다.
- 캐싱 효율성: 코드 변경 시 전체 번들을 다시 다운로드할 필요 없이 변경된 청크만 다시 다운로드하면 되므로, 브라우저 캐싱 효율이 높아집니다.
Next.js는 라우트 경계에 따라 코드를 자동 분할합니다. 라우트마다 정확히 하나의 독립 JS 파일이 생기는 방식은 아니며, 공통 의존성은 공유 청크에 포함될 수 있습니다.
지연 로딩(Lazy Loading)이란?
지연 로딩은 특정 컴포넌트나 모듈이 실제로 필요할 때(예: 사용자가 특정 섹션으로 스크롤하거나, 특정 버튼을 클릭했을 때) 비동기적으로 로드하는 기법입니다.
이는 코드 분할과 함께 사용하여 특정 페이지 내에서도 중요도가 낮은 컴포넌트의 로딩을 지연시켜 초기 로딩 성능을 더욱 최적화할 수 있습니다.
App Router에서 구현
Next.js App Router는 컴포넌트 및 라이브러리를 지연 로드하는 다양한 방법을 제공합니다.
next/dynamic을 사용한 지연 로딩
Next.js에서 클라이언트 컴포넌트를 지연 로드하는 가장 기본적인 방법은 next/dynamic 유틸리티를 사용하는 것입니다.
이는 React의 React.lazy() 및 Suspense와 유사하게 작동하지만, Next.js의 SSR(Server-Side Rendering) 환경에서 더 잘 통합됩니다.
가정: 지도 라이브러리(react-leaflet, google-maps-react 등)는 용량이 커서 초기 로딩 시 번들에 포함되는 것을 피하고 싶습니다.
src/components/MapComponent.tsx)// 이 컴포넌트는 클라이언트 컴포넌트여야 합니다.
"use client";
import { useEffect, useState } from 'react';
interface MapProps {
latitude: number;
longitude: number;
zoom: number;
}
export default function MapComponent({ latitude, longitude, zoom }: MapProps) {
const [mapLoaded, setMapLoaded] = useState(false);
useEffect(() => {
// 실제 지도 라이브러리 로딩 및 초기화 로직
// 여기서는 가상으로 2초 지연을 시뮬레이션합니다.
const timer = setTimeout(() => {
setMapLoaded(true);
console.log('지도 컴포넌트가 로드되었습니다.');
}, 2000); // 지도 라이브러리 로딩 시간 시뮬레이션
return () => clearTimeout(timer);
}, []);
if (!mapLoaded) {
return (
<div style={{ height: '300px', backgroundColor: '#e0e0e0', display: 'flex', justifyContent: 'center', alignItems: 'center', color: '#666', border: '1px dashed #ccc' }}>
지도 로딩 중...
</div>
);
}
return (
<div style={{ height: '300px', backgroundColor: '#f0f8ff', border: '1px solid #007bff', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#007bff' }}>
<h3>지도 표시 (위도: {latitude}, 경도: {longitude})</h3>
<p>확대: {zoom}</p>
<p>실제 지도 라이브러리가 여기에 렌더링됩니다.</p>
</div>
);
}MapComponent 지연 로드 (src/app/locations/page.tsx)"use client";
import dynamic from 'next/dynamic';
// ssr: false는 Client Component에서만 선언할 수 있습니다.
// 지도처럼 브라우저 API에 의존하는 라이브러리를 클라이언트에서만 렌더링합니다.
const DynamicMapComponent = dynamic(() => import('@/components/MapComponent'), {
ssr: false,
loading: () => <p style={{ textAlign: 'center', padding: '20px', border: '1px dashed #ccc' }}>지도를 불러오는 중입니다...</p>,
});
export default function LocationsPage() {
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '20px auto', border: '1px solid #28a745', borderRadius: '10px', boxShadow: '0 4px 8px rgba(0,0,0,0.1)', textAlign: 'center' }}>
<h1 style={{ color: '#28a745', marginBottom: '20px' }}>위치 정보 페이지</h1>
<p style={{ marginBottom: '30px' }}>아래는 지연 로딩된 지도 컴포넌트입니다.</p>
<DynamicMapComponent latitude={37.5665} longitude={126.9780} zoom={10} />
<p style={{ marginTop: '30px', fontSize: '0.9em', color: '#555' }}>
(지도 코드는 메인 번들에서 분리되며, 초기 클라이언트 렌더링 과정에서 별도 청크로 비동기 요청됩니다.)
</p>
</div>
);
}이 예제는 DynamicMapComponent를 바로 렌더하므로 클릭이나 스크롤을 기다리지 않습니다. 모듈 준비 뒤의 2초 타이머는 실제 지도 라이브러리나 네트워크를 측정한 결과가 아닙니다.
DynamicMapComponent는 페이지에서 즉시 렌더됩니다. 모듈이 준비되기 전에는 dynamic의 loading UI를, 마운트 후에는 내부 mapLoaded 상태에 따른 UI를 표시합니다. useEffect의 setTimeout(2000)은 모의 초기화이며 실제 모듈 전송 시간이나 정확한 완료 시각이 아닙니다.
dynamic 함수의 주요 옵션
ssr: false: 이 컴포넌트가 서버에서 렌더링되지 않고 클라이언트에서만 렌더링되도록 지정합니다. 이 옵션은 Client Component 안에서만 선언할 수 있습니다.loading: 컴포넌트가 로드되는 동안 표시될 React 컴포넌트나 JSX를 정의합니다.
React 자체의 지연 로딩을 선택한다면 React.lazy로 컴포넌트를 불러오고 Suspense의 fallback으로 로딩 UI를 제공합니다.
무엇을 분할할지는 “첫 화면에 꼭 필요한가”, “무거운가”, “사용자 행동 뒤에 필요한가”를 순서대로 확인하면 판단하기 쉽습니다.
Server Components에서 코드 분할
Next.js App Router의 Server Components는 기본적으로 자동으로 코드 분할됩니다.
Server Component의 구현 코드는 브라우저 번들에 보내지 않습니다. 브라우저에는 렌더 결과와 Client Component에 필요한 코드가 전달됩니다.
Server Component에서 Client Component를 동적 import하는 경우의 자동 코드 분할은 현재 지원되지 않습니다. 위 실습처럼 Client 경계에서 dynamic을 선언하는 경우와 구분합니다.
라우트 그룹으로 레이아웃과 폴더 구조 나누기
Next.js App Router의 라우트 그룹(Route Groups)은 URL 경로에 영향을 주지 않으면서 라우트를 논리적으로 그룹화하는 기능입니다.
라우트별 코드 분할은 괄호 폴더의 유무와 관계없이 Next.js가 자동으로 수행합니다.
Route Group은 URL을 바꾸지 않고 폴더를 정리하거나 서로 다른 레이아웃을 적용할 때 사용합니다.
예를 들어 관리자 화면과 일반 사용자 화면의 내비게이션과 권한 경계가 다르다면 다음처럼 구조를 나눌 수 있습니다.
각 페이지에 필요한 Client Component JavaScript만 전송되는 것은 자동 라우트 분할의 결과이며 Route Group이 별도 청크를 강제해서가 아닙니다.
그룹마다 서로 다른 root layout을 두면 두 root layout 사이 이동은 전체 페이지 로드가 될 수 있으므로, 단순한 번들 최적화 수단으로 그룹을 늘리지 않습니다.
Server Actions 및 Route Handlers에서
Server Actions와 Route Handlers의 구현은 서버에서 실행됩니다. 이를 브라우저의 지연 로딩 청크와 같은 의미로 해석하지 않습니다. Server Action을 호출하는 Client UI에는 참조와 호출을 위한 코드가 필요할 수 있습니다.
코드 분할 및 지연 로딩 최적화 팁
- 컴포넌트 단위로 생각하기: 큰 컴포넌트나, 특정 상호작용 후에만 필요한 컴포넌트는
next/dynamic을 사용하여 지연 로드하는 것을 고려합니다. - 서드파티 라이브러리 분석:
bundle-analyzer와 같은 도구를 사용하여 번들 크기를 분석하고, 어떤 서드파티 라이브러리가 가장 많은 공간을 차지하는지 확인합니다. 불필요한 라이브러리를 제거하거나, 필요한 부분만 임포트하는 방법을 찾습니다. - Lighthouse 감사: Google Lighthouse와 같은 성능 감사 도구를 정기적으로 실행하여 Reduce unused JavaScript와 같은 제안을 확인하고 개선합니다.
use client경계 최소화: App Router에서use client지시어는 클라이언트 모듈 그래프의 진입점을 정합니다. 그 아래 import 의존성의 번들 포함 범위를 확인합니다. 따라서use client의 사용 범위를 최소화하고, 가능한 한 Server Components 내에서 렌더링되도록 노력합니다.- 대기 화면의 공간:
dynamic의 짧은 로딩 문구와 지도 컴포넌트의 300px 영역은 높이가 다릅니다. 레이아웃 이동이 문제라면 대기 UI에도 필요한 공간을 확보합니다. - 이미지 및 폰트 최적화와 결합: 코드 분할 및 지연 로딩은 이미지, 폰트 최적화와 함께 웹 성능을 극대화하는 시너지 효과를 냅니다.
코드 분할과 지연 로딩은 초기 로딩 시간을 줄이고 전반적인 웹 애플리케이션의 성능을 향상시키는 데 필수적인 전략입니다.
Next.js는 이러한 복잡한 최적화 작업을 추상화하여 개발자가 더욱 쉽고 효율적으로 고성능 애플리케이션을 구축할 수 있도록 돕습니다.