February 6 February 10, 2017

이민규 edited this page Feb 14, 2017 · 5 revisions
Clone this wiki locally

Weekly Magazine

Weekly Pick!

원글: https://medium.com/dev-channel/javascript-start-up-performance-69200f43b201

자바스크립트 Start-up 성능

웹 개발자로서, 웹 페이지를 확장하기가 쉽다는 것은 알고 있다. 하지만 웹 페이지를 로딩한다는 것은 단순히 바이트를 전달하는 것 이상의 의미를 지닌다. 브라우저가 웹 페이지의 스크립트를 다운로드 한 후 파싱(parsing)하고 해석(interpret)하여 실행한다. 이 글에서, 자바스크립트를 수행할 때 왜 앱의 실행이 느려지고 이를 어떻게 해결할 수 있는지 알아볼 것이다.

지금까지 우리는 자바스크립트에서 파싱과 컴파일의 최적화에 대해 많은 시간을 투자하지 않았다. 대부분은 파서가 <script> 태그를 만나면 코드를 즉시 수행할 것이라 예상한다. 그런데 사실 그렇지 않다. 다음은 V8이 하는 일을 간단히 나타낸다:

v8-works
V8의 동작을 간단히 나타낸 이상적인 파이프라인이다.

이제 몇 가지 주요 단계에 초점을 맞춰보자.

무엇이 웹 앱의 시작을 늦추는가?

스크립트를 파싱하고 컴파일하여 수행하는 것은 자바스크립트의 엔진이 앱을 실행시키는데 상당한 시간을 소비한다. 그리고 이 시간이 사용자와 웹 페이지 간의 상호작용지연시킬 수 있다. 버튼이 보이는데 클릭하거나 터치해도 수초가 걸린다고 상상해보자. 이로 인해 사용자 경험이 저하될 수 있다.

parse-compile-time
크롬에서 어떤 유명한 웹 사이트에 접속했을때 V8의 파싱과 컴파일 시간이다. 모바일이 데스크톱보다 훨씬 느릴 수 있다.

성능에 민감한 코드는 Start-up 시간이 중요하다. 실제로 크롬의 자바스크립트엔진 V8은 Facebook, Wikipedia 및 Reddit과 같은 유명한 사이트들에서 스크립트 파싱하고 컴파일하는데 많은 시간을 소비한다.

time-spent-in-V8
분홍색 영역(자바스크립트)은 V8과 Blink의 C++에서 소비되는 시간이고, 오렌지색과 노란색은 파싱과 컴파일에 소비된 시간이다.

파싱과 컴파일은 여러 대규모 사이트와 프레임워크에서 병목 지점으로 지적되기도 한다. 아래는 Facebook의 Sebastian Markbage와 Google의 Rob Wormald의 트윗이다.

tweet
Sebastian Markbage 트윗, Rob Wormald 트윗

planning-for-javascript
Sam Saccone '성능 계획'('Planning for Performance')에 파싱 비용을 포함했다.

점차 모바일 환경으로 옮겨가면서 파싱과 컴파일 시간이 2-5배정도 더 늘어난다는 것은 중요하다. 고사양의 폰들(예를 들어 iPhone이나 Pixel)은 Moto G4와는 매우 다른 성능을 보여줄 것이다. 이는 대표적인 하드웨어들(고사양 뿐만 아닌)에 대한 테스트의 중요성을 강조하고, 이를 통해 사용자 경험을 저하시키지 않을 수 있다.

mobile-parse-time
1MB 번들 파일에 대해 데스크톱과 모바일의 파싱 시간이다.

만약 앱의 거대한 번들 파일을 제공하고 현대적인 번들링 기법으로 code-splitting, tree-shaking, 서비스워커 캐싱 등을 지원한다면 실제로 큰 차이를 만들 수 있다. 작은 번들 파일, 열악한 라이브러리 선택이 메인 스레드가 함수 호출이나 컴파일 시간에 오랫동안 멈추게 할 수도 있다. 어느 부분에서 실제 병목 현상이 발생하는지 전체적으로 측정하고 이해하는 것이 중요하다.

보통의 웹 사이트에서도 파싱과 컴파일의 병목 현상이 발생하는가?

