프로그래시브 웹앱 성능에 대한 리엑트, 프리엑트 케이스 스터디 : Treebo
🌴

프로그래시브 웹앱 성능에 대한 리엑트, 프리엑트 케이스 스터디 : Treebo

@May 31, 2018

트리보(Treebo) 200억 달러의 가치를 지닌 여행 산업 부문에서 활동 중인, 인도의 가성비 좋은 호텔 체인이다. 트리보는 최근 새로운 PWA(Progressive Web App) 모바일 사이트를 배포했다. 처음에는 React를 사용했지만, 나중에 production에서 Preact로 바꿨다.

새로운 사이트는 이전의 모바일 사이트에 비해 time-to-first-paint이 70% 이상, time-to-interactive는 31% 향상 되었다. 그리고 일반적인 고객이 사용하는 3G망에서 4초 이내에 로드되는 것을 확인했다. 새로운 웹 사이트는 WebPageTest의 slow 3G 에뮬레이션에서 5초 이내에 interactive 가능했다.

image

React에서 Preact로 전환하는 것만으로 time-to-interactive가 15% 향상되었다. Treebo.com에 접속해서 직접 경험해 볼 수 있다, 하지만 오늘은 이 PWA를 성공시킨 기술 여행을 같이 떠나보기로 하자.

image

성능개선 여행

이전 모바일 사이트

트리보의 이전 모바일 사이트는 하나의(monolithic) Django 셋업으로 구동되었다. 사용자는 매 페이지 전환마다 서버의 응답을 기다려야 했다. 이 기존 셋업에서 first-paint-time은 1.5초, first-meaningful-paint-time이 5.9초, 그리고first-interactive는 6.5초가 걸렸었다.

image

기본적인 Single Page React app

트리보는 첫 시작으로 React와 Webpack을 간단히 설정하여, 기존 웹사이트를 Single Page Application으로 다시 만들었다. 아래 코드는 실제 사용되었던 코드이다. 이 코드는 javascript와 CSS 번들을 한 덩어리씩 만들어 낸다.

이 설정으로 first-paint 4.8초, first-interactive 5.6초, 그리고 중요한 헤더 이미지가 그려지기 까지는 대략 7.2초가 걸리는 것을 경험했다.

image

Server-side Rendering(SSR)

다음으로 first-paint를 살짝 최적화하기 위해 서버 측 렌더링(SSR)을 시도했다. 잊지 말아야 할 것은 서버 사이드 렌더링은 공짜가 아니고, 무언가 하나를 희생해서 하나를 최적화하는 일이라는 점이다.

서버 사이드 렌더링을 사용하면 서버는 응답으로 렌더링 된 페이지의 HTML을 준다. 그러므로 브라우저는 모든 javascript가 다운로드되어 실행될 때까지 기다리지 않고 렌더링을 시작할 수 있다.

트리보는 React의 renderToString() 을 사용하여 컴포넌트를 HTML 문자열로 렌더링하고, 응용 프로그램의 초깃값을 설정했다.

이번 케이스에서 서버 SSR을 통해 first-paint-time이 1.1초, first-meaningful-paint-time은 2.4초로 줄었다. 이 향상은 사용자 입장에서 페이지가 얼마나 빠르게 준비되었는지 체감하고, 페이지 내용을 읽을 수 있는지를 말한다. SEO도 약간 나아졌다. 그러나 time-to-interactive에 상당히 부정적인 영향을 미치게 되었다.

image

사용자가 콘텐츠를 볼 수는 있지만, javascript가 부팅되는 동안 메인 스레드가 묶여 멈추게 된 것이다.

SSR을 사용하면 브라우저가 이전보다 훨씬 더 많은 HTML을 가져와 처리해야 한다. 그 다음 여전히 javascript를 가져오고, 파싱/컴파일하고 실행해야 한다. 뭐 결과적으로 더 많은 일을 하게 된 셈이다.

이 결과는 first-interative가 6.6초에 발생하여, 퇴보했다는 뜻이다.

