코드 스플리팅 : lazy & Suspense

Clapsheep·2025년 2월 19일

하나의 js로 합쳐지는 번들링

빌드과정에서 번들링을 진행하게 되면 여러 파일로 만들어진 js파일도 결국 하나의 budle.js 또는 main.js로 합쳐지는 것을 알 수 있다. 하나의 js 파일을 불러오게 되면 어떤 문제가 발생할까?

번들링으로 인한 초기 렌더링 시간 증가

첫 페이지에 진입해 브라우저가 js파일을 로드할 때, 해당 서비스 모두를 관통하는 단일 js 파일을 파싱한다면 굉장히 오랜 시간이 소요될 것이다. 이 과정에서 사용자가 당장 필요하지 않은 부분은 필요한 시점에 파싱할 수는 없을까?

문제해결

이 문제를 해결하기 위해 "코드 스플리팅" 사용한다. 필요한 js코드를 분리된 파일로 번들링해서 필요한 상황에 해당 파일을 파싱함으로써 각각의 페이지에서 읽어야하는 js의 양을 상대적으로 줄여줄 수 있다. 이러한 문제를 리액트에서 해결하기 위한 방법을 알아보자.

자바스크립트 함수 비동기 로딩

src/hello.js

export default function hello() {
  alert("안녕");
}

src/App.jsx

import hello from './hello';

function App(){
  const onClick = () => {
    hello();
  }
  return (
  	<div className="App">
    	<button onClick={onClick}>Click</button>
    </div>
  )
}
export default App;

위의 코드를 실행하면 번들링 시 App.jsx 내부로 hello.js가 포함되어 번들링이 될 것이다.
하지만 자바스크립트가 함수를 비동기로 로딩하는 방법을 사용하면 어떨까?

src/hello.js

export default const hello = () => {
  alert("안녕");
}

src/App.jsx

function App(){
  const onClick = () => {
    import('./hello').then(res => res.default());
  }
  return (
  	<div className="App">
    	<button onClick={onClick}>Click</button>
    </div>
  )
}
export default App;

이 방식은 import를 함수로 사용하면 Promise를 반환하는 특성을 활용한 방식으로, onClick 함수가 실행되는 시점에서 import를 하기 때문에 번들링 시 별도의 js파일로 남아있게 된다. 이 방식을 흔히 다이나믹 임포트라고 하며, 자바스트립트의 비동기 처리 방식을 활용한 예로 볼 수 있다.

React에서 제공하는 코드 스플리팅

React.lazy

React.lazy는 컴포넌트를 동적 렌더링 하기 위해 React에서 제공하는 유틸함수 이다.
앞서 동적 임포트는 Promise를 반환하는 import함수의 특성을 활용한 방법이라고 했다. 이를 컴포넌트에 적용해서 동적으로 해당 컴포넌트를 적용하는 방식이다.

const Children = React.lazy(()=>import('./Children'));

이렇게 컴포넌트를 import 하게 되면 번들링 시에 포함되지 않고 분리된 js로 해당 컴포넌트를 관리할 수 있기 때문에 코드 스플리팅의 장점을 활용할 수 있다.

제한사항

  1. 서버사이드 렌더링에서는 lazy를 사용할 수 없다.
    서버는 Dynamic import를 처리할 수 없고, 그에 따라 만들어 낸 HTML파일과 클라이언트가 렌더링하는 HTML이 다른 문제로 인해 hydration 문제를 만들어 내기 때문이다.
    (Next.js 나 Remix 등의 프레임워크는 이 기능을 제공한다.)

  2. 반드시 default export를 사용해야한다.
    내부적으로 모듈의 default export만 찾도록 설계된 점을 놓치지 않아야한다.

  3. 컴포넌트 내부가 아닌 모듈 최상위 레벨에서 선언해야한다.
    렌더링마다 새로운 lazy컴포넌트를 만들어내는 것을 방지하기 위해 아래와 같이 사용해야한다.

// 🚫 잘못된 사용
function ParentComponent() {
  const LazyComponent = lazy(() => import('./MyComponent')); // 컴포넌트 내부
  return <LazyComponent />;
}

// ✅ 올바른 사용
const LazyComponent = lazy(() => import('./MyComponent')); // 모듈 최상위

function ParentComponent() {
  return <LazyComponent />;
}

이렇게 만들어진 lazy로 부른 컴포넌트는 번들링 시 한 파일에 포함되지 않고 다른 파일로 처리할 수 있다. 앞서 설명한 Dynamic import와 동일한 로직으로 돌아간다면 이 함수는 분명 Promise를 반환할 것이다. 이 Promise의 상태는 어떻게 관리할 수 있을가?

Suspense