"나는 Facebook이 아니야.", "일반적인 사이트의 파싱과 컴파일 시간은 얼마나 걸리지?"라고 물을 수 있다. 측정해보자!

2개월 동안 React, Angular, Ember 및 Vue와 같은 다양한 라이브러리, 프레임 워크로 구축된 6000개 이상의 사이트들의 성능을 측정했다. 최근, 대부분의 테스트를 WebPageTest에서 다시 측정하였고, 여러분도 직접 쉽게 측정할 수 있다. 다음 몇 가지 결과를 보자.

앱이 사용 가능 상태가 되기까지 데스크톱(케이블 연결)에서 8초 정도, 모바일(3g의 Moto G4)에서 16초 정도 걸렸다.

3g-mobile

어떤 원인이 있었을까? 데스크톱에서 평균적으로 start-up(Parse/Compile/Exec)에 4초 정도 걸렸다.

start-up-time-desktop

모바일에서 파싱 시간은 데스크톱보다 36% 정도 더 걸렸다.

parse-times-on-mobile

모두가 큰 번들 파일들로 제공했을까? 예상만큼 크지는 않았지만, 개선의 여지는 있었다. 중간값을 보면 410KB의 JS 파일 전송이 있었다. HTTPArchive에서 보고한 '페이지당 평균 JS 420KB'와 거의 같다. 하지만 최악으로는 10MB 정도의 스크립트를 전송하기도 한다.

average-js-size-per-page
HTTPArchive stat

스크립트의 사이즈는 중요하지만 그게 전부는 아니다. 파싱과 컴파일 시간이 스크립트의 크기와 반드시 선형적으로 비례하는 것도 아니다. 일반적으로 번들 파일 사이즈가 작을수록 로드 시간이 (브라우저나 디바이스, 네트워크 연결에 관계 없이)빨라지긴 하지만 우리의 JS 200KB와 다른 누군가의 JS 200KB는 다르다. 파싱과 컴파일에 큰 차이가 있을 수 있다.

자바스크립트 파싱과 컴파일 측정하기

크롬 개발자 도구

Timeline (Performance panel) > Bottom-Up/Call Tree/Event Log에서 파싱과 컴파일 시간을 확인할 수 있다. 더 완벽한 그림(Parsing, Preparsing, Lazy Compiling에 걸리는 시간 측정)은 V8의 Runtime Call Stats에서 확인할 수 있다. Canary에서는 Timeline의 Experiments > V8 Runtime Call Stats에 있다.

timeline-bottom-up

크롬에서 추적하기

about: tracing - 크롬의 하위 레벨 추적 도구에서 disabled-by-default-v8.runtime_stats를 통해 v8이 소비하는 시간을 더 깊이 이해할 수 있다. V8의 가이드를 통해 사용법을 알 수 있다.

runtime-stats

WebPageTest

WebPageTest의 "Processing Breakdown" 페이지는 Chrome > Capture Dev Tools Timeline을 활성화 하여 성능 측정을 수행할 때 컴파일과 스크립트 실행, 함수 호출 시간들에 대한 정보를 보여준다.

그리고 Trace category에 disabled-by-default-v8.runtime_stats를 지정하여 Runtime Call Stats를 얻을 수 있다.

trace-categories

필자가 작성한 gist에 더 자세한 가이드가 있다.

User Timing

Lawson points가 아래에서 지적한 것처럼 User Timing API를 통해 파싱 시간을 측정할 수 있다.

user-timing-api
Nolan Lawson의 트윗

새번째 <script> 태그는 중요하지 않지만 첫번째 분리된 <script>는 중요하다.

이 접근법은 V8의 preparser에 의해 발생하는 후속 리로드(subsequent reloads)에 영향을 받을 수 있다. Nolan이 optimize-js 벤치마크에 했던것 처럼 스크립트의 마지막에 임의의 문자열을 추가하는 것으로 우회할 수 있다.

이와 유사한 접근법으로 Google Analytics를 사용하여 자바스크립트 파싱 시간에 대한 성능을 측정했다.