SSR은 또한 성능이 낮은 기기에서 메인 스레드를 잠금으로써 TTI(Time-to-interactive)를 늦추기도 한다.

Code-splitting(코드 분할) & route-based chunking(경로 기반 나누기)

그다음으로 트리보는 time-to-interactive 수치를 낮추기 위해 route-based chunking을 하기로 했다.

Route-based chunking은 경로의 코드를 필요에 따라 로드 할 수 있는 “덩어리(chunk)”로 Code-splitting하여, 경로에 따라 필요한 최소한의 코드만 제공하기 위함이다. 이렇게 하면 그것들이 사용된 세부 수준에 가깝게 가공된 리소스를 제공할 수 있다.

여기서 트리보는 vendor dependencies, Webpack 런타임 메니페스트, 그리고 경로의 소스를 개별 chunk로 나누었다.

이 작업은 time-to-first-interactive를 4.8초까지 낮춰 주었다. 어썸!

이 작업의 유일한 단점은 현재 경로의 javascript 다운로드가 초기 번들 실행이 완료된 후에야 시작한다는 것이었다. 이것이 최선이라 할 수는 없겠다.

그러나 일단은 긍정적인 결과다. route-based code-splitting과 그 결과를 놓고, 트리보는 조금 더 손을 봤다. chunk들을 비동기적으로 로드하기 위해서 getComponent()에서 Webpack import()를 호출하도록 React Router를 선언했다.

image

The PRPL 성능 패턴

Route-based chunking은 보다 섬세한 배포와 캐싱을 위한 지능적 빌드로의 큰 한 걸음이다. 트리보는 이러한 빌드를 위한 영감을 얻고자 PRPL 패턴을 살펴봤다.

PRPL은 앱이 배포되고 실행되는 성능에 중점을 두고 PWA를 구조화하고 제공하는 패턴이다.

PRPL은 다음의 약자이다 :

  • Push: 초기 URL 경로에 대한 중요한 리소스를 푸시.
  • Render: 초기 경로 렌더.
  • Pre-cache: 앞의 경로를 미리 캐시.
  • Lazy-load: 지연된 로딩과 요청에 따라 경로 생성.
image

“푸시 (Push)”패턴은 HTTP/2-서버/브라우저를 대상으로 하는 빌드는 번들링 하지 않기를 권장한다.(unbundled bulid) 이는 브라우저가 빠르게 first-paint하면서도 캐시를 최적화하기 위함이다. 이러한 리소스의 전달은 <link rel="preload"> 또는 HTTP/2 Push를 통해 효율적으로 시작할 수 있다.

트리보는 <link rel="preload"/>를 사용하여 미리 현재 경로의 chunk를 미리 로드하기로 했다. 이것은 first-interactive-time을 줄이는데 영향을 미친다. 왜냐하면 초기 번들들이 실행된 이후, Webpack이 현재 경로의 chunk를 서버에서 가져오려 할 때면 이미 캐시에 있을 것이기 때문이다. 이 작업으로 시간이 조금 줄어들었고 first-interactive는 4.6초가 되었다.

image

Preload의 한가지 단점은 크로스 브라우징이 되지 않는다는 것이다. 이제 Safari Tech Preview에는 링크 rel preload가 구현되었다. 올해 중에는 안착되었으면 좋겠다. 그리고 파이어폭스 역시 적용을 준비하고 있다.

HTML 스트리밍

renderToString()을 사용하기 까다로운 점이 있는데, 이것이 동기적으로 실행되며, 서버 사이드 렌더링에서 성능 병목 현상이 될 수 있다는 것이다. 서버는 전체 HTML이 만들어질 때까지 응답을 보내지 않을 것이다. 대신, 웹 서버가 콘텐츠를 스트리밍 해주면 브라우저는 전체 응답이 완료되기 전에 페이지를 렌더링 할 수 있다. react-dom-stream 같은 프로젝트가 도움이 될 수 있다.

