프로그레시브 웹 애플리케이션에 대한 광범위한 가이드
게시 됨: 2022-03-10아버지 생신이었는데 초콜릿 케이크와 셔츠를 주문하고 싶었습니다. 나는 초콜릿 케이크를 검색하기 위해 Google로 향했고 검색 결과의 첫 번째 링크를 클릭했습니다. 몇 초 동안 빈 화면이 나타났습니다. 무슨 일이 일어나고 있는지 이해하지 못했습니다. 몇 초 동안 참을성 있게 쳐다보니 모바일 화면이 맛있어 보이는 케이크로 가득 찼습니다. 세부 사항을 확인하기 위해 그 중 하나를 클릭하자마자 케이크를 주문하는 동안 실크처럼 부드러운 경험을 할 수 있도록 Android 애플리케이션을 설치하라는 추악한 뚱뚱한 팝업이 나타났습니다.
실망스러웠다. 내 양심은 "설치" 버튼을 클릭하는 것을 허용하지 않았습니다. 내가 하고 싶었던 모든 것은 작은 케이크를 주문하고 가는 길이었다.
최대한 빨리 팝업에서 빠져나오기 위해 팝업의 맨 오른쪽에 있는 십자가 아이콘을 클릭했습니다. 그러나 설치 팝업은 화면 하단에 자리 잡고 공간의 4분의 1을 차지했습니다. 그리고 불안정한 UI로 인해 아래로 스크롤하는 것이 어려웠습니다. 나는 그럭저럭 네덜란드 케이크를 주문할 수 있었다.
이 끔찍한 경험을 한 후, 다음 도전은 아버지께 드릴 셔츠를 주문하는 것이었습니다. 예전과 마찬가지로 나는 셔츠를 구글에서 검색한다. 나는 첫 번째 링크를 클릭했고, 눈 깜짝할 사이에 전체 콘텐츠가 내 눈앞에 있었다. 스크롤이 부드러웠습니다. 설치 배너가 없습니다. 마치 네이티브 애플리케이션을 탐색하는 듯한 느낌을 받았습니다. 형편없는 인터넷 연결이 끊어지는 순간이 있었지만 여전히 공룡 게임 대신 콘텐츠를 볼 수있었습니다. 내 버릇없는 인터넷에도 불구하고 나는 아버지를 위해 셔츠와 청바지를 주문할 수있었습니다. 무엇보다 가장 놀라운 것은 주문에 대한 알림을 받았다는 것입니다.
나는 이것을 실크처럼 부드러운 경험이라고 부를 것입니다. 이 사람들은 옳은 일을 하고 있었습니다. 모든 웹사이트는 사용자를 위해 이를 수행해야 합니다. 프로그레시브 웹 앱이라고 합니다.
Alex Russell은 자신의 블로그 게시물 중 하나에서 다음과 같이 말합니다.
“마케팅 부서나 매끄러운 패키징 없이 강력한 기술이 존재하게 되는 경우가 웹에서 때때로 발생합니다. 그들은 주변부에서 머물고 자라며 다른 모든 사람들에게는 거의 보이지 않는 채로 남아 있는 동안 작은 그룹에게는 구식의 사람이 됩니다. 누군가 이름을 지을 때까지.”
웹에서 부드럽고 매끄러운 경험(때로는 프로그레시브 웹 애플리케이션이라고도 함)
프로그레시브 웹 애플리케이션(PWA)은 강력한 웹 애플리케이션을 만들기 위한 기술 조합을 포함하는 방법론에 가깝습니다. 향상된 사용자 경험으로 사람들은 웹사이트에서 더 많은 시간을 보내고 더 많은 광고를 보게 될 것입니다. 그들은 더 많이 구매하는 경향이 있으며 알림 업데이트로 인해 더 자주 방문할 가능성이 높습니다. Financial Times는 2011년에 기본 앱을 포기하고 당시 사용 가능한 최고의 기술을 사용하여 웹 앱을 구축했습니다. 이제 제품은 본격적인 PWA로 성장했습니다.
하지만 이 모든 시간이 지난 후에도 네이티브 앱이 충분히 잘 작동할 때 웹 앱을 구축할 이유가 무엇입니까?
Google IO 17에서 공유되는 몇 가지 측정항목을 살펴보겠습니다.
50억 개의 장치가 웹에 연결되어 웹이 컴퓨팅 역사상 가장 큰 플랫폼이 되었습니다. 모바일 웹에서 1,140만 명의 월별 고유 방문자가 상위 1000개 웹 속성으로 이동하고 400만 명이 상위 1000개 앱으로 이동합니다. 모바일 웹은 기본 애플리케이션보다 약 4배 많은 사용자를 확보하고 있습니다. 그러나 이 수치는 약혼과 관련하여 급격히 떨어집니다.
사용자는 기본 앱에서 평균 188.6분을 보내고 모바일 웹에서 9.3분을 보냅니다. 기본 애플리케이션은 운영 체제의 강력한 기능을 활용하여 푸시 알림을 보내 사용자에게 중요한 업데이트를 제공합니다. 브라우저의 웹 사이트보다 더 나은 사용자 경험을 제공하고 더 빨리 부팅됩니다. 사용자는 웹 브라우저에 URL을 입력하는 대신 홈 화면에서 앱 아이콘을 탭하기만 하면 됩니다.
웹을 방문하는 대부분의 방문자는 다시 돌아올 가능성이 낮으므로 개발자는 방문자의 참여를 계속 유지하기 위해 기본 애플리케이션을 설치하기 위해 배너를 표시하는 해결 방법을 고안했습니다. 그러나 사용자는 기본 응용 프로그램의 바이너리를 설치하는 번거로운 절차를 거쳐야 합니다. 사용자가 응용 프로그램을 설치하도록 강제하는 것은 성가신 일이며 처음부터 응용 프로그램을 설치할 가능성을 줄입니다. 웹의 기회는 분명합니다.
추천 자료 : 네이티브 및 PWA: 도전자가 아닌 선택!
웹 애플리케이션이 풍부한 사용자 경험, 푸시 알림, 오프라인 지원 및 즉시 로드와 함께 제공된다면 세계를 정복할 수 있습니다. 이것이 프로그레시브 웹 애플리케이션이 하는 일입니다.
PWA에는 다음과 같은 몇 가지 장점이 있기 때문에 풍부한 사용자 경험을 제공합니다.
빠른
UI가 불안정하지 않습니다. 스크롤이 부드럽습니다. 그리고 앱은 사용자 상호작용에 빠르게 반응합니다.믿을 수있는
일반 웹사이트는 서버로 이동하느라 바쁜 동안 아무 것도 하지 않고 사용자를 기다리게 합니다. 한편 PWA는 캐시에서 즉시 데이터를 로드합니다. PWA는 2G 연결에서도 원활하게 작동합니다. 자산이나 데이터 조각을 가져오기 위한 모든 네트워크 요청은 서비스 작업자(나중에 자세히 설명)를 거치며 먼저 특정 요청에 대한 응답이 이미 캐시에 있는지 확인합니다. 사용자가 연결 상태가 좋지 않은 경우에도 실제 콘텐츠를 거의 즉시 얻을 때 앱을 더 신뢰하고 더 신뢰할 수 있다고 봅니다.매력적인
PWA는 사용자의 홈 화면에서 한 자리를 차지할 수 있습니다. 전체 화면 작업 영역을 제공하여 기본 앱과 같은 경험을 제공합니다. 푸시 알림을 사용하여 사용자의 참여를 유지합니다.
이제 PWA가 무엇을 가져오는지 알았으므로 PWA가 기본 응용 프로그램보다 우위를 점하게 하는 요소에 대해 자세히 알아보겠습니다. PWA는 서비스 워커, 웹 앱 매니페스트, 푸시 알림 및 캐싱을 위한 IndexedDB/로컬 데이터 구조 와 같은 기술로 구축됩니다. 각각에 대해 자세히 살펴보겠습니다.
서비스 워커
서비스 워커는 사용자의 상호 작용을 방해하지 않고 백그라운드에서 실행되는 JavaScript 파일입니다. 서버에 대한 모든 GET 요청은 서비스 워커를 거칩니다. 클라이언트 측 프록시처럼 작동합니다. 네트워크 요청을 가로채서 클라이언트로 다시 전송되는 응답을 완전히 제어합니다. 서비스 워커가 캐시의 데이터로 응답하여 네트워크에 대한 종속성을 제거하기 때문에 PWA가 즉시 로드됩니다.
서비스 작업자는 해당 범위에 있는 네트워크 요청만 가로챌 수 있습니다. 예를 들어 루트 범위의 서비스 작업자는 웹 페이지에서 들어오는 모든 가져오기 요청을 가로챌 수 있습니다. 서비스 워커는 이벤트 기반 시스템으로 작동합니다. 필요하지 않을 때 휴면 상태가 되어 메모리를 절약합니다. 웹 애플리케이션에서 서비스 워커를 사용하려면 먼저 JavaScript로 페이지에 서비스 워커를 등록해야 합니다.
(function main () { /* navigator is a WEB API that allows scripts to register themselves and carry out their activities. */ if ('serviceWorker' in navigator) { console.log('Service Worker is supported in your browser') /* register method takes in the path of service worker file and returns a promises, which returns the registration object */ navigator.serviceWorker.register('./service-worker.js').then (registration => { console.log('Service Worker is registered!') }) } else { console.log('Service Worker is not supported in your browser') } })()
먼저 브라우저가 서비스 워커를 지원하는지 확인합니다. 웹 애플리케이션에 서비스 워커를 등록하기 위해 navigator.serviceWorker
에서 사용할 수 있는 register
기능에 대한 매개변수로 해당 URL을 제공합니다( navigator
는 스크립트가 자체적으로 등록하고 활동을 수행할 수 있도록 하는 웹 API입니다). 서비스 워커는 한 번만 등록됩니다. 등록은 모든 페이지 로드에서 발생하지 않습니다. 브라우저는 활성화된 기존 서비스 워커와 최신 서비스 워커 간에 바이트 차이가 있거나 URL이 변경된 경우에만 서비스 워커 파일( ./service-worker.js
)을 다운로드합니다.
위의 서비스 워커는 루트( /
)에서 오는 모든 요청을 가로챕니다. 서비스 워커의 범위를 제한하기 위해 키 중 하나를 범위로 사용하여 선택적 매개변수를 전달합니다.
if ('serviceWorker' in navigator) { /* register method takes in an optional second parameter as an object. To restrict the scope of a service worker, the scope should be provided. scope: '/books' will intercept requests with '/books' in the url. */ navigator.serviceWorker.register('./service-worker.js', { scope: '/books' }).then(registration => { console.log('Service Worker for scope /books is registered', registration) }) }
위의 서비스 작업자는 URL에 /books
가 있는 요청을 가로챕니다. 예를 들어 /products
를 사용하여 요청을 가로채지 않지만 /books/products
를 사용하여 요청을 가로챌 수 있습니다.
앞서 언급했듯이 서비스 워커는 이벤트 기반 시스템으로 작동합니다. 이벤트(설치, 활성화, 가져오기, 푸시)를 수신하고 그에 따라 해당 이벤트 핸들러를 호출합니다. 이러한 이벤트 중 일부는 서비스 작업자의 수명 주기의 일부로 이러한 이벤트를 차례로 거쳐 활성화됩니다.
설치
서비스 워커가 성공적으로 등록되면 설치 이벤트가 발생합니다. 이것은 캐시를 설정하거나 IndexedDB에 개체 저장소를 만드는 것과 같은 초기화 작업을 수행하기에 좋은 곳입니다. (IndexedDB는 세부 사항을 살펴보고 나면 더 이해하기 쉬울 것입니다. 지금은 키-값 쌍 구조라고 말할 수 있습니다.)
self.addEventListener('install', (event) => { let CACHE_NAME = 'xyz-cache' let urlsToCache = [ '/', '/styles/main.css', '/scripts/bundle.js' ] event.waitUntil( /* open method available on caches, takes in the name of cache as the first parameter. It returns a promise that resolves to the instance of cache All the URLS above can be added to cache using the addAll method. */ caches.open(CACHE_NAME) .then (cache => cache.addAll(urlsToCache)) ) })
여기에서 다음 로드가 즉시 수행되도록 일부 파일을 캐싱합니다. self
는 서비스 워커 인스턴스를 나타냅니다. event.waitUntil
은 서비스 워커가 내부의 모든 코드 실행이 완료될 때까지 기다리게 합니다.
활성화
서비스 워커가 설치되면 아직 가져오기 요청을 수신할 수 없습니다. 오히려 activate
이벤트가 발생합니다. 동일한 범위의 웹 사이트에서 활성 서비스 워커가 작동하지 않으면 설치된 서비스 워커가 즉시 활성화됩니다. 그러나 웹 사이트에 이미 활성 서비스 워커가 있는 경우 이전 서비스 워커에서 작동하는 모든 탭이 닫힐 때까지 새 서비스 워커의 활성화가 지연됩니다. 이전 서비스 작업자가 새 캐시에서 현재 수정된 캐시 인스턴스를 사용하고 있을 수 있기 때문에 이는 의미가 있습니다. 따라서 활성화 단계는 오래된 캐시를 제거하기에 좋은 위치입니다.
self.addEventListener('activate', (event) => { let cacheWhitelist = ['products-v2'] // products-v2 is the name of the new cache event.waitUntil( caches.keys().then (cacheNames => { return Promise.all( cacheNames.map( cacheName => { /* Deleting all the caches except the ones that are in cacheWhitelist array */ if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName) } }) ) }) ) })
위의 코드에서는 이전 캐시를 삭제합니다. 캐시 이름이 cacheWhitelist
와 일치하지 않으면 삭제됩니다. 대기 단계를 건너뛰고 즉시 서비스 워커를 활성화하려면 skip.waiting()
을 사용합니다.
self.addEventListener('activate', (event) => { self.skipWaiting() // The usual stuff })
서비스 워커가 활성화되면 가져오기 요청 및 푸시 이벤트를 수신할 수 있습니다.
이벤트 핸들러 가져오기
웹 페이지가 네트워크를 통해 리소스에 대한 가져오기 요청을 실행할 때마다 서비스 작업자의 가져오기 이벤트가 호출됩니다. fetch 이벤트 핸들러는 먼저 캐시에서 요청된 리소스를 찾습니다. 캐시에 있는 경우 캐시된 리소스와 함께 응답을 반환합니다. 그렇지 않으면 서버에 페치 요청을 시작하고 서버가 요청된 리소스와 함께 응답을 다시 보낼 때 후속 요청을 위해 캐시에 넣습니다.
/* Fetch event handler for responding to GET requests with the cached assets */ self.addEventListener('fetch', (event) => { event.respondWith( caches.open('products-v2') .then (cache => { /* Checking if the request is already present in the cache. If it is present, sending it directly to the client */ return cache.match(event.request).then (response => { if (response) { console.log('Cache hit! Fetching response from cache', event.request.url) return response } /* If the request is not present in the cache, we fetch it from the server and then put it in cache for subsequent requests. */ fetch(event.request).then (response => { cache.put(event.request, response.clone()) return response }) }) }) ) })
event.respondWith
는 서비스 워커가 클라이언트에 맞춤형 응답을 보낼 수 있도록 합니다.
이제 오프라인 우선이 중요합니다. 중요하지 않은 요청의 경우 서버로 이동하는 대신 캐시에서 응답을 제공해야 합니다. 캐시에 자산이 없으면 서버에서 가져온 다음 후속 요청을 위해 캐시합니다.
서비스 워커는 모든 가져오기 요청에 대한 응답을 조작할 수 있는 권한이 있기 때문에 HTTPS 웹사이트에서만 작동합니다. 악의적인 의도를 가진 누군가가 HTTP 웹 사이트의 요청에 대한 응답을 변조할 수 있습니다. 따라서 HTTPS에서 PWA를 호스팅하는 것은 필수입니다. 서비스 워커는 DOM의 정상적인 기능을 방해하지 않습니다. 그들은 웹 페이지와 직접 통신할 수 없습니다. 웹 페이지에 메시지를 보내기 위해 포스트 메시지를 사용합니다.
웹 푸시 알림
모바일로 게임을 하느라 바쁘고 좋아하는 브랜드의 30% 할인을 알리는 팝업 알림이 표시된다고 가정해 보겠습니다. 더 이상 고민할 필요 없이 알림을 클릭하고 숨을 내쉬며 쇼핑합니다. 예를 들어, 크리켓이나 축구 경기에 대한 실시간 업데이트를 받거나 중요한 이메일 및 알림을 알림으로 받는 것은 제품에 대한 사용자 참여와 관련하여 큰 문제입니다. 이 기능은 PWA가 나올 때까지 기본 애플리케이션에서만 사용할 수 있었습니다. PWA는 웹 푸시 알림을 사용하여 기본 앱이 기본적으로 제공하는 이 강력한 기능과 경쟁합니다. 브라우저 탭에서 PWA가 열려 있지 않고 브라우저가 열려 있지 않더라도 사용자는 여전히 웹 푸시 알림을 받습니다.
웹 애플리케이션은 푸시 알림을 보내려면 사용자에게 권한을 요청해야 합니다.
사용자가 "허용" 버튼을 클릭하여 확인하면 브라우저에서 고유한 구독 토큰을 생성합니다. 이 토큰은 이 장치에 대해 고유합니다. Chrome에서 생성한 구독 토큰의 형식은 다음과 같습니다.
{ "endpoint": "https://fcm.googleapis.com/fcm/send/c7Veb8VpyM0:APA91bGnMFx8GIxf__UVy6vJ-n9i728CUJSR1UHBPAKOCE_SrwgyP2N8jL4MBXf8NxIqW6NCCBg01u8c5fcY0kIZvxpDjSBA75sVz64OocQ-DisAWoW7PpTge3SwvQAx5zl_45aAXuvS", "expirationTime": null, "keys": { "p256dh": "BJsj63kz8RPZe8Lv1uu-6VSzT12RjxtWyWCzfa18RZ0-8sc5j80pmSF1YXAj0HnnrkyIimRgLo8ohhkzNA7lX4w", "auth": "TJXqKozSJxcWvtQasEUZpQ" } }
위의 토큰에 포함된 endpoint
은 모든 구독에 대해 고유합니다. 평균적인 웹 사이트에서 수천 명의 사용자가 푸시 알림 수신에 동의하고 각 사용자에 대해 이 endpoint
은 고유합니다. 따라서 이 endpoint
의 도움으로 애플리케이션은 푸시 알림을 전송하여 향후 이러한 사용자를 대상으로 할 수 있습니다. expirationTime
은 특정 장치에 대해 구독이 유효한 시간입니다. expirationTime
시간이 20일이면 사용자의 푸시 구독이 20일 후에 만료되고 사용자가 이전 구독에 대한 푸시 알림을 받을 수 없음을 의미합니다. 이 경우 브라우저는 해당 장치에 대한 새 구독 토큰을 생성합니다. auth
및 p256dh
키는 암호화에 사용됩니다.
이제 앞으로 수천 명의 사용자에게 푸시 알림을 보내려면 먼저 각각의 구독 토큰을 저장해야 합니다. 이러한 사용자에게 푸시 알림을 보내는 것은 애플리케이션 서버(백엔드 서버, 아마도 Node.js 스크립트)의 작업입니다. 이것은 요청 페이로드의 알림 데이터를 사용하여 엔드포인트 URL에 POST
요청을 하는 것처럼 간단하게 들릴 수 있습니다. 그러나 사용자를 위한 푸시 알림이 서버에 의해 트리거될 때 사용자가 온라인 상태가 아닌 경우 다시 온라인 상태가 된 후에도 해당 알림을 받아야 합니다. 서버는 수천 개의 요청을 사용자에게 보내는 것과 함께 이러한 시나리오를 처리해야 합니다. 사용자의 연결을 추적하는 서버는 복잡하게 들립니다. 따라서 중간에 있는 것이 웹 푸시 알림을 서버에서 클라이언트로 라우팅하는 역할을 합니다. 이것을 푸시 서비스라고 하며 모든 브라우저에는 푸시 서비스의 자체 구현이 있습니다. 브라우저는 알림을 보내기 위해 푸시 서비스에 다음 정보를 알려야 합니다.
- 살 시간
메시지가 사용자에게 전달되지 않은 경우 메시지를 큐에 넣어야 하는 시간입니다. 이 시간이 지나면 메시지가 대기열에서 제거됩니다. - 메시지의 긴급성
이는 푸시 서비스가 우선 순위가 높은 메시지만 전송하여 사용자의 배터리를 보존하기 위한 것입니다.
푸시 서비스는 메시지를 클라이언트로 라우팅합니다. 해당 웹 애플리케이션이 브라우저에서 열려 있지 않더라도 푸시는 클라이언트에서 수신해야 하므로 백그라운드에서 지속적으로 모니터링하는 무언가가 푸시 이벤트를 수신해야 합니다. 당신은 그것을 추측했습니다. 그것이 서비스 워커의 일입니다. 서비스 워커는 푸시 이벤트를 수신하고 사용자에게 알림을 표시하는 작업을 수행합니다.
이제 브라우저, 푸시 서비스, 서비스 워커 및 애플리케이션 서버가 조화롭게 작동하여 사용자에게 푸시 알림을 보냅니다. 구현 세부 사항을 살펴보겠습니다.
웹 푸시 클라이언트
사용자에게 권한을 요청하는 것은 일회성입니다. 사용자가 이미 푸시 알림 수신 권한을 부여했다면 다시 요청하지 않아야 합니다. 권한 값은 Notification.permission
에 저장됩니다.
/* Notification.permission can have one of these three values: default, granted or denied. */ if (Notification.permission === 'default') { /* The Notification.requestPermission() method shows a notification permission prompt to the user. It returns a promise that resolves to the value of permission*/ Notification.requestPermission().then (result => { if (result === 'denied') { console.log('Permission denied') return } if (result === 'granted') { console.log('Permission granted') /* This means the user has clicked the Allow button. We're to get the subscription token generated by the browser and store it in our database. The subscription token can be fetched using the getSubscription method available on pushManager of the serviceWorkerRegistration object. If subscription is not available, we subscribe using the subscribe method available on pushManager. The subscribe method takes in an object. */ serviceWorkerRegistration.pushManager.getSubscription() .then (subscription => { if (!subscription) { const applicationServerKey = ' ' serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, // All push notifications from server should be displayed to the user applicationServerKey // VAPID Public key }) } else { saveSubscriptionInDB(subscription, userId) // A method to save subscription token in the database } }) } }) }
/* Notification.permission can have one of these three values: default, granted or denied. */ if (Notification.permission === 'default') { /* The Notification.requestPermission() method shows a notification permission prompt to the user. It returns a promise that resolves to the value of permission*/ Notification.requestPermission().then (result => { if (result === 'denied') { console.log('Permission denied') return } if (result === 'granted') { console.log('Permission granted') /* This means the user has clicked the Allow button. We're to get the subscription token generated by the browser and store it in our database. The subscription token can be fetched using the getSubscription method available on pushManager of the serviceWorkerRegistration object. If subscription is not available, we subscribe using the subscribe method available on pushManager. The subscribe method takes in an object. */ serviceWorkerRegistration.pushManager.getSubscription() .then (subscription => { if (!subscription) { const applicationServerKey = ' ' serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, // All push notifications from server should be displayed to the user applicationServerKey // VAPID Public key }) } else { saveSubscriptionInDB(subscription, userId) // A method to save subscription token in the database } }) } }) }
위의 subscribe
방법에서 userVisibleOnly
및 applicationServerKey
를 전달하여 구독 토큰을 생성합니다. userVisibleOnly
속성은 서버에서 보낸 모든 푸시 알림이 클라이언트에 표시될 것임을 브라우저에 알리기 때문에 항상 true여야 합니다. applicationServerKey
의 목적을 이해하기 위해 시나리오를 고려해 보겠습니다.
어떤 사람이 수천 개의 구독 토큰을 보유하고 있다면 이러한 구독에 포함된 끝점에 알림을 보낼 수 있습니다. 엔드포인트가 고유한 ID에 연결될 수 있는 방법은 없습니다. 웹 애플리케이션에서 생성된 구독 토큰에 고유한 ID를 제공하기 위해 VAPID 프로토콜을 사용합니다. VAPID를 사용하면 애플리케이션 서버가 푸시 알림을 보내는 동안 푸시 서비스에 자발적으로 자신을 식별합니다. 다음과 같이 두 개의 키를 생성합니다.
const webpush = require('web-push') const vapidKeys = webpush.generateVAPIDKeys()
web-push는 npm 모듈입니다. vapidKeys
에는 하나의 공개 키와 하나의 개인 키가 있습니다. 위에서 사용한 애플리케이션 서버 키는 공개 키입니다.
웹 푸시 서버
웹 푸시 서버(응용 서버)의 작업은 간단합니다. 구독 토큰에 알림 페이로드를 보냅니다.
const options = { TTL: 24*60*60, //TTL is the time to live, the time that the notification will be queued in the push service vapidDetails: { subject: '[email protected]', publicKey: ' ', privateKey: ' ' } } const data = { title: 'Update', body: 'Notification sent by the server' } webpush.sendNotification(subscription, data, options)
const options = { TTL: 24*60*60, //TTL is the time to live, the time that the notification will be queued in the push service vapidDetails: { subject: '[email protected]', publicKey: ' ', privateKey: ' ' } } const data = { title: 'Update', body: 'Notification sent by the server' } webpush.sendNotification(subscription, data, options)
const options = { TTL: 24*60*60, //TTL is the time to live, the time that the notification will be queued in the push service vapidDetails: { subject: '[email protected]', publicKey: ' ', privateKey: ' ' } } const data = { title: 'Update', body: 'Notification sent by the server' } webpush.sendNotification(subscription, data, options)
웹 푸시 라이브러리의 sendNotification
메소드를 사용합니다.
서비스 워커
서비스 작업자는 다음과 같이 사용자에게 알림을 표시합니다.
self.addEventListener('push', (event) => { let options = { body: event.data.body, icon: 'images/example.png', } event.waitUntil( /* The showNotification method is available on the registration object of the service worker. The first parameter to showNotification method is the title of notification, and the second parameter is an object */ self.registration.showNotification(event.data.title, options) ) })
지금까지 서비스 워커가 캐시를 사용하여 요청을 저장하고 PWA를 빠르고 안정적으로 만드는 방법과 웹 푸시 알림이 사용자의 참여를 유지하는 방법을 살펴보았습니다.
오프라인 지원을 위해 클라이언트 측에 많은 데이터를 저장하려면 거대한 데이터 구조가 필요합니다. 파이낸셜 타임즈 PWA를 살펴보자. 이 데이터 구조의 힘을 직접 목격해야 합니다. 브라우저에서 URL을 로드한 다음 인터넷 연결을 끕니다. 페이지를 새로고침합니다. 가! 여전히 작동합니까? 그것은. (내가 말했듯이 오프라인은 새로운 검정색입니다.) 데이터는 전선에서 나오지 않습니다. 집에서 대접하고 있습니다. Chrome 개발자 도구의 "응용 프로그램" 탭으로 이동합니다. "Storage" 아래에 "IndexedDB"가 있습니다.
"Articles" 개체 저장소를 확인하고 항목을 확장하여 마법을 직접 확인하십시오. Financial Times는 오프라인 지원을 위해 이 데이터를 저장했습니다. 방대한 양의 데이터를 저장할 수 있는 이 데이터 구조를 IndexedDB라고 합니다. IndexedDB는 구조화된 데이터를 저장하기 위한 JavaScript 기반 객체 지향 데이터베이스입니다. 다양한 목적을 위해 이 데이터베이스에 다른 개체 저장소를 만들 수 있습니다. 예를 들어 위의 이미지에서 볼 수 있듯이 "Resources", "ArticleImages" 및 "Articles"는 개체 저장소로 호출됩니다. 개체 저장소의 각 레코드는 키로 고유하게 식별됩니다. IndexedDB는 파일과 Blob을 저장하는 데에도 사용할 수 있습니다.
책을 저장하기 위한 데이터베이스를 생성하여 IndexedDB를 이해하도록 노력합시다.
let openIdbRequest = window.indexedDB.open('booksdb', 1)
데이터베이스 booksdb
가 아직 존재하지 않는 경우 위의 코드는 booksdb
데이터베이스를 생성합니다. open 메소드의 두 번째 매개변수는 데이터베이스 버전입니다. 버전을 지정하면 향후 발생할 수 있는 스키마 관련 변경 사항을 처리합니다. 예를 들어, booksdb
에는 이제 하나의 테이블만 있지만 애플리케이션이 성장하면 여기에 두 개의 테이블을 더 추가할 계획입니다. 데이터베이스가 업데이트된 스키마와 동기화되었는지 확인하기 위해 이전 버전보다 높은 버전을 지정합니다.
open
메서드를 호출해도 데이터베이스가 바로 열리지 않습니다. IDBOpenDBRequest
객체를 반환하는 비동기 요청입니다. 이 개체에는 성공 및 오류 속성이 있습니다. 연결 상태를 관리하려면 이러한 속성에 대한 적절한 처리기를 작성해야 합니다.
let dbInstance openIdbRequest.onsuccess = (event) => { dbInstance = event.target.result console.log('booksdb is opened successfully') } openIdbRequest.onerror = (event) => { console.log('There was an error in opening booksdb database') } openIdbRequest.onupgradeneeded = (event) => { let db = event.target.result let objectstore = db.createObjectStore('books', { keyPath: 'id' }) }
객체 저장소(객체 저장소는 SQL 기반 테이블과 유사하며 키-값 구조가 있음)의 생성 또는 수정을 관리하기 위해 openIdbRequest
객체에서 onupgradeneeded
메소드가 호출됩니다. 버전이 변경될 때마다 onupgradeneeded
메소드가 호출됩니다. 위의 코드 스니펫에서는 고유 키를 ID로 사용하여 책 개체 저장소를 만들고 있습니다.
이 코드 조각을 배포한 후 users
라는 개체 저장소를 하나 더 만들어야 한다고 가정해 보겠습니다. 이제 데이터베이스 버전은 2
가 됩니다.
let openIdbRequest = window.indexedDB.open('booksdb', 2) // New Version - 2 /* Success and error event handlers remain the same. The onupgradeneeded method gets called when the version of the database changes. */ openIdbRequest.onupgradeneeded = (event) => { let db = event.target.result if (!db.objectStoreNames.contains('books')) { let objectstore = db.createObjectStore('books', { keyPath: 'id' }) } let oldVersion = event.oldVersion let newVersion = event.newVersion /* The users tables should be added for version 2. If the existing version is 1, it will be upgraded to 2, and the users object store will be created. */ if (oldVersion === 1) { db.createObjectStore('users', { keyPath: 'id' }) } }
열기 요청의 성공 이벤트 핸들러에서 dbInstance
를 캐시했습니다. IndexedDB에서 데이터를 검색하거나 추가하기 위해 dbInstance
를 사용할 것입니다. 책 개체 저장소에 책 레코드를 추가해 보겠습니다.
let transaction = dbInstance.transaction('books') let objectstore = transaction.objectstore('books') let bookRecord = { id: '1', name: 'The Alchemist', author: 'Paulo Coelho' } let addBookRequest = objectstore.add(bookRecord) addBookRequest.onsuccess = (event) => { console.log('Book record added successfully') } addBookRequest.onerror = (event) => { console.log('There was an error in adding book record') }
우리는 특히 객체 저장소에 레코드를 작성하는 동안 transactions
을 사용합니다. 트랜잭션은 단순히 데이터 무결성을 보장하기 위한 작업의 래퍼입니다. 트랜잭션의 작업 중 하나가 실패하면 데이터베이스에서 작업이 수행되지 않습니다.
put
메소드를 사용하여 장부 레코드를 수정해 보겠습니다.
let modifyBookRequest = objectstore.put(bookRecord) // put method takes in an object as the parameter modifyBookRequest.onsuccess = (event) => { console.log('Book record updated successfully') }
get
메소드를 사용하여 도서 레코드를 검색해 보겠습니다.
let transaction = dbInstance.transaction('books') let objectstore = transaction.objectstore('books') /* get method takes in the id of the record */ let getBookRequest = objectstore.get(1) getBookRequest.onsuccess = (event) => { /* event.target.result contains the matched record */ console.log('Book record', event.target.result) } getBookRequest.onerror = (event) => { console.log('Error while retrieving the book record.') }
홈 화면에 아이콘 추가하기
이제 PWA와 기본 응용 프로그램 사이에 차이가 거의 없으므로 PWA에 주요 위치를 제공하는 것이 합리적입니다. 웹사이트가 PWA(HTTPS에서 호스팅되고 서비스 워커와 통합되며 manifest.json
이 있음)의 기본 기준을 충족하고 사용자가 웹 페이지에서 시간을 보낸 후 브라우저는 하단에 프롬프트를 호출하여 다음을 묻습니다. 아래와 같이 사용자가 홈 화면에 앱을 추가할 수 있습니다.
사용자가 "홈 화면에 FT 추가"를 클릭하면 PWA가 홈 화면과 앱 서랍에 표시됩니다. 사용자가 휴대전화에서 애플리케이션을 검색하면 검색어와 일치하는 모든 PWA가 나열됩니다. 시스템 설정에서도 볼 수 있으므로 사용자가 쉽게 관리할 수 있습니다. 이러한 의미에서 PWA는 기본 애플리케이션처럼 작동합니다.
PWA는 manifest.json
을 사용하여 이 기능을 제공합니다. 간단한 manifest.json
파일을 살펴보겠습니다.
{ "name": "Demo PWA", "short_name": "Demo", "start_url": "/?standalone", "background_color": "#9F0C3F", "theme_color": "#fff1e0", "display": "standalone", "icons": [{ "src": "/lib/img/icons/xxhdpi.png?v2", "sizes": "192x192" }] }
short_name
은 사용자의 홈 화면과 시스템 설정에 나타납니다. name
은 크롬 프롬프트와 시작 화면에 나타납니다. 시작 화면은 앱이 시작할 준비가 될 때 사용자에게 표시되는 것입니다. start_url
은 앱의 기본 화면입니다. 사용자가 홈 화면에서 아이콘을 탭할 때 얻는 것입니다. background_color
는 시작 화면에서 사용됩니다. theme_color
는 도구 모음의 색상을 설정합니다. display
모드에 대한 standalone
값은 앱이 전체 화면 모드(브라우저의 도구 모음 숨기기)에서 작동되어야 한다고 말합니다. 사용자가 PWA를 설치할 때 그 크기는 기본 응용 프로그램의 메가바이트가 아니라 킬로바이트에 불과합니다.
서비스 작업자, 웹 푸시 알림, IndexedDB 및 홈 화면 위치는 오프라인 지원, 안정성 및 참여를 보완합니다. 서비스 워커는 생명을 얻고 첫 번째 로드에서 작업을 시작하지 않는다는 점에 유의해야 합니다. 모든 정적 자산 및 기타 리소스가 캐시될 때까지 첫 번째 로드는 여전히 느립니다. 첫 번째 로드를 최적화하기 위해 몇 가지 전략을 구현할 수 있습니다.
자산 번들링
HTML, 스타일 시트, 이미지 및 JavaScript를 포함한 모든 리소스는 서버에서 가져와야 합니다. 파일이 많을수록 파일을 가져오는 데 더 많은 HTTPS 요청이 필요합니다. WebPack과 같은 번들러를 사용하여 정적 자산을 묶을 수 있으므로 서버에 대한 HTTP 요청 수를 줄일 수 있습니다. WebPack은 코드 분할(즉, 현재 페이지 로드에 필요한 파일만 번들로 묶지 않고 모두 함께 묶음) 및 트리 쉐이킹(예: 중복 종속성 제거 또는 가져왔지만 코드에서 사용되지 않는 종속성).
왕복 줄이기
웹에서 속도가 느려지는 주요 원인 중 하나는 네트워크 대기 시간입니다. 바이트가 A에서 B로 이동하는 데 걸리는 시간은 네트워크 연결에 따라 다릅니다. 예를 들어 Wi-Fi를 통한 특정 왕복에는 3G 연결에서 50밀리초 및 500밀리초가 걸리지만 2G 연결에서는 2500밀리초가 걸립니다. 이러한 요청은 HTTP 프로토콜을 사용하여 전송됩니다. 즉, 특정 연결이 요청에 사용되는 동안 이전 요청의 응답이 제공될 때까지 다른 요청에 사용할 수 없습니다. 웹 사이트는 HTTP 요청을 만들기 위해 6개의 연결을 사용할 수 있으므로 웹 사이트는 한 번에 6개의 비동기 HTTP 요청을 만들 수 있습니다. 평균적인 웹사이트는 대략 100개의 요청을 합니다. 따라서 최대 6개의 연결을 사용할 수 있는 경우 사용자는 단일 왕복에 약 833밀리초를 소비하게 될 수 있습니다. (계산은 833밀리초 - 100 ⁄ 6 = 1666 입니다. 왕복에 소요되는 시간을 계산하기 때문에 1666을 2로 나누어야 합니다.) HTTP2를 사용하면 처리 시간이 크게 단축됩니다. HTTP2는 연결 헤드를 차단하지 않으므로 여러 요청을 동시에 보낼 수 있습니다.
대부분의 HTTP 응답에는 last-modified
헤더와 etag
헤더가 포함되어 있습니다. last-modified
헤더는 파일이 마지막으로 수정된 날짜이고 etag
는 파일 내용에 따른 고유한 값입니다. 파일의 내용이 변경된 경우에만 변경됩니다. 캐시된 버전이 이미 로컬에서 사용 가능한 경우 이러한 헤더를 사용하여 파일을 다시 다운로드하지 않도록 할 수 있습니다. 브라우저에 이 파일의 버전이 로컬에서 사용 가능한 경우 다음과 같이 요청에 다음 두 헤더 중 하나를 추가할 수 있습니다.
서버는 파일의 내용이 변경되었는지 확인할 수 있습니다. 파일의 내용이 변경되지 않은 경우 상태 코드 304( 수정되지 않음 )로 응답합니다.
This indicates to the browser to use the locally available cached version of the file. By doing all of this, we've prevented the file from being downloaded.
Faster responses are in now place, but our job is not done yet. We still have to parse the HTML, load the style sheets and make the web page interactive. It makes sense to show some empty boxes with a loader to the user, instead of a blank screen. While the HTML document is getting parsed, when it comes across <script src='asset.js'></script>
, it will make a synchronous HTTP request to the server to fetch asset.js
, and the whole parsing process will be paused until the response comes back. Imagine having a dozen of synchronous static asset references. These could very well be managed just by making use of the async
keyword in script references, like <script src='asset.js' async></script>
. With the introduction of the async
keyword here, the browser will make an asynchronous request to fetch asset.js
without hindering the parsing of the HTML. If a script file is required at a later stage, we can defer the downloading of that file until the entire HTML has been parsed. A script file can be deferred by using the defer
keyword, like <script src='asset.js' defer></script>
.
결론
We've learned a lot of many new things that make for a cool web application. Here's a summary of all of the things we've explored in this article:
- Service workers make good use of the cache to speed up the loading of assets.
- Web push notifications work under the hood.
- We use IndexedDB to store a massive amount of data.
- Some of the optimizations for instant first load, like using HTTP2 and adding headers like
Etag
,last-modified
andIf-None-Match
, prevent the downloading of valid cached assets.
그게 다야, 여러분!