(번역) 어떻게 우리는 자바스크립트 번들 크기를 33% 줄였는가?
원문: https://dropbox.tech/frontend/how-we-reduced-the-size-of-our-javascript-bundles-by-33-percent
웹 사이트에서 버튼을 클릭하려다가 페이지가 이동해 엉뚱한 버튼을 클릭한 적이 있었나요? 아니면 로딩 시간이 너무 오래 걸리는 페이지에 화를 내며 페이지를 종료한 적이 있나요?
이런 문제는 저희처럼 풍부하고 인터랙션이 많은 앱에서 증폭됩니다. 더 복잡한 기능을 지원하기 위해 작성된 프런트엔드 코드가 많을수록 브라우저에 더 많은 바이트가 전송되어 파싱 및 실행이 이뤄져 성능이 저하될 수 있습니다.
드롭박스는 이런 경험이 얼마나 짜증 나는지 알고 있습니다. 지난 한 해 동안 Dropbox의 웹 성능 엔지니어링 팀은 성능 문제의 원인을 흔히 간과하는 모듈 번들러로 판단했습니다.
밀러의 법칙에 따르면 인간의 뇌는 한 번에 처리할 수 있는 정보의 양이 한계가 있습니다. 이것이 대부분의 현대 코드베이스가 더 작은 모듈로 분리되는 이유 중 하나입니다. 모듈 번들러는 자바스크립트나 CSS 같은 애플리케이션의 다양한 구성 요소를 번들로 통합하고 페이지가 로드될 때 브라우저에서 다운로드합니다. 가장 일반적인 형태는 웹 앱의 로직 대부분을 포함하는 압축된 자바스크립트 파일입니다.
모듈 번들러의 첫 번째 버전은 모듈 번들링에 대한 성능 우선 접근 방식이 인기를 끌기 시작한 무렵인 2014년에 구상되었습니다(특히, 2012년과 2015년에 각각 웹팩과 롤업에서). 이런 이유로 최신 옵션에 비해 상당히 초보적인 수준이었으며, 모듈 번들러에는 성능 최적화가 많이 통합되어 있지 않았고 작업하기가 번거로워 사용자 경험을 저해하고 개발 속도를 늦췄습니다.
기존 번들러가 노후화하면서 향후 성능을 최적화하는 가장 좋은 방법은 번들러를 교체하는 것이라고 결정했습니다. 마침 페이지를 새로운 웹 서비스 스택인 Edison으로 마이그레이션 하는 중이었기 때문에 기존 마이그레이션 계획에 편승할 기회가 생겼습니다. 이때 최신 번들러를 정적 자산 파이프라인에 더 간단하게 통합할 수 있는 아키텍처도 제공했습니다.
기존 아키텍처
기존 번들러는 빌드 시간에 비교적 효율적이었지만, 번들 크기가 방대해져 엔지니어가 유지 관리하기가 부담스러웠습니다. 엔지니어가 패키지와 함께 번들링 할 스크립트를 수동으로 정의해야 했고, 페이지 렌더링에 관련된 모든 패키지를 최적화하지 않은 채 단순히 배포했습니다. 시간이 지나면서 이러한 접근 방식의 문제점이 명확해졌습니다.
문제 1. 여러 버전의 번들 코드
최근까지 드랍박스는 Dropbox Web Server(DWS)라는 맞춤형 웹 아키텍처를 사용했습니다. 즉, 각 페이지는 여러 개의 pagelet(즉, 페이지의 하위 섹션)으로 구성되어 페이지 당 여러 개의 자바스크립트 엔트리 포인트가 존재했고, 각 servlet은 백엔드에서 자체 컨트롤러를 통해 제공되었습니다. 여러 팀에서 페이지를 작업하는 경우 배포 속도가 빨라지긴 했지만, pagelet이 서로 다른 백엔드 코드 버전에 있는 경우가 종종 있었습니다. 이 때문에 DWS는 동일한 페이지에 별도의 패키지 코드 버전을 제공하도록 지원해야 했으며, 이로 인해 잠재적으로 일관성 문제(예: 동일한 페이지에 싱글톤의 여러 인스턴스가 로드되는 경우)가 발생할 수 있었습니다. Edison으로 마이그레이션 하면 pagelet 아키텍처를 제거하여 보다 업계 표준 번들링 체계를 유연하게 채택할 수 있습니다.
문제 2. 수동 코드 분할
코드 분할은 브라우저가 현재 페이지에 필요한 코드 베이스 부분만 로드할 수 있도록 자바스크립트 번들을 더 작은 덩어리로 분할하는 프로세스입니다. 예를 들어, 사용자가 dropbox.com/home
을 방문한 다음 dropbox.com/recents
를 방문한다고 가정해 보겠습니다. 코드 분할을 사용하지 않으면 전체 bundle.js
가 다운로드 되므로 페이지로 이동하는 초기 탐색 속도가 상당히 느려질 수 있습니다.
하지만 코드 분할 후에는, 페이지에 필요한 청크만 다운로드 됩니다. 이렇게 하면 브라우저에서 다운로드하는 코드가 줄어들기 때문에 dropbox.com/home
으로 이동하는 속도가 빨라지며 추가적인 이점도 누릴 수 있게 됩니다. 중요한 스크립트가 먼저 로드된 뒤 중요하지 않은 스크립트가 비동기적으로 로드, 파싱, 실행됩니다. 공유된 코드도 브라우저에 캐시 되므로 페이지 간 이동 시 다운로드되는 자바스크립트의 양이 더욱 줄어듭니다. 위의 기능을 통해 웹앱의 로드 시간을 크게 단축할 수 있습니다.
기존 번들러에는 코드 분할 기능이 내장되어 있지 않았기 때문에 엔지니어가 패키지를 수동으로 정의해야 했습니다. 더 구체적으로 설명하자면, 저희가 사용하는 패키징 맵은 어떤 모듈이 어떤 패키지에 포함되어 있는지 나타내는 6,000줄 이상의 방대한 맵이었습니다.
상상하시는 것처럼 시간이 지남에 따라 유지 관리가 엄청나게 복잡해졌습니다. 최적이 아닌 패키징을 방지하기 위해 엄격한 테스트, 즉 패키지 테스트를 시행했는데, 변경할 때마다 모듈을 수동으로 재구성해야 하는 경우가 많았기 때문에 엔지니어들은 이를 두려워하게 되었습니다.
또한, 특정 페이지에 필요한 것보다 훨씬 더 많은 코드가 필요하게 되었습니다. 예를 들어 다음과 같은 패키지 맵이 있다고 가정해 보죠.
{
"pkg-a": ["a", "b"],
"pkg-c": ["c", "d"],
}
페이지가 모듈 a
, b
, c
에 종속된 경우 브라우저는 모듈당 한 번씩 세 번 호출하는 대신 두 번만 HTTP 호출(즉, pkg-a
와 pkg-b
를 가져오기 위해)하면 됩니다. 이렇게 하면 HTTP 호출 오버헤드는 줄어들지만, 불필요한 모듈(이 경우 d
)을 로드해야 하는 경우가 종종 발생합니다. 트리 셰이킹의 부재로 인해 불필요한 코드가 로드될 뿐만 아니라 페이지에 필요하지 않은 모듈 전체가 로드되어 전체적으로 UX가 느려집니다.
문제 3. 트리 셰이킹의 부재
트리 셰이킹은 사용하지 않는 코드를 제거해 번들 크기를 줄이는 번들 최적화 기법입니다. 앱에서 여러 모듈이 포함된 타사 라이브러리를 불러온다고 가정해 보겠습니다. 트리 쉐이킹이 없다면, 번들 코드의 대부분을 사용하지 않을 것입니다.
트리 쉐이킹을 사용하면 코드의 정적 구조가 분석되고, 다른 코드에서 직접 참조하지 않는 코드를 제거합니다. 그 결과 훨씬 더 간결한 최종 번들이 생성됩니다.
기존 번들러는 베어본이었기 때문에 트리 셰이킹 기능 또한 없었습니다. 그 결과 패키지에는 타사 라이브러리에서 가져왔지만 사용하지 않는 코드가 대량으로 포함되어 페이지 로딩 대기 시간이 불필요하게 길어지는 경우가 많았습니다. 또한, 프런트엔드에서 백엔드로의 효율적인 데이터 전송을 위해 Protobuf 정의를 사용했는데, 특정 관측 가능성 메트릭(instrumenting certain observability metrics)을 측정하기 위해 추가한 코드가 몇 메가의 사용하지 않는 코드를 추가하는 결과를 가져오기도 했습니다!
왜 롤업인가
수년 동안 많은 설루션을 고려했지만 자동 코드 분할, 트리 셰이킹, 번들링 파이프라인을 더욱 최적화하기 위한 플러그인과 같은 특정 기능이 필요하다는 것을 깨달았습니다. 롤업은 당시 가장 완성도가 높고 기존 빌드 파이프라인에 통합하기 가장 유연했기 때문에 적용하기로 결정했습니다.
다른 이유로는 엔지니어링 오버헤드가 적다는 점이 좋았습니다. 이미 NPM 모듈 번들링에 롤업을 사용하고 있었기 때문에(비록 유용한 기능이 많지는 않았지만), 롤업 도입을 확대하면 빌드 프로세스에 완전히 새로운 도구를 통합하는 것보다 엔지니어링 오버헤드가 적으리라 판단했습니다. 이 점은 다른 번들러에 비해 롤업의 특이점에 대한 코드베이스에서 더 많은 엔지니어링 전문 지식을 갖고 있다는 것을 의미하므로 미지의 가능성을 줄일 수 있습니다. 또한, 기존 모듈 번들러 내에서 롤업의 기능을 복제하려면 빌드 프로세스에 롤업을 더 깊이 통합하는 것보다 훨씬 더 많은 엔지니어링 시간이 필요합니다.
롤업 출시
우리는 모듈 번들러를 안전하고 점진적으로 출시하는 것이 결코 쉬운 일이 아닐 것임을 알고 있었습니다. 특히 동시에 두 개의 모듈 번들러(그리고 결과적으로 두 개의 서로 다른 생성된 번들)를 안정적으로 지원해야 하기 때문입니다. 저희의 주요 관심사에는 안정적이고 버그 없는 번들 코드를 보장하고, 빌드 시스템과 CI의 부하 증가, 그리고 팀이 소유한 페이지에 롤업 번들을 사용하도록 인센티브를 제공하는 방법이 포함되었습니다.
안정성과 확장성을 염두에 두고 출시 프로세스를 4단계로 나눴습니다.
- 개발자 미리 보기단계를 통해 엔지니어는 개발 단계에서 롤업을 선택할 수 있었습니다. 이를 통해 개발자가 롤업 번들로 인해 발생하는 예상치 못한 앱 동작을 표면화함으로써 효과적으로 QA 테스트를 외주할 수 있게 해 주었습니다. 이는 버그와 범위 변경을 해결할 충분한 시간을 제공했습니다.
- Dropboxer 미리 보기 단계는 모든 Dropbox 내부 직원에게 롤업 번들을 제공하는 것을 포함했습니다. 이는 초기 성능 데이터를 수집하고 애플리케이션의 동작 변경에 대한 피드백을 추가로 수집할 수 있게 했습니다.
- 일반 가용성 단계는 내부 및 외부 Dropbox 사용자 모두에게 점진적으로 배포하는 것을 포함했습니다. 이는 롤업 패키징이 철저히 테스트되고 사용자에게 충분히 안정하다고 판단된 후에만 수행했습니다.
- 유지 보수 단계는 프로젝트에 남아 있는 기술 부채를 해결하고 성능과 개발자 경험을 최적화하기 위해 롤업 사용을 반복하는 것을 포함했습니다. 저희는 이러한 대규모 프로젝트는 필연적으로 기술 부채를 축적하게 될 것이며, 이를 덮어두지 않고 어느 시점에 적극적으로 해결할 계획을 세워야 한다는 것을 깨달았습니다.
이러한 각 단계를 지원하기 위해 쿠키 기반 게이팅(gating)과 사내 기능 게이팅 시스템을 혼합해 사용했습니다. 과거 드랍박스의 대부분 출시는 사내 기능 게이팅 시스템을 통해서만 이뤄졌지만, 쿠키 기반 게이팅을 통해 롤업과 레거시 페이지 사이를 빠르게 전환할 수 있도록 해 디버깅 속도를 높이기로 했습니다. 출시는 1%, 10%, 25%, 50%, 100%로 점진적인 단계가 포함되어 있었습니다. 이는 초기 성능 및 안정성 결과를 수집하고, 발생할 경우 모든 중단 변경 사항을 원활하게 롤백할 수 있는 유연성을 제공하면서 내부 및 외부 사용자에 대한 영향을 최소화했습니다.
마이그레이션 해야 하는 페이지의 수가 많았기 때문에 페이지를 안전하게 롤업으로 전환할 수 있는 전략뿐만 아니라 페이지 소유자가 먼저 전환하도록 장려할 수 있는 전략도 필요했습니다. 웹 스택이 Edison으로 대대적인 개편을 앞두고 있었기 때문에, Edison의 출시에 편승하면 두 가지 문제를 모두 해결할 수 있다는 사실을 깨달았습니다. 롤업이 Edison 전용 기능이라면 개발팀은 롤업과 Edison 모두로 마이그레이션 하도록 유인하는 것이 더 쉬워질 것이고, 우리는 Edison의 마이그레이션 전략과도 긴밀하게 연결할 수 있을 것입니다.
또한 Edison은 자체적으로 성능과 개발 속도가 개선될 것으로 예상되었습니다. Edison과 롤업을 함께 사용하면 회사 전체에 혁신적 시너지 효과를 가져올 수 있을 것으로 생각했습니다.
도전 과제와 장애물
예상치 못한 문제가 발생할 것으로 예상은 했지만, 하나의 빌드 시스템(롤업)을 다른 시스템(기존 Bazel 기반 인프라)과 데이지 체이닝 방식으로 연결하는 것이 예상보다 더 어렵다는 것을 깨달았습니다.
첫째로, 두 개의 서로 다른 모듈 번들러를 동시에 실행하는 것은 예상보다 훨씬 리소스 집약적인 것을 확인했습니다. 롤업의 트리쉐이킹 알고리즘은 상당히 성숙했지만, 여전히 모든 모듈을 메모리에 로드하고 관계 분석과 코드 쉐이킹에 필요한 추상 구문 트리를 생성해야 했습니다. 또한 롤업을 Bazel에 통합하면서 중간 빌드 결과를 캐싱할 수 있다는 기능이 제한되어 CI가 빌드할 때마다 모든 롤업 청크를 다시 빌드 하고 압축해야 했습니다. 이에 따라 메모리 부족으로 CI 빌드 시간이 초과하고 출시가 상당히 지연되었습니다.
또한, 롤업의 트리 셰이킹 알고리즘이 지나치게 과감한 트리 셰이킹을 초래해 몇 가지 버그를 발생시켰습니다. 다행히도 개발자 미리보기 단계에서 발견되어 수정된 사소한 버그였기 때문에 사용자에게 영향을 미치지는 않았습니다. 또한, 레거시 번들러가 자바스크립트 엄격 모드와 호환되지 않는 타사 라이브러리의 일부 코드를 제공한다는 사실도 발견했습니다. 엄격 모드가 활성화된 새 번들러를 통해 동일한 코드를 제공하면 브라우저에서 런타임 오류가 발생했습니다. 이에 따라 전체 코드 베이스에 대한 일회성 감사를 수행하고, 엄격 모드와 호환되지 않는 코드를 패치해야 했습니다.
마지막으로, Dropboxer 미리보기 단계에서 롤업과 레거시 번들러간의 A/B 텔레메트리 지표에서 예상했던 만큼의 TTVC 개선 효과가 나타나지 않는 것을 발견했습니다. 결국 롤업이 레거시 번들러보다 훨씬 더 많은 청크를 생성하는 것으로 범위를 좁혀 확인했습니다. 처음에는 HTTP2의 멀티플렉싱이 더 많은 청크로 인한 성능 저하를 상쇄할 것이라는 가설을 세웠지만, 청크가 너무 많으면 브라우저가 페이지에 필요한 모든 모듈을 검색하는 데 훨씬 더 많은 시간을 쏟는다는 사실을 발견했습니다. 또한, 청크 수를 증가시키는 것도 압축 효율을 낮추는 결과를 가져왔습니다. Zlib과 같은 압축 알고리즘은 슬라이딩 윈도우 방식의 압축을 사용하기 때문에, 이는 하나의 큰 파일보다 많은 작은 파일의 압축 효율이 더 높습니다.
결과
모든 드랍박스 사용자에게 롤업을 배포한 결과, 이 프로젝트를 통해 자바스크립트의 번들 크기는 33%, 전체 자바스크립트 스크립트 수가 15% 감소했으며 TTVC가 소폭 개선되었습니다. 또한, 자동 코드 분할을 통해 프런트엔드 개발 속도가 크게 개선되어 개발자가 변경할 때마다 번들 정의를 수동으로 뒤섞을 필요가 없어졌습니다. 마지막으로 가장 중요한 것은 번들링 인프라를 현대화하여 2014년 이후 수년간 누적된 기술 부채를 줄임으로써 향후 유지보수 부담을 줄였다는 점입니다.
롤업 프로젝트는 매우 영향력 있는 출시였을 뿐만 아니라 기존 아키텍처의 여러 병목 현상(예: 렌더링을 차단하는 여러 RPC, 타사 라이브러리에 대한 과도한 함수 호출, 브라우저가 모듈 종속성 그래프를 로드하는 방식의 비효율성 등)을 드러냈습니다. 롤업의 풍부한 플러그인 에코시스템을 고려할 때, 코드 베이스에서 이러한 병목 현상을 해결하는 것이 그 어느 때보다 쉬워졌습니다.
전반적으로 롤업을 모듈 번들러로 완전히 채택함으로써 성능과 생산성이 즉각적으로 향상되었을 뿐만 아니라 향후에도 상당한 성능 개선이 이루어질 것으로 기대됩니다.