체감 성능을 개선하고 점진적으로 렌더링 하는 느낌을 앱에 도입하기 위해 트리보는 HTML 스트리밍을 고려했다. 이들은 link rel preload 태그들을 담은 head 태그를 스트림 한다. 이는 CSS와 javascript를 일찍이 preload 하기 위해서다. 그런 다음 서버 사이드 렌더링을 수행하고 그 나머지를 브라우저로 보낸다.

이러한 이점으로 자원들의 다운로드가 초기에 시작되어, first-paint는 0.9초, first-interactive는 4.4초로 빨라졌다. 이제 이 앱의 consistently-interactive는 4.9/5초 정도가 되었다.

image

여기서 단점은 클라이언트와 서버 간 어느 정도 긴 시간 연결을 열어 두었기 때문에, 만약 대기 시간이 길어지게 되면 문제가 발생할 수 있다는 것이다. 트리보는 HTML 스트리밍을 하기 위해, <head>에 early chunk를 넣었다. 그다음에 메인 콘텐츠와 late chunk를 넣었다. 이 모든 것들은 그 페이지에 삽입된다. 아래와 같이 말이다.

사실상, early chunk의 모든 스크립트 태그는 rel=preload 속성을 가진다. late chunk에는 서버에서 렌더링 된 html이 들어가며, 이외에도 상태를 포함하고 있거나 로드되어 실행될 javascript 같은 것들이 포함된다.

중요 경로 CSS 인라이닝(critical path css inlining)

CSS 스타일 시트는 렌더링을 멈출 수 있다. 브라우저가 스타일 시트를 요청, 수신, 다운로드 및 파싱 할 때까지는 페이지가 비어있는 채로 방치될 수 있다. 브라우저가 처리해야 하는 CSS의 양을 줄이고, 페이지에 critical path styles를 인라인(HTML에 하드코딩) 하면, HTTP 요청이 제거되고, 페이지를 더 빨리 렌더링 할 수 있다.

트리보는 현재 경로의 critical path CSS를 인라인하고, 나머지 CSS는 loadCSS를 사용하여 DOMContentLoaded에서 비동기적으로 로드했다.

렌더링을 멈추는 critical path link 태그를 제거하고, 주요 CSS만 몇 줄가량 인라인 하여, first-paint-time은 약 0.4초로 개선되었다.

그리고 이번에는 인라인 한 스타일을 다운로드/파싱 하느라 걸리는 시간이 늘었다는 단점이 생겼다. javascript가 실행되기 전에 걸리는 시간의 증가로 first-interactive시간이 4.6초가 조금 넘게 되었다.

image

정적 자원 오프라인 캐싱

Service Worker는 프로그래밍 가능한 네트워크 프락시로서 페이지의 네트워크 요청 처리 방법을 제어할 수 있다.

트리보는 커스텀 오프라인 페이지과 정적 자원(static assets)의 Service Worker캐싱 기능을 추가했다. 트리보가 리소스 캐싱과 Service Worker를 등록하기 위해 sw-precache-webpack-plugin을 어떻게 사용했는지 아래에서 확인해 보자.

image

CSS 및 JavaScript 번들과 같은 정적 자원 캐싱은 사용자 재 방문 시 페이지가 매번 네트워크를 사용하지 않고 디스크 캐시에서 로드 될 때 (거의) 즉시 로드된다는 것을 의미한다. 캐싱 헤더를 열심히 등록해 놓으면 디스크 캐시 적중률에 비례하는 효과를 가질 수 있지만, 오로지 Service Worker만이 오프라인 기능을 제공할 수 있다.

image

Service Worker에서 Cache API를 사용하여, javascript를 캐싱하면 (JavaScript Start-up Performance에서 다루었 듯) 스크립트를 V8의 코드 캐시에 초기 등록하는 좋은 효과가 있으므로, 재 방문 시 시작하는 데 약간의 시간 이득을 볼 수 있다.

React에서 Preact로 전환

Preact는 React ES2015 API에 상응하는 3kb 용량의 대체 라이브러리이다. Preact는 React 호환 레이어를 통해 React 에코 시스템에서 동작하면서도 높은 성능을 제공하기 위해 만들어졌다.

