Webpack 설정 하나도 모르는데 빌드 최적화해봤음.


이전 글에서 테스트 코드 이야기를 했는데, 테스트 코드를 반영하기 전의 프로젝트는 Webpack을 번들러로 사용하고 있었다.
당시, 빌드를 시도하면 기본 3분에서 5분 정도의 시간이 소요되다 보니, 빌드 결과물을 보려면 오랜 시간 기다려야했고 나의 인내심은 기다리는 시간에 비례해 떨어져갔다.
결국, 빌드 최적화에 대한 필요성을 느끼고 건의를 드려서 빌드 최적화를 직접 시도해보기로 했다. (혹시 실패하면 어쩌지 했는데 안 되면 롤백하면 된다고 선임 분이 웃으시면서 말씀해주셨는데 묘한 한기를 느꼈다(?))
그래서 이번 글에서는 웹팩에 대해 짚어보고, 프로젝트 구조 내에서 빌드 최적화를 한 과정을 서술해보기로 했다.
우선 웹팩이 무엇일까?
Webpack은 모던 JavaScript 애플리케이션을 위한 번들러입니다. Webpack이 애플리케이션을 처리할 때, 내부적으로는 프로젝트에 필요한 모든 모듈을 매핑하고 하나 이상의 번들을 생성하는 종속성 그래프를 만듭니다.
좀 더 쉽게 말하자면 프로젝트의 모든 코드나 모듈을 하나로 묶어서 번들을 만들어주는 도구이다.
웹팩을 사용하면 여러 파일을 하나로 합쳐 네트워크 요청을 줄이고 로딩 속도를 빠르게 할 수 있으며, 필요한 코드만 포함시키고 불필요한 코드는 제외해 최적화할 수 있다.
또, 이제는 쓰이지 않는 IE와 같은 구형 브라우저에서도 잘 작동하도록 코드를 변환해준다는 것도 웹팩의 특징이다.
웹팩 최적화를 관리하는 Optimization
웹팩에서는 최적화 위한 기능으로 optimization이라는 항목을 제공해준다. 이 Optimization을 통해 어떠한 기능들이나 플러그인들을 적용해 웹팩을 최적화할 것인지를 관리한다.
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = async () => {
...
return {
optimization: {
minimizer: [
new ESBuildMinifyPlugin({
css: true,
}),
],
nodeEnv: false,
},
};
};
이런 식으로 optimization의 기능을 넣는 것으로 번들 최적화를 시도해볼 수 있다.
optimization은 다양한 기능들을 제공해주고 있지만, 전부 다 적용하기에는 어려움이 있어 이 글에서는 시도해 본 몇 가지 사례들을 써보고자 한다.
다른 파일을 변환해서 사용해줄 수 있도록 해주는 Loader
Loader란 웹팩이 웹 애플리케이션을 로드할 때, JavaScript 외의 다른 파일을 변환해서 사용할 수 있도록 해주는 도구이다.
기본적으로 웹팩은 JSON과 JavaScript만을 이해하고 있는데 이런 로더들을 활용하면 웹팩에서도 다른 형식의 파일을 가져와 번들링할 수 있고, 이미지나 폰트 등의 파일 등도 Loader를 통해 최적화를 하면서 로딩 속도를 향상시킬 수 있다는 장점이 있다.
Loader를 배치하기 위해서는 optimaztion에 배치하는 것이 아니라 module에 배치를 해야한다.
module.exports = async (...args) => {
...
return {
module: {
rules: [
{
test: /\\.(jsx?)$/,
use:[
{
loader: 'babel-loader',
options: {
babelrc: true,
cacheDirectory: true,
rootMode: 'upward',
}
}
],
}
]
},
};
};
Webpack에 없는 동작들을 보완해주는 Plugin
웹팩에서는 웹팩에는 없는 동작들을 적용할 수 있도록 해주는 외부 Plugin들이 존재한다. 이 Plugin들을 이용하면 로더가 할 수 없는 작업들을 대신해주거나, 번들을 최적화하거나 에셋 관리, 환경 변수 주입 등의 광범위한 역할들을 수행할 수 있다.
Plugin은 웹팩에서 다양한 플러그인을 설치 없이 기본적으로 제공해주고 있으며, 없는 플러그인이라면 패키지를 설치, 직접 만들어 적용하는 것도 가능하다.
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = async () => {
.
.
.
return {
optimization: {
minimizer: [
new ESBuildMinifyPlugin({
css: true,
}),
],
nodeEnv: false,
},
};
};
프로젝트에서는 위와 같이 ESBuildMinifyPlugin라는 플러그인을 사용하고 있었다. 해당 플러그인이 뭔지 좀 찾아봤는데 카카오 엔터테인먼트 블로그에서 관련 내용을 확인할 수 있었다.
즉, 기존 Babel-Loader에서 문제가 되는 사소한 한 부분만을 수정하여도 전체 리빌딩을 해야하는 오버헤드 발생으로 인해 필요 이상의 시간이 소모되는 것을 방지하는 플러그인이다.
이름에 보이듯 Esbuild를 활용해 번들링하는 만큼 수행 속도가 더 빠르다는 점이 장점이기 때문에 프로젝트에서는 이미 해당 플러그인을 채택한 것으로 보인다.
이처럼 Loader와 Plugin은 이미 설정이 되어있는 상태에서… 과연 내가 시도해 볼 수 있는 것들이 있을까? 몇 가지 optimization 내용을 시도해보고 해봤지만 기본값으로 반영된 값이거나, 혹은 별 효과가 없었다.
단 하나 빼고 말이다.
splitChunks을 활용한 optimazation
여러 가지 방법을 써먹어봤으나 잘 되지 않았고, 그렇다면 불러오는 파일들을 잘게잘게 쪼개서 처리하는 건 어떨까 싶어 splitChunks를 사용했다.
splitChunks는 말그대로 코드 덩어리를 분할시킨다는 건데, 번들링된 코드 덩어리들을 여러 개의 더 작은 코드 번들로 쪼개놓는 방식이다. 이렇게 하면 필요한 코드만 우선적으로 불러와지기 때문에 동적 로드 처리로 초기 로딩이 빨라진다. 또한, 변경되지 않는 라이브러리와 같은 코드들은 번들이 쪼개지면서 한 번만 로드되고 이후에는 캐싱되기 때문에 중복되는 코드도 제거되고 파일 크기가 감소되는 등의 장점이 있다.
이 부분을 참고해 프로젝트에서는 아래 옵션과 같이 적용해보았다.
const { ESBuildMinifyPlugin } = require('esbuild-loader');
module.exports = async () => {
.
.
.
return {
optimization: {
splitChunks: {
chunks: 'all',
},
runtimeChunk: 'single',
minimizer: [
new ESBuildMinifyPlugin ({
css: true,
}),
],
nodeEnv: false,
},
};
};
프로젝트에선 단순하게 위와 같이 설정을 적용했다. 물론 위와 같은 사항 외에도 추가적인 옵션들이 존재한다. 그 중 일부를 아래에 정리해봤다.
chunks
코드 덩어리들을 어떤 기준으로 분할할 건지 설정한다.
async
: 비동기적으로 로드되는 코드 덩어리들만 분할한다. (기본)initial
: 초기 번들에서만 코드 덩어리 분할 작업을 하고, 이후에 발생하는 코드들에 대해서는 분할 작업을 하지 않는다.all
: 초기 번들링 이후 + 비동기적으로 로드되는 코드 덩어리들 모두 대응한다.
minSize
- 코드 덩어리들을 분할할 때 몇 byte 이상일 경우에 분할 작업을 처리할지를 결정한다. (기본값은 30000Byte)
minChunks
특정 모듈이 여러 코드 덩어리들에서 사용되는 횟수에 따라 해당 모듈을 분할할지를 결정한다.
기본적으로는 한 번 이상 쓰이면 분할되도록 처리되어 있다.
minRemainingSize
- 코드 덩어리를 분할시키고 남은 코드의 용량의 최소값을 확인하고, 최소값보다 큰 코드 덩어리만을 분할 처리한다. (기본값은 0)
maxAsyncRequests
- 비동기적으로 생성된 코드 덩어리들을 불러올 때, 한 번 불러올 때에 가져올 비동기 코드의 최대 요청 수를 제한한다. (기본값은 5)
enforceSizeThreshold
- 분할된 코드 덩어리의 용량을 확인하고, 정한 기준 이상의 용량을 가질 경우 분할 처리를 하지 않도록 한다. (기본값은 30000KB)
이외에도 다양한 옵션들을 splitChunks에서 제공해주고 있다.
splitChunks 외에도 runtimeChunk
라는 값도 적용했는데, 이건 런타임 코드를 별도의 코드 덩어리로 분리할지, 분리한다면 이 코드들도 또 분할할지 하나로 합칠지 여부를 결정할 수 있다. 런타임 코드가 분리되면 프로젝트 코드가 업데이트 되더라도 런타임 코드를 다시 로드할 필요가 없어 캐시 최적화가 가능하고, 번들 크기 또한 분할됨에 따라 줄어들어 로딩 속도 개선가 개선되는 장점이 있다.
자, 그럼 코드 덩어리를 쪼개고 쪼개서 얻은 효과는 어땠을까?
그래서, splitChunks만 적용했을 뿐인데 그 결과는?
아래의 사진은 최적화를 진행하기 전의 내용이다.
그리고 다음 사진이 splitChunks 옵션을 적용한 후의 내용이다.
무려 3배 이상의 차이가 난다!
이 작업이 반영된 후부터는 더 이상 빌드를 하더라도 팔짱끼고 배포 언제 완료되나 지켜보는 상황이 발생하지 않게 됐다.
(그리고 시간이 지나 Webpack을 걷어내게 된 건 안 비밀. 😭)
잊지 말자, 빌드 최적화 ≠ 성능 최적화라는 걸.
최적화라는 단어에는 되게 함정이 있다. 우리는 최적화라면 마냥 좋은 거니까 해야한다고 생각할 수 있는데, 아래 토스 모닥불에서 이야기하는 내용에 언급되듯 불필요한 최적화는 악이 될 수 있다.
실제로 빌드 최적화가 완료되긴 했지만, 이를 바로 배포하지 않고 테스트 버전에서 우선적으로 테스트를 시도해봤다. 빌드 최적화가 됐다고 해서 이게 성능 저하를 일으킨다면 좋은 최적화가 아니기 때문이다. 다행히 빌드 최적화의 결과물에 성능 저하를 일으키는 요인은 없었기에 해당 내용은 1주일 후 정상적으로 배포를 진행했다.
이처럼 우리가 최적화를 할 때는, 정말 많은 고민을 해야한다. 정말로 최적화를 할 필요가 있는지, 최적화를 해야만 하는 이유가 있는지를 고민해보고 확신이 들지 않는다면 주변에 이야기를 나눠보고 판단하자. 또한 최적화를 하면서도 이게 다른 사이드 이펙트를 일으키지 않는지 생각하며 좋은 코드, 좋은 프로젝트 환경을 만들 수 있도록 끊임없이 고민해보도록 하자.
그렇게해서 나온 최적화는 분명 좋은 결과를 가져올 것이다.
참고자료
Subscribe to my newsletter
Read articles from 리브레 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

리브레
리브레
2년차 프론트엔드 개발자입니다. 웹(React, Next.js)과 웹뷰, 앱(React Native) 개발 경험이 있어 플랫폼에 구애받는 개발이 가능합니다. 선 개발 후 개선에 따른 빠른 개발을 지향하고, 여러 번 QA와 테스트를 통해 기능을 개선합니다. 작업 경과를 중간, 완료 때마다 공유하고 논의해 소통 에러와 기능의 문제점을 최소화하고 있습니다.