👩🏻‍💻 Develop/Performance Optimization

[Next.js] Dynamic Import로 번들 사이즈 25% 최적화하기

dev.daisy 2025. 9. 18. 19:49
이번 최적화 작업은 단순히 '잘 작동하는 코드'를 넘어, 사용자 경험을 최우선으로 고려하는 개발의 중요성을 깊이 깨닫는 계기가 되었습니다. 처음에는 막연하게 번들 크기를 줄여야 한다는 목표만 있었지만, 막상 Lighthouse를 돌려 성능 지표를 눈으로 확인하고, 어떤 지점에서 병목이 발생하는지 직접 파악하는 과정이 개발자로서 한 단계 성장하는 시간이였다고 생각합니다.

특히, Lazy Loading 전략을 적용하며 'Above-the-fold''Below-the-fold' 콘텐츠를 구분한 것이 가장 인상 깊었습니다. 사용자가 페이지에 진입하자마자 핵심 콘텐츠를 빠르게 보여주고, 스크롤을 내릴 때 비로소 다음 콘텐츠를 로드하는 방식이 단순한 기술 적용을 넘어 사용자에게 기다림을 최소화하는 배려라는 것을 느꼈습니다.

모달이나 특정 기능 컴포넌트들을 필요할 때만 로드하는 조건부 로딩 또한 마찬가지였습니다. "혹시 필요할 수도 있으니까 미리 로드해두자"는 기존의 안일한 생각을 버리고, '정말로 필요한 순간'에만 코드를 불러오는 습관을 들이게 되었습니다. 이러한 접근이 전체 번들 크기를 줄이는 데 얼마나 큰 효과를 주는지 직접 경험하며, 코드 한 줄의 무게를 다시 한번 생각하게 되었습니다.

최근 Next.js 기반 프로젝트의 First Contentful Paint (FCP)Total Blocking Time (TBT)를 개선하기 위해 번들 크기 최적화 작업을 진행했습니다. Dynamic Import를 전략적으로 활용하여 불필요한 초기 로드를 줄이고, 사용자에게 핵심 콘텐츠를 더 빠르게 제공하는 과정에서 얻은 지식을 공유드리겠습니다.

문제점 분석하기

프로젝트의 초기 번들은 여러 페이지에서 공통으로 사용되는 컴포넌트들 때문에 예상보다 컸습니다. 특히 react-hot-toast와 같은 알림 라이브러리, 여러 종류의 모달 컴포넌트, 그리고 페이지마다 다른 레이아웃 컴포넌트들이 모든 페이지의 초기 번들에 포함되어 있었습니다.

이로 인해 Home 페이지와 같은 핵심 페이지의 초기 로딩 시간이 길어졌고, 이는 사용자가 체감하는 성능 저하로 이어졌습니다. 구체적으로 다음과 같은 문제들이 나타났습니다.

  • Toast 알림이나 모달처럼 특정 상황에서만 필요한 코드가 불필요하게 모든 페이지에서 미리 로드
  • 초기 번들 크기가 커져 네트워크 전송 및 JavaScript 파싱/실행 시간이 증가함
  • 웹 바이탈 지표 분석 결과 FCP(첫 콘텐츠가 표시되는 시간) TBT(메인 스레드가 블록되는 총 시간) 지표에 부정적인 영향을 줌

이러한 문제들을 해결하기 위해 Next.js의 Dynamic Import 기능을 활용한 코드 분할을 시도했습니다.


최적화 전략: Dynamic Import를 통한 코드 분할

사용자가 즉시 보거나 필요로 하는 것만 먼저 로드하는 것을 기준으로 분리했습니다. 세부적으로 진행한 방법은 다음과 같습니다.

1. Root 레벨 컴포넌트 분리: react-hot-toast

react-hot-toast는 알림 메시지를 보여주는 용도로, 모든 페이지에서 필요한 기능은 아닙니다. 이 라이브러리를 초기 번들에서 분리해, 필요한 페이지에서만 동적으로 로드하도록 수정했습니다. Toaster 코드가 초기 번들에서 제거되어 알림 기능이 없는 페이지에서는 불필요한 JavaScript를 다운로드하지 않게 되었습니다.

수정 전: _app.tsx 또는 layout.tsx에 Toaster 컴포넌트가 직접 임포트
수정 후: 별도의 동적 컴포넌트를 생성하여, 필요한 레이아웃에만 추가
// src/components/common/DynamicToaster.tsx
'use client';

