Pinterest 프로그래시브 웹 앱 성능 케이스 스터디

원글: https://medium.com/dev-channel/a-pinterest-progressive-web-app-performance-case-study-3bd6ed2e6154

Pinterest는 새로이 Progressive Web App을 출시했다. 이 글은 Pinterest가 모바일 하드웨어에서도 빠르게 로드될 수 있도록 자바스크립트를 가볍게 하고, 네트워크 탄력성을 가질 수 있도록 서비스 워커(Service Worker)를 도입한 것에 대해 다루겠다.

Pinterest의 새로운 모바일 사이트를 확인하고 싶다면 휴대폰으로 https://pinterest.com를 확인하자.

왜 프로그래시브 웹 앱인가?

Pinterest는 해외 시장의 성장에 초점을 맞추었고, 모바일 웹을 만들면서 PWA를 시작했다.

사이트에 로그인하지 않은 사용자의 패턴을 분석해본 결과, Pinterest의 오래되고 느린 사이트는 1%만이 가입, 로그인하거나 모바일 앱 인스톨을 하고 있다는 점을 알게 되었다. 이 전환율을 개선할 잠재력은 어마어마했고, PWA에 투자하게 되었다.

3달안에 PWA만들어 내보내기

Pinterest는 3달간 React, Redux, 그리고 Webpack을 사용하여 새로운 모바일 웹 사이트로 재구성했다. 새로 단장한 모바일 웹 사이트는 몇몇 핵심적인 사업 수치를 긍정적으로 개선했다.

기존 모바일 웹 사이트에 비교해 사용자는 40% 오래 머물렀고, 이로 인한 광고 수익은 44% 증가했으며, 핵심 관여도(core engagement)는 60% 증가했다.

성능도 여러 측면 개선되었다.

일반적인 3G 모바일 하드웨어에서 빠르게 로딩하기

Pinterest의 이전 모바일 웹 사이트는 하나의 둔탁한 덩어리로 되어있었는데, 여기에는 Pin 페이지들이 얼마나 빠르게 로드되어 동작될 수 있을지를 결정할 CPU를 잡아먹는 거대한 자바스크립트 번들들이 포함되어있었다.

방문자들은 종종 어떤 UI도 동작하지 않는 페이지를 23초간 기다려야 했다.

Pinterest의 기존 모바일 사이트를 사용하기 위해서는 23초가 걸렸다. 2.5MB(메인 번들 ~1.5MB, 레이지 로딩하는 1MB) 자바스크립트를 다운로드 시켰는데, 메인 스레드가 이를 파싱하고 컴파일을 마쳐 화면이 동작하기 까지는 수 초의 시간이 필요했다.

자바스크립트를 자르고 수백 KB를 깎아내어 핵심 번들의 크기를 650KB에서 150KB로 줄였을 뿐 아니라, 주요 성능 지표도 개선했다. 첫 의미 있는 페인트(First Meaningful Paint)는 4.2초를 1.8초로, 화면이 동작하기까지 걸리는 시간(Time To Interactive)은 23초를 5.6초로 줄였다.

일반적인 3G 안드로이드 기준이며, 재방문 시에는 이보다 훨씬 빠르다.

Service Worker를 사용해 자바스크립트, CSS, 그리고 정적 UI 에셋들을 캐싱하여, 재방문 시 동작하기까지 걸리는 시간을 3.9초까지 떨어뜨릴 수 있었다.

Pinterest가 비록 iOS & Android 앱을 출시했지만, 네이티브 앱과 동일한 중요 홈 피드 페이지를 낮은 다운로드 비용(150KB 이하로 미니파이되고 압축되었다)으로 웹에서도 제공할 수 있게 되었다. 이는 동일한 페이지를 제공하는데 안드로이드에서 9.6MB, iOS에서 56MB가 필요한 것과 비교하면 확실히 눈에 띈다.

