본문으로 건너뛰기

안동민 개발노트

본문 시작

코드 분할 및 지연 로딩

라우트 단위 코드 분할과 next/dynamic 지연 로딩으로 초기 JavaScript를 줄이고 무거운 UI를 필요할 때 불러옵니다.

웹 애플리케이션의 성능 최적화에 있어 이미지와 폰트 외에도, 페이지 로딩 시 전송되는 JavaScript 코드의 양을 줄이는 것이 매우 중요합니다.

애플리케이션의 규모가 커질수록 JavaScript 번들의 크기는 기하급수적으로 증가할 수 있으며, 이는 초기 로딩 시간을 지연시키고 사용자 경험을 저해하는 주된 원인이 됩니다.

코드 분할(Code Splitting)지연 로딩(Lazy Loading)은 이러한 문제를 해결하기 위한 핵심적인 기술입니다.

Next.js는 이러한 기법들을 자동으로 적용하거나, 개발자가 직접 제어할 수 있는 API를 제공합니다.

이 절에서는 코드 분할과 지연 로딩의 개념, Next.js App Router에서 이를 구현하는 방법, 그리고 관련 최적화 기법들을 상세히 알아보겠습니다.

지연 로딩은 늦게 보여도 되는 무거운 UI에만 쓴다

첫 화면 필요성·브라우저 의존성·상호작용 시점을 차례로 물어 chunk 경계를 정한다.

  1. LCP 핵심
    초기 bundle 유지

    첫 화면 내용과 핵심 탐색은 바로 제공

  2. 클릭 뒤 사용
    dynamic()

    loading UI와 함께 지도·차트·에디터를 행동 뒤 로드

  3. window 필요
    ssr: false

    Client Component에서 브라우저 전용 UI에만 선언

  4. 곧 필요
    조건부 prefetch

    사용 확률이 높은 다음 chunk만 미리 요청


코드 분할(Code Splitting)이란?

코드 분할은 애플리케이션의 전체 JavaScript 코드를 하나의 큰 번들 파일로 만드는 대신, 여러 개의 작은 파일(청크, chunks)로 나누는 기법입니다.

이렇게 분할된 코드 청크는 필요할 때만 로드되므로, 초기 페이지 로딩 시 브라우저가 다운로드하고 파싱해야 하는 JavaScript의 양을 줄일 수 있습니다.

코드 분할의 이점
  • 초기 로딩 시간 단축: 사용자가 처음 방문하는 페이지에 필요한 코드만 다운로드되므로, 페이지가 더 빠르게 표시됩니다.
  • 리소스 효율성: 사용자가 아직 방문하지 않은 페이지의 코드는 미리 다운로드되지 않으므로, 네트워크 대역폭과 브라우저 리소스를 절약합니다.
  • 캐싱 효율성: 코드 변경 시 전체 번들을 다시 다운로드할 필요 없이 변경된 청크만 다시 다운로드하면 되므로, 브라우저 캐싱 효율이 높아집니다.

Next.js는 파일 시스템 기반 라우팅을 사용하므로, 각 페이지나 라우트 그룹은 기본적으로 별도의 코드 청크로 자동 분할됩니다. (예: app/dashboard/page.tsxapp/settings/page.tsx와 별도의 JS 파일로 분할됨)


지연 로딩(Lazy Loading)이란?

지연 로딩은 특정 컴포넌트나 모듈이 실제로 필요할 때(예: 사용자가 특정 섹션으로 스크롤하거나, 특정 버튼을 클릭했을 때) 비동기적으로 로드하는 기법입니다.

이는 코드 분할과 함께 사용하여 특정 페이지 내에서도 중요도가 낮은 컴포넌트의 로딩을 지연시켜 초기 로딩 성능을 더욱 최적화할 수 있습니다.

아래 다이어그램은 어떤 코드를 처음부터 보내고, 어떤 코드를 사용자 행동 뒤로 미룰지 판단하는 기준을 정리합니다.