lazy를 사용하면 컴포넌트가 필요한 때에 해당 컴포넌트가 번들링된 js를 읽기 시작하고, 이를 위해 Promise의 pending 상태를 관리할 필요성이 생긴다.
이 상태를 관리하는 것이 Suspense컴포넌트라고 할 수 있다.

Suspense는 props로 fallback을 받게 되어있고, children에 있는 컴포넌트가 pending 상태일 때 fallback에 있는 컴포넌트를 보여주게 된다. 이를 활용해 스켈레톤 UI나 로딩 스피너를 표시한다면 사용자는 해당 컴포넌트 렌더링 시간동안 만족스러운 UX를 제공받을 수 있다.

import React, {Suspense} from 'react';
import Loading from './Loading';

const Children = React.lazy(()=>import('./Children'));

function Parents() {
	return (
    	<div>
      		<Suspense fallback = {<Loading/>}>
              <Children />
        	</Suspense>
      	</div>
    );
}

이렇게 코드 스플리팅이란 무엇이고, 어떻게 스플리팅을 진행하는 지 살펴보았다.
그런데 의문이 생겼다. 그러면 다 쪼개는 것이 맞을까? 얼마나 쪼개야할까??

코드 스플리팅 방법론

코드 스플리팅을 하는 것을 최적화를 위한 방법인 만큼 양날의 검으로 작용한다.
너무 많은 청크를 나누어 스플리팅 하게 된다면, 그만큼 초기 렌더링 속도는 빨라지겠지만 추가적인 js를 요청하기 위한 HTTP 요청 횟수가 늘어날 것이다.
반대로 스플리팅을 하지 않아 너무 큰 청크의 js파일을 파싱해야한다면, 로딩 후에 속도는 굉장히 빠르겠지만, 그만큼 초기 렌더링 속도는 늘어날 것이다.

이러한 고민을 해결하기 위해 보편적인 스플리팅 방법론들을 조사해보았다.

라우트 기반 코드 스플리팅

먼저 진입하는 라우트를 기반으로 코드를 나누는 방법이다.
React router와 함께 사용할 수 있고, route파일 내에서 lazy를 활용해 각 페이지를 나누는 방식이다.

import { createBrowserRouter } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "dashboard",
        element: <Suspense fallback={<Loading />}>
          <DashboardPage />
        </Suspense>,
        lazy: () => import("./pages/Dashboard")
      },
      {
        path: "settings",
        element: <Suspense fallback={<Loading />}>
          <SettingsPage />
        </Suspense>,
        lazy: () => import("./pages/Settings")
      }
    ]
  }
]);

컴포넌트 기반 코드 스플리팅

다음은 너무 무거운 컴포넌트를 다룰 때 적합한 방식으로 컴포넌트를 스플리팅 하는 방법이다.

// 무거운 UI 컴포넌트
const HeavyChart = lazy(() => import('./components/HeavyChart'));
const DataGrid = lazy(() => import('./components/DataGrid'));

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<ChartSkeleton />}>
        <HeavyChart />
      </Suspense>
      <Suspense fallback={<GridSkeleton />}>
        <DataGrid />
      </Suspense>
    </div>
  );
}

특정 기능이나 모듈 기반 코드 스플리팅

다음은 모든 사용자가 꼭 해당 기능을 사용하지 않을 수 있을 때 적합한 모듈 기반의 코드 스플리팅이다.

// 텍스트 에디터 예시
function BlogPost() {
  const [isEditing, setIsEditing] = useState(false);
  
  // 에디터는 필요할 때만 로드
  const RichTextEditor = lazy(() => import('./RichTextEditor'));
  
  return (
    <div>
      {isEditing ? (
        <Suspense fallback={<EditorSkeleton />}>
          <RichTextEditor />
        </Suspense>
      ) : (
        <ReadOnlyView />
      )}
    </div>
  );
}

사용자 행동기반 프리로딩

lazy를 활용하는 방법 중 사용자의 행동을 예측하고 사용자가 다음 페이지로 진입하기 전에 필요한 data를 미리 다운받는 preLoading(preFectiong) 방법으로 활용할 수 있다.

const ProfilePage = lazy(() => import('./ProfilePage'));

function NavBar() {
  const prefetchProfile = () => {
    // 마우스 호버 시 미리 로드
    const profilePromise = import('./ProfilePage');
  };

  return (
    <Link to="/profile" onMouseEnter={prefetchProfile}>
      프로필
    </Link>
  );
}

위의 코드를 보면 사용자가 Link태그에 호버 시 필요한 js에 대해 로드를 시작하는 것을 볼 수 있다. 이러면 사용자가 링크를 눌러서 요청하는 것보다 미리 요청을 보내 사용자에게 빠른 UX를 제공할 수 있다는 장점이 있다.

꼭 호버가 아니라 다양한 이벤트에 따라 dynamic import를 트리거 할 수 있고 아래에 그 예시를 들어보았다.

