Webpack 빠르게 유지하기: 더 나은 빌드 퍼포먼스를 위한 실전 가이드
📄

Webpack 빠르게 유지하기: 더 나은 빌드 퍼포먼스를 위한 실전 가이드

Created
Aug 25, 2021 06:50 AM
Tags
js
webpack
performance
(Webpack 4 기준)

들어가기에 앞서...

  • 2017년은 slack 팀에게 있어 야심찬 해였음
    • 몇 년 간 쌓인 기술적 부채에 대해 현대화 할 계획을 세움
    • 가장 중요했던 과제 = "React 기반의 UI 컴포넌트 재작성 및 최신 JavaScript 구문 사용"
    • 이를 지원할 수 있는 빌드 시스템이 필요
  • 이 시점까지는 단순히 파일을 연결(Concatenation)하는 수준에 머물러 있었음
    • 이 방법으로는 한계가 있었기에 빌드 시스템이 반드시 필요
    • 활발한 커뮤니티, 어렵지 않은 이용, 많은 기능이 있는 Webpack을 선택
  • 대부분의 경우 마이그레이션 과정은 순조로웠음
    • 다만 실제 빌드 시 수 분 이상이 소요되는 것을 확인
    • 하루에도 수 십 번의 배포가 이루어지기에 상당한 부담으로 다가옴
  • 빌드 퍼포먼스는 Webpack 사용하는 사람들에게 있어 오랬동안 관심의 대상
    • 우리는 이러한 빌드 퍼포먼스를 개선할 수 있는 여러 단계를 발견
    • 최대 10분의 1까지 Build time을 줄일 수 있도록 도와줄 것

퍼포먼스 측정하기

  • 최적화 전, 먼저 어디서 퍼포먼스가 떨어지는지 파악해야 함
  • 이는 Webpack이 아닌 다른 도구(방법)를 이용

Node Inspector

  • Node의 Inspector를 이용해 빌드에 대한 프로파일링 가능
    • 다만 퍼포먼스 프로파일링이 익숙하지 않은 사람들에게는 어렵게 다가올 수 있음
    • Google은 이에 대해 자세하게 설명 (Performance features reference)
  • Webpack의 빌드 과정을 얕게(Rough)나마 이해하고 있으면 많은 퍼포먼스 개선에 꽤 도움이 됨
  • 몇 백 개 이상의 모듈이나 수 분 이상의 빌드 시간을 갖게 되는 경우
    • Crashed 되는 것을 방지하기 위해 여러 섹션으로 나눠 프로파일링을 진행해야 할 수도 있음

(빌드 초기만이 아닌)오랜 시간 동작하며 로깅하기

  • 프로파일링은 초기 빌드 시 퍼포먼스 떨어지는 부분을 파악하기에 적합
    • 다만 시간에 따른 경과를 파악하는 데는 부적합
  • 빌드 시 시간별 세분화된 데이터를를 파악할 수 있기를 바랐음
    • 이를 통해 각 단계(Transpilation, Minification, Localization, ...) 중 어디에서 시간을 많이 소요하는지 파악할 수 있게 되고
    • 최적화가 잘 작동하는지 확인 또한 가능하게 됨
  • Slack 팀은 대부분 Webpack이 아닌 수 많은 loaders와 plugins에 의해 수행됨
    • 이들은 세분화된 시간별 데이터를 제공하지 않았음
    • Webpack 또한 이를 제공할 수 있도록 표준을 정해놓지 않았음
  • 디펜던시에 대한 작업을 진행하는 loaders
    • 장기적으로 보기에는 좋은 전략은 아님
    • 다만 최적화 중 왜 퍼포먼스가 떨어지는지 이유를 찾는 데 매우 유용했음
  • 반면, plugins는 프로파일링하기 더 쉬웠음