처음부터 보낼 코드만 남긴다

코드 분할은 모든 코드를 작게 쪼개는 일이 아니라, 첫 화면과 사용자 행동 뒤의 코드를 구분하는 일이다.

  1. 첫 화면에 필요한가?

    Question 1 필수 UI라면 기본 번들에 남기고, 아래쪽 섹션이면 지연 로딩 후보로 본다.

  2. 무거운 의존성이 있는가?

    Question 2 지도, 차트, 에디터처럼 큰 라이브러리는 next/dynamic 을 검토한다.

  3. 상호작용 뒤에 필요한가?

    Question 3 버튼 클릭, 탭 전환, 모달 열림 이후에 필요한 코드는 늦게 받아도 된다.

  4. 라우트 단위 코드는 Next.js가 기본으로 나눈다

    자동 분할 라우트 단위 코드는 Next.js가 기본으로 나눈다 페이지 이동 비용은 라우트 경계에서 먼저 나뉜다고 보고 시작한다.

  5. 무거운 UI는 필요할 때 불러온다

    동적 임포트 무거운 UI는 필요할 때 불러온다 조건부로 보이는 클라이언트 컴포넌트에 사용한다.

  6. 브라우저 JS가 필요 없는 로직은 서버에 둔다

    서버 경계 브라우저 JS가 필요 없는 로직은 서버에 둔다 계산과 데이터 조립은 client 경계 밖으로 빼는 것이 가장 강하다.


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)
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)
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>
  );
}
dynamic 함수의 주요 옵션
  • ssr: false: 이 컴포넌트가 서버에서 렌더링되지 않고 클라이언트에서만 렌더링되도록 지정합니다. 이 옵션은 Client Component 안에서만 선언할 수 있습니다.
  • loading: 컴포넌트가 로드되는 동안 표시될 React 컴포넌트나 JSX를 정의합니다.

React 자체의 지연 로딩을 선택한다면 React.lazy로 컴포넌트를 불러오고 Suspensefallback으로 로딩 UI를 제공합니다.

무엇을 분할할지는 “첫 화면에 꼭 필요한가”, “무거운가”, “사용자 행동 뒤에 필요한가”를 순서대로 확인하면 판단하기 쉽습니다.

Next.js 코드 분할 의사결정

코드 분할은 많이 나누는 일이 아니다. 첫 화면 필요성, 코드 무게, 호출 시점을 함께 보고 경계를 정한다.

  1. keep

    첫 화면에 꼭 필요한가? · 헤더, 핵심 CTA, 즉시 보이는 리스트는 초기 청크에 남기는 편이 낫다.

  2. split

    특정 화면 전용인가? · 관리자 도구, 차트 페이지, 지도 화면처럼 라우트 전용 코드는 별도 청크가 자연스럽다.

  3. lazy

    사용자 행동 뒤에 필요한가? · next/dynamic 의 loading 또는 React.lazy 와 Suspense 를 사용한다.

  4. Route split

    페이지 단위 자동 분할을 활용하고 Route Group은 레이아웃과 폴더 조직에 사용한다.

  5. Dynamic import

    클라이언트 전용 컴포넌트를 필요한 순간 불러온다.

Server Components에서 코드 분할

Next.js App Router의 Server Components는 기본적으로 자동으로 코드 분할됩니다.

각 Server Component는 서버에서 렌더링되며, 필요한 경우에만 클라이언트 컴포넌트(Interactive Part)와 함께 최소한의 JavaScript를 클라이언트로 전송합니다.

개발자가 별도로 설정할 필요 없이 Next.js가 최적의 번들 크기를 위해 동작합니다.

라우트 그룹으로 레이아웃과 폴더 구조 나누기

Next.js App Router의 라우트 그룹(Route Groups)은 URL 경로에 영향을 주지 않으면서 라우트를 논리적으로 그룹화하는 기능입니다.

