
요약
웹 구성요소, Polymer, Material Design을 사용하여 단일 페이지 앱을 빌드하고 Google.com에서 프로덕션으로 출시한 방법을 알아보세요.
결과
- 네이티브 앱보다 높은 참여도 (모바일 웹 4분 6초, Android 2분 40초)
- 서비스 워커 캐싱을 통해 재방문자의 첫 페인트가 450ms 빨라짐
- 방문자의 84% 가 서비스 워커를 지원함
- 2015년 대비 홈 화면에 추가 저장이 900% 증가했습니다.
- 3.8% 의 사용자가 오프라인 상태였지만 11,000회의 페이지 조회수를 계속 생성했습니다.
- 로그인한 사용자의 50% 가 알림을 사용 설정했습니다.
- 사용자에게 536,000개의 알림이 전송되었으며, 이 중 12% 가 다시 돌아왔습니다.
- 사용자 브라우저의 99% 가 웹 구성요소 폴리필을 지원함
개요
올해는 'IOWA'라는 애칭으로 불리는 Google I/O 2016 프로그레시브 웹 앱을 작업할 수 있었습니다. 모바일에 최적화되어 있으며 완전히 오프라인에서 작동하며 Material Design에서 많은 영감을 받았습니다.
IOWA는 웹 구성요소, Polymer, Firebase를 사용하여 빌드된 단일 페이지 애플리케이션 (SPA)으로, App Engine (Go)으로 작성된 광범위한 백엔드를 보유하고 있습니다. 서비스 워커를 사용하여 콘텐츠를 미리 캐시하고, 새 페이지를 동적으로 로드하고, 뷰 간에 원활하게 전환하며, 첫 번째 로드 후 콘텐츠를 재사용합니다.
이 사례에서는 프런트엔드에 대해 내린 몇 가지 더 흥미로운 아키텍처 결정을 살펴보겠습니다. 소스 코드에 관심이 있으면 GitHub에서 확인하세요.
웹 구성요소를 사용하여 SPA 빌드
모든 페이지를 구성요소로 표시
프런트엔드의 핵심 측면 중 하나는 웹 구성요소를 중심으로 한다는 점입니다. 실제로 SPA의 모든 페이지는 웹 구성요소입니다.
<io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
<io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
<io-attend-page></io-attend-page>
<io-extended-page></io-extended-page>
<io-faq-page></io-faq-page>
변경하는 이유는 무엇인가요? 첫 번째 이유는 이 코드를 읽을 수 있기 때문입니다. 처음 읽는 사람에게는 앱의 모든 페이지가 무엇인지 완전히 명확합니다. 두 번째 이유는 웹 구성요소에 SPA를 빌드하기 위한 몇 가지 유용한 속성이 있기 때문입니다. <template>
요소, 맞춤 요소, Shadow DOM의 고유한 기능 덕분에 많은 일반적인 불편함 (상태 관리, 뷰 활성화, 스타일 범위 지정)이 사라집니다. 브라우저에 내장된 개발자 도구입니다. 이 혜택을 활용해 보세요.
각 페이지에 맞춤 요소를 만들면 다음과 같은 이점이 있습니다.
- 페이지 수명 주기 관리
- 페이지에만 적용되는 범위 지정된 CSS/HTML
- 페이지에 특화된 모든 CSS/HTML/JS가 번들로 묶여 필요에 따라 함께 로드됩니다.
- 뷰는 재사용할 수 있습니다. 페이지는 DOM 노드이므로 페이지를 추가하거나 삭제하기만 하면 뷰가 변경됩니다.
- 향후 유지보수자는 마크업을 이해하기만 하면 앱을 이해할 수 있습니다.
- 서버에서 렌더링된 마크업은 브라우저에서 요소 정의를 등록하고 업그레이드함에 따라 점진적으로 개선될 수 있습니다.
- 맞춤 요소에는 상속 모델이 있습니다. DRY 코드는 좋은 코드입니다.
- …등등 다양한 기능이 있습니다.
IOWA에서는 이러한 이점을 최대한 활용했습니다. 자세히 살펴보겠습니다.
페이지 동적 활성화
<template>
요소는 재사용 가능한 마크업을 만드는 브라우저의 표준 방법입니다. <template>
에는 SPA에서 활용할 수 있는 두 가지 특성이 있습니다. 먼저 <template>
내의 모든 항목은 템플릿 인스턴스가 생성될 때까지 비활성 상태입니다. 둘째, 브라우저가 마크업을 파싱하지만 기본 페이지에서 콘텐츠에 연결할 수 없습니다. 재사용 가능한 마크업 덩어리입니다. 예를 들면 다음과 같습니다.
<template id="t">
<div>This markup is inert and not part of the main page's DOM.</div>
<img src="profile.png"> <!-- not loaded by the browser -->
<video id="vid" src="vid.mp4"></video> <!-- doesn't load/start -->
<script>alert("Not run until the template is stamped");</script>
</template>
Polymer는 몇 가지 유형 확장 맞춤 요소(<template is="dom-if">
및 <template is="dom-repeat">
)를 사용하여 <template>
를 확장합니다. 둘 다 추가 기능으로 <template>
를 확장하는 맞춤 요소입니다. 웹 구성요소의 선언적 특성 덕분에 두 방법 모두 예상대로 작동합니다.
첫 번째 구성요소는 조건문을 기반으로 마크업을 스탬프합니다. 두 번째는 목록 (데이터 모델)의 모든 항목에 마크업을 반복합니다.
IOWA는 이러한 유형 확장 요소를 어떻게 사용하고 있나요?
기억하시겠지만 IOWA의 모든 페이지는 웹 구성요소입니다. 하지만 첫 로드 시 모든 구성요소를 선언하는 것은 어리석은 일입니다. 즉, 앱이 처음 로드될 때 모든 페이지의 인스턴스를 만들어야 합니다. 특히 일부 사용자는 1~2페이지로만 이동하므로 초기 로드 성능을 저하시키고 싶지 않았습니다.
해결 방법은 속이는 것이었습니다. IOWA에서는 각 페이지의 요소를 <template is="dom-if">
로 래핑하여 첫 부팅 시 콘텐츠가 로드되지 않도록 합니다. 그런 다음 템플릿의 name
속성이 URL과 일치하면 페이지가 활성화됩니다. <lazy-pages>
웹 구성요소가 이 모든 로직을 처리합니다. 마크업은 다음과 같습니다.
<!-- Lazy pages manages the template stamping. It watches for route changes
and sets `template.if = true` on the appropriate template. -->
<lazy-pages>
<template is="dom-if" name="home">
<io-home-page date="2016-05-18T17:00:00Z"></io-home-page>
</template>
<template is="dom-if" name="schedule">
<io-schedule-page date="2016-05-18T17:00:00Z"></io-schedule-page>
</template>
<template is="dom-if" name="attend">
<io-attend-page></io-attend-page>
</template>
</lazy-pages>
이 방법의 장점은 모든 페이지가 페이지가 로드될 때 파싱되고 실행 준비가 되지만 CSS/HTML/JS는 상위 <template>
가 스탬프될 때만 주문형으로 실행된다는 점입니다. 웹 구성요소를 사용하는 동적 + 지연 뷰가 최고입니다.
향후 개선사항
페이지가 처음 로드될 때 각 페이지의 모든 HTML 가져오기가 한 번에 로드됩니다. 요소 정의가 필요할 때만 지연 로드하는 것이 좋습니다. Polymer에는 HTML 가져오기를 비동기식으로 로드하는 유용한 도우미도 있습니다.
Polymer.Base.importHref('io-home-page.html', (e) => { ... });
IOWA는 a) 게으름을 피우고 b) 성능이 얼마나 향상될지 확실하지 않기 때문에 이렇게 하지 않습니다. 첫 번째 페인트는 이미 1초 정도였습니다.
페이지 수명 주기 관리
Custom Elements API는 구성요소의 상태를 관리하기 위한 '수명 주기 콜백'을 정의합니다. 이러한 메서드를 구현하면 구성요소의 수명 주기에 대한 무료 후크를 사용할 수 있습니다.
createdCallback() {
// automatically called when an instance of the element is created.
}
attachedCallback() {
// automatically called when the element is attached to the DOM.
}
detachedCallback() {
// automatically called when the element is removed from the DOM.
}
attributeChangedCallback() {
// automatically called when an HTML attribute changes.
}
IOWA에서 이러한 콜백을 쉽게 활용할 수 있었습니다. 모든 페이지는 독립형 DOM 노드입니다. SPA에서 '새 뷰'로 이동하는 것은 한 노드를 DOM에 연결하고 다른 노드를 삭제하는 문제입니다.
attachedCallback
를 사용하여 설정 작업 (init 상태, 이벤트 리스너 연결)을 실행했습니다. 사용자가 다른 페이지로 이동하면 detachedCallback
가 정리 작업 (리스너 삭제, 공유 상태 재설정)을 실행합니다. 또한 자체 수명 주기 콜백을 몇 가지 자체 콜백으로 확장했습니다.
onPageTransitionDone() {
// page transition animations are complete.
},
onSubpageTransitionDone() {
// sub nav/tab page transitions are complete.
}
이러한 기능은 작업을 지연하고 페이지 전환 간의 버벅거림을 최소화하는 데 유용했습니다. 이건 나중에 다시 설명하죠
페이지 전반에서 공통 기능을 DRY 처리
상속은 맞춤 요소의 강력한 기능입니다. 웹에 표준 상속 모델을 제공합니다.
안타깝게도 Polymer 1.0은 이 글을 작성하는 시점에 요소 상속을 아직 구현하지 않았습니다. 그동안 Polymer의 동작 기능도 마찬가지로 유용했습니다. 동작은 믹스인일 뿐입니다.
모든 페이지에 동일한 API 노출 영역을 만드는 대신 공유 믹스인을 만들어 코드베이스를 DRY-up하는 것이 좋습니다. 예를 들어 PageBehavior
는 앱의 모든 페이지에 필요한 공통 속성/메서드를 정의합니다.
PageBehavior.html
let PageBehavior = {
// Common properties all pages need.
properties: {
name: { type: String }, // Slug name of the page.
...
},
attached() {
// If the page defines a `onPageTransitionDone`, call it when the router
// fires 'page-transition-done'.
if (this.onPageTransitionDone) {
this.listen(document.body, 'page-transition-done', 'onPageTransitionDone');
}
// Update page meta data when new page is navigated to.
document.body.id = `page-${this.name}`;
document.title = this.title || 'Google I/O 2016';
// Scroll to top of new page.
if (IOWA.Elements.Scroller) {
IOWA.Elements.Scroller.scrollTop = 0;
}
this.setupSubnavEffects();
},
detached() {
this.unlisten(document.body, 'page-transition-done', 'onPageTransitionDone');
this.teardownSubnavEffects();
}
};
IOWA.IOBehaviors = IOWA.IOBehaviors || {PageBehavior: PageBehavior};
보시다시피 PageBehavior
는 새 페이지가 방문될 때 실행되는 일반적인 작업을 실행합니다. document.title
업데이트, 스크롤 위치 재설정, 스크롤 및 하위 탐색 효과에 대한 이벤트 리스너 설정 등이 여기에 해당합니다.
개별 페이지는 PageBehavior
를 종속 항목으로 로드하고 behaviors
를 사용하여 PageBehavior
를 사용합니다.
필요한 경우 기본 속성/메서드를 재정의할 수도 있습니다. 예를 들어 다음은 홈페이지 'subclass'가 재정의하는 내용입니다.
io-home-page.html
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="PageBehavior.html">
<!-- rest of the import dependencies used by the page. -->
<dom-module id="io-home-page">
<template>
<!-- PAGE'S MARKUP -->
</template>
<script>
Polymer({
is: 'io-home-page',
behaviors: [IOBehaviors.PageBehavior], // All pages have common functionality.
// Pages define their own title and slug for the router.
title: 'Schedule - Google I/O 2016',
name: 'home',
// The home page has custom setup work when it's added navigated to.
// Note: PageBehavior's attached also gets called.
attached() {
if (this.app.isPhoneSize) {
this.listen(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
}
},
// The home page does its own cleanup when a new page is navigated to.
// Note: PageBehavior's detached also gets called.
detached() {
this.unlisten(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
},
// The home page can define onPageTransitionDone to do extra work
// when page transitions are done, and thus preventing janky animations.
onPageTransitionDone() {
...
}
});
</script>
</dom-module>
스타일 공유
앱의 여러 구성요소 간에 스타일을 공유하기 위해 Polymer의 공유 스타일 모듈을 사용했습니다. 스타일 모듈을 사용하면 CSS 청크를 한 번 정의하고 앱 전반의 여러 위치에서 재사용할 수 있습니다. 여기서 '여러 위치'는 여러 구성요소를 의미합니다.
IOWA에서는 페이지와 제작한 다른 구성요소 간에 색상, 서체, 레이아웃 클래스를 공유하기 위해 shared-app-styles
를 만들었습니다.
shared-app-styles.html
<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../bower_components/paper-styles/color.html">
<dom-module id="shared-app-styles">
<template>
<style>
[layout] {
@apply(--layout);
}
[layout][horizontal] {
@apply(--layout-horizontal);
}
.scrollable {
@apply(--layout-scroll);
}
.noscroll {
overflow: hidden;
}
/* Style radio buttons and tabs the same throughout the app */
paper-tabs {
--paper-tabs-selection-bar-color: currentcolor;
}
paper-radio-button {
--paper-radio-button-checked-color: var(--paper-cyan-600);
--paper-radio-button-checked-ink-color: var(--paper-cyan-600);
}
...
</style>
</template>
</dom-module>
io-home-page.html
<link rel="import" href="shared-app-styles.html">
<!-- Rest of import dependencies used by the page. -->
<dom-module id="io-home-page">
<template>
<style include="shared-app-styles">
:host { display: block} /* Other element styles can go here. */
</style>
<!-- PAGE'S MARKUP -->
</template>
<script>Polymer({...});</script>
</dom-module>
여기서 <style include="shared-app-styles"></style>
는 'shared-app-styles'라는 모듈에 스타일을 포함하라는 Polymer의 문법입니다.
애플리케이션 상태 공유
이제 앱의 모든 페이지가 맞춤 요소라는 것을 알게 되었습니다. 수백 번 말했잖아. 하지만 모든 페이지가 독립적인 웹 구성요소인 경우 앱 전반에서 상태를 공유하는 방법을 궁금해할 수 있습니다.
IOWA는 상태 공유를 위해 종속 항목 주입 (Angular) 또는 redux (React)와 유사한 기법을 사용합니다. 전역 app
속성을 만들고 공유 하위 속성을 여기에 연결했습니다. app
는 데이터가 필요한 모든 구성요소에 삽입하여 애플리케이션 전반에 전달됩니다. Polymer의 데이터 결합 기능을 사용하면 코드를 작성하지 않고도 배선을 할 수 있으므로 쉽게 연결할 수 있습니다.
<lazy-pages>
<template is="dom-if" name="home">
<io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
</template>
<template is="dom-if" name="schedule">
<io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
</template>
...
</lazy-pages>
<google-signin client-id="..." scopes="profile email"
user="{ % templatetag openvariable % }app.currentUser}}"></google-signin>
<iron-media-query query="(min-width:320px) and (max-width:768px)"
query-matches="{ % templatetag openvariable % }app.isPhoneSize}}"></iron-media-query>
<google-signin>
요소는 사용자가 앱에 로그인하면 user
속성을 업데이트합니다. 이 속성은 app.currentUser
에 바인딩되므로 현재 사용자에 액세스하려는 모든 페이지는 app
에 바인딩하고 currentUser
하위 속성을 읽기만 하면 됩니다. 이 기법은 앱 전반에서 상태를 공유하는 데 유용합니다. 하지만 단일 로그인 요소를 만들고 사이트 전반에서 결과를 재사용할 수 있다는 또 다른 이점이 있습니다. 미디어 쿼리도 마찬가지입니다. 모든 페이지에서 로그인을 중복하거나 자체 미디어 쿼리 세트를 만드는 것은 낭비가 될 수 있습니다. 대신 앱 전체 기능/데이터를 담당하는 구성요소는 앱 수준에 있습니다.
페이지 전환
Google I/O 웹 앱을 탐색할 때 Material Design 스타일의 멋진 페이지 전환을 확인할 수 있습니다.

사용자가 새 페이지로 이동하면 다음과 같은 일련의 작업이 실행됩니다.
- 상단 탐색 메뉴에서 선택 바가 새 링크로 슬라이드됩니다.
- 페이지의 제목이 사라집니다.
- 페이지의 콘텐츠가 아래로 미끄러진 후 사라집니다.
- 이러한 애니메이션을 역전시켜 새 페이지의 제목과 콘텐츠를 표시합니다.
- (선택사항) 새 페이지에서 추가 초기화 작업을 실행합니다.
성능을 저하시키지 않으면서 이처럼 매끄러운 전환을 구현하는 방법을 찾는 것이 과제 중 하나였습니다. 동적 작업이 많이 이루어지며 버벅거림은 환영받지 못했습니다. 솔루션은 Web Animations API와 Promises를 조합한 것입니다. 이 두 가지를 함께 사용하면 다목적성, 플러그 앤 플레이 애니메이션 시스템, 세밀한 제어를 통해 das 버벅거림을 최소화할 수 있었습니다.
작동 방식
사용자가 새 페이지를 클릭하거나 뒤로/앞으로를 누르면 라우터의 runPageTransition()
가 일련의 Promise를 실행하여 마법을 실행합니다. Promise를 사용하면 애니메이션을 신중하게 조정하고 CSS 애니메이션의 '비동기'와 동적으로 콘텐츠를 로드하는 것을 합리화할 수 있었습니다.
class Router {
init() {
window.addEventListener('popstate', e => this.runPageTransition());
}
runPageTransition() {
let endPage = this.state.end.page;
this.fire('page-transition-start'); // 1. Let current page know it's starting.
IOWA.PageAnimation.runExitAnimation() // 2. Play exist animation sequence.
.then(() => {
IOWA.Elements.LazyPages.selected = endPage; // 3. Activate new page in <lazy-pages>.
this.state.current = this.parseUrl(this.state.end.href);
})
.then(() => IOWA.PageAnimation.runEnterAnimation()) // 4. Play entry animation sequence.
.then(() => this.fire('page-transition-done')) // 5. Tell new page transitions are done.
.catch(e => IOWA.Util.reportError(e));
}
}
'DRY 유지: 페이지 전반의 공통 기능' 섹션에서 다시 확인하면 페이지는 page-transition-start
및 page-transition-done
DOM 이벤트를 수신 대기합니다. 이제 이러한 이벤트가 실행되는 위치가 표시됩니다.
runEnterAnimation
/runExitAnimation
도우미 대신 Web Animations API를 사용했습니다. runExitAnimation
의 경우 몇 개의 DOM 노드 (마스트헤드 및 기본 콘텐츠 영역)를 가져와 각 애니메이션의 시작/끝을 선언하고 GroupEffect
를 만들어 두 애니메이션을 동시에 실행합니다.
function runExitAnimation(section) {
let main = section.querySelector('.slide-up');
let masthead = section.querySelector('.masthead');
let start = {transform: 'translate(0,0)', opacity: 1};
let end = {transform: 'translate(0,-100px)', opacity: 0};
let opts = {duration: 400, easing: 'cubic-bezier(.4, 0, .2, 1)'};
let opts_delay = {duration: 400, delay: 200};
return new GroupEffect([
new KeyframeEffect(masthead, [start, end], opts),
new KeyframeEffect(main, [{opacity: 1}, {opacity: 0}], opts_delay)
]);
}
배열을 수정하여 뷰 전환을 더 복잡하게 또는 더 간단하게 만들 수 있습니다.
스크롤 효과
IOWA는 페이지를 스크롤할 때 몇 가지 흥미로운 효과가 있습니다. 첫 번째는 사용자를 페이지 상단으로 되돌리는 플로팅 작업 버튼 (FAB)입니다.
<a href="#" tabindex="-1" aria-hidden="true" aria-label="back to top" onclick="backToTop">
<paper-fab icon="io:expand-less" noink tabindex="-1"></paper-fab>
</a>
부드러운 스크롤은 Polymer의 app-layout 요소를 사용하여 구현됩니다. 고정/반환 상단 탐색 메뉴, 그림자, 색상 및 배경 전환, 시차 효과, 부드러운 스크롤과 같은 기본 제공 스크롤 효과를 제공합니다.
// Smooth scrolling the back to top FAB.
function backToTop(e) {
e.preventDefault();
Polymer.AppLayout.scroll({top: 0, behavior: 'smooth',
target: document.documentElement});
e.target.blur(); // Kick focus back to the page so user starts from the top of the doc.
}
<app-layout>
요소를 사용한 또 다른 곳은 고정된 탐색 메뉴입니다. 동영상에서 볼 수 있듯이 사용자가 페이지를 아래로 스크롤하면 사라지고 위로 스크롤하면 다시 표시됩니다.

를 사용하여 스크롤 내비게이션 고정
<app-header>
요소를 거의 그대로 사용했습니다. 앱에 쉽게 넣고 멋진 스크롤 효과를 얻을 수 있었습니다. 물론 직접 구현할 수도 있었지만 세부정보가 재사용 가능한 구성요소로 이미 코딩되어 있으므로 시간을 많이 절약할 수 있었습니다.
요소를 선언합니다. 속성으로 맞춤설정합니다. 모두 마쳤습니다!
<app-header reveals condenses effects="fade-background waterfall"></app-header>
결론
I/O 프로그레시브 웹 앱의 경우 웹 구성요소와 Polymer의 사전 제작된 Material Design 위젯 덕분에 몇 주 만에 전체 프런트엔드를 빌드할 수 있었습니다. 네이티브 API (맞춤 요소, Shadow DOM, <template>
)의 기능은 SPA의 역동성에 자연스럽게 적용됩니다. 재사용하면 시간이 많이 절약됩니다.
직접 프로그레시브 웹 앱을 만들고 싶다면 앱 도구 상자를 확인하세요. Polymer의 앱 도구 상자는 Polymer로 PWA를 빌드하기 위한 구성요소, 도구, 템플릿 모음입니다. 간편하게 시작할 수 있습니다.