최근 웹 개발 프로젝트 하나를 마쳤습니다 🙌 (Fitory GitHub 바로가기)
짧은 시간 동안 최선을 다한 만큼 완결성 높은 결과물이 나온 것 같아 만족스럽습니다.
해당 프로젝트에서 본인은 프론트엔드 개발을 담당하여 수행했으며, 그 과정에서 프론트엔드 성능을 개선한 경험을 공유해보려 합니다.
성능 개선 경험들 중 이번 글에서는 번들 최적화에 대해서 다루겠습니다.
문제 발생
모바일 기기 환경의 Lighthouse를 찍어봤습니다.
성능 측정 항목 대부분의 시간이 오래 걸리는 것이 보이네요..
해당 결과를 확인 후, 사용자가 빠르게 콘텐츠를 인식할 수 있도록 성능 개선의 필요성을 느끼게 되었습니다.
또 이러한 지표를 보고 성격 상 절대 그냥 지나칠 수 없습니다..😅
그래서 우선적으로 한 것은 바로 BundleAnalyzerPlugin Webpack 플러그인을 통한 번들 분석!
1. react-icons 라이브러리를 @react-icons/all-files 라이브러리로 대체
위 번들 분석 이미지에서 react-icons 라이브러리가 가장 큰 크기를 차지하고 있는 것이 보입니다.
저희 프로젝트에서 react-icons 라이브러리를 통해 사용한 아이콘은 단 2개 밖에 없습니다.
그런데 쓸데없이 이만큼이나 차지하고 있다니..
react-icons 라이브러리는 아이콘 종류별로 하나의 JavaScript 파일이 존재하며, 각 파일에서 종류별 아이콘 전체를 포함하고 있습니다.
- 따라서 빌드 시에 해당 파일 내 아이콘 전체가 포함되기 때문에 번들 사이즈가 커지게 되는 것이죠.
이와 달리 @react-icons/all-files 라이브러리는 아이콘 별로 JavaScript 파일이 존재합니다 .
- 따라서 빌드 시에 Tree Shaking으로 인해 더 적은 크기의 번들이 만들어집니다.
Tree Shaking? 사용하지 않는 코드를 제거하는 방식을 말합니다.
자, 번들을 다시 분석해봅시다.
node_modules 번들(727.js) 사이즈가 3.53 MB에서 1.27 MB로 줄어들었습니다!
흠.. 그런데 홈 페이지에서는 사용하지도 않는 chart.js 등 라이브러리가 많은 크기를 차지하고 있네요.
2. React의 lazy와 Suspense 적용
각 페이지들을 React의 lazy와 Suspense를 사용하여 Code Splitting을 적용했습니다.
Code Splitting? 코드를 분리하고 필요할 때만 불러오는 방식을 말합니다.
lazy를 통해 동적 import로 사용할 컴포넌트를 가져오고, Suspense를 통해 해당 컴포넌트를 가져오는 동안 로딩 화면을 렌더링해주는 것입니다.
따라서 이전에 정적이게 받아오던 코드들이 분할되어 번들이 나누어지게 되고, 동적으로 각 번들을 불러오도록 합니다.
const HomePage = lazy(() => import("@pages/HomePage"));
const ChallengePage = lazy(() => import("@pages/ChallengePage"));
const RecordPage = lazy(() => import("@pages/RecordPage"));
const ProfilePage = lazy(() => import("@pages/ProfilePage"));
const FollowPage = lazy(() => import("@pages/FollowPage"));
const LoginPage = lazy(() => import("@pages/LoginPage"));
const JoinPage = lazy(() => import("@pages/JoinPage"));
const StatisticsPage = lazy(() => import("@pages/StatisticsPage"));
const SearchPage = lazy(() => import("@pages/SearchPage"));
...
<Suspense fallback={<div>loading..</div>}>
<Routes>
<Route path={RoutePath.HOME} element={<HomePage />} />
<Route path={RoutePath.CHALLENGE} element={<ChallengePage />} />
<Route path={RoutePath.RECORD} element={<RecordPage />} />
<Route path={RoutePath.SEARCH} element={<SearchPage />} />
<Route path={RoutePath.STATISTICS} element={<StaticsPage />} />
<Route path={RoutePath.PROFILE} element={<ProfilePage />} />
<Route path={RoutePath.LOGIN} element={<LoginPage />} />
<Route path={RoutePath.JOIN} element={<JoinPage />} />
<Route path={RoutePath.SEARCH} element={<SearchPage />} />
<Route path={RoutePath.FOLLOW} element={<FollowPage />} />
</Routes>
</Suspense>
...
위 코드로 변환하여 Code Splitting을 적용한 후 시행한 번들 분석 결과입니다.
번들이 분할된 것을 볼 수 있습니다.
또한 홈 페이지에서 요청되는 번들은 index.js와 744.js 뿐! 훨씬 가벼워졌습니다.
그에 따라 메인 번들(index.js)의 크기가 307.19 KB에서 10.2 KB로 확연히 줄어들었습니다.
3. TypeScript enum을 const와 union type으로 변환
TypeScirpt의 enum을 사용하면 일부 번들러에서 Tree Shaking이 되지 않아 번들 사이즈가 커질 수 있습니다.
저희 팀이 사용 중이었던 Webpack5는 이를 지원했기에 염려하지 않았습니다.
그러나! Tree Shaking을 떠나서 enum의 빌드 코드의 양은 다른 방법에 비해 많습니다.
프론트엔드 개발에서 상수화 및 타입 지정을 위해 사용한 enum이 너무나 많았고 '티끌 모아 태산'이 되버릴 수도 있겠다는 생각에, 실험도 해볼겸 극한으로 개선해보자 결심했습니다.
그래서 모든 enum을 const와 union type으로 변환했습니다.
모든 enum을 한 파일 내에서 관리하고 있었기에 const + union type으로 변환하는 것이 매우 수월했습니다.
// constants/enum.ts
export enum RoutePath {
HOME = "/",
STATICS = "/statics",
SEARCH = "/search",
PROFILE = "/profile",
CHALLENGE = "/challenge",
RECORD = "/record",
LOGIN = "/login",
JOIN = "/join",
FOLLOW = "/follow",
};
...
이랬던 코드를 아래와 같이!
// constants/enum.ts
export const RoutePath = {
HOME: "/",
STATICS: "/statics",
SEARCH: "/search",
PROFILE: "/profile",
CHALLENGE: "/challenge",
RECORD: "/record",
LOGIN: "/login",
JOIN: "/join",
FOLLOW: "/follow",
} as const;
export type RoutePath = typeof RoutePath[keyof typeof RoutePath];
...
그 결과..
메인 번들(index.js) 사이즈가 10.2 KB에서 7.79 KB로 줄어들었습니다!
❗️단, enum의 장점 또한 명확하기에 무조건적으로 사용하지 않는 것보다는, 팀 내부적으로 어떠한 목표를 지향할 것인지 논리적인 근거를 바탕으로 선택하면 좋을 것 같습니다.
4. 이미지 최적화
이 기세를 이어 이미지 최적화 또한 수행했습니다.
JPG, JPEG, PNG 이미지 파일들을 WebP 파일로 변환하여 크기를 줄였습니다.
Webp? 구글이 웹페이지 로딩 속도를 높이기 위해 개발한 이미지 포맷입니다. JPG, PNG 등 이미지보다 크기는 작지만 이미지 품질은 동일하게 유지됩니다.
저는 PIXLR 사이트에서 모두 수동으로 변환했습니다.
🏆 최종 결과
(동일하게 모바일 환경에서 Lighthouse를 찍은 결과입니다.)
👍 전체 번들 사이즈가 3.83 MB에서 1.68 MB로 줄어들었습니다.
👍 메인 번들(index.js) 사이즈가 307.15 KB에서 7.79 KB로 줄어들었습니다.
👍 Speed Index가 7.9초에서 0.6초로 줄어들었습니다.
👍 Total Blocking Time이 240 밀리초에서 130 밀리초로 줄어들었습니다.
Time to Interactive, Largest Contentful Paint 성능 요소는 크게 줄이지 못했습니다..😭
데스크톱 환경의 Lighthouse에서는 Time to Interactive, Largest Contentful Paint 성능 결과가 높게 나왔습니다.
그래서 여러 문서들을 참조해보니, 모바일 환경의 Lighthouse는 3G 연결을 통해 로드되는 것을 테스트하기에 낮은 점수가 나오는 것 같습니다.
물론 현재 제가 모르는 특정 원인으로 인한 문제일 수 있기에, 계속해서 분석하고 개선하는 시도를 하고 있습니다.