라우트별 코드 분할은 괄호 폴더의 유무와 관계없이 Next.js가 자동으로 수행합니다.

Route Group은 URL을 바꾸지 않고 폴더를 정리하거나 서로 다른 레이아웃을 적용할 때 사용합니다.

예를 들어 관리자 화면과 일반 사용자 화면의 내비게이션과 권한 경계가 다르다면 다음처럼 구조를 나눌 수 있습니다.

page.tsx
page.tsx
page.tsx
page.tsx
layout.tsx # 관리자 레이아웃 (관리자 관련 코드만 포함)
page.tsx
layout.tsx # 전역 레이아웃

각 페이지에 필요한 Client Component JavaScript만 전송되는 것은 자동 라우트 분할의 결과이며 Route Group이 별도 청크를 강제해서가 아닙니다.

그룹마다 서로 다른 root layout을 두면 두 root layout 사이 이동은 전체 페이지 로드가 될 수 있으므로, 단순한 번들 최적화 수단으로 그룹을 늘리지 않습니다.

Server Actions 및 Route Handlers에서

Server Actions와 Route Handlers도 기본적으로 코드 분할되어 필요한 경우에만 로드됩니다.

이는 특히 Server Actions를 사용하면 클라이언트 측 JavaScript를 거의 보내지 않고도 서버에서 작업을 수행할 수 있다는 큰 이점을 제공합니다.

아래 보드는 App Router에서 자동 분할에 맡길지, next/dynamic으로 지연 로딩할지, 서버 경계로 옮길지 판단하는 절차를 정리한 것입니다.

코드 분할 경계는 필요성·무게·실행 위치로 고른다

App Router의 route 분할을 기본으로 두고 큰 클라이언트 UI만 수동으로 늦춘다.

  1. route별
    자동 분할

    경로 여정이 다르면 기본 chunk 경계를 사용

  2. 무거운 widget
    next/dynamic

    행동 후 필요한 chart·editor를 별도 로드

  3. 상호작용 없음
    Server boundary

    계산·데이터 준비를 서버로 옮겨 JS 자체를 제거

  4. 초기 핵심
    분할하지 않음

    LCP와 첫 입력에 필요한 코드는 바로 제공


코드 분할 및 지연 로딩 최적화 팁

아래 다이어그램은 코드가 클라이언트 번들로 새어 들어가는 대표 경로와 줄이는 방법을 정리합니다.

use client 경계에서 import 그래프를 자른다

지시어는 상호작용 컴포넌트의 진입점이다. 브라우저에 필요 없는 변환 작업은 서버에 두고, 작은 클라이언트 잎에는 직렬화 가능한 결과만 건넨다.

  1. 넓은 경계 bundle leak
    넓은 경계

    bundle leak Page shell 'use client' → parser · chart 무거운 import 결과 · 정적 변환 코드까지 클라이언트 모듈 그래프에 들어간다.

  2. 작은 경계 server first
    작은 경계

    server first Server page fetch · 변환 → Filter leaf state · event 결과 · 서버 결과만 props로 넘어가고 상호작용 코드만 hydrate한다.

다음 다이어그램은 코드 분할과 지연 로딩을 라우트, 컴포넌트, 서버 경계, 상호작용 비용으로 나누어 적용 위치를 정리합니다.

코드는 route·client·server 경계에서 서로 다르게 나뉜다

어디에서 실행되는지와 언제 필요한지를 함께 보면 분할 대상이 선명해진다.

  1. route
    경로 chunk

    App Router가 페이지 여정별 코드를 자동 분리

  2. dynamic
    지연 client UI

    클릭 뒤 필요한 무거운 widget만 늦게 로드

  3. server
    브라우저 제외

    상호작용 없는 데이터 조립 코드는 서버에 유지

  4. fallback
    공간 예약

    늦게 도착해도 layout shift 없이 자리를 보존

