Javascript #6 객체의 얕은 복사와 깊은 복사에 대해 알아봅니다.
웹 성능 최적화 #2 모듈 번들러
2024-12-24
Explanation
웹 성능 최적화 두 번째 시간!
오늘은 모듈 번들러를 활용한 웹 서비스의 성능 최적화에 대해서 알아볼게요!
글에 잠깐 나오는 “택배 배송 조회” 프로젝트는 아래의 리포지토리에서 모든 코드를 확인할 수 있습니다.
링크: https://github.com/falsy/delivery-tracker-for-whale
이제 모듈 번들러는 웹 프론트엔드 개발에 있어서 거의 필수가 된거 같아요. 모듈 번들러는 이름 그대로 모듈화를 지원해서 애플리케이션의 서비스를 작은 모듈로 나누어 개발할 수 있게 하고, 각 모듈간의 의존성 분석과 코드 압축, 그리고 사용하지 않는 코드를 제거하거나 청크를 분리하는 등.. 프론트엔드 개발에 있어 아주 많은 편리함을 주고 있습니다.
대표적으로 Webpack, Vite, Rollup, Parcel 등.. 다양한 모듈 번들러가 있는데요. 저는 이 중에 Parcel은 사용해 보지 못했고, 간단한 라이브러리를 개발할 때는 Rollup을 사용하고, 웹 서비스를 개발할 때는 Webpack을 사용했었는데요. 지금은 주로 Vite를 사용하고 있습니다.
글 주제와 별개의 이야기지만.. 제가 Webpack을 사용하다가 Vite를 사용하게 된 것은.. 일단 기본적으로 개발하는 과정에서 코드를 핫 로드하거나 빌드할 때 Vite가 조금 더 빠르다는 점과, 이전까지는 Styled-components나 Emotion을 사용하다가 최근에는 Panda CSS를 사용하면서, Panda CSS를 사용하기에도 Webpack 보다는 Vite가 더 기본 설정이 편해서 Vite를 사용하고 있습니다.
앞서 이야기한 메이저 한 모듈 번들러들은 이미 많이 발전하고 또 안정화를 거쳤기 때문에, 특별한 설정을 해주지 않아도 기본적으로 많은 기능을 제공한답니다. 그리고 그중에서 성능과 관련된 부분들은 사용하지 않는 코드를 제거해 주는 기능(Tree Shaking)이나 번들을 효과적으로 나누어 번들링 하는 기능(Code Splitting), 그리고 압축(Minification)이 있을 거 같아요.
그리고 이번엔 보편적으로 많이 사용되는 Webpack과 Vite를 가지고 조금 더 알아볼게요!
뭔가.. 적고 보니 굉장히 자극적인 제목이네요..
특별한 내용은 아니고, 글을 적으면서 기본 설정으로 프로젝트를 빌드했을 때, 둘 중에 어떤 번들 파일이 더 크기가 작을 지 궁금해서 한번 해봤는데요.
앞서 이야기한 ‘택배 배송 조회’의 클라이언트 코드를 빌드했을 때, 메인 번들의 크기가 Webpack은 “191KB” 였고 Vite는 “179KB”로 만들어 졌답니다! 그래서 오! Vite가 더 효과적으로 압축하나?! 싶었으나..
제가 TypeScript의 빌드 타겟을 "ES5"로 설정해 놓았는데요. Webpack은 기본적으로 ES5를 지원하기 때문에 ES5로 빌드되어서 조금 더 번들 크기가 큰거 였고, Vite는 “modules” 라는 기본값으로 [‘es2020’, ‘edge88’, ‘firefox78’, ‘chrome87’, ‘safari14’]로 빌드되서 번들이 작은 거 였어요.
링크: https://ko.vitejs.dev/config/build-options.html#build-target
Webpack에서도 타입스크립트의 빌드 타겟을 "ES6"로 빌드하니까 번들 크기가 “180KB”로 만들어졌습니다. 결론은! Webpack과 Vite의 번들 크기는 대동소이합니다. (물론, 번들링 속도는 Vite가 유의미하게 빠릅니다.)
하지만 기본적으로 ES5에서도 동작하는 Webpack은 또 나름의 장점인 거 같아요.
현대의 대부분의 주요 브라우저는 ES6 이상의 문법을 모두 사용이 가능하지만, 모바일 네이티브의 웹뷰 환경에서 서비스되는 경우에는 보수적으로 대응하는 경우도 많기 때문에, 예를 들어 안드로이드 웹뷰에서 앞서 이야기한 “ES Modules”까지 사용 가능한 버전은 안드로이드 8.0(Oreo) 이상이기 때문에 타겟을 직접 설정해 주어야 합니다.
만약에 안드로이드 5.0(Lollipop)까지 지원해야 한다면, Vite의 경우에는 “@vitejs/plugin-legacy” 플러그인을 추가로 사용해서 설정해 주어야 합니다.
– 안드로이드 5.0(Lollipop)을 갤럭시로 생각하면 갤러시 S5 정도? 입니다.
– 안드로이드 6.0(Marshmallow)부터 안정적으로 ES6를 지원합니다.
기본적으로 Webpack과 Vite 모두 ESM 기반의 코드에서 Tree Shaking을 지원하는데요. 그래서 간단한 프로젝트에서는 두 프로젝트의 번들 파일의 크기가 크게 차이나지 않습니다.
기본적으로 두 번들러 모두, 현재 프로젝트 내부의 ESM 기반 코드에 대해서는 해당 코드의 사용 여부를 정확하게 판단할 수 있기 때문에 Tree Shaking이 기본적으로 동작하지만, 외부 라이브러리를 가져와 사용할 때에는 약간의 차이가 있는데요.
Webpack의 경우에는 외부 라이브러리의 package.json 파일의 sideEffects 속성을 참고하여 sideEffects 속성이 false라면 해당 패키지의 코드가 안전하다고 확인하고 Tree Shaking을 수행하며, sideEffects 속성이 없거나 true라면 안전을 위해 사용되지 않는 코드라도 번들에 포함될 수 있습니다.
하나의 예로 ‘lodash-es’의 package.json 파일에는 sideEffects가 false로 설정되어 있습니다.
링크: https://github.com/lodash/lodash/blob/4.17.21-es/package.json
반면, Vite는 외부 라이브러리에도 sideEffects 속성과 상관없이 Rollup 기반으로 코드를 분석하여 부작용 여부를 판단하고 조금 더 공격적으로 Tree Shaking을 수행하는 차이가 있습니다.
여기서부터는 글의 단순화를 위해 Vite를 가지고 이야기할게요.
기본적인 기능이기 때문에 다른 모듈 번들러에도 모두 간단하게 구현할 수 있습니다!
Code Splitting는 이름 그대로 코드를 나누어서 제공하는 기능인데요. 이렇게 번들을 나누어서 구성하면 몇 가지 장점이 있는데요. 우선, 첫 번째로 HTTP/2부터 여러 리소스를 병렬로 동시에 처리할 수 있는, 멀티플렉싱을 지원하기 때문에 하나의 큰 파일을 로드하는 것보다 빠를 수 있습니다.
두 번째로 자주 변경되지 않는 외부 라이브러리 들을 별도로 분리하면 실제로 프로젝트의 코드가 변경되더라도 변경된 코드의 번들만 다시 요청하고 분리된 외부 라이브러리 번들은 변경되지 않았기 때문에 브라우저에서 캐싱한 데이터를 그대로 사용할 수 있는 장점이 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { defineConfig } from "vite" import react from "@vitejs/plugin-react" export default defineConfig(({ mode }) => { return { build: { rollupOptions: { output: { manualChunks: { vendor: ["react", "react-dom", "jotai"] } } } }, plugins: [react()] } }) |
Vite를 사용하는 React + jotai 프로젝트라면, 위와 같은 설정으로 react와 react-dom 그리고 jotail를 하나의 번들로 따로 생성할 수 있습니다.
그리고 세 번째로, 웹 페이지가 처음 로드될 때 바로 필요한 부분을 제외하고 나머지 코드들은 지연 로드(Lazy Load)하면 웹 서비스의 초기 로딩 속도를 빠르게 할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import { useEffect, useState, lazy, Suspense } from "react" ... const TrackerSection = lazy( () => import("@containers/trackers/sections/TrackerSection") ) const TipMessage = lazy(() => import("@components/commons/boxs/TipMessage")) const Dashboard = () => { const [isLoading, setLoading] = useState(true) useEffect(() => { getCarrierList() }, []) ... return ( <> <Header /> <main> {isLoading && <Loading />} <ErrorMessage /> <div> {carriers.length > 0 && !isLoading && ( <Suspense> <TrackerSection trackers={trackers} getTrackers={getTrackers} /> <TipMessage resetTrackers={handleClickReset} /> </Suspense> )} </div> </main> <Footer /> </> ) } export default Dashboard |
위 예시는 “택배 배송 조회” 서비스의 첫 화면 컴포넌트인데요. 위 컴포넌트는 처음 화면이 렌더링이 되면, 비동기로 ‘getCarrierList’라는 택배사 리스트 API를 요청하고 해당 요청에 대한 응답이 오면, 이후에 “TrackerSection” 컴포넌트와, “TipMessage” 컴포넌트를 렌더링 하게 되는데요. 위와 같은 흐름에서는 “TrackerSection”, “TipMessage” 컴포넌트는 초기 화면 렌더링에는 필요하지 않기 때문에 이 두 컴포넌트는 지연 로드하도록 구성하였답니다.
이렇게 React를 사용하는 경우에는 Lazy를 사용해서 지연 로드하도록 하면, Vite는 빌드할 때 동적으로 가져오는 “TrackerSection”, “TipMessage”를 별도의 번들로 분리해 준답니다. 그리고 이렇게 하면, 웹 서비스에 처음 접근했을 때 “TrackerSection”, “TipMessage” 만큼 작아진 초기 번들만을 빠르게 로드한 후 초기 렌더링을 진행하고 그 이후에 “TrackerSection”, “TipMessage”를 로드하게 되어 초기 로딩 속도가 빨라집니다.
하지만 그렇다고 너무 작게 나누게 되면, 오히려 여려 개의 HTTP 요청을 처리하는 오버헤드가 발생하여 성능이 저하될 수 있습니다.
모듈 번들러는 코드를 압축해서 번들의 크기를 작게 만들어주는데요, Vite는 기본적으로 esbuild를 사용해서 빠르고 가벼운 압축을 제공하는데요. 압축 방식을 terser로 설정하여 구체적인 설정도 가능하답니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { defineConfig } from "vite" import react from "@vitejs/plugin-react" export default defineConfig(({ mode }) => { return { build: { minify: "terser", terserOptions: { compress: { drop_console: true, // console.log 제거 drop_debugger: true // debugger 제거 }, format: { comments: true // 주석 제거 } } }, plugins: [react()] } }) |
그 밖의 옵션들은 Terser 공식 문서에서 확인할 수 있습니다.
링크: https://terser.org/docs/api-reference/#minify-options
마지막으로 “source-map-explorer”와 같은 번들된 JavaScript 파일의 크기를 분석하고 시각화해주는 도구를 통해서 번들의 중복 코드나 번들 크기를 분석할 수 있습니다.
1 |
$ yarn add source-map-explorer -D |
라이브러리를 설치해주고
1 2 3 4 5 6 7 8 9 10 11 |
import { defineConfig } from "vite" import react from "@vitejs/plugin-react" export default defineConfig(({ mode }) => { return { build: { sourcemap: mode !== "production" }, plugins: [react()] } }) |
Vite 설정에서 sourcemap 옵션을 production이 아닌 경우 true가 되도록 설정한 후
1 |
$ yarn vite build --mode development |
development 모드로 빌드하고
1 |
$ yarn source-map-explorer ./dist/assets/*.js |
빌드한 스크립트 파일을 대상으로 실행해주면, 번들된 모듈, 패키지 그리고 코드의 구성을 세부적으로 시각화하여 확인할 수 있습니다.