Plugin을 이용해 간단하게 퍼포먼스 측정해보기

  • plugins는 빌드의 각 단계에서 dispatch되는 event에 대해 handling이 가능
    • 이를 이용해 각 단계의 실행 시간을 대략적으로 측정할 수 있음
  • UglifyJSPluginoptimize-chunk-assets 단계에서 대부분의 작업 처리
    • 측정하는 코드 예시
      • class CrudeTimingPlugin { apply(compiler) { compiler.plugin('compilation', compilation => { let startOptimizePhaze; compilation.plugin('optimize-chunk-assets', (chunks, callback) => { // 좀 조잡하긴 한데 이런 방식으로 측정이 가능 // 참고로 UlifyJSPlugin은 이 컴파일 단계에서 모든 작업을 수행하기에 // 전체 동작 시간을 측정할 수 있게 됨 startOptimizePhase = Date.now(); // 비동기 작업 수행을 위해 반드시 실행해줘야만 한다고 함 callback(); }); compilation.plugin('after-optimize-chunk-assets', () => { const optimizePhaseDuration = Date.now() - startOptimizePhase; console.log(`optimize-chunk-asset phase duration: ${optimizePhaseDuration}`); }); }); } } module.exports = CrudeTimingPlugin;
    • 위 plugin은 다음과 같이 사용할 수 있음
      • const CrudeTimingPlugin = require('./crude-timing-plugin'); module.exports = { plugins: [ new CrudeTimingPlugin(), ], };
    • 이렇게 시간 측정이 가능하며, 이러한 정보를 바탕으로 효과적인 최적화가 가능해짐

병렬화

  • Webpack에서 수행되는 많은 Tasks는 병렬 처리에 적합
    • 가능한 많은 프로세서로 작업을 분산하는 것이 좋음
  • 이러한 목적으로 만들어진 몇 가지 패키지가 있음
  • 물론 병렬로 실행하는 데 적잖은 비용이 필요
    • 프로파일링 데이터를 기반, 정말로 병렬적으로 처리해야 하는 부분에만 적용해야 할 것

작업량 최적화

  • 필요 이상으로 Webpack 작업이 진행되고 있었을 수도 있음

Minification 최소화하기

  • Minification 작업은 상당한 작업 시간을 필요로 함
    • 거의 절반, 적어도 1/3 이상을 차지하고 있었음
    • Butternut 그리고 babel-minify 같은 모듈을 이용했지만, UglifyJS의 병렬 옵션이 가장 빨랐음
  • UglifyJS의 README 가장 마지막에 나와있는 성능 관련 글
    • 잘 알려져 있지는 않은 지식
    • 공백을 제거하거나 Mangling(함수의 이름을 컴파일러가 바꾸는 것)은 대부분의 경우 minified 작업의 95%를 차지함
    • 이러한 압축을 비활성화하면 Uglify의 빌드 속도를 3~4배 높일 수 있음
  • Slack은 이 내용을 기반으로 시도해봤고, 결과는 충격적
    • 번들링 된 파일의 크기는 거의 변하지 않았음에도 불구하고 빌드 속도가 3배나 빨라짐
    • 다만 일반적인 상황이 아닐수도 있기에 측정 후, 그리고 파일 크기와 속도 중 어느것이 더 중요한지 결정하고 진행하는것을 권고함
  • 참고로 React를 사용해 개발하는 경우 개발 버전이 배포될 수도 있기에, 아래와 같은 설정을 이용하도록 함
    • new UglifyJsPlugin({ uglifyOptions: { compress: { arrows: false, booleans: false, cascade: false, collapse_vars: false, comparisons: false, computed_props: false, hoist_funs: false, hoist_props: false, hoist_vars: false, if_return: false, inline: false, join_vars: false, keep_infinity: true, loops: false, negate_iife: false, properties: false, reduce_funcs: false, reduce_vars: false, sequences: false, side_effects: false, switches: false, top_retain: false, toplevel: false, typeofs: false, unused: false, // `react-devtools`에게 프로덕션 빌드임을 명시하기 위해 // 이와 관련된 압축 설정을 끔 conditionals: true, dead_code: true, evaluate: true, }, mangle: true, }, }),
      React 16 이상을 사용하는 경우는 간단히 compress: false 옵션만으로 가능

코드 공유

  • 동일한 코드가 두 개 이상의 번들로 들어가는 것은 당연히 비효율적임
    • minified와 같은 작업 또한 두 배 이상이 되어버리겠지
  • 이를 위한 도구 (슬랙 팀은 이를 이용해 중복 제거했다고 함)

구문 분석 건너뛰기

  • Webpack은 모든 JS 디펜던시를 찾아내며 이를 AST(Abstract Syntax Tree) 기반으로 구문 분석을 잔행하게 됨
    • 그러나 이러한 과정은 빌드 시간을 적잖게 요구함
  • import, require 등을 이용해 실제로 사용하지 않을 것이라 확신하는 경우
    • noParse 옵션 등을 이용해 구문 분석을 진행하지 않도록 명시할 수 있음
    • 이를 통해 퍼포먼스 향상 가능

제외(Exclude)

  • Loader로 처리할 파일을 제외할 수 있음 (Webpack Rule.exclude)
    • Plugin도 비슷하게 옵션들을 제공하곤 함 (e.g. UglifyJS)
  • 이러한 옵션들을 이용하면 Transpiling이나 Minification과 같은 작업의 퍼포먼스 향상이 가능
    • Slack은 ES6 피처에 대해서만 Transpiling하도록 하고, 프로덕션 코드가 아닌 경우는 Minification을 건너뛰도록 구성함으로써 성능 향상을 이뤄냄

DLL 플러그인

  • Webpack의 DllPlugin을 이용하면 나중에 Webpack에서 이를 사용할 수 있도록 Pre-Bundling 가능
    • 굉장히 느리고 무거운 디펜던시에 적합
  • 과거에는 꽤나 무겁고 복잡했던 설정이 필요했으나, autodll-webpack-plugin 과 같은 대안이 계속해서 떠오르고 있음
    • 다만 Webpack5에 들어서 DllPlugin은 거의 사용되지 않고, 이 대신 자체적으로 지원하는 Disk Caching Module을 이용할 것이라 보임 (제거되진 않음, #6527)

Record를 이용해 모듈 ID 유지

  • Webpack은 디펜던시 트리 내 모듈에 대해 ID를 부여함
    • 모듈이 추가되거나 제거되면, 트리와 함께 영향을 받는 (하위의)모듈에 대한 ID도 변경됨
    • 따라서 만약 트리 level이 높은 모듈이 변경된다면 불필요하게 많은 Re-build가 발생될 수 있음
  • 이는 Record를 구성해 빌드 시에도 모듈 ID를 유지하도록 구성할 수 있으며
    • 이를 이용해 성능 향상 또한 꾀할 수 있음

매니페스트 청크 생성

  • Slack에서는 버전 업데이트 때마다 Hashed된 파일 이름을 제공하며, 이를 통해 Cache-Bust 수행
    • 다만 이는 이를 Mapping해주는 파일(Digest)이 반드시 필요
    • Digest는 간단히 Module ID와 실제 파일 이름을 Mapping해주는 역할을 함
      • { 0: "d4920286de51402132dc", /* <- 해시된 애플리케이션 번들 이름 */ 1: "29a3cf9344f1503c9f8f", 2: "e22b11ab6e327c7da035", /* .. 등등 ... */ }
  • 기본적으로 Webpack은 모든 번들 최상단에 추가되는 Boilerplate를 이용해 이 Digest를 추가시킴
    • 즉, 모듈이 추가되거나 제거될 때마다 이 Digest 역시 갱신된다는 것
    • 이로 인해 오버헤드가 발생되었을 뿐만 아니라
    • 의도했든 그렇지 않았든 Cache-bust도 항상 발생되어 이를 다시 요청해야 하는 문제점이 있었음
    • 바로 위, Record를 이용해 모듈 ID를 유지하도록 하는 방법엔 한계가 있음
  • 이 대신 Module Digest를 별도 파일로 추출하여 관리하도록 함
    • Slack 팀은 CommonsChunkPlugin을 이용해 매니페스트 파일을 만듦
    • 이를 통해 Re-build 빈도가 크게 줄었들음
    • Boilerplate 코드도 하나만 제공하도록 구성이 가능해짐

Source Maps

  • Source Map은 디버깅 시 요긴하게 사용될 수 있는 도구
    • 그러나 이를 생성하는 것에도 시간이 필요함
  • Webpack의 Devtool 옵션들을 이용해
    • 더 좋은 퍼포먼스를 내는 디버깅 옵션이 있는지 확인
    • Slack 팀은 cheap-source-map 옵션이 빌드 퍼포먼스와 디버깅이 가능한 선을 가장 잘 맞추는 것이라 판단되어 사용중

캐싱

  • Slack의 배포 주기는 매우 짧음
    • 즉, 빌드 시 이전 버전과는 눈에 띄게 달라진 점은 없음
  • 적절한 파일을 캐싱하도록 하면 빌드 퍼포먼스 향상이 가능

HardSourceWebpackPlugin

  • Webpack 작업 대부분은 Loader 및 Plugin에서 실행됨
    • 이들은 일반적으로 Caching하지 않도록 구성되었음
    • 이를 위해 HardSourceWebpackPlugin을 이용해 각 중간 과정의 결과물을 캐싱하도록 함
  • 다만 이를 적용하기 위해서는 캐싱이 손상되는 모든 외부 요인을 파악하고 있어야 함
    • 번역, CDN, 디펜던시 버전 등
    • 캐시를 이용하고 난 후 빌드는 20초가 더 빨라짐
  • 마지막으로, 디펜던시 버전이 바뀔 때마다 캐시를 지워줘야 한다는 것을 유의
    • 오래되고 호환되지 않는 디펜던시 캐시는 빌드를 실패하게 만들고 예상치 못한 에러를 발생시킬 수 있음

최신 상태를 유지

  • Webpack 생태계는 항상 최신 상태를 유지하는 것이 좋음
    • Webpack 코어 팀은 항상 빌드 속도를 향상시키기 위해 꾸준하게 작업함
    • 지금 당장 그럴 필요성을 느끼지 못하더라도, 계획에는 반드시 포함시키는 것이 좋음
    • 정기적으로 업데이트 하도록 하고, 앞서 말한 병렬 처리와 같은 새로운 기능을 항상 숙지함
  • Slack은 웹팩 3.0에서 3.4로 버전을 업데이트 한 후, 약 10초 가량 빌드 시간이 단축됨
    • 별 다른 설정을 추가해주지 않았음에도 이러한 성능 향상을 보인 것
  • Node 버전도 최신을 유지하는 것을 잊지 말아야 함
    • 패키지만이 유일한 개선점이 아니라는 것

하드웨어에 투자하기

  • 빌드 퍼포먼스를 향상시키기 위해 효과적인 방법 중 하나는 하드웨어에 투자하는 것
    • 아무리 퍼포먼스를 위해 최적화한들, 구식 하드웨어 위에서는 의미가 없다
  • Slack은 기존 AWS EC2의 C3 인스턴스를 이용했으나 이번에 더 좋은 프로세서를 이용하는 C4로 바꿈
    • 이 결과 빌드 및 병렬 처리와 관련된 퍼포먼스 향상됨을 봄
  • 예산을 설정하고, 하드웨어 벤치마크를 통해 적절한 타협점을 찾도록 하자

기여하기

  • Webpack과 같은 인프라 레벨의 프로젝트는 시간이든 돈이든 기여가 매우 중요
    • 이를 통해 자신 뿐만 아니라 커뮤니티 내 모든 사람들을 도와줄 수 있음
    • Slack은 돈 뿐만 아니라 기술적으로도 많은 기여를 하고 있음

마지막으로

  • Webpack은 추가적인 비용을 요구하지 않는, 환상적이고 다재다능한 도구
    • 이 기술들은 Slack 내 빌드 시간을 170초에서 17초로 줄여주었고
    • 엔지니어의 배포 환경 또한 많이 개선시켰음
  • 물론 아직 완벽하지는 않음
    • 빌드 성능을 추가적으로 개선시킬 수 있다 생각되면 항상 Slack에 의견을 던질 수 있다
    • 또한 이런 문제를 해결하는 것을 좋아한다면 같이 일할 수도 있음

다른 읽을 거리들