본문 바로가기
코드의 해부학

코드 분할(Code Splitting) : 디지털 미니멀리즘

by Zev 2025. 6. 1.
728x90
반응형

🧩 코드 분할(Code Splitting) : 나눔의 미학

**코드 분할(Code Splitting)**은 모던 프론트엔드 개발에서 핵심적인 성능 최적화 전략 중 하나입니다. 특히 싱글 페이지 애플리케이션(SPA)에서 초기 번들 사이즈를 줄이고, 페이지 로딩 속도를 개선하는 데 필수적인 기법입니다. 이는 단순히 파일을 쪼개는 것을 넘어, 리소스 관리, 네트워크 효율, 그리고 **사용자 경험(UX)**을 총체적으로 고려하는 아키텍처적 접근입니다.

이 글에서는 코드 분할의 컴퓨터 과학적 개념과 구조, 내부 작동 원리, 다양한 구현 전략, 그리고 실전 적용 시 고려사항까지 깊이 있게 파헤쳐 보겠습니다.


1. 개념: 코드 분할이란 무엇인가? (Definition and Motivation)

**코드 분할(Code Splitting)**은 애플리케이션의 **전체 자바스크립트 번들(Bundle)**을 기능적 또는 논리적 단위로 쪼개어, **필요한 시점(Just-in-Time)**에만 해당 코드 청크(Chunk)를 네트워크를 통해 동적으로 로드하는 최적화 기법입니다.

📚 정식 정의: Code Splitting은 번들된 자바스크립트 코드를 **논리적이고 독립적인 단위(Chunks)**로 분리하여, 애플리케이션의 **런타임(Runtime)**에 해당 청크가 **요청(On-Demand)**될 때만 네트워크를 통해 전송하고 실행하는 모듈화 및 최적화 전략입니다.

왜 코드 분할이 필요한가? (문제점 및 해결)

모던 웹 애플리케이션은 복잡한 기능을 제공하기 위해 방대한 양의 자바스크립트 코드를 포함합니다. 이 모든 코드를 하나의 거대한 번들 파일로 묶을 때 다음과 같은 문제점이 발생합니다.

문제 상황 (Eager Loading)코드 분할 도입 시 해결 (Lazy Loading)
초기 로딩 시간이 너무 느림 (FCP/TTI 지연) 초기 번들을 작게 만듭니다. 사용자가 처음 페이지에 진입할 때 필수적인 코드만 로드하여 First Contentful Paint (FCP) 및 **Time To Interactive (TTI)**를 크게 개선합니다. 이는 사용자가 빠르게 콘텐츠를 보고 상호작용할 수 있게 하여 **사용자 경험(UX)**을 향상시킵니다.
하나의 자바스크립트 파일이 너무 큼 (네트워크/파싱 오버헤드) 페이지 단위, 기능 단위, 또는 컴포넌트 단위로 코드를 분리합니다. 거대한 파일을 다운로드하고 파싱/컴파일하는 데 드는 네트워크 대역폭CPU 리소스 소모를 줄여, 모바일 환경이나 저사양 기기에서도 앱이 빠르게 로드될 수 있도록 합니다.
전체 앱을 한 번에 가져오는 비효율 (자원 낭비) 경로별, 기능별로 필요한 모듈만 로딩합니다. 사용자가 모든 기능을 항상 사용하는 것이 아니므로, 당장 필요하지 않은 코드는 로드하지 않음으로써 메모리 점유율을 낮추고, 불필요한 리소스 낭비를 방지합니다. 이는 가비지 컬렉션(GC) 부담을 줄이는 데도 기여합니다.
브라우저 캐시 비효율성 (작은 코드 변경에도 전체 재다운로드) 캐싱 효율성을 극대화합니다. 특정 모듈의 코드만 변경되었을 때, 전체 번들을 재다운로드하는 대신 해당 변경된 '청크'만 다시 다운로드하게 합니다. 이는 브라우저 캐시를 더 효과적으로 활용하여 재방문 시 로딩 속도를 더욱 빠르게 만듭니다.
불필요한 코드 실행 및 메모리 상주 (메모리 사용량 증가) '지연 평가(Lazy Evaluation)' 원칙을 적용합니다. 실제 사용되지 않는 코드는 메모리에 로드되지 않으므로, 애플리케이션의 **메모리 발자국(Memory Footprint)**을 줄이고 전반적인 시스템 자원 효율성을 높입니다. 이는 핫 리로드(Hot Reloading) 환경에서도 재번들링 시간을 단축시킬 수 있습니다.
 

2. 코드 분할의 구조와 작동 원리 (Mechanisms and Bundle Orchestration)

코드 분할은 주로 **모듈 번들러(Module Bundler)**의 지원을 받아 동작합니다. Webpack, Rollup, Vite와 같은 번들러는 코드의 종속성을 분석하고, 개발자가 정의한 분할 지점(Split Points)에 따라 최종 번들 파일을 여러 개의 작은 청크(Chunk)로 나눕니다.