google-analytics
'parse'를 측정하기 위해 커스텀한 Google Analytics를 사용해 실제 사용자 기준에서 자바스크립트의 파싱 시간을 측정할 수 있다.

DeviceTiming

Etsy의 DeviceTiming도구는 제어되는 환경에서 파싱과 실행 시간을 측정할 수 있도록 도와준다. 로컬 스크립트를 측정 코드로 감싸서 다른 장치들(랩탑, 폰, 태블릿 등)이 웹 페이지에 접근할 때마다 파싱과 실행 시간을 비교할 수 있다. Danimel Espeset의 Benchmarking JS Parsing and Execution on Mobile Devices에 자세한 설명이 있다.

device-timing

자바스크립트의 파싱 시간을 줄이려면?

  • 보다 적은 사이즈의 자바스크립트 파일 전송. 파싱이 필요한 스크립트가 적을수록 파싱과 컴파일에 소요되는 시간이 줄어든다.
  • code-splitting을 사용하면 사용자가 필요로 하는 코드만 제공하고 나머지는 lazy-load를 수행할 수 있다. 이는 한번에 너무 많은 자바스크립트 파싱을 피할 수 있게 한다. RPRL과 같은 패턴은 라우트 기반의 청킹(route-based chunking)을 권장한다. Flipkart, Housing.com, 트위터에서 사용하는 방식이다.
  • Script streaming: 과거에 V8은 개발자들에게 async/defer를 사용하면 Script streaming에서 파싱 시간을 10~20% 향상시킬 수 있다고 말했다. 이는 HTML 파서가 리소스를 더 빠르게 분석하고, 스크립트 스트리밍 스레드에 작업을 전달하여 문서 파싱을 멈추지 않도록 한다. 이제 parser-blocking 스크립트에도 적용돼서, 사실 우리가 여기에서 무언가 더 할 것은 없다고 생각한다. V8은 스트리머 스레드가 1개라서 더 큰 번들 파일을 먼저 불러오기를 권장한다. (자세한 내용은 다음에 설명)
  • 라이브러리나 프레임워크와 같은 의존 모듈들의 파싱 비용을 측정하자. 가능하면 파싱 시간이 빠른 것들(예를 들어 React를 대체하는 Preact 또는 Inferno는 더 적은 바이트를 필요로 하고 파싱과 컴파일도 더 빠르다)로 대체하자. Paul Lewis는 최근 framework bootup 비용에 대해 글을 작성했다. Sebastian Markbage도 언급했듯, .프레임워크의 start-up 비용을 측정하는 좋은 방법은 첫번째 뷰를 렌더링하고, 지우고 다시 렌더링하여 얼마나 차이가 있는지 보는 것이다. 첫번째 렌더링은 lazy-compiled 코드들을 워밍업 시키는 경향이 있다.

컴파일에서 ahead-of-time 모드(AoT)를 지원하는 프레임워크는 파싱과 컴파일 시간을 크게 줄일 수 있다. 예를 들어 Angular와 같은 프레임워크가 있다.

aot
Nolan Lawson의 'Solving the Web Performance Crisis'

파싱과 컴파일 시간을 줄이기 위해 브라우저는 무엇을 하는가?

개발자들만이 start-up 시간을 단축시키기 위해 노력하는 것은 아니다. V8은 이전부터 벤치마크로 사용해왔던 Octane에서 우리가 보통 테스트하는 25개의 인기 있는 사이트들의 성능을 제대로 제대로 측정하지 못했다는 것을 발견했다. Octane은 1) 자바스크립트 프레임워크(보통 mono/polymorphic하지 않은 코드), 2) 실제 애플리케이션의 startup을 제대로 측정하지 못할 수 있다. 이 두 가지 케이스는 웹에서 꽤 중요하다. 즉, Octane이 모든 종류의 작업에 합리적인 것은 아니다.

V8팀은 start-up 시간을 개선하는데 노력해 왔다.

v8-start-up-time
Addy Osmani 트윗

또한 Octane-Codeload의 숫자를 보면 많은 페이지에서 V8의 파싱 시간이 25% 향상될 것으로 추정한다.

octane-codeload

그리고 Pinterest에서도 성능 향상을 확인할 수 있다. 지난 몇년 동안 V8의 파싱과 컴파일 시간을 단축하기 위해 다양한 노력이 있었다.