const prefetchStrategies = {
    // 1. 마우스 호버 시
    onHover: <Link onMouseEnter={prefetchProfile}>,
    
    // 2. 화면에 보일 때
    onVisible: useEffect(() => {
      const observer = new IntersectionObserver(entries => {
        if (entries[0].isIntersecting) {
          import('./ProfilePage');
        }
      });
      observer.observe(linkRef.current);
    }, []),

    // 3. 페이지 로드 후 유휴 시간에
    onIdle: useEffect(() => {
      if ('requestIdleCallback' in window) {
        requestIdleCallback(() => {
          import('./ProfilePage');
        });
      }
    }, [])
  };

이 외에도 다양한 방법이 있지만 이를 외우고 무조건적으로 적용하기 보단 사용자의 상황을 생각하며 유연하게 적용하는 것이 좋겠다고 생각했다.
같은 기능일지라도 서비스에 따라 대부분의 사용자들이 사용하는 기능이라면 lazy를 적용하지 않고 하나의 청크로 가져오는 것이 효율적일 수 있고, 대부분의 사용자가 사용하지 않는다면 lazy를 통해 필요할 때 로드하는 방식이 효율적일 수 있다.

브라우저의 작동을 고려할 때는 20kb~100kb 정도의 청크가 적당한 크기라고 한다.
이러한 자료를 이용해서 build 시 생성된 각각의 청크의 크기를 비교해 적절한 기준을 잡고 스플리팅을 하는 것도 좋은 방법이라고 할 수 있겠다.

부록 - Suspense 적용하기 with use(React19)

React19+ 에서 변화

앞서 알아본 lazy와 Suspense를 사용하는 것을 보았다. 코드 스플리팅을 하기 위해 lazy를 써서 컴포넌트를 import 했고, 그 상태를 관리하기 위해 Suspense를 사용했다. 그런데 컴포넌트가 pending 상태를 가질 수 있는 것이 꼭 코드 스플리팅만 있을까?

Fetch도 Promise를 반환한다!

코드 스플리팅을 위해 Suspense를 사용했던 제한에서 벗어나 data fetch를 했을 때도 pending상태가 발생한다.
기존에는 이것을 Suspense에 적용할 수 없었지만 React 19버전에서 출시한 use훅을 사용하면 이를 컴포넌트 상태에 반영할 수 있다.

const UserProfile = () => {
 const user = use(fetchUser(userId));
 const posts = use(fetchUserPosts(user.id));  // user 데이터에 의존
 
 return (
   <div>
     <h1>{user.name}</h1>
     <PostList posts={posts} />
   </div>
 );
}

이 코드는 19의 use를 사용해서 data를 fetch하는 코드이다.
굉장히 간단한 형태로 코드가 짧아진 것을 볼 수 있는데 만약 기존 useEffect, useState를 사용해서 상태를 관리했다면 어땠을까?

const Component = () => {
 const [data, setData] = useState(null);
 const [isLoading, setIsLoading] = useState(true);
 const [error, setError] = useState(null);

 useEffect(() => {
   fetch('/api/data')
     .then(res => res.json())
     .then(data => {
       setData(data);
       setIsLoading(false);
     })
     .catch(err => setError(err));
 }, []);

 if (isLoading) return <Loading />;
 if (error) return <Error />;
 return <div>{data.title}</div>;
}

// use hook 사용 (React 19)
const Component = () => {
 const data = use(fetch('/api/data').then(res => res.json()));
 return <div>{data.title}</div>;
}

useEffect와 useState를 선언하지 않고 use 훅이 내부에서 이 로직을 감당하므로 상당히 많은 양의 보일러 플레이트가 줄은 것을 확인할 수 있다. 이를 통해 개발자는 훨씬 더 읽기 좋은 코드를 작성하고, 여러 useEffect를 직접 관리하면서 생기는 리렌더링 순서에 관한 이슈를 피할 수 있기 때문에 편리한 DX라고 생각한다. 그리고 Suspense를 사용할 수 없기 때문에 isLoading에 따른 내부 로직을 작성해야 하므로 컴포넌트의 볼륨이 커져서 가독성을 헤치는 모습을 발견할 수 있다.

마무리

확실히 리액트가 점점 더 편리한 방향으로 흘러가는 동시에 더욱 공부해야할 것과 알아야할 것들이 많아지는 것 같다. 그러나 코드 스플리팅은 실제 서비스를 운영할 때 렌더링 시간을 감축하기 위해 필요한 과정이기 때문에 프론트엔드 개발자라면 성능 개선을 위해 꼭 학습해야할 부분이라는 생각이 들었다

profile
왜 사용하는지 적어보려고 블로그를 합니다.

0개의 댓글