2.1. 동적 임포트(Dynamic Import) 기반 분할: 비동기 모듈 로딩의 핵심

JavaScript의 표준 import() 구문은 Promise 기반 비동기 모듈 로딩을 가능하게 합니다. 번들러는 이 import() 호출을 분할 지점으로 인식하여 해당 모듈을 별도의 청크로 분리합니다.

  • 원리: import('./path/to/module')는 해당 모듈이 포함된 새로운 Promise를 반환합니다. 이 Promise는 모듈이 성공적으로 로드되고 파싱되면 default 익스포트를 포함하는 객체로 resolve됩니다.
  • 작동 방식:
    1. 번들러는 import() 호출을 발견하면, 해당 모듈과 그 종속성들을 현재 번들에서 분리하여 새로운 .js 파일(청크)로 생성합니다.
    2. 런타임에 import()가 실행되면, 브라우저는 해당 청크 파일을 **네트워크 요청(HTTP Request)**을 통해 비동기적으로 다운로드합니다.
    3. 다운로드된 청크는 브라우저에 의해 파싱되고 실행되어 필요한 모듈을 제공합니다.
TypeScript
 
// React 예시: React.lazy와 Suspense는 동적 임포트와 연동됩니다.
import { lazy, Suspense } from 'react';

// Settings 컴포넌트가 처음 렌더링될 때까지 해당 코드 청크의 로딩을 지연합니다.
const Settings = lazy(() => import('./pages/Settings')); // 이 부분이 분할 지점(Split Point)

function App() {
  return (
    // Suspense는 lazy 로드되는 컴포넌트가 로드될 때까지 대체 UI (fallback)를 보여줍니다.
    <Suspense fallback={<div>Loading Settings...</div>}>
      <Settings /> {/* Settings 컴포넌트가 렌더링되는 시점에 동적 로딩 시작 */}
    </Suspense>
  );
}

 

Webpack의 동작: 위 import('./pages/Settings')를 만나면, Webpack은 Settings 컴포넌트와 관련된 모든 코드를 settings.[hash].js와 같은 이름의 **별도 청크(Chunk)**로 분할하여 출력합니다. 런타임에 Settings 컴포넌트가 필요해지면, 브라우저는 이 settings.[hash].js 파일을 네트워크에서 가져오게 됩니다.

 

2.2. 라우트 기반 코드 분할 (Route-based Splitting)

**싱글 페이지 애플리케이션(SPA)**에서 가장 일반적이고 효과적인 코드 분할 전략입니다. 사용자가 특정 경로(URL)로 이동할 때만 해당 경로에 필요한 컴포넌트와 모듈을 로드합니다.

  • 원리: 라우터 설정 시 각 라우트 컴포넌트를 동적 임포트로 감싸면, 해당 라우트로의 전환 시에만 해당 컴포넌트 청크를 로드합니다.
  • 이점: 초기 번들 사이즈를 핵심 페이지(예: 랜딩 페이지) 로직으로만 제한하여, 사용자가 실제로 방문하는 페이지에 대한 코드만 로드하게 됩니다.
TypeScript
 
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { lazy, Suspense } from 'react'; // React.lazy와 Suspense는 필수

// 각 페이지 컴포넌트를 lazy 로드 (각각 별도의 청크로 분할됨)
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));