Code caching

v8-code-caching
V8의 code caching

크롬 42버전에서 code-caching–처음 컴파일된 파일을 로컬에 저장하고 사용자가 페이지로 돌아왔을 때 가져와서 파싱과 컴파일과 같은 단계를 건너뛰는 방식을 소개했다. 이 방식으로 인해 크롬은 어떤 페이지를 재 방문할 경우 약 40%의 컴파일 시간을 피할 수 있었다. 조금 더 자세한 기능을 알아보자면:

  • Code-caching은 72시간 내에 두 번 수행되는 스크립트에 대해 적용된다.
  • 서비스 워커 스크립트의 경우도 72시간 내에 두 번 수행되는 코드에 대해 적용된다.
  • 서비스 워커를 통해 캐시 저장소에 저장된 스크립트의 경우 첫번째 수행에서 code-caching이 적용된다.

즉, 우리의 코드가 캐싱 대상인 경우 V8은 세번째 로드에서 파싱과 컴파일을 건너 뛴다.

_chrome://flags/#v8-cache-stragtegies-for-cache-storage_를 통해 그 차이점을 확인할 수 있다. 또한 _js-flags=profile-deserialization_을 통해 크롬을 실행하여 code-cache에서 로드된 것들을 확인(역직렬화 이벤트 로그에 나타남)할 수 있다.

code-caching에 한가지 주의할 점은 적극적으로 컴파일된 것들만 캐싱한다는 것이다. 일반적으로 전역 값을 설정하기 위해 실행하는 최상위 코드들이다. 함수 정의는 보통 지연 컴파일되고 항상 캐쉬되는 것은 아니다. (optimize-js 사용자들의)IIFEs는 적극적으로 컴파일 되므로 V8의 code-cache에 포함된다.

Script Streaming

Script streaming은 async 또는 defer 스크립트에 대해 다운로드 시작과 함께 분리된 백그라운드 스레드에서 파싱하여 페이지 로드 시간을 약 10% 향상시킨다. 앞서 언급했듯, 이제 sync 스크립트에서도 적용된다.

script-streaming

이 기능이 소개되거 나서, V8은 모든 스크립트에 대해 적용하였고, 심지어 파서가 블로킹되는 <script src="">구문도 백그라운드 스레드에서 파싱되어 모두가 성능 향상을 얻을 수 있도록 하였다. 한가지 주의할 점은 백그라운드 스레드가 1개이기 때문에 중요하고/큰 스크립트를 먼저 적용시키는 것이 좋다는 것이다. 여기에서 잠재적인 성능 향상을 측정하는것도 중요하다.

실제로, <head>안에 <script defer>를 추가하면 리소스를 일찍 발견하여 백그라운드 스레드에서 파싱할 수 있다.

또한 DevTools의 Timeline에서 스크립트가 올바르게 스트리밍 되는지 확인할 수 있다. 파싱 시간을 좌우하는 큰 스크립트가 있는 경우, 스트리밍으로 처리되는지 확인하는것이 좋다.

script-streamer-thread

더 나은 파싱 & 컴파일

더 가볍고 빠른 파서가 메모리를 덜 차지하고 자료 구조를 더욱 효과적으로 처리하기 위한 작업이 진행중이다. V8 메인 스레드의 가장 큰 성능 저하(jank) 이유는 바로 비선형적인 파싱 비용이다. UMD(Universal Module Definition) 구조를 살펴보자.

(function (global, module) { … })(this, function module() { my functions })

V8은 module이 확실히 필요한 것인지 모르기때문에 메인 스크립트가 컴파일될 때 이를 컴파일 하지 않는다. module을 컴파일 하기로 결정할 때 내부 함수를 전부 다시 파싱해야 한다. 이것이 V8의 파싱 시간을 비선형으로 만든다. n번째 깊이의 모든 함수는 n번 파싱되어 성능 저하를 야기한다.

V8은 이제 최초 컴파일에서 내부 함수들에 대한 정보를 수집하여 이후 컴파일은 그 내부 함수들을 무시할 수 있도록 개발중이다. module 스타일의 함수에서 큰 성능 향상이 있을 것이다.

