React|성능 최적화와 보안

번들 사이즈 다이어트: React.lazy와 Suspense를 활용한 Code Splitting 전략

1
번들 사이즈 다이어트: React.lazy와 Suspense를 활용한 Code Splitting 전략

크리스가 만든 앱이 날이 갈수록 무거워지고 있다. 기능이 추가될 때마다 자바스크립트 파일 용량(Bundle Size)도 정직하게 늘어났다.

사용자가 처음 사이트에 접속하면 하얀 화면만 3초 동안 멀뚱히 보고 있어야 한다. 브라우저가 5MB짜리 거대한 자바스크립트 덩어리를 다운로드하고 실행하느라 바쁘기 때문이다.

"아니, 나는 메인 페이지만 보고 싶은데 왜 '관리자 페이지'랑 '차트 라이브러리' 코드까지 다 받아야 해?"

사용자의 불만은 타당하다. 이 비효율을 해결하려면 거대한 코드를 조각내서, "지금 필요한 것만" 다운로드하게 만들어야 한다. 이것이 바로 코드 분할(Code Splitting)이다.

1. 통짜 번들의 문제점

리액트(CRA, Vite 등)로 빌드하면 기본적으로 모든 컴포넌트와 라이브러리가 하나의 파일(main.js 또는 bundle.js)로 합쳐진다.

  • 장점: 파일 하나만 받으면 되니까 요청 수가 적다.
  • 단점: 앱이 커지면 초기 로딩 속도(TTI)가 느려진다. 사용자가 절대 안 들어갈 페이지 코드까지 모두 받는다.
  • 우리는 이 덩어리를 청크(Chunk) 단위로 쪼개야 한다.

    2. 동적 임포트 (Dynamic Import)

    자바스크립트는 import() 문법을 통해 모듈을 동적으로 불러올 수 있다.

    // 일반적인 임포트 (파일 맨 위) -> 무조건 번들에 포함됨
    import { add } from './math';
    
    // 동적 임포트 (필요할 때 실행) -> 별도의 파일로 분리됨
    const handleClick = async () => {
      const module = await import('./math');
      console.log(module.add(1, 2));
    };

    웹팩(Webpack)이나 Vite 같은 번들러는 import() 구문을 만나면, 해당 파일을 별도의 JS 파일로 분리해서 빌드한다.

    3. React.lazy와 Suspense

    리액트는 컴포넌트 레벨에서 이를 쉽게 적용할 수 있도록 React.lazy를 제공한다.

    라우트 기반 분할 (Route-based Splitting)

    가장 효과가 좋은 방법은 페이지 단위로 쪼개는 것이다. 사용자가 /admin에 가지 않는다면, 어드민 관련 코드는 다운로드하지 않는다.

    // App.tsx
    import React, { Suspense, lazy } from 'react';
    import { BrowserRouter, Routes, Route } from 'react-router-dom';
    
    // 1. lazy로 감싸서 임포트한다.
    // 이제 이 컴포넌트들은 별도의 JS 파일(청크)로 분리된다.
    const Home = lazy(() => import('./pages/Home'));
    const About = lazy(() => import('./pages/About'));
    const Admin = lazy(() => import('./pages/Admin'));
    
    function App() {
      return (
        <BrowserRouter>
          {/* 2. 코드를 받아오는 동안 보여줄 로딩 화면 설정 */}
          <Suspense fallback={<div>페이지 로딩 중...</div>}>
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/about" element={<About />} />
              <Route path="/admin" element={<Admin />} />
            </Routes>
          </Suspense>
        </BrowserRouter>
      );
    }

    이제 사용자가 /about 메뉴를 클릭하는 순간, 네트워크 탭에서는 src_pages_About_tsx.js 같은 파일을 새로 받아오는 것을 볼 수 있다.

    4. 컴포넌트 기반 분할 (Component-based Splitting)

    페이지뿐만 아니라, 덩치가 큰 특정 컴포넌트도 쪼갤 수 있다.

    상황: 텍스트 에디터나 복잡한 차트 라이브러리는 용량이 매우 크다. 하지만 이 기능은 모달 창을 열어야만 보인다.

    // EditorModal.tsx
    import React, { useState, Suspense, lazy } from 'react';
    
    // 용량이 1MB나 되는 무거운 에디터
    const HeavyEditor = lazy(() => import('./components/HeavyEditor'));
    
    function EditorModal() {
      const [isOpen, setIsOpen] = useState(false);
    
      return (
        <div>
          <button onClick={() => setIsOpen(true)}>에디터 열기</button>
    
          {isOpen && (
            <Suspense fallback={<div>에디터 불러오는 중...</div>}>
              <HeavyEditor />
            </Suspense>
          )}
        </div>
      );
    }

    이렇게 하면 초기 로딩 시에는 가벼운 버튼 코드만 받고, 사용자가 버튼을 눌렀을 때 비로소 무거운 에디터 코드를 받아온다.

    5. 주의할 점과 전략

    코드 분할은 공짜가 아니다. 너무 잘게 쪼개면 네트워크 요청이 너무 많아져서 오히려 느려질 수 있다.

    UX 고려하기

  • Lazy Loading의 딜레이: 사용자가 페이지를 이동할 때마다 로딩 스피너(Suspense)가 뜬다면 경험이 좋지 않다.
  • Preloading: 사용자가 버튼 위에 마우스를 올렸을 때(Hover), 코드를 미리 받아오는 전략을 쓰면 클릭 시 로딩 없이 바로 뜰 수 있다.
  • // 마우스 올리면 미리 로드
    const onHover = () => {
      import('./pages/About'); // 미리 요청 보내기
    };

    핵심 정리

  • 문제: 거대한 번들 파일은 초기 로딩 속도를 느리게 만든다.
  • 해결: React.lazySuspense를 사용해 코드를 필요한 순간에 불러온다(Lazy Loading).
  • 전략:
  • 이제 앱의 몸무게를 줄여서 로딩 속도를 높였다. 하지만 데이터 자체가 너무 많다면? 화면에 10,000개의 리스트를 그려야 한다면 번들 사이즈와 상관없이 브라우저가 멈춰버릴 것이다.

    DOM 노드의 개수를 일정하게 유지하는 마법, 가상화(Virtualization)를 알아볼 차례다.

    "대용량 데이터 렌더링: 가상화(Virtualization - React Window)로 DOM 노드 갯수 유지하기" 편에서 계속된다.


    🔗 참고 링크

  • React Docs - Code Splitting
  • React Docs - lazy
  • Vite - Dynamic Import
  • 댓글 (0)

    0/1000
    댓글을 불러오는 중...