물론 이는 공평한 비교가 될 수는 없다. PWA는 새로운 경로가 요청됨에 따라 코드를 로드하고, 이 추가적인 코드 비용은 애플리케이션을 사용함에 따라 증가한다. 추가적으로 발생한 데이터 비용은 여전히 앱을 다운로드하는 것보다는 작다.

파이어폭스, 엣지, 사파리 모바일에서의 Pinterest 프로그래시브 웹 앱

경로 기반 자바스크립트 분할

방문자가 선제적으로 필요한 코드만 로드하도록 하면 동작하기까지 걸리는 시간을 줄일 수 있다. 이는 네트워크 전송과 자바스크립트 파싱/컴파일 시간을 줄인다. 핵심 리소스가 아닌 것들은 필요에 따라 나중에 로드(lazy load) 할 수 있다.

수 메가 바이트의 자바스크립트 번들을 3가지 카테고리 Webpack 조각들(chunk)로 쪼갰더니 꽤 괜찮았다.

  • vendor조각은 벤더 코드 조각(외부에서 가져온 라이브러리들: react, redux, react-router 등) 포함한다. ~ 73KB
  • entry조각은 앱을 그리는데 필요한(예를 들면 공통 라이브러리, 페이지의 골격, redux store) 대부분의 코드를 포함한다. ~ 72KB
  • async경로 조각은 각각의 경로에 속한 코드를 포함한다. ~ 13-18KB

위의 결과를 아래의 네트워크 디버깅 도구로 확인하자. 필요한 코드를 점진적으로 제공하는 방식으로의 전환은 거대한 번들 덩어리들이 필요치 않음을 잘 보여준다.

장기 캐싱을 위해, 각 파일 이름 마다 파일 내용에 따른 해시를 사용하기도 한다. 

벤더 번들(vendor bundle)은 Webpack의 CommonsChunkPlugin를 사용해 아래처럼 각자 캐쉬 될 수 있는 조각으로 나눈다.

코드 분할(code-splitting)을 추가하기 위해 React Router도 사용한다.

대상 브라우저에 맞는 트랜스파일만 하기 위해 babel-preset-env 사용하기

Pinterest는 레거시 브라우저는 지원하지 않기에, babel-preset-env을 사용하여 최신 브라우저에서 지원되지 않는 ES2015+ 문법만을 트랜스파일 한다. 최신 브라우저의 마지막 2개 버전 지원을 목표로 하며, .babelrc 설정은 아래와 같다.

조건에 따라 폴리필(polyfill)을 제공할 수 있도록 조금 더 최적화할 여지가 남아있다.(예를 들면 Internationalization API는 사파리 대상으로만 제공한다든지.) 물론 추후 개선될 것으로 계획되어 있다.

Webpack Bundle Analyzer를 써서 개선점 찾아내기

Webpack Bundle Analyzer는 방문자에게 보내는 코드에 무엇들이 들어있는지 파악하기 정말 좋은 도구이다.

아래는 Pinterest의 초기 빌드 결과인데, 많은 수의 보라, 분홍, 파란색 블록들이 보인다. 이 블록들은 경로에 따라 지연 로드(lazy load) 되는 비동기 코드 조각들이다. Webpack Bundle Analyzer는 이들 코드 조각들이 중복된 코드를 포함하고 있음을 보여준다.

Webpack Bundle Analyzer를 사용하면 문제가 되는 이 코드 조각들의 비율을 확인할 수 있다.

Pinterest는 이 중복 코드를 보고 나서 결정을 내릴 수 있었다. 중복 코드들을 비동기 코드 조각에서 메인 코드 조각으로 옮기기로. 이 결과로, 초기에 필요한 코드 조각은 20% 증가했지만, 지연 로드 되는 코드 조각들은 최대 90%까지 줄였다!

이미지 최적화

Pinterest에서 지연 로드되는 콘텐츠의 대부분은 Masonry Grid가 처리한다. Masonry Grid는 가상화를 통해 뷰포트 안에 있는 내용만 로드하는 기능을 가지고 있다.

