1. What?
A new way to deliver amazing user experiences on the web.
웹에서 놀라운 사용자 경험을 전달하는 새로운 방식입니다. 웹 본연의 (편리한) 경험 외에도 Reliable, Fast, Engaging이라는 3가지 특징이 있습니다.
- 신뢰성(Reliable): 불안정한 네트워크 환경에서도 즉시 로드되며, (연결이 끊겼다고 해서) 갑자기 구석기 시대로 돌아가지 않습니다.
신뢰성은 오프라인 캐싱을 의미합니다. 네트워크가 끊긴 상태에서도 캐시를 사용하여 오프라인 시나리오에서도 가용성을 보장합니다. service worker와 cache API가 결합하여 캐시-프록시 메커니즘을 구축합니다.
- 신속성(Fast): 부드러운 애니메이션으로 즉각적인 상호작용 피드백을 제공하며, 프레임 드랍이나 끊김 없는 스크롤을 구현합니다.
신속성은 상호작용 피드백이 "빠르게 느껴지는 것"을 강조하며, 이는 권장되는 Material Design과 관련이 있습니다. 절대적인 속도 우위가 있는 것은 아닙니다(최소한 첫 화면 로딩 속도는 그렇습니다).
또한 캐시-프록시 메커니즘 덕분에 재방문 시 로컬 캐시를 사용하므로 상당히 빠릅니다.
- 네이티브 앱 유사성(Engaging/Native-like): 장치의 네이티브 앱처럼 몰입감 있는 사용자 경험(즉, 전체 화면)을 제공합니다.
전체 화면 외에도 홈 화면 아이콘(웹 앱이 홈 화면의 한 자리를 차지하게 함)과 시스템 알림("사용자 재유입" 능력)이 포함됩니다. 이는 Web App Manifest 설정을 통해 구현되며 사용자 환경의 지원 여부에 의존합니다.
P.S. 'Engaging'이라는 추상적인 형용사는 번역하기 참 어렵네요. 여기서는 실제 의미를 따서 '네이티브 앱 유사성'으로 취급하겠습니다.
따라서 겉으로 보기에 PWA의 핵심은 두 부분으로 나뉩니다.
-
(오프라인) 캐시-프록시 메커니즘
-
전체 화면, 홈 화면 아이콘, 시스템 알림 등 네이티브 앱과 유사한 기능
캐싱 메커니즘은 웹 앱이나 SPA에서 전혀 새로운 것이 아닙니다. 데이터 레이어를 분리한 후 캐싱을 덤으로 구현하곤 했죠. 하지만 초점이 다릅니다. PWA의 캐싱 메커니즘은 정적 리소스 캐싱에 치중하는 반면, 웹 앱/SPA의 캐시 레이어는 주로 동적 콘텐츠 캐싱에 사용됩니다(이전 콘텐츠가 만료되지 않았다면 동적 부분을 다시 가져오지 않고 클라이언트 사이드 렌더링을 직접 수행).
전체 화면, 홈 화면 아이콘 및 시스템 알림과 같은 네이티브 앱 유사 기능은 점진적 향상(Progressive Enhancement) 중 향상에 해당하며, 이를 지원하는 사용자 환경에서만 작동합니다(일부 브라우저는 지원을 제공하지만, 더 광범위한 WebView 환경에서는 가까운 미래에도 어려울 수 있습니다). 하지만 이는 웹이 점진적 향상 방식을 통해 PC 시대를 벗어나 모바일화로 나아가고 있음을 보여줍니다.
2. 실습
종속 환경
- HTTPS
서비스 소스가 반드시 안전해야 하므로 HTTPS 환경이 필요합니다. 웹 정보 보안 측면뿐만 아니라 HTTPS 보급을 추진하려는 목적도 큽니다. 웹 기술 발전의 필수 인프라인 HTTPS는 카메라, 녹음, Push API 등 새로운 기능을 사용하기 위해 사용자 허가를 받는 권한 워크플로우의 핵심적인 부분입니다.
P.S. permission.site에서 HTTPS와 HTTP 환경 간의 사용자 권한 획득 차이를 체험해 볼 수 있습니다.
네이티브 앱 유사 기능 강화
Web App Manifest 설정 파일을 도입하여 구현합니다. PWA를 지원하는 브라우저에서 활성화됩니다(지원하지 않는 환경에서는 최악의 경우 JSON 파일 하나를 더 요청할 뿐입니다).
<link rel="manifest" href="./manifest.json">
참고로 Application Cache(HTML5 기능, 현재는 구식)라는 유사한 것이 있는데, manifest를 불러오는 방식이 다릅니다.
<html manifest="example.appcache">
...
</html>
불러오는 방식이 다르므로 Web App Manifest와 Application Cache는 서로 무관하며, 과거의 기술적 부채에 대해 걱정할 필요가 없습니다.
P.S. Application Cache는 SPA에는 잘 작동하지만 다중 페이지 앱에는 적합하지 않으며, 여러 문제가 존재합니다. 여기서는 자세히 다루지 않겠습니다.
홈 화면 아이콘
Web App Manifest 내용 예시는 다음과 같습니다.
{
"short_name": "홈 화면 표시 이름",
"name": "설치 배너 표시 이름",
"icons": [
{
"src": "launcher-icon-1x.png",
"type": "image/png",
"sizes": "48x48",
"density": "1.0"
},
{
"src": "launcher-icon-2x.png",
"type": "image/png",
"sizes": "96x96",
"density": "1.0"
},
{
"src": "launcher-icon-4x.png",
"type": "image/png",
"sizes": "192x192",
"density": "1.0"
}
],
"start_url": "index.html?launcher=true"
}
P.S. 설치 배너는 권한 획득 패널과 유사하게 사용자가 홈 화면에 추가하거나 취소할 수 있는 팝업입니다. 특정 조건을 만족하면 Chrome에서 자동으로 설치 배너를 띄웁니다. 자세한 내용은 Web App Install Banners를 참조하세요.
이상적인 상황에서 우리는 홈 화면 아이콘을 갖게 되며, Web App Manifest를 지원하는 환경은 가장 적합한(48dp에 가장 가까운) 아이콘을 선택합니다.
주의: index.html의 내용은 첫 화면 렌더링에 필요한 최소한의 내용이어야 합니다. 첫 화면이 즉시 로드되는 효과를 주기 위해 로딩 인디케이터와 기본 플레이스홀더가 포함된 페이지 프레임을 App Shell로 보여줄 수 있습니다. 또한 첫 화면 성능을 극대화하기 위해 데이터 직출(Data direct output)과 같은 일반적인 웹 앱 최적화 수단들도 PWA에서 권장됩니다. 앞서 말했듯이 PWA 자체가 첫 화면 성능 우위를 가지는 것은 아니므로 일반적인 최적화 수단은 여전히 필수적입니다.
스플래시 화면(Splash)
홈 화면 아이콘을 통해 진입할 때 표시되는 맞춤형 시작 화면입니다. 제목, 배경색, 이미지를 포함합니다. 설정 항목은 다음과 같습니다.
// 배경색
"background_color": "#2196F3",
// 툴바를 포함한 테마 색상
"theme_color": "#2196F3",
icons 중에서 128dp에 가장 가까운 이미지가 스플래시 화면으로 선택됩니다. 움직이는 이미지는 지원되지 않습니다.
또한 표시 모드와 페이지 방향을 지정할 수 있습니다.
// 전체 화면 (브라우저 UI 숨김)
"display": "standalone",
// 브라우저 외관 표시 (북마크를 여는 것처럼)
"display": "browser",
// 가로 모드
"orientation": "landscape"
P.S. 스플래시 화면에 대한 예시와 더 자세한 정보는 Adding a Splash Screen for Installed Web Apps in Chrome 47을 확인하세요.
특별 주의: manifest.json 파일이 업데이트되어도 사용자가 홈 화면에 앱을 다시 추가하지 않는 한 변경 사항이 자동으로 반영되지 않습니다.
시스템 알림
Web App Manifest와는 무관하며 Push API에 의존합니다. 간단한 예시는 다음과 같습니다.
// service-worker.js
self.addEventListener("push", function (event) {
event.waitUntil(
self.self.registration.showNotification("새 글이 올라왔어요", {
body: "새로운 글이 게시되었습니다. 클릭해서 확인하세요."
})
);
});
여기서는 자세히 다루지 않겠습니다(현재(2017/12/15) 기준으로 이 기능은 거의 존재하지 않는다고 봐도 무방합니다). 명세에서 API는 정의했지만 통일된 Push 프로토콜을 규정하지 않았기 때문입니다. 그래서 브라우저마다 Push 메커니즘이 다르며, 예를 들어 Chrome의 GCM은 국내 환경에서 사용이 원활하지 않을 수 있습니다.
Push API에 대한 더 자세한 정보는 【Service Worker】消息推送功能“全军覆没”를 확인하세요.
캐시-프록시
캐싱은 다음 부분으로 나뉩니다.
-
첫 화면 정적 리소스 캐싱(사전 캐싱)
-
방문한 리소스 캐싱(런타임 캐싱)
-
동적 콘텐츠 캐싱(런타임 캐싱)
캐싱은 순수 데이터 작업(지속성 포함)이며, service worker는 백그라운드에서 실행될 수 있어 페이지나 상호작용과 무관한 일을 처리하기에 매우 적합합니다. 그래서 service worker는 Cache API, Push API와 짝을 이룹니다. 하지만 service worker 자체도 "향상" 항목으로 간주해야 합니다. service worker를 지원하지 않는 환경에서는 캐싱 메커니즘을 건너뛰고 기본적인 페이지 경험을 보장해야 합니다. 간단한 기능 감지 방안은 다음과 같습니다.
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('./service-worker.js')
.then(function() { console.log('Service Worker Registered'); });
}
service worker는 install 이벤트 핸들러에서 App Shell을 포함한 첫 화면 정적 리소스 캐싱을 수행합니다.
// service-worker.js
var cacheName = 'weatherPWA-step-6-1';
var filesToCache = [
// 진입 URL
'/',
'/index.html',
'/scripts/app.js',
'/styles/inline.css',
// App Shell에 필요한 리소스
'/images/ic_add_white_24px.svg',
'/images/ic_refresh_white_24px.svg',
// 콘텐츠 표시에 사용될 수 있는 리소스
'/images/clear.png',
'/images/cloudy-scattered-showers.png',
'/images/cloudy.png',
'/images/fog.png',
'/images/partly-cloudy.png',
'/images/rain.png',
'/images/scattered-showers.png',
'/images/sleet.png',
'/images/snow.png',
'/images/thunderstorm.png',
'/images/wind.png'
];
self.addEventListener('install', function(e) {
console.log('[ServiceWorker] Install');
e.waitUntil(
caches.open(cacheName).then(function(cache) {
console.log('[ServiceWorker] Caching app shell');
//! 하나라도 실패하면 다음 것을 진행하지 않음
return cache.addAll(filesToCache);
})
);
});
물론 캐시에 대한 기본적인 버전 관리도 필요합니다.
// service-worker.js
self.addEventListener('activate', function(e) {
console.log('[ServiceWorker] Activate');
e.waitUntil(
caches.keys().then(function(keyList) {
return Promise.all(keyList.map(function(key) {
// cacheName을 캐시 키로 사용하여 구버전 캐시가 있으면 삭제
if (key !== cacheName) {
console.log('[ServiceWorker] Removing old cache', key);
return caches.delete(key);
}
}));
})
);
// 에지 케이스 방지를 위해 service worker를 즉시 활성화
return self.clients.claim();
});
P.S. 에지 케이스란 특정 상황에서 service worker가 즉시 활성화되지 않아 캐시를 타지 않는 경우를 말합니다. 이를 방지하기 위해 GoogleChromeLabs/sw-precache를 사용하여 캐시 제어 문제(만료, 업데이트 전략 등)를 처리하는 것을 추천합니다.
캐시가 준비되었으니 이제 프록시 부분을 구현합니다. 요청을 가로채고 캐시된 내용을 응답으로 전달합니다.
// service-worker.js
// 요청 가로채기
self.addEventListener('fetch', function(event) {
console.log('[ServiceWorker] Fetch', e.request.url);
// 응답 내용 커스텀
e.respondWith(
// 캐시를 찾고 없으면 요청
caches.match(e.request).then(function(response) {
return response || fetch(e.request);
})
);
});
이로써 기본적인 캐시-프록시 메커니즘이 준비되었습니다. 우리는 다음 작업을 수행했습니다.
-
리소스 목록에 따라 정적 리소스를 사전 캐싱
-
요청 가로채기
-
캐시된 내용을 응답으로 제공
3가지 주의 사항이 있습니다.
-
브라우저 캐시가 캐시 업데이트에 영향을 줄 수 있으므로
install이벤트 핸들러의 요청은 캐시를 타지 않고 직접 네트워크로 연결되도록 해야 합니다. -
service worker를 등록 해제해도 캐시가 지워지지는 않습니다. 캐시 키가 변하지 않으면 나중에 다시 구버전 캐시 내용을 가져오게 됩니다.
-
기본적으로 새로 등록된 service worker는 페이지를 다시 불러와야 적용됩니다. 특수 처리를 하지 않는 이상 그렇습니다.
또한 우리의 간략한 구현에는 몇 가지 문제가 있습니다.
-
캐시 버전 관리가 고정된 캐시 키에 의존하므로
service-worker.js를 업데이트할 때마다 키를 수정해야 합니다. -
캐시 키가 바뀌면 모든 캐시를 삭제하고 다시 요청하게 되는데 정적 리소스의 경우 ��비가 심합니다.
-
런타임 캐싱이 부족하여 리소스 목록이 유연하지 못합니다. 방문 시점에 캐싱하는 기능이 필요합니다.
첫 번째 문제는 뾰족한 수가 없지만, 두 번째 문제는 리소스 유형을 세분화하여 완화할 수 있습니다.
// Shorthand identifier mapped to specific versioned cache.
var CURRENT_CACHES = {
font: 'font-cache-v' + FONT_CACHE_VERSION,
css: 'css-cache-v' + CSS_CACHE_VERSION,
img: 'img-cache-v' + IMG_CACHE_VERSION
};
더 세밀한 버전 관리를 통해 강제 캐시 업데이트 비용을 어느 정도 낮출 수 있습니다. 물론 캐시 레이어 아래에는 HTTP Cache가 받쳐주고 있으므로 업데이트 비용이 아주 결정적인 문제는 아닙니다.
런타임 캐싱은 마지막 한 단계만 더 진행하면 됩니다.
- 캐시 미스 시 리소스를 요청*하고 캐싱*합니다.
구체적으로는 다음과 같습니다.
// 캐시를 찾고 없으면 요청
caches.match(e.request).then(function(response) {
return response || fetch(e.request).then(function(res) {
return caches.open(dataCacheName).then(function(cache) {
// 캐시에 저장
cache.put(e.request.url, res.clone());
return res;
)
})
})
또한 리소스 유형 및 상황에 맞게 적절한 캐시 전략을 선택할 수 있습니다.
// service-worker.js
self.addEventListener('fetch', function(e) {
console.log('[Service Worker] Fetch', e.request.url);
var dataUrl = 'https://cache.domain.com/fresh/';
// 전략 1: 실시간성이 중요한 리소스, 요청 우선, fetch then cache
if (e.request.url.indexOf(dataUrl) > -1) {
e.respondWith(
caches.open(dataCacheName).then(function(cache) {
return fetch(e.request).then(function(response){
cache.put(e.request.url, response.clone());
return response;
});
})
);
} else {
// 전략 2: 일반 리소스, 캐시 우선, cache falling back to fetch
}
});
P.S. 더 많은 캐시 전략은 참고 자료를 확인하세요.
3. Demo
공식 데모: Weather PWA, 접속이 원활하지 않을 수 있습니다.
미러 데모(공식 데모를 GitHub Pages로 옮김): https://ayqy.github.io/pwa/demo/weather-pwa/index.html
P.S. GitHub Pages는 실험실로 사용하기 아주 좋습니다. 안정적인 HTTPS를 제공하며 콘텐츠 게시에 제한이 없어 마음껏 테스트할 수 있습니다. 앞으로의 블로그 데모들은 점차 이곳으로 옮겨질 예정입니다(이전에는 개인 FTP를 썼는데 참 어리석었네요..).
[caption id="attachment_1610" align="alignnone" width="169"]
weather-pwa[/caption]
다소 아쉬운 소식: 사실 사용자 환경을 정성껏 준비하고(정품 Chrome + 공식 데모) Mi 4 기기에서 테스트했지만, 설치 배너가 자동으로 뜨지 않았습니다(조작 방식 등 조건 미충족 가능성, 위 내용 참조). 수동으로 "홈 화면에 추가"를 클릭하니 추가 성공 토스트 메시지가 떴지만 홈 화면에는 아무것도 나타나지 않았습니다... 이것이 제가 데모를 직접 작성해 보는 것에 흥미를 느끼지 못한 이유입니다(물론 주된 이유는 귀찮음 때문입니다 ;)).
4. 사례
-
Ele.me: 이상하게 캐시 효과가 느껴지지 않네요.
참고로 시크릿 모드에서는 알리바바 국제 사이트의 service worker가 다음과 같은 오류를 던질 수 있습니다.
Uncaught (in promise) DOMException: Quota exceeded.
일반 환경에서는 정상적으로 체험할 수 있습니다.
P.S. 더 많은 사례는 Case Studies | Web | Google | Developers에서 확인하세요.
5. 응용 시나리오
간단히 말해 PWA는 웹 앱의 업그레이드 버전이며, 핵심은 네이티브 앱 지원입니다. 점진적 향상 방식을 통해 큰 비용 없이 웹 앱을 PWA로 "업그레이드"할 수 있으며, 지원 환경을 사용하는 사용자들에게 더 빠르고(캐시) 더 편리하며(홈 화면 아이콘) 네이티브 앱과 유사한 경험(전체 화면)을 제공할 수 있습니다.
구체적인 응용 시나리오는 다음과 같습니다.
-
캐싱을 통해 뚜렷한 이득을 볼 수 있는 웹 앱
-
오프라인 기능이나 네이티브 앱 같은 경험, 혹은 단순히 홈 화면 아이콘을 원하는 웹 서비스
-
기술적 트렌드에 합류하거나 그 발전을 돕고자 하는 웹 서비스 또는 브라우저 벤더
응용 시나리오를 떠나서, zxx의 캐시(혹은 worker?) 관련 글에서 언급했듯이, 적은 비용으로 페이지에 오프라인 기능을 부여하고 캐시의 이점을 확실히 누릴 수 있다면 마다할 이유가 없지 않을까요?
또한 Angular, React, Vue 등 주요 프레임워크들은 PWA 스캐폴딩을 제공합니다. 자세한 내용은 The Ultimate Guide to Progressive Web Applications를 참조하세요.
참고 자료
-
The offline cookbook: 캐시 전략 도식화 자료. 아주 유용하며 ServiceWorker Cookbook과 함께 보길 권장합니다.
-
Your First Progressive Web App: 공식 튜토리얼
-
기존 웹사이트를 PWA로 변신시키기: 원문 Retrofit Your Website as a Progressive Web App
아직 댓글이 없습니다