Preact는 React의 Synthetic Event와 PropType validation을 제거하여 작은 용량만을 가진다. 또한 아래의 특징도 가지고 있다.

  • Virtual DOM을 직접 DOM에 diff
  • for, class 같은 props 허용
  • render에 props, state 전달
  • 표준 브라우저 이벤트 사용
  • 완전한 비동기 렌더링 지원
  • 기본적으로 subtree invalidation

많은 PWA에서 React -> Preact로 전환을 통해, JS 번들 크기가 작아지고 응용 프로그램의 초기 javascript 부팅 시간이 단축됨을 경험했다. Lyft, Uber 및 Housing.com과 같은 최근의 PWA는 모두 production에서 Preact를 사용한다.

참고 : 작업은 React로 하면서도, Preact로 배포하고자 하는가? 이상적으로는 개발자, 시험 및 테스트 빌드에 preact 와 preact-compat을 사용해야 한다. 초기에 호환성 버그를 발견할 수 있기 때문이다. 만약 Webpack production 빌드에서만 preact 와 preact-compat를 사용하고자 하는 경우(Enzyme을 사용하는 경우라던가) 서버에 배포하기 전에 모든 것이 제대로 작동하는지 철저히 테스트해야 한다.

트리보의 경우 React에서 Preact로의 전환은 vendor bundle 크기를 140kb에서 100kb로 줄여 주었다.(압축한 사이즈 기준이며, 이후에도 용량은 압축한 상태를 기준으로 하겠다) 이것은 트리보가 대상으로 하는 모바일 하드웨어에서 first-interactive time을 4.6 초에서 3.9초로 줄이는 효과다.

image

Webpack config에서 react와 react-dom의 alias로 preact-compat를 설정하여 같은 일을 할 수 있다.

이 접근 방식의 단점은 트리보의 경우 Preact가 React의 생태계(플러그인들)와 동일하게 작동하도록 몇 가지 workaround를 해야 했다는 것이다.

React를 사용하려 한다면, 대부분(95%)의 경우 Preact를 사용할 수 있다. 나머지 5% 정도는 edge case를 해결하기 위해 버그를 신고해야 할지도 모른다.

참고 : WebPageTest는 현재 인도에서 직접 실제 Moto G4를 테스트할 수 있는 방법을 제공하지 않으므로 “Mumbai — EC2 — Chrome — Emulated Motorola G (gen 4) — 3GSlow — Mobile”설정에서 성능 테스트를 수행했다. 이 내용을 보고 싶다면 여기를 참조하자.

스켈레톤 스크린

“스켈레톤 스크린은 기본적으로 정보가 점차 로드되는 페이지의 빈 버전이다.” — “A skeleton screen is essentially a blank version of a page into which information is gradually loaded.”Luke Wroblewski
image

트리보는 preview enhanced components(각 컴포넌트의 스켈레톤 스크린 같은)를 사용하여 골격 화면을 구현한다. 이 접근법은 기본적으로 작은 구성 요소(텍스트, 이미지 등)가 미리보기를 표현할 수 있도록 개선하여, 컴포넌트에 필요한 원본 데이터가 없으면 대신 컴포넌트의 미리보기 버전이 표시되도록 했다.

예를 들어, 위의 목록 항목에서 호텔 이름, 도시 이름, 가격 등을 보면 <Text />와 같은 타이포그래피 구성 요소를 사용하여 구현되어 있다. 그리고 추가적으로 미리 보기 기능을 위한 preview와 previewStyle 프로퍼티를 가지게 되었다.

기본적으로 hotel.name이 존재하지 않으면 컴포넌트는 배경을 previewStyle로 전달된 크기의 회색으로 변경하고 다른 스타일은 그대로 적용한다. (previewStyle이 전달되지 않으면 너비는 기본적으로 100%가 된다).