import dynamic from 'next/dynamic';

const ToasterComponent = dynamic(
  () => import('react-hot-toast').then((mod) => ({ default: mod.Toaster })),
  {
    ssr: false, // 이 컴포넌트는 클라이언트에서만 렌더링되도록 설정
  },
);

export default function DynamicToaster() {
  return <ToasterComponent />;
}

 

2. 레이아웃 및 모달 컴포넌트 조건부 로딩

애플리케이션에는 여러 종류의 레이아웃과 모달이 존재했는데, 사용자가 특정 액션을 취하거나 특정 페이지에 진입했을 때만 로드하도록 동적으로 분리했습니다.

 

1) 레이아웃 컴포넌트 분리

Home 페이지는 다른 페이지와 다른 레이아웃을 사용합니다. 이를 명확히 분리하여, 다른 페이지에서는 Home 페이지의 레이아웃 코드가 로드되지 않도록 했습니다. usePathname을 사용하여 현재 경로에 따라 필요한 레이아웃만 동적으로 로드하도록 했습니다.

// src/components/layout/OptimizedClientLayout.tsx
'use client';

import dynamic from 'next/dynamic';
import { usePathname } from 'next/navigation';

const BasicLayout = dynamic(() => import('@/components/layout/BasicLayout'), { ssr: true });
const HomeLayout = dynamic(() => import('@/components/layout/HomeLayout'), { ssr: true });

export default function OptimizedClientLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();

  if (pathname === '/home') {
    return <HomeLayout>{children}</HomeLayout>;
  }

  return <BasicLayout>{children}</BasicLayout>;
}

 

2) 모달 컴포넌트 분리

모달은 사용자가 버튼을 클릭하는 등 특정 이벤트가 발생했을 때만 나타납니다. 따라서 모달 코드를 처음부터 로드할 필요가 없습니다. 따라서 아래와 같이 모달이 실제로 열리는 상태 변수(isConfirmModalOpen)에 따라 모달 컴포넌트를 조건부로 렌더링했습니다. 이렇게 함으로써 모달이 필요한 페이지에서도, 모달을 열기 전까지는 관련 코드를 다운로드하지 않아 초기 번들 크기가 감소했습니다.

// src/components/layout/ClientLayoutContent.tsx (일부)
const DynamicConfirmModal = dynamic(
  () => import('../common/ConfirmModal').then((mod) => ({ default: mod.ConfirmModal })),
  { ssr: false, loading: () => null }
);

// 컴포넌트 렌더링 로직
{
  isConfirmModalOpen && <DynamicConfirmModal />;
}

3. 점진적 로딩: Above-the-fold vs. Below-the-fold

가장 큰 효과를 본 전략은 점진적 로딩입니다. Home 페이지의 경우, 사용자가 스크롤해야 볼 수 있는 콘텐츠(BannerSection, MatchTypeSelector)를 처음부터 로드할 필요가 없다고 판단했습니다. 이 두 영역을 명확히 구분하여 로딩 우선순위를 설정했습니다.

  • Above-the-fold: 사용자가 페이지에 접속하자마자 보이는 화면 영역. (ex) ClickWebPushBanner
  • Below-the-fold: 사용자가 스크롤해야 보이는 화면 영역. (ex) BannerSection, MatchTypeSelector

이 전략을 통해 핵심 UI는 빠르게 표시하면서, 뒤따르는 콘텐츠들은 메인 스레드에 부담을 주지 않고 자연스럽게 로드될 수 있도록 했습니다. 각 컴포넌트의 로딩 상태를 보여주는 스켈레톤 UI를 함께 구현하여 더 좋은 사용자 경험을 만들었습니다.

// src/app/home/page.tsx
'use client';

import { Suspense, useState, useEffect } from 'react';
import dynamic from 'next/dynamic';

// 즉시 로드: 사용자가 바로 보는 핵심 UI
const ClickWebPushBanner = dynamic(() => import('@/components/home/ClickWebPushBanner'), { ssr: false });

// 300ms 지연 로드: 스크롤해야 보는 콘텐츠 (embla-carousel 포함)
const BannerSection = dynamic(() => import('@/components/home/BannerSection'), { ssr: false });

// 500ms 지연 로드: 사용자 인터랙션 요소
const MatchTypeSelector = dynamic(() => import('@/components/home/MatchTypeSelector'), { ssr: false });

