프로그레시브 웹 앱 (PWA, Progressive Web Apps ) 도입사례(1) : Building m.uber : 세계 시장을 위한 고성능 웹 응용 프로그램 엔지니어링

개요

Uber 의 PWA 는 2G에서도 빠르게 작동하도록 설계되었습니다. 핵심 응용프로그램은 Gk로 50k에 불과하며 2G 네트워크에 로드하는데 3초도 걸리지 않습니다.

상세

Building m.uber : 세계 시장을 위한 고성능 웹 응용 프로그램 엔지니어링(Building m.uber: Engineering a High-Performance Web App for the Global Market) 에서 번역 발췌하였습니다. 

( 이미지 출처 : Building m.uber: Engineering a High-Performance Web App for the Global Market

Uber가 새로운 시장으로 확장함에 따라 모든 사용자가 위치, 네트워크 속도 및 장치에 관계없이 신속하게 승차를 요청(request a ride)할 수 있습니다. 이를 염두에 두고 Google은 웹 클라이언트를 처음부터 native mobile app(기본 모바일 응용 프로그램)의 실행 가능한 대안으로 재 구축했습니다.

모든 최신 브라우저와 호환되는 m.uber 는 기본 클라이언트가 지원하지 않는 기기를 포함하여 로우 엔드 기기(low-end devices, 2G를 이용하는 저사양의 장치들)를 사용하는 라이더에게 앱과 유사한 사용환경(an app-like experience)을 제공합니다. 응용 프로그램도 매우 작습니다. 코어 라이드 요청 응용 프로그램은 50kB에서 제공되므로 2G 네트워크에서도 응용 프로그램을 빠르게 로드 할 수 있습니다.

이 기사에서는 m.uber (moo-ber라고 발음)를 제작하고 초경량 웹 앱에서 네이티브 앱 환경을 구현하는 방법에 대해 설명합니다.

 

m.uber는 기본 Uber 앱 흐름을 모방하여 라이더가 승차 요청을 지정하고 일치 한 후 드라이버 위치를 추적 할 수있게합니다.

( 이미지 출처 : Building m.uber: Engineering a High-Performance Web App for the Global Market

 

 

작고 빠름 : 우리가 만든 방법

m.uber는 ES2015 +로 작성되었으며 Babel for ES5 transpilation을 사용합니다. 가장 중요한 디자인 과제는 기본 앱의 풍부한 경험을 유지하면서 클라이언트의 공간을 최소화하는 것이었습니다. 따라서 우리의 전통적인 아키텍처가 React ( Redux 사용 ) 및 Browserify를 사용 하여 모듈 번들링을 수행하는 동안 우리는 Preact 에서 크기 이점을, Webpack 은 동적 번들 분할 및 트리 – 떨림 기능( tree-shaking capabilities )을 대신했습니다 . 아래에서는 애플리케이션 아키텍처 전반에서 이러한 문제와 다른 문제를 어떻게 해결했는지 설명합니다.

초기 서버 렌더링

클라이언트는 모든 핵심 자바 스크립트 번들을 다운로드 할 때까지 마크 업 렌더링을 시작할 수 없으므로 m.uber는 Preact를 서버에 렌더링하여 초기 브라우저 요청에 응답합니다. 결과 상태 및 마크 업은 서버 응답의 문자열로 인라인되어 콘텐츠가 거의 즉시 로드됩니다.  

주문형 번들 제공

m.uber의 목표는 사용자가 최대한 빨리 주행을 요청할 수 있도록하는 것이지만 JavaScript의 대부분은 지불 옵션 업데이트, 여행 진행 상황 확인 또는 설정 편집과 같은 부수적인 작업을 위한 것입니다. 우리가 필요로 하는 자바 스크립트만을 제공하기 위해, 코드 분할을 위해 Webpack을 사용합니다.

비동기 구성 요소에 래핑 된 보조 번들을 반환하는 splitPage 함수를 사용합니다 . 예를 들어 설정 페이지는 아래 함수에 의해 호출됩니다.

const AsyncSettings = splitPage (
 {load : () => import ( ‘../../screen/ settings’)} 
);

이 함수를 사용하면 AsyncSettings 가 부모 렌더링 메서드에 조건부로 포함된 경우에만 설정 번들을 가져올 수 있습니다 . 아주 느린 연결의 경우, AsyncSettings 는 번들 페치가 완료 될 때까지 “로드 중” 모달을 렌더링합니다.

작은 라이브러리들(Libraries : 소프트웨어 개발에 쓰이는 하부 프로그램들의 모임)

m.uber는 2G 네트워크에서도 빠르게 설계되므로 클라이언트 크기가 중요합니다. 우리의 핵심 어플리케이션 (당신이 타고 요청할 수있는 애플 리케이션의 필수 부분)은 단지 2GB (250kB / s, 300ms 대기 시간) 네트워크에서 상호 작용하는 3 초의 시간이 걸리고 gzip 및 minified 된 단지 50kB로 제공됩니다. (Our core app (the essential part of the app that allows you to request a ride) comes in at just 50kB gzipped and minified, which means a three second time to interaction on typical 2G (250kB/s, 300ms latency) networks.)

아래에서 저희는 m.uber 프로젝트 시작 시점과 현재의 벤더 번들 크기와 종속성의 차이를 강조하여 표시하였습니다. 

m.uber 공급 업체 번들 크기 및 종속성 수는 프로젝트가 시작되었을 때보 다 훨씬 적습니다.

( 이미지 출처 : Building m.uber: Engineering a High-Performance Web App for the Global Market

 

반응을 통해 프리 액션(Preact over React)

크기면에서 React (45kB)보다 Preact (3kB GZip / minified)를 선택했습니다. Preact는 React가 하는 거의 모든 작업을 수행 할 수 있으며 PropTypes 또는 합성 이벤트를 지원하지 않으며 자체의 몇 가지 멋진 리플렉션 기능을 추가합니다. Preact는 구성 요소와 요소를 재활용 할 때 조금 지나치게 열중합니다 ( 그러나 이 요소로 작업하고 있습니다 ). 이는 예상하지 못한 요소에 대해 키를 정의해야한다는 것을 의미합니다. 그렇지 않으면 우리의 요구에 잘 맞습니다.

최소 종속성(Minimal Dependencies)

의존성이 커지는 것을 막기 위해 우리는 클라이언트에서 사용되는 npm 패키지에 대해 선택적이었습니다. Just 와 같은 라이브러리를 사용하는 모듈은 하나의 함수만 담당하고 종속성은 없습니다. 값 비싼 데이터 변환을 서버에 한정하여 Moment 와 같은 무거운 모듈 을 다운로드 할 필요가 없음을 발견했습니다. 종속성의 원인을 확인하기 위해 source-map-explorer 와 같은 도구를 많이 사용했습니다 .

조건부 기능 세트(Conditional Feature Set, 여기서 Feature 는 2G phone중 피처폰을 의미합니다.)

m.uber의 사명은 모든 사람에게 어디에서나 쉽게 장치를 요청하고 장치 및 네트워크에서 추가 기능을 제공할 수있는 기능을 제공하는 것입니다. 

window.performance API를 사용하여 첫 번째 상호 작용을 하는 시간을 감지하고 결과에 따라 상호작용하는 지도 환경을 숨기거나 로드합니다. 네트워크성능을 감지할 수없는 사용자의 설정 페이지에서 (상호작용하는) 지도를 켜고 끌 수 있습니다.

 

=================================================================================================================

PWA을도입하면 어떤 이익을 얻을 수 있는지 또는 강점이 무엇인지 알아보기 위해 이 게시글을 시작하였는데 너무 기술적인 요소들이 설명되고 있어 더 이상 번역하지 않았습니다.

구글 번역기로 돌린 내용과 원문을 같이 표시하였습니다. 이하의 내용이 궁금하신 분들은 추가로 더 알아보시고 저는 여기서 글을 마무리하겠습니다. 

 =================================================================================================================

최소 렌더링 호출

Preact ( React 와 같은 )는 변경이 발생하면 VDOM을 사용하여 새 마크 업을 생성하지만 이는 렌더링 호출 이 무료 라는 것을 의미하지 않습니다 . 아무 일도 일어나지 않을 것을 알기 위해 렌더링 을 위한 많은 JavaScript 잡담이 필요합니다. 우리는 shouldComponentUpdate를 광범위하게 사용 하여 렌더링 호출을 최소화합니다 .

캐싱

서비스 워커(Service Workers)

서비스 작업자는 URL 요청을 차단하여 일반적으로 브라우저의 Cache API를 활용하는 사용자 정의 가져 오기 논리로 네트워크 및 로컬 디스크 가져 오기를 대체 할 수 있습니다 . 서비스 담당자는 JavaScript 번들뿐만 아니라 초기 HTML 응답을 캐싱하여 m.uber가 간헐적으로 네트워크가 손실되는 경우에도 컨텐츠를 계속 제공 할 수 있습니다.

서비스 작업자도로드 시간을 크게 줄일 수 있습니다. 디스크 I / O 성능은 운영 체제 및 장치에 따라 크게 다르며 대부분의 경우 디스크 캐시에서 데이터를 가져 오는 경우조차도 느려집니다 . 서비스 작업자가 지원되는 경우 HTML을 포함한 모든 다시 가져온 콘텐츠가 브라우저 캐시에서 직접 가져와 페이지를 즉시 다시로드 할 수 있습니다.

m.uber 클라이언트는 각 빌드 후에 새 서비스 작업자를 설치합니다. WebPack은 동적 번들 이름을 생성하기 때문에 빌드 프로세스는 새로운 이름을 서비스 작업자 모듈에 직접 작성합니다. 설치시 핵심 자바 스크립트 라이브러리를 캐싱 한 다음 HTML 및 보조 JavaScript 번들을 가져 오는 동안 지연 캐시합니다.

로컬 저장소

서비스 작업자가 너무 변동하는 응답 데이터를 캐시해야하는 경우 브라우저의 로컬 저장소에 저장합니다. m.uber는 몇 초마다 주행 상태를 폴링합니다. 라이더가 앱으로 돌아 왔을 때 로컬 저장 공간에 최신 상태 데이터를 유지하면 API 왕복을 기다리지 않고도 신속하게 페이지를 다시 렌더링 할 수 있습니다. 상태 데이터가 작고 저장된 데이터 크기가 한정되어 있으므로 스토리지 업데이트가 빠르고 안정적이며 궁극적으로 indexedDB 와 같은 비동기 로컬 스토리지 API를 사용할 필요가 없음을 알게되었습니다 .

스타일링

스티 틀론

스타일은 각 구성 요소 내에서 JavaScript 객체로 정의됩니다. 구성 요소가 렌더링되면 Styletron 은 이러한 정의에서 스타일 시트를 동적으로 생성합니다. 구성 요소로 스타일을 배치하면 쉽게 번들을 분할하고 스타일을 비동기 적으로로드 할 수 있습니다. 사용되지 않는 CSS는 절대로드되지 않습니다.

Styletron은 각 고유 한 규칙에 대한 원자 스타일 시트를 작성하여 스타일 선언을 중복 제거하여 최소한의 CSS 런타임 및 최상급 렌더링 성능을 제공 합니다. 우리는 m.uber의 모든 컴포넌트 레벨 CSS 생성에 Styletron을 사용합니다.

SVGs

공간을 절약하기 위해 가능할 때마다 아이콘과 같은 이미지에 SVG 형식을 사용 하고 render 메소드 에서 인라인합니다 . 튜닝을 위해 수동 최적화와 함께 SVGO 를 사용하여 경로를 더욱 단축했습니다. 때로는 폴리선을 기본 모양으로 대체 할 수 있었고, 경로의 값 비싼 십진수를 피하기 위해 적절한 제수로 뷰 상자 치수를 사용했습니다.

전반적인 앱 크기에 대한이 전략의 영향은 중요합니다. 예를 들어 로고 크기를 7.4kB (png)에서 500 바이트 (튜닝 된 SVG)로 줄였습니다.

글꼴

크기와 색상을 현명하게 사용하면 시각적 디자인을 크게 손상시키지 않고 사용자 정의 글꼴을 완전히 제거 할 수있었습니다.

Minimal render Calls

Preact (like React) uses a VDOM to generate new markup when a change occurs, but that does not mean calling render is free. It takes a lot of JavaScript chatter for render to figure out that nothing needs to happen. We use shouldComponentUpdate extensively to minimize calls to render.

Caching

Service Workers

Service workers intercept URL requests, enabling network and local disk fetches to be replaced by custom fetch logic, which typically leverages the browser’s Cache API. By caching the initial HTML response as well as JavaScript bundles, service workers allow m.uber to continue to serve content in the event of intermittent network loss.

Service workers can also significantly decrease load times. Disk I/O performance varies greatly across operating systems and devices, and in many cases, even fetching data from disk cache is frustratingly slow. Where service workers are supported, all re-fetched content (including HTML) comes directly from the browser cache, enabling pages to reload immediately.

m.uber clients install a new service worker after each build. Since WebPack generates dynamic bundle names, our build process writes new names directly to the service worker module. On install, we cache our core JavaScript libraries then lazily cache HTML and ancillary JavaScript bundles as they are fetched.

Local Storage

Where we need to cache response data that is too volatile for service workers, we save it to the browser’s local storage. m.uber polls for the ride status every few seconds; keeping the latest status data in local storage means when a rider returns to the app, we can quickly re-render their page without waiting for a round trip to the API. Since our status data is small and the stored data size is finite, storage updates are fast and reliable, and we ultimately found that we did not need to use an asynchronous local storage API like indexedDB.

Styling

Styletron

Styles are defined as JavaScript objects within each component. When a component is rendered, Styletron dynamically generates stylesheets from these definitions. Colocation of styles with components allows for easy bundle splitting and asynchronous loading of styles. CSS that is not used is never loaded.

Styletron de-duplicates style declarations by creating an atomic stylesheet for each unique rule, allowing for a minimal CSS runtime and best-in-class rendering performance. We use Styletron for all component-level CSS generation on m.uber.

SVGs

To save on space, we use the SVG format for icon-like images whenever possible, and inline them in the render method. For tuning, we used SVGO together with manual optimizations to further shorten the paths. Sometimes, we were able to replace polylines with basic shapes, and we used view box dimensions with suitable divisors to avoid expensive decimals in paths.

The impact of this strategy on overall app size is significant; for example, we reduced our logo size from 7.4kB (png) to 500 bytes (tuned SVG).

Fonts

With judicious use of size and color we found we were able to entirely eliminate custom fonts, without significantly compromising the visual design.

오류 처리

희박한 기술 스택이 항상 쉬운 오류 진단에 도움이되는 것은 아니기 때문에 다음과 같은 도움을주는 간단한 도구를 추가했습니다.

  • 무겁고, 페그가 아닌 에러 모니터링 라이브러리를 사용하는 대신 server.onerror 를 확장 하여 서버의 클라이언트 오류 기자에게 오류를 게시했습니다.
  • 우리는 Preact의 render 와 shouldComponentUpdate를 래핑하여 재귀 적 라이프 사이클 메소드 오류를 단락 시켰다 .
  • 우리 설계에서, CORN (Cross-Origin Resource Sharing) 헤더에 의해 적절한 사용 권한이 제공되지 않는 한, CDN 호스트 파일에서 던진 오류는 window.onerror에 유용한 데이터를 제공하지 않습니다 . 그러나 이러한 헤더가있는 경우에도 비동기 이벤트 중에 발생하는 오류를 상위 모듈로 추적 할 수 없으므로 window.onerror 는 어둡게 남아 있습니다. try / catch를 통해 부모 모듈에 오류를 전달할 수 있도록 모든 이벤트 리스너를 래핑했습니다.

 

Error Handling

A lean tech stack is not always conducive to easy error diagnosis, so we added some lightweight tooling to help, for instance:

  • Instead of using a hefty, off-the-peg error monitoring library, we extended window.onerror to post errors to a client-error reporter on the server.
  • We short-circuited recursive lifecycle method errors by wrapping Preact’s render and shouldComponentUpdate.
  • In our design, errors thrown by a CDN-hosted file will not provide useful data to window.onerrorunless the appropriate permissions are provided by cross-origin resource sharing (CORS) headers. Even with such headers, however, errors thrown during an asynchronous event cannot be traced back to the parent module and so window.onerror will remain in the dark. We wrapped all event listeners to allow errors to be passed to the parent module via try/catch.

 

다음 단계

m.uber와의 공동 작업을 통해 우리는 성능 기준에 맞는 패키지에서 기본 앱과 유사한 경험을 만드는 데 많은 노력을 기울였지만 아직 완료되지 않았습니다. 개선의 기회는 여전히 많습니다. 다음 달에는 추가 최적화 계획을 발표 할 예정입니다.

  • 구성 요소가 프리미티브 및 배열 소품의 플랫 콜렉션만 허용함으로써 렌더링 호출을 최소화하기 위한 전략을 공식화합니다. 이것은 우리가 사용할 수 있도록 모두 React.pureComponent (자동으로 구현 shouldComponentUpdate을  얕은 소품 비교하여)를하고 렌더링 대신 논리와 다른 접선 작업을 분기의 마크 업 세대에 초점을 맞춥니 다. 병합된 프리미티브에 대한 API 응답을 서버 로직 ( normalizr 참조 ) 및 / 또는 mapStateToProps 에 적절하게 위임 할 수 있습니다 .
  • 번들 분리를보다 직관적으로 만드는 동작과 축소기를 결합합니다.
  • 모든 요청에 HTTP / 2 를 사용 하고 폴링 API를 푸시 알림으로 바꿉니다.

또한 m.uber의 인프라 조각을 오픈 소스 아키텍처로 추상화하여 향후 Uber 웹 애플리케이션의 기반이 될 것입니다. 

 

Next Steps

Through our work with m.uber, we have put a lot of effort into creating a native, app-like experience in a performant package, but we are not finished—there are still plenty of opportunities for improvement. In the coming months, we are planning on releasing additional optimizations, including:

  • Formalizing a strategy for minimizing render calls by having components only accept a flat collection of primitives and array props. This will both allow us to use React.pureComponent (which automatically implements shouldComponentUpdate with a shallow prop comparison) and render to focus on markup generation instead of branching logic and other tangential tasks. Transforming API responses to flattened primitives can be delegated to server logic (see normalizr) and/or mapStateToProps as appropriate.
  • Combining actions and reducers, which would make bundle separation more intuitive.
  • Using HTTP/2 for all requests and replace polling APIs with Push notifications.

Additionally, we are abstracting the infrastructure pieces from m.uber into an open source architecture which will serve as the foundation for future lightweight Uber web apps—stay tuned for an upcoming article on this topic.