트리보는 미리 보기 모드로 전환하는 로직이 실제로 표시된 데이터와는 관계없기 때문에 이러한 접근 방식을 선호한다. “Incl. of all taxes” 부분을 살펴보면, 이는 정적 텍스트이므로 시작과 동시에 표시될 수 있지만, API가 호출되는 동안 가격이 보이지 않으므로 사용자에게 혼란스러워 보일 수 있다.

그러므로 정적 텍스트 “Incl. of all taxes”가 프리뷰로 보일지 여부는 다른 UI와 함께 가격 데이터가 로드 되었는지를 기준으로 한다.

이렇게하면 가격이 로드되는 동안 미리 보기 UI가 표시되고 API가 성공하면 모든 데이터를 볼 수 있다.

Webpack-bundle-analyzer

이쯤에서 트리보는 손쉽게 최적화할 수 있는 것들이 무엇이 있는지 번들 분석을 해보고 싶었다.

참고 : 모바일에서 React와 같은 라이브러리를 사용한다면, 가져오는 vendor libraries에 대해 부지런히 조사해야 한다. 그렇게 하지 않으면 성능에 부정적인 영향을 미칠 수 있으니 말이다. 주어진 경로가 필요한 것만 로드하도록 vendor libraries를 더 잘 잘라보자.

트리보는 각 경로의 chunk에 포함된 모듈을 모니터링하고 번들의 크기를 추적하기 위해 webpack-bundle-analyzer를 사용했다. 또한 moment.js 로케일 정보를 제거하고 깊은 곳에 있는 dependency library를 재사용하는 등 번들 사이즈를 줄이기 위해 이 도구를 사용했다.

webpack으로 moment.js 최적화하기

트리보는 날짜 처리를 위해 moment.js에 많이 의존한다. moment.js를 가져 와서 Webpack과 함께 번들하면 번들은 디폴트로 모든 moment.js와 ~61.95kb의 로케일을 포함한다. 이렇게 하면 최종 vendor 번들 크기가 심각하게 커지게 된다.

image

moment.js의 크기를 최적화하기 위해 사용할 수 있는 webpack 플러그인은 두 가지가 있다 : IgnorePluginContextReplacementPlugin

트리보는 로케일 정보가 필요 없었기 때문에 IgnorePlugin으로 모든 로케일 파일을 제거하기로 했다.

new webpack.IgnorePlugin(/^./locale$/, /moment$/)

로케일이 제거되고, moment.js 압축한 번들 크기가 ~16.48kb로 떨어졌다.

image

moment.js 로케일을 제거하는 효과로 가장 큰 개선점은 vendor 번들 크기가 ~179kb에서 ~119kb로 떨어졌다는 것이다. vendor 번들은 제일 먼저 로드되는 모듈이다. 이 중요한 번들에서 작지 않은 60kb가 줄어든 것이다. 이 모든 것이 first-interaction-time을 크게 줄여준다. 여기서 moment.js 최적화에 대해 더 자세히 읽어보자.

기존의 깊은 곳에 있는 dependencies 재사용

트리보는 초기에 “qs”모듈을 사용하여 쿼리 문자열 작업을 하고 있었다. 트리보는 webpack-bundle-analyzer를 사용하여 “react-router”에는 “history”모듈이 포함되어 있으며, 이 모듈에는 “query-string”모듈이 포함되어 있음을 찾아냈다.

image

동일한 작업을 수행하는 두 개의 다른 모듈이 있었기 때문에, “qs”를 “history”모듈에 포함된 “query-string”(명시적으로 설치하여)을 사용하도록 소스 코드를 변경했다. 이제 번들에서 2.72kb(qs 모듈 크기)가 추가적으로 줄어들게 되었다.

트리보는 훌륭한 오픈 소스 시민 중 하나다. 그들은 많은 오픈 소스 소프트웨어를 사용해왔다. 그 답례로, 그들은 Webpack config 대부분을 오픈 소스로 공개했을 뿐 아니라, 그들이 production 환경에서 사용하고 있는 많은 설정을 포함하는 boilerplate를 공개했다. 요기에서 둘러보도록 하자 : https://github.com/lakshyaranganath/pwa

