프론트엔드 개발자로서 할 수 있는 성능 향상..? 유저 퍼포먼스 개선? 주니어라면 한번쯤 내 프로젝트의 성능 향상을 하고싶다! 라는 생각을 해본 경험이 있다고 생각한다. 그리고 실무에서 성능 향상을 실제로 향상 시켜본 구체적인 경험은 채용 담당자 입장에서는 충분히 매력적일 것이라고도 생각된다. (수치화 까지 한다면 프론트엔드 이력서의 약점...보완.. 이 되지 않을까..?) 그래서 이번에 찍먹해볼 일이 있어 강의를 듣다가 더 찾아보고 싶어져서 이렇게 공부하게 되었다!
일단은 위 컨닝페이퍼에서 코드스플리팅에 대해 먼저 공부하고 정리해 보고자 한다!
코드 스플리팅은 말 그대로 코드 쪼개기! 이다.
코드를 쪼갤 수 있는 방법들은 아래와 같다.
요지가 무언가 하니 프론트엔드는 html위에 js 스크립트를 그린다.
이런 페이지가 있다고 해보자.
하나의 js파일에 모든 home, dashboard, profile, settings, advanved feature을 몰아넣고 조건에 따라 화면만 변경된다고 하면 첫 화면을 그릴 때 js청크 파일의 크기가 엄청나게 커져 첫 로딩이 한세월 걸릴 것이다.
하지만 만약 라우팅을 사용한다면?
home, dashboard, profile, settings, advanved feature 모두 페이지 라우팅을 진행하게 된다면 해당 라우트 이동했을때에만 쪼개진 해당 컴포넌트의 js 청크가 로딩되게된다. 그렇게 된다면 기존에 모든 라우트가 한번에 로딩되게 하는것보다 유저의 청크 로딩 속도가 훨씬 빨라지게 되어 퍼포먼스 또한 향상이 될것이다!
큰 컴포넌트나 자주 사용되지 않는 컴포넌트를 별도로 분리하여 동적으로 로드할 수 있다. React.lazy()와 Suspense를 사용하면 이를 쉽게 구현할 수 있다.
// App.js
import React, { Suspense } from 'react';
// 동적으로 import할 컴포넌트를 React.lazy()
const HeavyComponent = React.lazy(() => import('./HeavyComponent'));
function App() {
return (
<div>
<h1>내 앱</h1>
<Suspense fallback={<div>로딩 중...</div>}>
<HeavyComponent />
</Suspense>
</div>
);
}
export default App;
// HeavyComponent.js
import React from 'react';
function HeavyComponent() {
return <div>이것은 무거운 컴포넌트입니다.</div>;
}
export default HeavyComponent;
이 두가지 설정으로 초기 번들 사이즈를 줄여 유저는 무거운 컴포넌트가 로드되는 동안 빈 화면이 아닌 로딩 화면을 보며 로딩 중임을 확인 할 수 있다.
실제 프로젝트에서는 모달, 복잡한 차트, 대시보드와 같은 무거운 컴포넌트등에 적용하게 되면 효과를 볼 수 있다!
이벤트 기반 스플리팅은 단어에서 알 수 있겠지만 특정 사용자 상호작용이나 이벤트가 발생했을 때만 필요한 코드를 동적으로 로드한다.
import React, { useState } from 'react';
function App() {
const [showModal, setShowModal] = useState(false);
const [ModalComponent, setModalComponent] = useState(null);
const handleOpenModal = async () => {
if (!ModalComponent) {
// 동적으로 모달 컴포넌트를 import
const { default: Component } = await import('./Modal');
setModalComponent(() => Component);
}
setShowModal(true);
};
return (
<div>
<h1>이벤트 기반 코드 스플리팅 예제</h1>
<button onClick={handleOpenModal}>모달 열기</button>
{showModal && ModalComponent && <ModalComponent onClose={() => setShowModal(false)} />}
</div>
);
}
export default App;
// Modal.js
import React from 'react';
function Modal({ onClose }) {
return (
<div style={{ border: '1px solid black', padding: '20px' }}>
<h2>모달 내용</h2>
<p>이 모달은 동적으로 로드되었습니다.</p>
<button onClick={onClose}>닫기</button>
</div>
);
}
export default Modal;
예시를 읽어보기만 해도 어떤 느낌인지 살짝은 감이온다! 예시를 보면 handleOpen이라는 유저의 이벤트 발생시 Component를 동적으로 불러와 state로 저장하고 그 때! 화면에 동적으로 불러온 컴포넌트를 노출시켜준다.
(이 방법은 너무나 신기하자나?0ㅇ0) 이런 이벤트 기반 스플리팅은 위 두가지 스플리팅 방법과 마찬가지로 초기 로드 속도를 줄여 유저 퍼포먼스를 향상 시켜준다.
중요하지 않은 기능이나 콘텐츠는 초기 로드 후 지연 로딩으로 처리한다. 이는 초기 페이지 로드 시간을 줄이는 데 도움이 된다.
import React, { Suspense, lazy } from 'react';
// 높은 우선순위 컴포넌트는 일반적인 방식으로 import
import Header from './Header';
import MainContent from './MainContent';
// 낮은 우선순위 컴포넌트는 lazy로 import
const Footer = lazy(() => import('./Footer'));
const Sidebar = lazy(() => import('./Sidebar'));
const Comments = lazy(() => import('./Comments'));
function App() {
return (
<div>
<Header />
<MainContent />
<Suspense fallback={<div>로딩 중...</div>}>
<Footer />
</Suspense>
<Suspense fallback={<div>사이드바 로딩 중...</div>}>
<Sidebar />
</Suspense>
<Suspense fallback={<div>댓글 로딩 중...</div>}>
<Comments />
</Suspense>
</div>
);
}
export default App;
// MainContent.js
import React, { useState, lazy, Suspense } from 'react';
const HeavyChart = lazy(() => {
return new Promise(resolve => {
// 3초 후에 컴포넌트 로드 (네트워크 지연 시뮬레이션)
setTimeout(() => resolve(import('./HeavyChart')), 3000);
});
});
function MainContent() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<h1>메인 콘텐츠</h1>
<p>이 부분은 즉시 로드됩니다.</p>
<button onClick={() => setShowChart(true)}>차트 보기</button>
{showChart && (
<Suspense fallback={<div>차트 로딩 중...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
export default MainContent;
예시를 보면 세가지 포인트를 찾아낼 수 있다.
이런 방법을 사용하여 우선순위 기반 스플리팅을 사용 할 수 있다. 이 방법은 사용사자 어떤 화면을 먼저 확인하고 봐야 하는지에 대한 파악이 필요할 것 같다! 측정 방법은... 성능 측정 도구를 사용해야 한다고 한다.
애플리케이션 코드와 서드파티 라이브러리를 분리하여 캐싱 효율을 높이고 빌드 시간을 줄이는 기법이다.이 방법은 웹팩에 해당 설정을 넣어두면 웹팩이 스스로 라이브러리코드와 앱 코드로 분리하고 캐싱하게 된다. 사실 여기까지만 들으면 잘 감이 오지 않는다.
// 기존 방식의 결과
main.js (5MB) - 앱 코드 + React + 기타 라이브러리
<script src="main.js"></script>
// 벤더 스플리팅 적용 후 결과
main.js (1MB) - 앱 코드
vendor-react.js (2MB) - React 라이브러리
vendor-other.js (2MB) - 기타 라이브러리
<script src="vendor-react.js"></script>
<script src="vendor-other.js"></script>
<script src="main.js"></script>
위를 보면 약간 이해가 되는데 기존 방식으로 진행시 main.js에서 앱코드와 모든 라이브러리 코딩을 직렬적으로 수행하지만 벤더 스플리팅 적용시 앱코드와 분리되어 병렬적으로 로딩된다.
이 방법의 장점은
1. 필요 코드만 먼저 로드 가능하여 초기 로딩 속도가 증가될 수 있다.
2. 캐싱을 통해 첫 랜더 이후 빠른 로딩이 가능하다.
이렇게 두가지를 꼽을 수 있을것같다. 사용 방법은 차후에 정리해 보도록 해야겠다!
여러 페이지나 컴포넌트에서 공통으로 사용되는 모듈을 별도의 청크로 분리하여 중복을 줄이고 캐싱을 개선한다. 이 방법 역시 마찬가지로 웹팩에게 해줘!! 하는 방법중에 하나다. 작동 방식을 살펴보자!
// webpack.config.js
const path = require('path');
module.exports = {
entry: {
main: './src/index.js',
admin: './src/admin.js'
},
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
optimization: {
splitChunks: {
chunks: 'all',
minSize: 0,
cacheGroups: {
common: {
name: 'common',
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
위는 공통모듈 스플리팅을 위한 웹팩 설정 내용이다. 해당 파일을 해석해보면 entry로 설정한 main, admin 파일에서 splitChunks > cacheGroups을 통해 공통으로 임포트된 모듈을 자동으로 추출하여 common.js 파일을 만든다.
예를들어 main과 admin 파일이 아래와 같다고 가정하면.
// src/index.js
import { renderPage } from './common/utils';
import { fetchUserData } from './api';
async function renderUserPage() {
const userData = await fetchUserData();
renderPage('user', userData);
}
renderUserPage();
// src/admin.js
import { renderPage } from './common/utils';
import { fetchAdminData } from './api';
async function renderAdminPage() {
const adminData = await fetchAdminData();
renderPage('admin', adminData);
}
renderAdminPage();
웹팩은 자동으로 renderPage 를 common.js로 분리하여 로드시 불러올 수 있게 해준다. 즉 정리해보자면
// 기존 방식 (코드 스플리팅 없음)
main.js:
- index.js 코드
- renderPage 함수 코드 (중복)
- fetchUserData 함수 코드
admin.js:
- admin.js 코드
- renderPage 함수 코드 (중복)
- fetchAdminData 함수 코드
// 공통 모듈 스플리팅 적용 후
main.[hash].js:
- index.js 고유 코드
- fetchUserData 함수 코드
- common.[hash].js 참조
admin.[hash].js:
- admin.js 고유 코드
- fetchAdminData 함수 코드
- common.[hash].js 참조
common.[hash].js:
- renderPage 함수 코드
이런식으로 분리되는 것이다. 이렇게 분리된 파일들은 다음과 같은 이점을 가지게 된다.
우리가 패키지json 파일을 보면
"dependencies": {
"chart.js": "^4.3.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.11.2"
},
"devDependencies": {
"@babel/core": "^7.22.1",
"@babel/preset-env": "^7.22.4",
"@babel/preset-react": "^7.22.3",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"css-minimizer-webpack-plugin": "^7.0.0",
"html-webpack-plugin": "^5.5.0",
"style-loader": "^4.0.0",
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.84.1",
"webpack-bundle-analyzer": "^4.9.0",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.0"
},
여기까지 코드스플리팅을 할 수 있는 6가지 방법에 대해 공부하고 알아봤다! 내가 생각하기에 6가지 내용에서 공통적으로 강조하는 내용은
동적 로딩을 잘 활용할것!!! 그리고 웹팩을 통해 외부 라이브러리를 쪼개고 캐싱하자!!!
인것 같다. 물론 여러 참조한 문서들에서 공통적으로 했던 말은 무조건적인 코드 스플리팅은 좋지 않다는 얘기들이였다. 요청수 증가로 인한 트래픽상승 등의 문제가 생길 수 있기 때문에 회사의 인프라, 팀의 수준에 따라 진행해야 한다는 말이였다!
코드 스플리팅을 공부하면서 계속 혼잣말로 읊조린 단어가 있다. "와... 이런게있어? 우와.." 계속 중얼중얼 거리면서 공부했던것 같다. 모두 실무수준에서 적용이 가능한 내용인것 같아 꼭프로젝트에 적용시켜 보고 싶다는 생각을 했던 것 같다. 나머지 7가지 내용도 차차 공부해야겠다! 끝!