Pinterest는 PWA에 이미지를 점진적(Progressive)으로 로딩하는 기술 또한 사용한다. 각 Pin마다 많이 쓰이는 색상으로 placeholder를 만든다. Pin 이미지는 Progressive JPEGs로 제공되는데, 매 스캔마다 이미지가 뚜렷해진다.

React의 성능 문제

Masonry grid를 React와 사용하면서 성능 문제가 나타났다. Pin 같은 거대한 컴포넌트들을 올리고 내리는 일은 꽤 느렸다. 아래서 보듯 Pin은 꽤 복잡한 컴포넌트다.

Pinterest를 개발할 당시 React 15.5.4를 사용하고 있었지만, React 16 (Fiber)가 컴포넌트를 DOM에서 제거하는 성능이 개선되길 기대했다. React 16을 기다리는 동안은 그리드를 가상화하여 컴포넌트 제거에 걸리는 시간을 개선했다.

초기 Pin들을 빠르게 계산하고 그려내기 위해 DOM에 추가될 Pin들의 수를 제한하기도 했다. 하지만 이는 전체적으로 기기의 CPU가 할 일이 더 많다는 뜻이기도 하다.

경로 전환

체감 성능을 올리기 위해 경로와 상관없이 선택된 내비게이션 바의 아이콘을 먼저 업데이트한다. 경로를 전환하면서 생기는 네트워크로 인한 느린 느낌을 없애줄 수 있다. 방문자는 데이터가 도착할 때까지 기다릴 필요 없이 빠르게 업데이트된 UI를 볼 수 있다.

Redux를 써보니

Pinterest는 모든 API 데이터를 스키마에 따라 깊이가 있는 JSON을 정규화(normalize) 해주는 normalizr를 사용한다. Redux DevTools를 사용하면 아래처럼 보인다.

이 방식은 역 정규화(정규화했던 데이터를 되돌리는 일: denormalization)가 느리기 때문에, 결국 이 일을 memoization 해주는 reselect를 쓸 수밖에 없다는 단점이 있다. 또한 대규모의 재-랜더링을 막기 위해, 항상 가능한 말단의 데이터 단위로 처리했다.

예를 들면, 이 grid 아이템 리스트는 단지 Pin 아이디들이며, Pin 컴포넌트는 자체적 역 정규화한다. 어떠한 하나의 Pin이 변경되더라도, 전체 그리드를 새로이 랜더링 할 필요는 없다. 이 때문에 아주 많은 Redux subscriber가 생겨나긴 하지만, 딱히 눈에 보일만한 성능 문제를 야기하지는 않는다.

Service Worker로 에셋 캐싱하기

Pinterest는 Service Worker들을 생성하고 관리하기 위해 Workbox 라이브러리들을 사용한다.

cache-first 전략을 사용해 모든 JavaScript, CSS 번들을 캐싱 한다. application shell을 사용하여 사용자 인터페이스 또한 캐싱 한다.

cache-first설정은, 요청이 캐시 되어 있으면 그것을 응답으로 한다. 캐시에 없는 경우 네트워크에서 리소스를 가져오려 시도한다. 네트워크 요청이 성공했다면 캐시를 업데이트한다. Service Worker의 캐싱 전략에 대해 더 알고 싶다면 Jake Archibald’s Offline Cookbook를 읽자.

application shell이 로드하는 초기 번들(webpack runtime, 벤더 코드 조각들, 초기에 필요한 코드 조각들) 또한 선재 캐싱 한다.

Pinterest는 다국어 지원을 하는 세계적인 사이트인 만큼, 지역별로 번들을 미리 캐싱 하기 위해 지역 단위 Service Worker 설정을 만든다. 최상위 비동기 경로 번들을 미리 캐싱 하기 위해 webpack named chunk 역시 사용한다.