image

그들은 또한 공개한 오픈소스를 최신 정보로 유지하려고 노력하고 있다. 그 소스들이 업데이트 됨에 따라 당신은 또 다른 PWA 구현의 참조로서 활용할 수 있다.

결론과 미래

트리보는 세상에 완벽한 응용 프로그램은 없다는 것을 알고 있기에, 사용자에게 제공되는 경험을 향상시키기 위해 많은 방법을 적극적으로 모색하고 있다. 그중 일부는 다음과 같다.

이미지 lazy loading

몇몇 분들은 network waterfall graph를 보고, 이미 웹 사이트 이미지 다운로드가 JS 다운로드와 대역폭을 두고 경쟁하고 있다는 것을 알아챘을 것이다.

image

이미지 다운로드는 브라우저가 img 태그를 파싱 하자마자 트리거 되므로 JS 다운로드 중에 대역폭을 공유한다. 간단한 해결 방법은 이미지가 사용자의 뷰포트에 들어올 때만 이미지를 lazy load 하는 것으로, time to interactive이 크게 개선될 것이다.

Lighthouse는 오프 스크린 이미지 감사에서 이러한 문제를 잘 찾아준다.

image

Dual Importing

트리보는 또한 나머지 부분들의 CSS(critical CSS를 인라인 하고 남은)를 비동기로 로드하지만, 앱 덩치가 커지면서 장기적으로는 사용자에게 노출되지도 않는다는 것을 알아챘다. 더 많은 기능과 경로는 더 많은 CSS를 필요로 하고, 쓰이지 않는 그 모두를 다운로드하면 대역폭을 많이 낭비하게 된다.

이 새로운 접근 방식을 사용하면, 모든 CSS가 DOMContentLoaded에 다운로드되는 이전 방식과 달리 두 개의 병렬 비동기 요청(JS 요청 하나, CSS 요청 하나)이 경로가 바뀔 때 발생한다. 사용자가 경로에 방문할 때 꼭 필요한 CSS만 다운로드하기 때문에 효과적이다.

A/B 테스트

트리보는 서버와 클라이언트 렌더링 동안 사용자 필요에 따라 변형된 것을 다운로드 시켜주기 위해 서버 사이드 렌더링과 code splitting에서 AB 테스트 방식을 구현하고 있다. (트리보는 자신들이 이것을 어떻게 해결하고 있는지 블로그 내용을 계속 게시할 것이다).

Eager Loading

트리보는 초기 페이지 로드시 앱의 모든 split chuck를 항상 로드하는 방식을 바꾸고 싶어 한다. 중요한 리소스 다운로드할 때 대역폭 경합을 피할 수 있을 테니 말이다. 또한 Service Worker로 캐싱 하지 않아 재방문시 발생하는 대역폭은, 특별히 모바일 사용자에게 귀중한 대역폭의 낭비이다. 만약 Consistently Interactive와 같은 지표를 트래보가 얼마나 잘 수행하고 있는지 살펴보면 여전히 개선의 여지가 많이 있다.

image

이것들은 트리보가 개선을 위해 실험하고 있는 영역이다. 하나의 예는 버튼의 ripple 애니메이션 동안 다음 경로의 chunk를 eager load 하는 것이다. Treebo를 클릭하면 webpack dynamic import()가 다음 경로의 chunk 항목을 호출하고, 그동안 setTimeout으로 경로 전환을 지연한다. 트리보는 또한 다음 경로의 chunk가 느린 3g 네트워크에서 주어진 400ms 타임아웃 내에 충분히 다운로드될 수 있도록 충분히 작게 만드는 노력을 하고 있다.

마치며

이 글을 쓰는 동안 재미있었다. 물론 더 많은 일이 있겠지만, 트래보의 성능개선 이야기가 여러분께 재미있었으면 좋겠다. 🙂 트위터에서 @addyosmani 와 @__lakshya(언더 스코어 2개 맞다)로 우리를 찾을 수 있으니 여러분의 생각을 들려주었으면 좋겠다.