이번 포스팅에서는 기존에 진행했던 팀 프로젝트에서 빌드 후 배포하는 과정과 용량을 최적화하는 과정에 대해서 포스팅해보려고 한다.
이 과정이 중요한 이유는 고정적으로 발생하는 네트워크 비용을 줄일 수 있기 때문이다. 구체적인 수치는 아래 글에서 비교, 분석해 보는 것으로 하겠다.
⚠️Warning
일전에 프로젝트를 완료하고 Vercel 배포를 진행하기 전, 배포 명령어인 `npm run build`가 제대로 동작하는지 로컬에서 확인하고자 하였다. 해당 명령어를 실행하니 빌드는 잘되었다. 하지만..
에러는 아니지만 Vite에서 경고문구를 출력해 주었다. 이 문구의 내용은 다음과 같다.
(!) 일부 청크는 최소화 후 500 kB보다 큽니다. 다음을 고려해 보세요:
- 동적 import() 사용: 애플리케이션을 코드 분할하기 위해 동적 import()를 사용하세요.
- build.rollupOptions.output.manualChunks 사용: 청크 분할을 개선하기 위해 이 옵션을 사용하세요.
- 청크 크기 제한 조정: build.chunkSizeWarningLimit을 사용하여 이 경고에 대한 청크 크기 제한을 조정하세요.
여기서 언급하는 청크란 데이터 덩어리를 의미한다. 즉 우리가 구현한 코드로직을 한 파일에 담은 js파일 덩어리가 매우 크니 이를 조정할 필요성이 있다는 경고문구인 것이다.
SPA의 가장 큰 단점이기도 하다. 초기에 모든 로직을 담은 js 파일을 로딩하다 보니 사용자가 로딩해야 하는 파일이 크고 로딩시간이 꽤 오래 소모될 수 있다는 단점이다.
그렇다면 이런 청크를 줄이기 위한 방법에는 어떤 방법들이 있을지 알아보도록 하자.
✂️ 코드 스플리팅
이를 해결하기 위한 첫 번째 방식으로 React에서 제공하는 lazy와 Suspense를 사용하여 코드 스플리팅을 진행했다.
코드 스플리팅이란 코드를 여러 개의 작은 번들로 분할하고, 필요한 시점에만 로드하여 초기 로딩 시간을 줄이는 기술이다. 즉, 기존에 SPA에서 진행되는 초기 로딩 속도 및 용량을 보완하기 위한 기술이다.
기법은 간단하다.
// AS-IS
import Auth from '@pages/AuthPage';
import Hub from '@pages/HubPage';
import Main from '@pages/MainPage';
import MyBundle from '@pages/MyBundlePage';
import NotFound from '@pages/NotFoundPage';
import Policy from '@pages/PolicyPage';
import Report from '@pages/ReportPage';
import SharedItem from '@pages/SharedItemPage';
import Shared from '@pages/SharedPage';
<>
// 페이지 동작 로직
</>
// =============================================================
// TO-BE
const Main = lazy(() => import('@pages/MainPage'));
const Hub = lazy(() => import('@pages/HubPage'));
const Auth = lazy(() => import('@pages/AuthPage'));
const Shared = lazy(() => import('@pages/SharedPage'));
const SharedItem = lazy(() => import('@pages/SharedItemPage'));
const NotFound = lazy(() => import('@pages/NotFoundPage'));
const MyBundle = lazy(() => import('@pages/MyBundlePage'));
const MyBundleDetail = lazy(
() => import('@/components/MyBundle/MyBundleDetail')
);
<>
<Suspense fallback={<Spinner />}>
// 페이지 동작 로직
</Suspense>
</>
lazy키워드로 감싸서 로딩하면 된다. 이후 해당 번들이 로딩될 동안 표시될 컴포넌트를 Suspense로 감싸 넣어주면 된다. 우리는 별도의 페이지나 UI를 만들지는 않았고 기존의 Spinner 컴포넌트를 재활용하였다.
이제 분리 후 결과를 보자.
이때 여기서 의문이 든다. 저 왼쪽에 뜨는 gzip, map 그리고 숫자들은 무엇을 뜻할까?
일단 map은 소스 맵의 크기를 나타낸다. 이는 디버깅을 위해서 사용되고 배포에서는 필요 없으므로 vite에서는 기본 옵션이 false로 되어있으니 신경 쓰지 않아도 된다.
🗜️ HTTP 압축
위에서 언급한 gzip에 대해서 이야기하기 전에 우리는 HTTP 압축에 대해서 알아야 한다. HTTP 압축은 서버와 브라우저 간의 데이터 전송 속도와 대역폭을 개선하기 위한 기술이다. 서버가 브라우저에게 보내는 데이터를 더 작은 사이즈로 압축된 파일로 전송하여 네트워크 비용을 줄일 수 있다. 실제 브라우저의 네트워크 창을 보면서 설명해 보도록 하자.
Request Header란, 브라우저(클라이언트)에서 서버에 요청을 보낼 때 함께 보내는 헤더 정보이다. 즉 헤더는 클라이언트가 요청하는 데이터의 형식, 요청의 출처, 클라이언트의 환경을 서버가 적절히 이해하고 응답할 수 있도록 정보를 제공하는 역할을 한다.
여기서 압축 규격에 대해 명시한다. 위 이미지에서도 `Content-Encoding: gzip`를 확인할 수 있다.
대중적으로 알려진 압축 알고리즘은 다음과 같다.
- deflate: LZ77과 허프만 코딩을 결합한 무손실 압축 알고리즘으로, 여러 압축 포맷의 기반이 된다.
- gzip: deflate 알고리즘을 사용하는 전통적인 압축 포맷으로, 호환성과 안정성이 높다.
- br (Brotli): 웹 전송에 최적화된 고압축률 알고리즘으로, gzip보다 더 높은 압축률을 가지고 있다.
- zstd (Zstandard): 빠른 속도와 높은 압축률을 제공하는 현대적인 압축 알고리즘이다.
위에서 우리가 보았던, 빌드 결과 스크린 샷에서의 gzip은 현재 빌드된 파일을 gzip으로 압축했을 시 크기를 나타낸다. 가장 큰 청크의 크기가 421.95kb에서 압축해서 142.36kb가 되었으니 약 66.31%만큼 압축되는 높은 압축률을 자랑하고 있다.
이 부분을 적용하기 위해 vite-plugin-compression2라는 라이브러리를 활용했다. 왜 2인가 하면, 기존에 vite-plugin-compression이라는 라이브러리가 있지만 3년 전부터 유지보수가 되지 않기에 유지되는 라이브러리를 선택했다. 아쉬운 점이라고 하면, zstd를 지원하지 않는다는 것인데 gzip과 br으로도 충분하다고 생각하여 넘어가기로 했다.
적용법은 다음과 같다.
import { compression } from 'vite-plugin-compression2';
// https://vitejs.dev/config/
export default defineConfig({
// 기타 설정
plugins: [
react(),
compression({
algorithm: 'gzip',
}),
compression({
algorithm: 'brotliCompress', // Brotli 압축 알고리즘 사용
})
],
세부적으로 설정할 수도 있었지만 웬만한 부분이 다 자동화되어 있어서 건들지 않았다.
이렇게 용량이 적은 압축파일이 생성되었다. 다만 후술 하겠지만, Vercel이나 AWS, CloudFlare Pages, Netlify와 같은 배포 서비스에서는 이 부분이 자동화되어 있다.
📊 번들 시각화를 통한 분석
위까지 완료하고 조금 더 할 수 있는 게 없을까 찾아보다가 트리 셰이킹에 대해서 알게 되었다. 다만, Vite에서는 트리 셰이킹이 기본적으로 잘 동작한다고 하기에, 조금 더 체계적으로 할 수 있는 것을 찾아보기 위해 `vite-bundle-visualizer` 번들 시각화 도구를 사용해 깊게 분석해 보았다.
이 도구를 사용하면 어떤 라이브러리를 얼만큼 사용하는지 시각적으로 더 잘 체감하며 알 수 있다.
이 중에서 가장 큰 문제가 되었던 것은 lodash였다. 우리 프로젝트에서 lodash는 그렇게 많은 코드를 사용하지 않는데도 모든 라이브러리 코드를 로딩하고 있었다. 그 원인 중 하나는 lodash의 모든 기능을 로드하고 있는 코드였는데 이를 해결하려면 개별 import 혹은 `lodash-es`를 사용하는 것이라고 한다.
간단하게 개별 Import 형태로 몇 개의 코드만 바꿔주었다.
//AS-IS
import { debounce } from 'lodash';
//TO-BE
import debounce from 'lodash/debounce';
그 결과 기존에 압축 기준 94.76KB를 차지하던 용량이 6.33KB가 되었다.
📦수동 청크 만들기
앞서 진행했던 과정에서 용량을 많이 줄일 수 있었다. 다만, 코드 스플리팅에서 자꾸 걸리는 부분은 너무 잦은 로딩이 발생하게 되면 이 역시 사용자 입장에서 그렇게 좋지 않을 것 같고, 오히려 SPA 형태에 장점도 단점도 잘 살리지 못하는 방향성이라고 생각되었다.
또한 사이트의 특성상 특정 기능들이 특정 페이지에서만 동작하는 경우가 가끔 있기에 이를 분리하는 것이 좋을 것이라고 생각되었고 vite의 rollup 옵션 중 manualChunks라는 기능을 알게 되었다. 이 기능은 청크의 단위를 개발자가 임의로 분리하여 특정 ID 단위의 청크를 만들 수 있는 기능이다.
build: {
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (id.includes('node_modules')) {
if (id.includes('lodash')) {
return 'lodash-vendor';
}
if (id.includes('axios')) {
return 'axios-vendor';
}
if (id.includes('sentry')) {
return 'sentry-vendor';
}
if (id.includes('react-hook-form')) {
return 'react-hook-form-vendor';
}
if (id.includes('react-beautiful-dnd')) {
return 'react-beautiful-dnd-vendor';
}
}
},
},
},
},
위와 같이 특정 위치에서 사용되는 코드들을 id 기준으로 묶어줄 수 있다. 특히 위 코드 중에 우리 사이트에서 `react-beautiful-dnd`는 정말 특수한 상황에서만 호출되기에 더더욱 효율적인 과정일 것이라고 판단하여 적용했다.
⚡️ esbuild vs terser
마지막으로 압축 방식까지 알아보았다. 기본적으로 vite에서 지원하는 빌드 방식은 esbuild이다. vite와 마찬가지로 Go 언어로 작성되어 매우 빠른 속도를 자랑한다. 일반적으로 terser의 10배라고 한다. 반면에 terser는 js기반 방식이기에 느리다. 하지만 조금 더 최적화된 압축과 난독화를 진행해 줄 수 있다고 한다.
보통은 속도의 장점으로 인해 esbuild를 그대로 사용하는 것 같았다. 그래서 어떻게 쓰면 될까 고민하다가 `환경 변수`를 활용해서 개발단계에서는 모두 esbuild를 사용하고 배포 단계에서만 terser를 사용할 수 있도록 코드를 수정해 주었다.
build: {
minify: process.env.DEV ? 'esbuild' : 'terser',
}
차이는 정말 크지는 않다. 약 3% 정도로 미미하지만, 전체적인 크기로 보면 3%도 무시할 수 없기에 이런 결정을 했다.
📝 정리 및 알게 된 점
그렇게 800kb짜리 큰 청크를 자르고 압축하여 작은 형태의 청크로 나눌 수 있었다. 원래는 코드 스플리팅 정도만 했었는데 이번에는 조금 더 여러 방향으로 최적화를 진행할 수 있었다.
다만, 이러한 과정을 진행하면서 생각보다 우리가 활용하는 툴에서 미리 자동화가 많이 진행된 부분도 확인할 수 있었다. HTTP 압축의 경우 gzip 압축을 별도로 적용하지 않았는데도 적용되어 있었는데 알고 보니 대부분의 서비스에서 자동으로 지원한다는 사실을 알 수 있었다. Vercel은 gzip, brotli 압축 형식을 지원한다. AWS CloudFront는 Gzip, Brotli 압축 형식을 지원한다. 두 형식이 모두 있는 경우 Brotli를 선호한다. Netlify Edge는 Gzip, Brotli 압축 형식을 지원한다.
이는 vite-plugin-compression2 라이브러리 제작자도 명시해 두었다. 따라서 개인 서버 nginx 혹은 tomcat과 같은 개인 웹 서버를 쓰는 경우에나 유용하지 일반적으로는 크게 필요 없는 설정이라는 것이다. 반대로 말하면 개인 서버에서는 직접 하지 않으면 네트워크 비용이 줄어들지 않는다는 이야기이기도 하다.
생각보다 얇고 넓게 필요한 공부를 할 수 있는 시간이었다.