이 작업은 여러차례 작은 단위로 적용되었다.

  • Pinterest의 첫 번째 Service Worker는 단지 요청에 따른 지연 로드 스크립트만 실시간 캐싱 할 뿐이었다. 이는 V8’s code caching으로 반복적인 화면을 빠르게 로드하기 위해서이다. Service Worker가 있는 Cache Storage에서 제공되는 스크립트들은 쉽게 코드 캐싱에 등록될 수 있다. 이 리소스들이 반복적인 화면에서 쓰인다는 것을 브라우저가 알아챌 가능성이 높기 때문이다. 
  • 다음으로 벤더 코드 조각과 초기 코드 조각들을 선재 캐싱 했다.
  • 그리고 가장 자주 방문되는 경로를 선재 캐싱 했다.(홈페이지, pin 페이지, 검색 페이지 처럼)
  • 마지막으로 지역별 번들을 캐싱 할 수 있도록 각 지역에 맞는 Service Worker를 생성 했다. 이는 로딩 성능을 위해서뿐만 아니라, 대부분의 방분자에게 기본적인 오프라인 렌더링을 지원하기 위해 중요한 일이다.

Application Shell에 도전

application shell을 구현하는 일은 좀 까다로웠다. 데스크톱이 주류를 이루는 시기에 케이블 연결을 통해 얼마나 데이터를 보낼 수 있는지 추정한 것 때문에, 초기 페이로드는 사용자 실험 그룹, 사용자 정보, 문맥 정보 따위의 별로 중요하지 않은 많은 정보들을 포함하느라 거대했다.

“이것들을 application shell에 캐싱 해야 하나? 아니면 이것들을 가져오기 위한 렌더링이 이루어지기 전까지 네트워크 요청을 차단하는 성능 제한을 감수해야 하나?”라는 고민을 해야만 했다.

방문자가 로그아웃할 때나 사용자 정보를 업데이트할 때처럼 app shell을 무효화할 시점에 관리가 필요하지만, Pinterest는 이 정보를 application shell에 캐싱 하기로 결정했다. 매 요청과 응답에 appVersion을 추가해, 만약 app version이 변경되면 Service Worker를 등록 해지시키고, 새로운 Service Worker를 등록한다. 새로운 Service Worker를 등록하면, 경로 변경 시 전체 페이지를 새로 로드한다.

이 정보를 application shell에 추가하는 일은 좀 까다로웠지만, 렌더링을 막는 요청은 피하는 쪽이 좋다.

Lighthouse로 감사 실행하기

Pinterest는 성능 개선이 제 궤도에 이르고 있는지 검증하기 위해 한 차례씩 Lighthouse를 썼다. 이는 Time to Consistently Interactive 같은 수치를 지속적으로 모니터링하기 유용한 도구다.

이듬해엔 Lighthouse를 페이지가 여전히 빠르게 로드되는지 회귀(regression) 방식으로 검증할 수 있길 기대하고 있다.

이후엔

Pinterest just deployed support for Web Push notifications and have also been working on the unauthenticated (logged-out) experience for their PWA.

Pinterest는 최근 Web Push notification 기능을 배포했다. 또한 로그인하지 않는 경우도 PWA를 적용하는 작업을 하고 있다.

핵심적인 번들을 미리 로드하고 첫 방문 시 로드되는 JavaScript 중 안 쓰이는 코드를 줄이기 위해 <link rel=preload> 도입을 검토하고 있다. 향후 있을 이들의 멋진 성능 개선 작업을 기대하시라!

Pinterest의 프로그래시브 웹 앱을 배포하고 이 글의 자료를 제공해준 Zack Argyle, YenWei Liu, Luna Ruan, Victoria Kwong, Imad Elyafi, Langtian Lang, Becky Stoneman 그리고 Ben Finkel에게 축하의 말을 전한다. 이 글을 리뷰해준 Jeffrey Posnick 와 Zouhir는 고마워요.

KyuWoo Choi
FrontEnd Developer
Frontend Developer Skilled in OpenSource and JavaScript. Experienced in Web, Android, Mobile Game development with Java, C# languages as well. Please check my current works on GitHub.

댓글 남기기

This site uses Akismet to reduce spam. Learn how your comment data is processed.