export default function HomePage() {
  const [shouldLoadBelowFold, setShouldLoadBelowFold] = useState(false);
  const [shouldLoadMatchTypeSelector, setShouldLoadMatchTypeSelector] = useState(false);

  useEffect(() => {
    const timer = setTimeout(() => setShouldLoadBelowFold(true), 300);
    const matchTypeSelectorTimer = setTimeout(() => setShouldLoadMatchTypeSelector(true), 500);

    return () => {
      clearTimeout(timer);
      clearTimeout(matchTypeSelectorTimer);
    };
  }, []);

  return (
    <main>
      <Suspense fallback={<div className="h-12 animate-pulse" />}>
        <ClickWebPushBanner />
      </Suspense>

      {shouldLoadBelowFold && (
        <Suspense fallback={<BannerSkeleton />}>
          <BannerSection />
        </Suspense>
      )}

      {shouldLoadMatchTypeSelector && (
        <Suspense fallback={<MatchTypeSkeleton />}>
          <MatchTypeSelector />
        </Suspense>
      )}
    </main>
  );
}

최적화 결과 확인하기

코드 분할을 통해 핵심적으로 개선된 부분은 번들 크기 감소와 핵심 성능 지표인 LCP(Largest Contentful Paint)의 향상입니다.

 

1) 번들 크기 최적화 결과

  • 전체 번들 크기: 455KB → 451KB (-4KB, -0.9%)
  • Vendor chunk: 353KB → 348KB (-5KB, -1.4%)
  • Home 페이지 번들 크기: 1.35KB → 1.01KB (-0.34KB, -25%)

특히 Home 페이지의 번들 크기가 25% 감소했다는 점이 놀라웠습니다. 페이지 진입 시점에 불필요한 컴포넌트들을 지연 로딩(Lazy Loading)하여 초기 로드 번들 크기를 줄인 결과라고 생각합니다.

 

2) 핵심 성능 지표 분석

Largest Contentful Paint (LCP) 또한 21.6초에서 16.8초로 약 22.2% 감소했습니다. LCP는 뷰포트 내 가장 큰 콘텐츠 요소의 렌더링 시간을 의미하며, 이 지표의 감소는 사용자가 페이지의 핵심 콘텐츠를 더 빠르게 인지할 수 있음을 의미합니다.

최적화 전 → 최적화 후


회고

이번 최적화 작업은 단순히 '잘 작동하는 코드'를 넘어, 사용자 경험을 최우선으로 고려하는 개발의 중요성을 깊이 깨닫는 계기가 되었습니다. 처음에는 막연하게 번들 크기를 줄여야 한다는 목표만 있었지만, 막상 Lighthouse를 돌려 성능 지표를 눈으로 확인하고, 어떤 지점에서 병목이 발생하는지 직접 파악하는 과정이 개발자로서 한 단계 성장하는 시간이였다고 생각합니다.

 

특히, Lazy Loading 전략을 적용하며 'Above-the-fold''Below-the-fold' 콘텐츠를 구분한 것이 가장 인상 깊었습니다. 사용자가 페이지에 진입하자마자 핵심 콘텐츠를 빠르게 보여주고, 스크롤을 내릴 때 비로소 다음 콘텐츠를 로드하는 방식이 단순한 기술 적용을 넘어 사용자에게 기다림을 최소화하는 배려라는 것을 느꼈습니다. 덕분에 FCP와 TBT 같은 지표가 개선되는 것을 보며, 성능 최적화가 숫자 놀음이 아니라 실제 사용자의 만족도를 높이는 일임을 실감했습니다.

 

모달이나 특정 기능 컴포넌트들을 필요할 때만 로드하는 조건부 로딩 또한 마찬가지였습니다. "혹시 필요할 수도 있으니까 미리 로드해두자"는 기존의 안일한 생각을 버리고, '정말로 필요한 순간'에만 코드를 불러오는 습관을 들이게 되었습니다. 이러한 접근이 전체 번들 크기를 줄이는 데 얼마나 큰 효과를 주는지 직접 경험하며, 코드 한 줄의 무게를 다시 한번 생각하게 되었습니다.

 

이번 경험을 통해 성능 최적화는 단순히 기술적인 문제를 해결하는 것을 넘어, 사용자의 입장에서 끊임없이 고민하고 더 나은 경험을 제공하려는 노력이 필요하다는 것을 깨달았습니다. 앞으로는 기능 구현에만 급급하지 않고, 눈에 보이지 않는 성능 병목까지 찾아 해결하는 책임감을 가진 개발자가 되어야겠다는 생각이 들었습니다.