프로젝트를 제작 중 규모가 점점 커지면서 코드량과 컴포넌트 갯수 또한 기하급수적으로 늘어나는걸 확인하게 되었습니다.
아직 개발 단계이기 때문에 성능에 대해 많은 고려를 하면서 개발을 진행하지는 않았는데
슬쩍 돌려본 light house
결과와 slow 3G
로 확인한 network 탭 결과는 충격적이었습니다.
이대로는 안되겠다 싶어서 간단한 최적화 정도는 개발 단계에서 진행하려고 마음을 먹었고 그 중 첫 번째로 code splitting
을 위해 lazy loading
을 적용시키기로 결정했습니다.
code splitting
은 코드를 분할하는 것으로 코드를 번들된 코드 혹은 컴포넌트로 분리하는 것을 얘기합니다. 서비스 규모가 커지면 코드량도 많아지고 서드파티 라이브러리 개수가 많아지면서 용량도 늘어나며 자연스럽게 JavaScript 파일이나 번들이 커지게 됩니다.
React같은 SPA 특성상 초기 로딩때 번들 파일 로딩이 다 이루어져야 사용자가 화면을 접할 수 있기 때문에 번들 파일 크기가 커서 로딩이 오래 걸릴 수록 사용자의 UX를 해치기 마련입니다.
code splitting은 하나의 번들 파일을 여러 개의 번들 파일로 나누는 작업으로 webpack
, rollup
, browserify
와 같은 모듈 번들러를 이용해 만들어진 하나의 번들 파일을 여러 개로 나누는 것입니다.
하나의 번들 파일을 여러 개의 번들 파일로 나눈 뒤 실제 로드될 화면에 필요한 번들 파일만 불러오고 나머지 번들 파일은 호출하지 않고 지연시킴으로 써 작업량을 줄여 더 빠른 속도로 화면이 보일 수 있게 도와줍니다.
전체적인 코드 총량은 같지만 파일 숫자와 용량은 늘어날 수 있습니다. 다만 초기 로딩에 필요한 코드와 불필요 코드를 나눠서 로딩할 수 있기 때문에 사용자의 입장에서는 더 좋은 UX를 겪을 수 있습니다.
하지만 무분별한 code splitting은 번들링의 의미가 없어지기 때문에 너무 남발하면 안되는 점도 존재합니다.
React 공식 문서에서 언급하길 서비스에 코드 분할을 도입하는 가장 좋은 방법은 Dynamic import(동적 import())
문법을 사용하는거라고 합니다.
Dynamic import은 동적 불러오기로 우리가 평소에 사용하는 최상단에 import 구분을 사용하여 불러오는 방식은 static import(정적 불러오기)
입니다.
동적 불러오기는 코드 분할을 위해서 사용할 수 있는 방식으로 기본 사용법은 다음과 같습니다.
// 정적 불러오기
import { add } from './math';
console.log(add(16, 26));
// 동적 불러오기
import("./math").then(math => {
console.log(math.add(16, 26));
});
번들러가 동적 불러오기 구문을 만나면 코드 분할이 이루어지는 방식으로 코드의 위치와 관계 없이 사용이 가능하기 때문에 모듈을 사용자가 필요로 할 때 불러올 수 있습니다.
하지만 React에서는 위에서 본 동적 불러오기를 사용해 코드 분할시 에러가 발생합니다. React에서 컴포넌트를 동적으로 불러오기 위해서는 React.lazy
를 사용해야 합니다.
React.lazy()
를 사용하면 동적으로 불러오는 컴포넌트를 정의할 수 있습니다. 그러면 번들의 크기를 줄이고, 초기 렌더링에서 사용되지 않는 컴포넌트를 불러오는 작업을 지연시킬 수 있습니다.
다음 코드는 React.lazy의 기본 예제 코드입니다.
import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
React.lazy는 import() 구문을 반환하는 콜백함수를 인자로 받습니다.
동적 불러오기로 불러와지는 모듈은 React Component
를 포함해야 하며 default export
를 가진 모듈이어야 합니다.
React.lazy로 불러온 컴포넌트는 단독으로 쓰일 수 없고 React.Suspense
컴포넌트 하위에서 렌더링되어야 합니다.
Suspense
는 컴포넌트가 렌더링할 준비가 되지 않은 경우 loading
상태에 대한 렌더링 기능을 제공합니다.
예를 들어 컴포넌트에서 서버에 데이터를 요청하고 데이터 요청이 완료되어 결과값이 사용자에게 보여지기 전까지 3초의 시간이 필요하다는 가정을 해보겠습니다.
그럼 사용자는 원하는 데이터를 확인하기 위해 3초를 기다려야 할텐데 서비스에서 loading 관련 처리를 하지 않는다면 사용자는 지금 무슨 일이 일어나는지 확인할 방법이 없습니다.
그렇기에 보통 Skeleton UI를 사용하거나 loading이 끝나고 사용자에게 UI를 제공하는 등의 방법을 채택하곤 합니다.
import React, { Suspense } from "react"
const User = () => {
const { data } = axios.get("/api/user").then((res) => res.json());
return (
<div>
<h1>user name: {data.name}</h1>
</div>
)
}
const App = () => {
<Suspense fallback={<div>로딩중...</div>}>
<User />
</Suspense>
}
위 코드를 보면 User 컴포넌트에서는 user data를 요청하고 있습니다. 이때 네트워크가 느려서 저 user api 호출 함수가 오래 걸린다면 사용자는 지금 오류가 난건지 작업이 완료된건지 알 방법이 없습니다.
이때 보통 loading state
구축을 통해서 사용자에게 로딩중일때 UI를 제공할 수도 있겠지만 Suspense
를 사용해서 loading시 fallback UI
를 제공할 수 있습니다.
Suspense
에 대한 자세한 내용과 사용법 등은 다음에 알아보겠습니다.
결론적으로 lazy
도 Suspense 내부에서 이루어져야 하는 이유는 lazy
컴포넌트가 로딩되는 동안 Suspense
의 fallback을 통해 로딩 UI를 제공해야하기 때문입니다.
또한 여러 lazy 컴포넌트들을 Suspense 하나로 묶어서 lazy loading도 가능합니다.
import React, { Suspense } from 'react';
const OtherComponent = React.lazy(() => import('./OtherComponent'));
const AnotherComponent = React.lazy(() => import('./AnotherComponent'));
function MyComponent() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
<AnotherComponent />
</Suspense>
</div>
);
}
위 내용들을 종합해봐도 lazy loading을 통해 코드 분할하는 방법은 어렵지 않습니다. 다만 서비스에서 어디에 코드 분할을 도입할지 결정하는데 어려움을 겪을 수 있습니다. 처음에 언급했듯 과한 코드 분할은 번들러 사용의 의미를 해치기 때문입니다.
React 공식 문서에서는 lazy를 React-Router 라이브러리를 사용한 routing 기반 코드 분할을 추천하고 있습니다.
import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));
const App = () => (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route path="/" element={<Home />}/>
<Route path="/about" element={<About />}/>
</Switch>
</Suspense>
</Router>
);
아래와 같이 routing 페이지 단위로 lazy loading을 적용시키는걸 추천한다고 합니다.
저는 AppRouter.tsx
파일을 만들어 routing을 관리하고 App.tsx
파일에서 routing을 import 해서 사용하고 있었습니다.
// router.tsx
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import App from "@/App";
import { PATH } from "@/constants/path";
import StoryPage from "@/pages/StoryPage";
import SirenPage from "@/pages/SirenPage";
const AppRouter = () => {
const router = createBrowserRouter([
{
path: PATH.ROOT,
element: <App />,
errorElement: <Error404Page />,
children: [
{
path: "",
element: <StoryPage />
},
{
path: PATH.SIREN,
element: <SirenPage />
},
// {...} 일부 생략
],
},
]);
return <RouterProvider router={router} />;
};
export default AppRouter;
// App.tsx
import { Outlet } from "react-router-dom";
import { ToastContainer } from "react-toastify";
import LogIn from "@/components/common/LogIn/LogIn";
import ScrollTop from "@/components/common/ScrollTop/ScrollTop";
import Header from "@/components/Header/Header";
const App = () => {
return (
<>
<ScrollTop />
<LogIn>
<Header />
<main>
<Outlet />
</main>
</LogIn>
</>
);
};
export default App;
이렇게 lazy loading
이 적용되지 않은 코드를 build 시켜보면 index.js 파일 하나로 전체가 build되는걸 확인할 수 있습니다.
프로젝트 규모가 크다는 점, 성능 최적화를 진행하지 않은걸 고려해도 큰 용량의 빌드 파일이 결과물로 나오는걸 확인할 수 있습니다.
여기에 lazy loading을 적용시켜보았습니다.
App.tsx
는 동일합니다.
// router.tsx
import { Suspense } from "react";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import * as Lazy from "@/router/lazy";
import App from "@/App";
import { PATH } from "@/constants/path";
import Error404Page from "@/pages/Error404Page";
import StoryPageSkeleton from "@/pages/StoryPage/StoryPageSkeleton";
import SirenPageSkeleton from "@/pages/SirenPage/SirenPageSkeleton";
const AppRouter = () => {
const router = createBrowserRouter([
{
path: PATH.ROOT,
element: <App />,
errorElement: <Error404Page />,
children: [
{
path: "",
element: (
<Suspense fallback={<StoryPageSkeleton />}>
<Lazy.StoryPage />
</Suspense>
),
},
{
path: PATH.SIREN,
element: (
<Suspense fallback={<SirenPageSkeleton />}>
<Lazy.SirenPage />
</Suspense>
),
},
// {...} 일부 생략
],
},
]);
return <RouterProvider router={router} />;
};
export default AppRouter;
// lazy.ts
import { lazy } from "react";
export const StoryPage = lazy(() => import("@/pages/StoryPage/StoryPage"));
export const SirenPage = lazy(() => import("@/pages/SirenPage/SirenPage"));
// {...} 일부 생략
이후 build를 다시 해보면 lazy를 통한 코드 분할 파일에 맞춰서 여러 파일에 나뉘어서 build 결과물이 나오는걸 확인할 수 있습니다.
또한 index.js의 용량도 현저히 줄어들었습니다.
계속 언급하듯 프로젝트 규모가 크고 최적화도 이루어지지 않아서 그럼에도 index.js의 용량이 크지만 SPA 특성상 초기 로딩 시간이 매우 긴 단점이 존재하기 때문에 이정도의 용량 압축도 사용자에게는 큰 UX 차이를 제공하게 됩니다.
간단하지만 중요한 작업인 lazy를 통한 코드 분할을 통해 최적화를 진행해보았습니다. 이외에도 진행해야 하는 많은 최적화들이 존재하지만 개발을 진행하면서 간단히 적용시킬 수 있을거라는 생각에 lazy는 접목시켜놓고 개발을 진행하기로 했습니다.
개발자는 항상 사용자에게 제공되는 서비스를 제작하기에 사용자의 입장에서 생각하면서 개발을 진행해야 한다고 생각합니다. 물론 네트워크가 빠른 요즘 시대에 React의 단점인 긴 초기 로딩 시간이 길어봐야 얼마나 길겠어 생각하실 수도 있지만 개선되는 수치가 0.1초라도 개선된다면 사용자를 위해서 적용시켜야하는게 당연하다고 생각합니다.
다음에는 Suspense에 대해 자세히 알아보고, 다른 성능 개선 기술들에 대해서도 다뤄보겠습니다.
감사합니다.
Code splitting (코드분할)
https://developer.mozilla.org/ko/docs/Glossary/Code_splitting
코드 분할 React 공식 문서
https://ko.legacy.reactjs.org/docs/code-splitting.html
lazy React 공식 문서
https://react.dev/reference/react/lazy
Transfer Size vs. Resource Size
https://webperf.tips/tip/resource-size-vs-transfer-size/
dynamic import와 React.lazy
https://velog.io/@code-bebop/dynamic-import%EC%99%80-React.lazy