'The V8 Parser(s) — Design, Challenges, and Parsing JavaScript Better'에서 자세한 내용을 알아보자.

V8은 또한 startup동안 백그라운드에서 자바스크립트 분할 컴파일(offloading parts of JavaScript compilation)을 연구하고 있다.

Precompiling 자바스크립트?

몇년 마다 코드를 컴파일하거나 파싱하는데 시간을 허비하지 않기 위해 스크립트를 프리컴파일 하는 엔진을 제안했다. 이 아이디어는 만약 빌드 시간이나 서버 측의 툴이 바이트 코드를 생성할 수 있다면 start-up 시간에 큰 성능 향상을 얻을 수 있다는 것이다. 필자의 의견으로는 바이트 코드를 보내는 것은 (사이즈가 더 크기 때문에)load-time을 늘릴 수 있고, 보안을 위해 코드에 서명하는 작업이 필요할 수도 있다. 현재 V8의 입장에서 보면 내부적인 reparsing을 피하면 precompile로 너무 많은 것들을 제공하지 않아도 충분한 성능 향상을 얻을 수 있을 것이라 생각한다. 하지만 startup 시간을 줄이는 아이디어에 대한 토론은 항상 열려있다. V8은 서비스 워커에서 사이트를 업데이트할 때 컴파일링과 code-caching에 더욱 적극적으로 노력하고 있으며, 큰 성능 향상을 기대하고 있다.

BlinkOn7에서 precompilation에 대해 Facebook, Akamai와 함께 토론한 내용을 필자의 노트에서 찾아볼 수 있다.

JS의 lazy-parsing 최적화 ('hack')

전체 파싱이 끝나기 전에 대부분의 함수를 미리 파싱하는 V8과 같은 자바스크립트 엔진들은 lazy parsing 휴리스틱을 가지고 있다. 이 아이디어는 대부분의 페이지들이 지연 실행되는 함수들을 가지고 있다는 것에 기반하고 있다.

speed-boost-using-optimize-js

Pre-parsing은 브라우저가 함수에 대해 알고자 하는 것들만 최소화 시켜서 체크하여 startup 시간을 줄일 수 있다. 이는 IIFEs에서 적용되지 않는다. 비록 엔진이 이를 pre-parsing에서 피하려고 하지만, 휴리스틱은 항상 신뢰할 수 있는것은 아니기에 optimize-js와 같은 도구가 유용할 수 있다.

optmize-js는 스크립트를 미리 파싱하고, 즉시 실행 될(또는 휴리스틱으로 추정하는) 함수에 괄호를 넣어 빠른 실행을 가능하게 한다. paren-hacked(예를 들어 '!'가 붙은 IIFEs) 함수들을 보장한다. 다른 것들은 휴리스틱을 기반으로 한다(예를 들어 Browserify나 Webpack의 번들은 실제로 반드시 그렇지는 않지만 모든 모듈에서 로드된다고 가정한다). 결국, V8은 이러한 hack이 필요하지 않기를 바라고 있지만 현재 최적화가 필요하다면 고려해볼 수 있다.

V8은 또한 우리가 잘못 추측하는 경우에 대한 비용을 줄이기 위해 노력하고 있으며 parens-hack과 같은 것들의 필요성을 줄여야 한다.

결론

Start-up 성능 문제. 느린 파싱과 컴파일, 실행 시간들은 더 빨라져야 할 페이지의 실제 병목지점이 될 수 있다. 이 작업들에 얼만큼의 시간이 걸리는지 측정하자. 더 빨라지기 위해 무엇을 할 수 있을지 찾아보자.

V8도 앞으로 계속 start-up 성능을 향상시키도록 노력할 것이다.

더 읽어보기

V8 팀(Toon Verwaest, Camillo Bruni, Benedikt Meurer, Marja Hölttä, Seth Thompson), Nolan Lawson (MS Edge), Malte Ubl (AMP), Tim Kadlec (Synk), Gray Norton (Chrome DX), Paul Lewis, Matt Gaunt and Rob Wormald (Angular) 그리고 이 글의 리뷰어들에게 감사를 전한다.