function AppRouter() {
  return (
    <BrowserRouter>
      {/* 라우트 로딩 중 fallback UI 제공 */}
      <Suspense fallback={<div>페이지 로딩 중...</div>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="/contact" element={<ContactPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

📦 위 코드에서 /, /about, /contact 각 경로는 개별 청크로 나뉘며, 사용자가 해당 경로로 접근할 때만 브라우저는 해당 청크를 요청하고 로드합니다.

 

2.3. 라이브러리 분할 (Vendor Splitting): 캐싱 효율 극대화

애플리케이션의 의존성 중에는 react, lodash, axios 등과 같이 자주 변경되지 않는 서드파티 라이브러리가 많습니다. 이들을 별도의 청크로 분리하여 브라우저 캐싱 효율을 높일 수 있습니다.

  • 원리: 번들러 설정에서 node_modules 폴더의 라이브러리들을 특정 청크(예: vendors)로 묶도록 지정합니다.
  • 이점: 애플리케이션 코드가 변경되어도 라이브러리 코드가 포함된 vendors 청크는 변경되지 않으므로, 사용자가 재방문 시 이 청크를 다시 다운로드할 필요 없이 캐시된 버전을 사용합니다. 이는 네트워크 요청 수다운로드 바이트를 줄여줍니다.
JavaScript
 
// Webpack config 예시: optimization.splitChunks를 통한 라이브러리 분할
module.exports = {
  // ...
  optimization: {
    splitChunks: {
      chunks: 'all', // 모든 청크에 대해 최적화 적용
      minSize: 20000, // 최소 분할 크기 (바이트)
      maxInitialRequests: 3, // 초기 로딩 시 최대 요청 수
      cacheGroups: {
        vendor: { // 'vendor'라는 캐시 그룹 정의
          test: /[\\/]node_modules[\\/]/, // node_modules 폴더의 모듈을 대상으로 함
          name: 'vendors', // 생성될 청크 파일 이름
          priority: -10 // 다른 캐시 그룹보다 낮은 우선순위
        },
        // 추가적인 캐시 그룹 정의 가능 (예: common, components 등)
        default: {
          minChunks: 2, // 최소 2번 이상 사용된 모듈
          priority: -20,
          reuseExistingChunk: true // 이미 분리된 청크 재활용
        }
      }
    }
  }
};

🧠 브라우저 캐싱: vendors.[hash].js와 같이 해시(hash)가 포함된 파일명은 내용이 변경되지 않는 한 동일하게 유지됩니다. 브라우저는 이 파일을 한 번 캐싱하면 재방문 시 **HTTP 캐시(Cache-Control 헤더)**를 통해 304 Not Modified 응답을 받고 다시 다운로드할 필요가 없어집니다.

 

2.4. 조건부 분할 (Conditional Splitting): 이벤트 기반 동적 로딩

특정 사용자 상호작용(예: 버튼 클릭)이나 특정 조건(예: 로그인 여부, 디바이스 타입)에서만 필요한 코드는 해당 이벤트 발생 시점에 동적으로 로드할 수 있습니다.

TypeScript
 
// TypeScript 예시: 사용자 상호작용에 따른 동적 임포트
async function openEditor() {
  // 사용자가 '편집' 버튼을 클릭했을 때만 RichEditor 모듈 로드
  // 이 모듈은 초기 번들에 포함되지 않습니다.
  const { default: RichEditor } = await import('./components/RichEditor');
  
  // 로드된 RichEditor 인스턴스 생성 및 마운트
  const editor = new RichEditor();
  editor.mount();
}

// HTML: <button onclick="openEditor()">편집</button>

이 방식은 사용 빈도가 낮은 기능이나 매우 무거운 모듈에 적용하여 초기 로딩 부담을 최소화하는 데 효과적입니다.

 

2.5. 번들러에서의 코드 분할 구조 (Chunk Graph)

모듈 번들러는 애플리케이션의 **모듈 의존성 그래프(Module Dependency Graph)**를 분석하여 최적의 청크 분할 전략을 수립합니다.

  • Webpack의 Chunk 구조:
    • Entry Chunk: 애플리케이션의 진입점(Entry Point)에서 시작되어 첫 로딩 시 필수적으로 로드되는 코드.
    • Lazy Chunk: import()와 같은 동적 임포트 구문을 통해 생성된 코드. 필요할 때 비동기적으로 로드됩니다.
    • Vendor Chunk / Shared Chunk: node_modules의 라이브러리나 여러 청크에서 공유되는 모듈을 분리하여 생성된 청크. 캐싱 효율을 높이고 중복 로딩을 방지합니다.
dist/
├── main.[hash].js         // Entry Chunk (초기 로딩 필수)
├── settings.[hash].js     // Lazy Chunk (Settings 컴포넌트 코드)
├── about.[hash].js        // Lazy Chunk (About 페이지 코드)
├── contact.[hash].js      // Lazy Chunk (Contact 페이지 코드)
├── vendors.[hash].js      // Vendor Chunk (react, react-dom 등 라이브러리)
├── common.[hash].js       // Shared Chunk (여러 Lazy Chunk에서 공유되는 공통 모듈)
  • Vite에서의 접근: Vite는 ESBuild 기반으로 더 빠른 동적 분할을 지원하며, Rollup을 번들러로 사용하여 최적화를 수행합니다. Rollup의 manualChunks() 옵션을 통해 개발자가 수동으로 청크를 세밀하게 제어할 수도 있습니다.Vite는 개발 환경에서 ES Module을 직접 서빙하며 빠른 개발 경험을 제공하고, 프로덕션 빌드 시 Rollup을 통해 최적화된 번들링 및 코드 스플리팅을 수행합니다.
  • TypeScript
     
    // vite.config.ts 예시: Rollup의 manualChunks를 통한 세밀한 제어
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      build: {
        rollupOptions: {
          output: {
            // manualChunks를 사용하여 특정 모듈들을 특정 청크로 묶을 수 있습니다.
            manualChunks(id) {
              if (id.includes('node_modules')) {
                // node_modules의 모든 것을 'vendor' 청크로
                return 'vendor';
              }
              // 대용량 라이브러리나 특정 기능별로 수동 분할 가능
              if (id.includes('src/heavy-feature')) {
                return 'heavy-feature';
              }
            }
          }
        }
      }
    });
    

3. 코드 분할의 성능적 이점 (Quantifiable Impact)

코드 분할은 사용자에게 직접적으로 체감되는 성능 지표(Core Web Vitals)를 개선하는 데 결정적인 역할을 합니다.

메트릭개선 전 (단일 번들)코드 분할 적용 후개선 효과
JS 번들 사이즈 (초기 로딩) 1.8MB 650KB 네트워크 전송량 감소: 초기 로딩 시 다운로드해야 하는 자바스크립트 파일 크기가 크게 줄어듭니다. 이는 특히 모바일 네트워크 환경에서 다운로드 시간을 단축하고 사용자 데이터 사용량을 절약합니다.
FCP (First Contentful Paint) 3.4s 1.6s 콘텐츠 초기 표시 시간 단축: 사용자가 페이지에 진입했을 때 화면에 콘텐츠가 처음 그려지기까지의 시간이 줄어듭니다. 이는 사용자에게 즉각적인 시각적 피드백을 제공하여 로딩 지연에 대한 인지를 줄여줍니다.
TTI (Time To Interactive) 5.3s 2.1s 사용자 상호작용 가능 시간 단축: 페이지가 완전히 로드되고 자바스크립트가 파싱/컴파일되어 사용자가 버튼 클릭 등과 같은 상호작용을 시작할 수 있는 시점까지의 시간이 줄어듭니다. 이는 애플리케이션의 **반응성(Responsiveness)**을 극대화하고, '먹통' 상태를 방지하여 긍정적인 UX를 제공합니다. **
Sheets로 내보내기

lazy() 함수: React에서 특정 컴포넌트를 지연 로드하기 위해 사용됩니다. lazy(() => import('...')) 형태로 사용하여 import() 구문을 통해 동적 임포트된 컴포넌트를 래핑합니다. 이 함수는 컴포넌트가 실제로 렌더링될 때까지 코드 로드를 지연시킵니다. * 내부 동작: React.lazy는 React 런타임에 **코드 스플리팅 지점(Split Point)**을 등록하고, 해당 컴포넌트가 처음으로 렌더링 요청을 받을 때 번들러가 생성한 청크 파일을 비동기적으로 로드하도록 지시합니다.

  • <Suspense> 컴포넌트: lazy()로 로드되는 컴포넌트가 완전히 로드될 때까지 사용자에게 보여줄 **대체 UI (fallback)**를 정의하는 역할을 합니다. 네트워크 지연 등으로 인한 사용자 경험 저하를 방지합니다.
    • 내부 동작: Suspense는 자식 컴포넌트 중 Promise를 던지는(throw a Promise) 컴포넌트(즉, 아직 로딩 중인 lazy 컴포넌트)를 감지하면, 해당 Promise가 resolve될 때까지 fallback props에 정의된 UI를 렌더링합니다. Promise가 resolve되면 실제 컴포넌트를 렌더링합니다. 이는 **선언적 비동기 처리(Declarative Asynchronous Handling)**의 한 형태입니다.
TypeScript
 
// React에서 Lazy Loading Component의 기본 구조
import React, { Suspense, lazy } from 'react';

// `MyLazyComponent`는 이 줄에서 코드가 로드되지 않습니다.
// 실제로 <MyLazyComponent />가 렌더링 트리에 추가될 때까지 로딩이 지연됩니다.
const MyLazyComponent = lazy(() => import('./path/to/MyLazyComponent'));

function App() {
  return (
    <div>
      <h1>애플리케이션 메인 콘텐츠</h1>
      <Suspense fallback={<div>컴포넌트 로딩 중...</div>}>
        {/* 사용자가 이 섹션으로 스크롤하거나 특정 이벤트가 발생할 때만 MyLazyComponent 로드 */}
        <MyLazyComponent />
      </Suspense>
    </div>
  );
}

 

2.2. 라우트 기반 코드 분할 (Route-based Code Splitting)

**싱글 페이지 애플리케이션(SPA)**에서 가장 흔하고 효과적인 코드 분할 전략입니다. 사용자가 특정 라우트(URL 경로)로 이동할 때만 해당 라우트에 필요한 컴포넌트와 관련 모듈을 동적으로 로드합니다.

  • 원리: 라우터 설정 시 각 라우트 컴포넌트를 lazy()로 감싸면, 해당 라우트로의 전환이 발생할 때만 해당 컴포넌트의 코드 청크를 요청하고 로드합니다.
  • 이점: 초기 번들 사이즈를 핵심적인 진입 페이지(Entry Page) 로직으로만 제한하여, 사용자가 실제로 방문하는 페이지에 대한 코드만 로드하게 됩니다. 이는 네트워크 전송량을 줄이고, 파싱 및 컴파일 시간을 단축시켜 FCP와 TTI를 직접적으로 개선합니다.
TypeScript
 
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { lazy, Suspense } from 'react'; // React.lazy와 Suspense는 필수

// 각 페이지 컴포넌트를 lazy 로드 (각각 별도의 청크로 분할됨)
const HomePage = lazy(() => import('./pages/HomePage'));
const AboutPage = lazy(() => import('./pages/AboutPage'));
const ContactPage = lazy(() => import('./pages/ContactPage'));

function AppRouter() {
  return (
    <BrowserRouter>
      {/* 라우트 전환 중 사용자에게 보여줄 fallback UI 제공 */}
      <Suspense fallback={<div>페이지 로딩 중...</div>}>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/about" element={<AboutPage />} />
          <Route path="/contact" element={<ContactPage />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

📦 위 코드에서 /, /about, /contact 각 경로는 번들러에 의해 개별 청크로 나뉘며, 사용자가 해당 경로로 접근할 때만 브라우저는 해당 청크를 요청하고 로드합니다. 이는 사용자의 실제 탐색 경로에 기반한 지연 로딩을 구현합니다.

 

2.3. 라이브러리 분할 (Vendor Splitting): 캐싱 효율 극대화 전략

애플리케이션의 의존성 중에는 react, lodash, axios 등과 같이 자주 변경되지 않는 서드파티 라이브러리가 많습니다. 이들을 별도의 청크로 분리하여 브라우저 캐싱 효율을 극대화할 수 있습니다.

  • 원리: 번들러 설정에서 node_modules 폴더 내의 라이브러리들을 특정 청크(예: vendors.js)로 묶도록 지정합니다. 번들러는 코드의 **해싱(Hashing)**을 통해 파일 내용을 기반으로 파일명을 생성하므로, 라이브러리 내용이 변경되지 않는 한 이 청크의 파일명도 변경되지 않습니다.
  • 이점: 애플리케이션의 비즈니스 로직 코드가 변경되어도 라이브러리 코드가 포함된 vendors 청크는 변경되지 않으므로, 사용자가 재방문 시 이 청크를 다시 다운로드할 필요 없이 **브라우저 캐시(Browser Cache)**에 저장된 버전을 즉시 사용합니다. 이는 네트워크 요청 수와 **총 다운로드 바이트(Total Download Bytes)**를 획기적으로 줄여줍니다.
JavaScript
 
// Webpack config 예시: optimization.splitChunks를 통한 라이브러리 분할
module.exports = {
  // ... (다른 Webpack 설정)
  optimization: {
    splitChunks: {
      chunks: 'all', // 'async', 'initial', 'all' 중 선택. 'all'은 동적/초기 로딩 모두 고려.
      minSize: 20000, // 청크로 분할될 최소 모듈 크기 (바이트). 너무 작으면 HTTP 요청 오버헤드 증가.
      maxSize: 0, // 최대 청크 크기 (0 = 제한 없음). 너무 크면 지연 로딩 의미 퇴색.
      minChunks: 1, // 청크가 되기 위한 모듈의 최소 참조 횟수.
      maxAsyncRequests: 30, // 병렬 비동기 요청 최대 수.
      maxInitialRequests: 30, // 초기 로딩 시 병렬 요청 최대 수.
      automaticNameDelimiter: '~', // 청크 이름 구분자.
      enforceSizeThreshold: 50000, // 강제 크기 임계값 (이보다 작아도 강제 분할).
      cacheGroups: {
        // 'vendor' 캐시 그룹: node_modules 폴더의 모든 모듈을 대상으로 함
        vendor: {
          test: /[\\/]node_modules[\\/]/, // 정규 표현식으로 경로 매칭
          name: 'vendors', // 생성될 청크 파일 이름
          priority: -10, // 우선순위. 높은 숫자가 먼저 처리됨. (기본 그룹보다 높게 설정하여 먼저 분리되도록)
          reuseExistingChunk: true, // 기존 청크를 재활용할지 여부.
        },
        // 'default' 캐시 그룹: 위 규칙에 해당하지 않는 모듈 중 공통으로 사용되는 모듈
        default: {
          minChunks: 2, // 최소 2번 이상 사용된 모듈
          priority: -20, // 낮은 우선순위
          reuseExistingChunk: true, // 기존 청크 재활용
        }
      }
    }
  }
};

🧠 브라우저 캐싱과 해싱: vendors.[hash].js와 같이 파일명에 **콘텐츠 해시(Content Hash)**가 포함되면, 파일 내용이 변경되지 않는 한 동일한 해시 값을 유지합니다. 브라우저는 이 해시 값을 기반으로 파일을 캐싱하고, 재방문 시 **HTTP 캐시(Cache-Control, ETag 등)**를 통해 서버에 다시 요청하지 않고 캐시된 버전을 사용합니다. 이는 네트워크 비용서버 부하를 동시에 줄여줍니다.

2.4. 조건부 분할 (Conditional Splitting): 런타임 결정 기반 동적 로딩

특정 사용자 상호작용(예: 버튼 클릭, 모달 열기)이나 특정 조건(예: 사용자 권한, 디바이스 타입)에서만 필요한 코드는 해당 조건이 만족되는 런타임 시점에 동적으로 로드할 수 있습니다.

TypeScript
 
// TypeScript 예시: 사용자 상호작용에 따른 동적 임포트
async function openRichTextEditor() {
  // 사용자가 '고급 편집기' 버튼을 클릭했을 때만 해당 모듈 로드
  // 이 모듈은 초기 번들에 포함되지 않고, 필요한 시점에만 네트워크 요청을 통해 로드됩니다.
  try {
    const { default: RichEditor } = await import('./components/RichEditor');
    const editor = new RichEditor();
    editor.mount();
    console.log("Rich Editor loaded and mounted successfully.");
  } catch (error) {
    console.error("Failed to load Rich Editor:", error);
    // 사용자에게 에러 메시지 표시
    alert("편집기 로딩에 실패했습니다. 다시 시도해 주세요.");
  }
}

// HTML 예시: <button onclick="openRichTextEditor()">고급 편집기 열기</button>

이 방식은 사용 빈도가 낮은 기능이나 **매우 무거운 모듈(예: 지도 라이브러리, 차트 라이브러리, 이미지 편집 도구)**에 적용하여 초기 로딩 부담을 최소화하는 데 효과적입니다. 에러 핸들링 로직을 포함하여 네트워크 문제 등으로 인한 로딩 실패에 대비하는 것이 중요합니다.

 

2.5. 번들러에서의 코드 분할 구조 (Chunk Graph & Optimization)

모듈 번들러는 애플리케이션의 **모듈 의존성 그래프(Module Dependency Graph)**를 분석하여 최적의 청크 분할 전략을 수립하고, 이 청크들을 어떻게 로드할지 결정합니다.

  • Webpack의 Chunk 구조:
    • Entry Chunk: 애플리케이션의 진입점(Entry Point)에서 시작되어 첫 로딩 시 필수적으로 로드되는 코드. 일반적으로 main.[hash].js 등으로 명명됩니다.
    • Lazy Chunk (Async Chunk): import()와 같은 동적 임포트 구문을 통해 생성된 코드. 필요할 때 비동기적으로 HTTP 요청을 통해 로드됩니다.
    • Vendor Chunk / Shared Chunk: node_modules의 라이브러리나 여러 청크에서 공유되는 모듈을 분리하여 생성된 청크. optimization.splitChunks 설정을 통해 관리되며, 캐싱 효율을 높이고 코드 중복을 방지합니다.
    dist/
    ├── index.html           // 메인 HTML 파일
    ├── main.[hash].js         // Entry Chunk (앱 핵심 로직)
    ├── settings.[hash].js     // Lazy Chunk (Settings 페이지/컴포넌트 코드)
    ├── about.[hash].js        // Lazy Chunk (About 페이지 코드)
    ├── vendors.[hash].js      // Vendor Chunk (React, ReactDOM 등 서드파티 라이브러리)
    ├── common.[hash].js       // Shared Chunk (여러 Lazy Chunk에서 공통으로 사용되는 커스텀 모듈)
    
    Webpack은 내부적으로 청크 간의 의존성을 관리하여, 특정 청크를 로드하기 위해 필요한 다른 청크들을 자동으로 로드합니다. 이는 webpackJsonp (Webpack 4 이하) 또는 __webpack_require__.f (Webpack 5)와 같은 런타임 로더를 통해 구현됩니다.
  • Vite에서의 접근: Vite는 개발 환경에서 ES Module을 직접 서빙하며 매우 빠른 개발 경험을 제공하고, 프로덕션 빌드 시에는 Rollup을 번들러로 사용하여 최적화된 번들링 및 코드 스플리팅을 수행합니다. Rollup은 Webpack과 유사하게 코드 스플리팅 기능을 내장하고 있으며, manualChunks() 옵션을 통해 개발자가 수동으로 청크를 세밀하게 제어할 수도 있습니다.Vite는 개발 시에는 번들링 과정을 최소화하여 개발 서버의 응답 속도를 극대화하고, 배포 시에는 Rollup의 강력한 최적화 기능을 활용하여 프로덕션 번들의 효율성을 보장합니다.
  • TypeScript
     
    // vite.config.ts 예시: Rollup의 manualChunks를 통한 세밀한 청크 제어
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    
    export default defineConfig({
      plugins: [react()],
      build: {
        rollupOptions: {
          output: {
            // `manualChunks` 함수는 모듈 ID를 받아 청크 이름을 반환합니다.
            // 반환 값이 없으면 Rollup의 기본 분할 로직을 따릅니다.
            manualChunks(id) {
              // `node_modules` 내부의 모든 것을 'vendor' 청크로 묶음
              if (id.includes('node_modules')) {
                return 'vendor';
              }
              // 특정 대용량 라이브러리나 기능별로 별도 청크 생성
              if (id.includes('src/heavy-feature-module')) {
                return 'heavy-feature-module';
              }
            }
          }
        }
      }
    });
    

 

4. 코드 분할의 성능적 이점 (Quantifiable Impact & Core Web Vitals)

코드 분할은 사용자에게 직접적으로 체감되는 **성능 지표(Core Web Vitals)**를 개선하는 데 결정적인 역할을 합니다.

| 핵심 성능 메트릭 | 개선 전 (단일 대형 번들) | 코드 분할 적용 후 | 코드 분할의 영향 (컴퓨터 과학적 관점) **

JavaScript
 
// Webpack에서 Lazy Loading을 위한 동적 임포트 예시
import(/* webpackChunkName: "my-module" */ './my-module.js').then(module => {
  // 모듈 로드 성공 시 실행될 로직
  console.log('Module loaded:', module.default);
}).catch(error => {
  // 모듈 로드 실패 시 에러 처리
  console.error('Module load failed:', error);
});
  • webpackChunkName 주석: Webpack이 이 동적 임포트를 처리할 때, 생성될 청크 파일에 지정된 이름을 부여합니다. 이를 통해 디버깅이 용이해지고, 청크 간의 관계를 시각화하는 데 도움이 됩니다.
  • Promise 기반: import()는 Promise를 반환하므로, .then()과 .catch()를 사용하여 비동기 로딩의 성공/실패를 처리할 수 있습니다. 이는 네트워크 지연이나 오류 발생 시 사용자에게 적절한 피드백을 제공하는 데 중요합니다.

 

3. 코드 분할의 성능적 이점 (Quantifiable Impact & Core Web Vitals)

코드 분할은 사용자에게 직접적으로 체감되는 **성능 지표(Core Web Vitals)**를 개선하는 데 결정적인 역할을 합니다.

| 핵심 성능 메트릭 | 개선 전 (단일 대형 번들) | 코드 분할 적용 후 | 코드 분할의 영향 (컴퓨터 과학적 관점) ** | LCP (Largest Contentful Paint) | 3.4s | 1.6s | 초기 화면 로드 가속: 사용자에게 가장 큰 의미 있는 콘텐츠(보통 이미지나 큰 텍스트 블록)가 렌더링되는 시간을 줄입니다. 코드 분할로 핵심 번들 크기를 최소화하여, 브라우저가 더 빨리 DOM을 파싱하고 렌더링을 시작할 수 있게 합니다. 이는 **지각된 성능(Perceived Performance)**을 크게 향상시킵니다. ```

**`loading="lazy"` 속성:** HTML 표준 (`loading` 속성)에서 `<iframe>` 태그와 `<img>` 태그에 적용할 수 있습니다. 이를 통해 **브라우저 자체적인 Lazy Loading 기능**을 활용할 수 있습니다.
* **작동 방식:** 브라우저는 이 속성이 지정된 미디어 요소가 **뷰포트 근처(Near Viewport)**에 올 때까지 리소스 로드를 지연시킵니다. 사용자가 스크롤하여 해당 요소가 화면에 나타날 임계점에 도달하면 브라우저가 자동으로 리소스 로드를 시작합니다.
* **장점:** 개발자가 별도의 JavaScript 코드를 작성할 필요 없이 간단하게 Lazy Loading을 적용할 수 있어 **개발 생산성**이 높고, 브라우저가 최적화된 방식으로 로딩을 관리하므로 **성능 효율성**이 뛰어납니다. 모든 브라우저에서 지원되는 것은 아니지만, 점차 지원 범위가 넓어지고 있습니다.

```html
<img src="placeholder.jpg" data-src="real-image.jpg" alt="게으른 로딩 이미지" loading="lazy">

<iframe src="placeholder.html" data-src="real-content.html" title="게으른 로딩 iframe" loading="lazy"></iframe>

주의: loading="lazy" 속성은 브라우저가 지원하지 않을 경우 폴백(Fallback) 동작이 없으므로, 모든 브라우저를 지원해야 하는 경우 JavaScript 기반의 Intersection Observer 등의 기법을 함께 사용하는 것이 안전합니다.


 

4. 코드 분할 도입 시 고려사항 및 도전 과제 (Challenges and Best Practices)

코드 분할은 강력한 최적화 기법이지만, 잘못 적용하면 오히려 사용자 경험을 해치거나 예상치 못한 문제를 야기할 수 있습니다. 이는 **시스템 설계의 트레이드오프(Trade-offs)**를 이해하는 과정이기도 합니다.

  • 4.1. 과도한 청크 분할 (Too Many Chunks): HTTP 요청 오버헤드
    • 문제: 코드를 너무 잘게 나누면, 애플리케이션 초기 로딩 시 또는 라우트 전환 시 수많은 작은 HTTP 요청이 발생할 수 있습니다. 각 HTTP 요청에는 TCP 핸드셰이크, SSL/TLS 협상 등의 오버헤드가 따르므로, 요청 수가 지나치게 많아지면 오히려 **네트워크 지연(Network Latency)**이 증가하여 전체적인 로딩 시간이 길어질 수 있습니다.
    • 해결: optimization.splitChunks 설정에서 minSize, maxAsyncRequests, maxInitialRequests 등의 옵션을 적절히 조절하여 청크의 크기와 수를 최적화해야 합니다. HTTP/2를 사용하는 경우, 여러 요청을 동시에 처리할 수 있어 이 문제가 완화되지만, 여전히 과도한 요청은 성능 저하의 원인이 됩니다.
  • 4.2. 코드 중복 (Code Duplication): 번들 사이즈 증가의 함정
    • 문제: 동일한 모듈이 여러 청크에 중복으로 포함될 수 있습니다. 예를 들어, utility.js 파일이 HomePage 청크와 AboutPage 청크 모두에서 import될 경우, 이 유틸리티 코드가 두 청크에 각각 포함되어 최종 번들 사이즈가 불필요하게 커질 수 있습니다.
    • 해결: 번들러의 optimization.splitChunks 설정(minChunks, cacheGroups)을 통해 **공통 모듈(Common Modules)**을 별도의 공유 청크로 분리해야 합니다. 이렇게 하면 여러 청크에서 사용되는 코드는 한 번만 다운로드되고 캐싱되어 재활용됩니다.
  • 4.3. SSR (Server-Side Rendering) 및 SEO 이슈 (웹 프론트엔드): 크롤러 가시성
    • 문제: 클라이언트 측에서 JavaScript를 통해 동적으로 Lazy Loading되는 콘텐츠는 검색 엔진 크롤러가 초기 로드 시점에는 JavaScript를 실행하지 않거나 제한적으로 실행하여 접근하지 못할 수 있습니다. 이는 **검색 엔진 최적화(SEO)**에 불리하게 작용할 수 있습니다.
    • 해결:
      • 하이드레이션(Hydration) 문제: SSR 프레임워크(Next.js, Nuxt.js)는 서버에서 HTML을 미리 렌더링하지만, 클라이언트에서 JavaScript가 로드되어 DOM과 상호작용하는 하이드레이션(Hydration) 과정에서 Lazy Loading된 컴포넌트가 아직 로드되지 않아 **불일치(Mismatch)**가 발생할 수 있습니다. next/dynamic과 같은 SSR 프레임워크의 특정 API를 사용하여 Lazy Loading된 컴포넌트가 SSR 시에는 포함되지 않도록 하고, 클라이언트에서만 로드되도록 제어해야 합니다.
      • 크롤러 친화적 Lazy Loading: 중요한 콘텐츠는 초기 번들에 포함하거나, SSR/SSG(Static Site Generation)를 사용하여 서버에서 미리 렌더링해야 합니다. 중요도가 낮은 콘텐츠는 Lazy Loading을 적용하되, 검색 엔진이 JavaScript를 실행하여 동적 콘텐츠를 크롤링할 수 있도록 Google Search Console 등을 통해 확인하고 최적화해야 합니다.
  • 4.4. 초기 렌더링 시점 판단 기준 명확화 (User Experience): '지연'과 '버벅임'의 경계
    • 문제: Lazy Loading이 너무 늦게 발생하면, 사용자에게 '지연'이 아니라 '버벅임'이나 '빈 화면'으로 느껴질 수 있습니다. 예를 들어, 스크롤을 내렸는데 이미지가 뒤늦게 뚝뚝 나타나는 경우 UX가 저해됩니다.
    • 해결: Intersection Observer의 rootMargin (뷰포트 여백)이나 threshold (가시성 임계값)와 같은 옵션을 조절하여 **사용자가 도달하기 전에 미리 리소스를 로드(Preload / Prefetch)**하도록 설정해야 합니다. 또한, 로딩 중에는 **스켈레톤 UI(Skeleton UI)**나 로딩 스피너를 적절히 배치하여 사용자에게 시각적인 피드백을 제공해야 합니다.
  • 4.5. 네트워크 예측 및 프리페칭(Prefetching) / 프리로드(Preloading): 다음 행동 예측
    • 문제: Lazy Loading은 '필요할 때' 로드하지만, 사용자의 다음 행동을 미리 예측하여 미리 로드할 수 있다면 UX를 더욱 개선할 수 있습니다.
    • 해결:
      • rel="preload": 현재 페이지에서 곧 사용될 것으로 확실시되는 리소스(예: 다음 라우트의 필수 이미지)를 브라우저에게 우선적으로 로드하라고 지시합니다.
      • rel="prefetch": 현재 페이지에서 당장 필요하진 않지만, 사용자가 나중에 방문할 가능성이 높은 페이지의 리소스(예: 특정 라우트의 JavaScript 청크)를 미리 캐시해 두도록 브라우저에 지시합니다. 이는 유휴 네트워크 시간을 활용합니다.
      • Webpack Magic Comments: import(/* webpackPrefetch: true */ './DetailComponent.js') 와 같은 주석을 사용하여 번들러가 특정 청크를 프리페치하도록 지시할 수 있습니다.

5. 결론: 코드 분할, 단순한 기술을 넘어선 아키텍처 전략

**코드 분할(Code Splitting)**은 단순한 JavaScript 번들러 설정이 아닙니다. 이는 애플리케이션의 초기 로딩 성능을 극대화하고, 네트워크 자원 활용을 최적화하며, 궁극적으로 **사용자 경험(UX)**을 혁신하는 아키텍처적 설계 전략입니다.

“빠르게 보이게 만드는 것이 아니라, 진짜로 빠르게 만드는 것이 진짜 최적화다.”

 

728x90
반응형