아래 다이어그램은 next/dynamic, 서버 컴포넌트, 라우트 그룹이 번들 경계를 나누는 방식을 보여줍니다.

코드 분할과 지연 로딩 기준

lazy loading은 기능을 숨기는 기법이 아니라 사용자가 아직 보지 않은 클라이언트 코드를 별도 청크로 늦게 받게 만드는 경계 설계다.

  1. 초기 번들

    헤더, 레이아웃, 첫 화면 텍스트처럼 즉시 보여야 하는 코드만 먼저 보낸다. · page shell

  2. 지연 경계

    차트, 에디터, 지도처럼 무거운 컴포넌트 앞에 청크 경계를 둔다. · dynamic(() => import(...))

  3. 나중 청크

    사용자가 열거나 스크롤한 순간 필요한 코드만 추가로 내려받는다. · chart.chunk.js

  4. 화면 합류

    loading 또는 Suspense fallback 이 실제 기능으로 교체된다.

  5. Server Component
    데이터 조합과 정적 설명

    상호작용이 없다면 클라이언트 번들에 넣지 않는다.

  • 컴포넌트 단위로 생각하기: 큰 컴포넌트나, 특정 상호작용 후에만 필요한 컴포넌트는 next/dynamic을 사용하여 지연 로드하는 것을 고려합니다.
  • 서드파티 라이브러리 분석: bundle-analyzer와 같은 도구를 사용하여 번들 크기를 분석하고, 어떤 서드파티 라이브러리가 가장 많은 공간을 차지하는지 확인합니다. 불필요한 라이브러리를 제거하거나, 필요한 부분만 임포트하는 방법을 찾습니다.
  • Lighthouse 감사: Google Lighthouse와 같은 성능 감사 도구를 정기적으로 실행하여 Reduce unused JavaScript와 같은 제안을 확인하고 개선합니다.
  • use client 경계 최소화: App Router에서 use client 지시어는 그 파일과 그 안에서 임포트되는 모든 모듈을 클라이언트 번들에 포함시킵니다. 따라서 use client의 사용 범위를 최소화하고, 가능한 한 Server Components 내에서 렌더링되도록 노력합니다.
  • 이미지 및 폰트 최적화와 결합: 코드 분할 및 지연 로딩은 이미지, 폰트 최적화와 함께 웹 성능을 극대화하는 시너지 효과를 냅니다.

코드 분할과 지연 로딩은 초기 로딩 시간을 줄이고 전반적인 웹 애플리케이션의 성능을 향상시키는 데 필수적인 전략입니다.

Next.js는 이러한 복잡한 최적화 작업을 추상화하여 개발자가 더욱 쉽고 효율적으로 고성능 애플리케이션을 구축할 수 있도록 돕습니다.

아래 다이어그램은 코드 분할 및 지연 로딩을 렌더링 비용, 전송 비용, 측정 지표 순서로 좁혀 봅니다.

코드 분할은 전후 비교로 승인한다

경계를 옮긴 뒤 “빨라 보인다”로 끝내지 않는다. 같은 경로에서 import 체인과 클라이언트 전송량을 비교하고 실제 사용자 지표가 나빠지지 않았는지 확인한다.

  1. 같은 경로 기록

    BASELINE 같은 경로 기록 초기 JS와 LCP·INP 기준값을 남긴다.

  2. import 체인 추적

    TRACE import 체인 추적 큰 모듈의 client 진입점을 찾는다.

  3. 경계 하나 변경

    CUT 경계 하나 변경 서버 이동 또는 지연 로딩을 적용한다.

  4. 동일 조건 비교

    VERIFY 동일 조건 비교 전송량과 사용자 경험을 함께 본다.

  5. Bundle diff · 예시

    before 184 kB after 113 kB

  6. Regression guard

    LCP 첫 화면 INP 상호작용 CLS fallback 이동