Progressive Web Apps

웹에서 새로운 사용자 경험을 제공하는 방법론

우리가 웹이라고 부르는 환경은 아주 다양한 환경을 가지고 있다. 내가 이 글을 쓰는 시점에는 15인치 맥북 프로로 작성하고 있지만 누군가는 이 글을 아이폰 X로 볼 수도 있고, 갤럭시 S9으로 보고 있을 수도 있다. 혹은 아이패드 프로로 이 글을 보고 있을 수도 있겠다.


우리가 흔히 Desktop으로 부르는 환경에서 Mobile 환경으로 시대가 흘러감에 따라서 웹에서도 Mobile 환경에 적응하기 위한 다양한 노력을 하였다. 누군가는 Desktop 환경과 동일한 환경에서 CSS를 이용해 레이아웃을 변경하는 작업만 하기도 하였지만, 누군가는 Mobile 환경에 맞추어서 리소스를 최적화하고 상대적으로 부족한 컴퓨팅 환경에서 어떻게 좋은 사용자 경험을 제공할 수 있을 지에 대해서 고민하였다.


하지만 Mobile 환경이건 Desktop 환경이건 웹의 여러가지 한계점으로 인해 Application보다 좋은 사용성을 제공하기에는 일부 어려운 점이 있었다. 웹에서 콘텐츠에 접근하려면 반드시 ONLINE 상태여야했고, 리소스를 접속할 때마다 요청하기 때문에 네트워크 사용량이 많아야 했던 점들이 있다.


PWA (Progressive Web Apps)는 웹이 가지는 이러한 한계점들을 극복하고 새로운 사용자 경험을 제공하기 위한 개발 방법론의 집합이다. 따라서 그 구성이 해가 지남에 따라서 일부 달라지기도 하고, 새로이 추가되는 기능이 있기도 하며, 브라우저에서 지원하는 형태에 따라서 구현체가 달라지기도 한다.


이 글에서는 간단한 주소록 앱을 만들어 나가면서 PWA의 특성들과 그 활용법에 대해서 다루어보려고 한다.


주소록 예제는 https://rawgit.com/techhtml/pwa-contact/master/index.html 에서 확인할 수 있다.


목차

  1. FIRE 원칙
  2. Service Worker
  3. Web App manifest

Current Status of PWA

  1. iOS에서는 11.3 버전부터 Service Worker를 지원한다.
  2. Chrome은 45버전부터 지원했다.
  3. MS Edge에서도 현재는 Service Worker를 지원한다.

PWA에 있는 모든 Feature를 모든 브라우저가 대응하는 것은 아니다. iOS가 11.3 버전에서 Service Worker를 지원하고 있고, MS Edge도 지원하고 있기 때문에 현재 최신 브라우저는 모두 PWA의 핵심 Feature를 지원한다고 이야기할 수 있다.


PWA에서 Progressive는 점진적으로 개선한다는 의미로 모든 Feature를 지원하는 기기에서는 완벽한 환경을 제공할 수 있지만, 일부 Feature를 지원하지 않는 기기에서는 내가 원한 100%의 효과를 내지는 못할 수도 있다.


FIRE 원칙


네 단어의 앞단어를 딴 FIRE 원칙은 PWA를 관통하는 핵심 개념이라고 이야기할 수 있다. 네트워크 환경이 좋지 않거나, 디바이스의 성능이 떨어지는 경우에도 앱이 언제나 동작할 수 있도록 하여 좋은 환경을 제공하는 것이 목표라고 할 수 있다.


예를 들어 빠르다고 느끼게 하려면, Loading 중인 상태에서 Loading Progress를 보여주는 것보다 콘텐츠의 Placeholder를 보여주는 것이 더 효과적이다. 그리고 한번 로딩된 콘텐츠를 캐싱해두면 추후에 같은 웹 어플리케이션에 방문하였을 때 동일한 리소스를 다시 다운로드 받지 않아도 된다.


한국은 다른 나라에 비하면 네트워크 속도가 빠르다는 인식이 있지만, LTE 요금제를 다 사용하여 한시적 3G 요금제를 사용하거나 와이파이를 사용하는 경우에 네트워크 속도가 비약적으로 느려지는 경우가 있다. 특히 우리 집 네트워크 느리다. (슬프다) 그런 경우에도 웹을 잘 사용할 수 있도록 하는 것이 PWA에서 중요한 원칙이라고 이야기할 수 있겠다.


Service Worker


서비스 워커 (Service Worker)는 브라우저가 백그라운드에서 실행하는 스크립트로, 웹 페이지와는 별개로 동작하기 때문에 웹 페이지 또는 유저 인터렉션이 필요하지 않는 기능을 사용할 수 있다. 서비스 워커를 이용하면 푸시 알림 (Push Notification, Android Chrome 한정) 이나 백그라운드 동기화 (Background Sync, Android Chrome 한정) 가 가능하며, 다른 무엇보다 중요한 것은 서비스 워커를 이용하면 오프라인 환경을 통제할 수 있다는 점이다.


서비스 워커를 사용할 때 몇가지 유의사항이 있다.

  1. 반드시 HTTPS여야한다.
  2. 서비스 워커는 DOM에 직접 접근할 수 없다. 즉 서비스 워커 자체를 이용해서 DOM을 제어하는 건 불가하다.
  3. 서비스 워커는 페이지의 네트워크 요청 처리 방법을 제어할 수 있다.
  4. 서비스 워커는 사용되지 않을 때는 종료되고, 다음에 필요할 때 다시 시작된다. 서비스 워커가 종료 상태에서 재시작할 때 다시 사용해야하는 정보가 있는 경우 서비스 워커가 IndexedDB 에 대한 접근 권한을 가진다.
  5. 서비스 워커는 Promise를 주로 사용한다.

다만 개발자가 로컬에서 HTTPS 환경을 완전히 구축하기 어려우니, 로컬에서 작업하는 경우에는 HTTP에서도 테스트를 할 수 있다.


서비스 워커 라이프사이클

서비스 워커의 라이프사이클은 웹 페이지와 완전히 다르다.


  1. 서비스 워커를 등록한다.
  2. 서비스 워커를 설치한다.
    1. 설치 중 에러가 나면 활성화되지 않는다.
  3. 정상적으로 설치되면 활성화된다.
  4. 유휴 상태 (idle state)를 유지한다.
    1. 페이지에서 네트워크 요청이나 메시지가 생성될 때 Fetch나 Message 이벤트를 처리한다.
    2. 종료된다. (종료되더라도 제어권한을 가지면 다시 유휴 상태로 이동한다)

서비스 워커는 먼저 페이지에서 등록하고, 서비스 워커를 등록하면 브라우저가 백그라운드에서 서비스 워커를 설치한다. 서비스 워커는 설치 단계에서 Static Resource를 캐시하고, 모든 파일이 성공적으로 캐시되면 서비스 워커가 설치된다. 만약 파일 다운로드 및 캐시에 실패하면 서비스 워커가 설치되지 않는다.


설치가 완료되면 활성화 단계에 접어든다. 활성화 단계 후에는 서비스 워커 범위 안의 모든 페이지를 제어하지만 서비스 워커를 처음으로 등록한 페이지는 다시 로드해야 제어할 수 있다. 서비스 워커에 제어 권한이 부여된 경우 서비스 워커는 메모리를 절약하기 위해 종료되거나 페이지에서 네트워크 요청이나 메시지가 생성될 때 fetch 및 Message 이벤트를 처리한다.


서비스 워커 등록하기

서비스 워커를 등록하는 건 서비스 워커 스크립트 파일이 어디있는 지 브라우저에 알려주는 행위다.


index.js

// 서비스워커를 지원하는 브라우저인 경우
if ('serviceWorker' in navigator) {
  // 윈도우가 로딩되었을 때
  window.addEventListener('load', function() {
    // 서비스워커 (sw.js)를 등록한다.
    navigator.serviceWorker.register('/sw.js').then(function(registration) {
      // 등록이 성공했다면 (즉 설치가 완료되었다면)
      console.log("등록이 완료되었습니다");
    }).catch(function(err) {
      // 등록이 실패했다면
      console.log("등록이 실패했습니다");
    })
  })
}


서비스 워커를 등록하여 페이지가 아닌 브라우저 백그라운드에서 해당 도메인의 제어권을 가질 수 있다.


서비스 워커 설치하기

이번에는 설치 (install) 이벤트를 처리하는 서비스 워커를 살펴보자.


self.addEventListener('install', function(event) {
  // 설치를 여기서 진행한다
})


서비스 워커의 활용 범위에 따라서 내부가 상이하지만, 제일 먼저 하는 일은 리소스를 캐시하는 것이다.



// CACHE 네임스페이스
const CACHE_NAME = "contact-app";

// CACHE할 파일목록

const cache_urls = [
  '/',
  '/index.html',
  '/styles/contact.css',
  '/scripts/contact.js'
]

self.addEventListener('install', function(event) {
  // 설치가 시작되면 동작한다.
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(function(cache) {
         console.log('캐시가 열렸습니다');
         return cache.addAll(cache_urls);
      })
  );
})


이렇게 하면 서비스 워커에 리소스를 캐시해둘 수 있다. 여러가지 장점이 있지만 가장 큰 장점은 유저가 다음에 다시 서비스에 방문하였을 때 리소스를 다시 네트워크에서 로딩할 필요가 없다는 것이다.

오프라인 환경 제어하기

서비스 워커를 이용하는 또 다른 장점은 오프라인 환경을 제어할 수 있다는 것이다. 정확히는 브라우저에 설치된 서비스 워커가 해당 도메인 스코프 내에서 네트워크 요청이나 메시지를 제어할 수 있기 때문에, 서비스에서 요청하는 네트워크 요청이 이미 캐시된 것이라면 캐시된 리소스에서 불러오게 함으로서 오프라인 환경에서도 브라우저 캐시를 이용해 마치 네트워크에 연결된 것처럼 동작할 수 있게 되는 것이다.


코드는 아주 단순하다.


self.addEventListener('fetch', function(event) {
  // 'fetch' 이벤트가 발생하였을 때
  event.respondWith(
    // event.request와 일치하는 것을 찾는다
    caches.match(event.request)
      .then(function(response) {
        // 만약 일치하는 게 있다면 반환한다.
        if (response) {
          return response;
        }
        // 일치하는 게 없다면 이벤트의 요청을 발송한다. (즉 페이지 로딩)
        return fetch(event.request);
      }
    )
  );
});


지금은 서비스워커에서 index만 캐시해두었지만 유저의 Critical Journey에 속하는 리소스들을 미리 캐시해둔다면 유저가 오프라인에서건 온라인에서던 비슷한 사용성을 얻을 수 있다.


Web App 설정하기

웹과 앱의 차이는 무엇일까? 여러가지가 있겠지만 몇가지 큰 차이점을 살펴보자


  1. 웹은 브라우저를 통해 접근하고, 앱은 앱 아이콘을 통해 접근한다.
  2. 웹은 브라우저 Frame (주소창 등)이 노출되고, 앱은 어플리케이션 UI가 노출된다.

하지만 우리가 지금 만들고 있는건 웹 어플리케이션이니 어플리케이션에서 느꼈던 사용성을 웹에서 느낄 수 있게 만들어야 한다. 웹 앱을 설정하는 방법이 Android Chrome과 iOS가 다르지만 개발자는 둘 다 대응해야한다. 따라서 이 글에서는 iOS와 Android Chrome 대응법을 둘 다 작성해둔다.


App Icon 추가하기

App Icon은 add to homescreen 을 실행하였을 때 노출되는 아이콘으로 웹 어플리케이션에서 중요한 위치를 가지고 있다.


Android Chrome & MS Edge

Android에서는 Web App Manifest라고 부르는 JSON 규격을 이용해서 웹 앱을 정의한다. 현재 표준화를 진행하고 있으며 현재는 Android Chrome 및 Chromium을 포크한 구현체, Edge에서 대응하고 있다.


표준 스펙 : Web App Manifest


우선 가장 간단한 Web App Manifest 파일을 하나 만들어보자.


manifest.json

{
  "name": "주소록",
  "description": "조은의 번호만 있는 주소록"
}


표준 스펙에서는 webmanifest 확장자 사용을 권고하고 있으나 일반적으로 브라우저는 json 같은 확장자를 지원한다. 따라서 이 예제에서 확장자는 .json 을 사용하고자 한다.


Manifest 작성 후에는 link 요소를 이용해 페이지와 연결하면 된다.


index.html

<link rel="manifest" href="/manifest.json" />


다시 본론으로 넘어가 아이콘을 추가해보자. 안드로이드의 product 아이콘 사이즈는 48dp인데 dp는 밀도에 의존하지 않는 픽셀 (density independent pixel)이기 때문에 실제 기기의 해상도에 따라서 아이콘 크기가 달라진다.


ldpi (0.75x)	@ 48.00dp	= 36.00px
mdpi (1x)        @ 48.00dp    	= 48.00px
hdpi (1.5x)	@ 48.00dp	= 72.00px
xhdpi (2x)	@ 48.00dp	= 96.00px
xxhdpi (3x)	@ 48.00dp	= 144.00px
xxxhdpi (4x)	@ 48.00dp	= 192.00px


ldpi나 hdpi는 대응하지 않는다고 가정한다면 네종류의 아이콘을 만들어야한다.


  1. 48 x 48
  2. 96 x 96
  3. 144 x 144
  4. 192 x 192

아이콘만 있다면 실제 대응은 간단하다. 아까 작성한 manifest 파일에 아이콘을 추가하자.


manifest.json

{
  "name": "주소록",
  "description": "조은의 번호만 있는 주소록",
  "icons": [{
    "src": "icon48.png",
    "sizes": "48x48",
    "type": "image/png"
  }, {
    "src": "icon96.png",
    "sizes": "96x96",
    "type": "image/png"
  }, {
    "src": "icon144.png",
    "sizes": "144x144",
    "type": "image/png"
  }, {
    "src": "icon192.png",
    "sizes": "192x192",
    "type": "image/png"
  }]
}


이렇게하면 App icon을 사이트에 추가할 수 있다.


iOS

iOS에서는 자체 meta 규격을 이용해서 App icon을 추가할 수 있도록 하고있다.


index.html

<link rel="apple-touch-icon" sizes="180x180" href="touch-icon-iphone-retina.png">


iOS에서 권장하는 App icon 사이즈는 다음과 같다.

App Icon - Icons and Images - iOS - Human Interface Guidelines - Apple Developer


  1. 180 x 180 (60 x 60 @3x , iPhone)
  2. 120 x 120 (60 x 60 @2x, iPhone)
  3. 167 x 167 (83.5 x 83.5 @2x, iPad Pro)
  4. 152 x 152 (76 x 76 @2x, iPad & iPad mini)
  5. 1024 x 1024 (1024 x 1024 @1x, App Store)

언젠가 지원할거라는 생각은 들지만 앱 스토어에 PWA 등록은 현재 불가능하기 때문에 iPad 까지만 대응하도록 하자.


index.html

<link rel="apple-touch-icon" sizes="180x180" href="touch-icon-iphone-retina.png">
<link rel="apple-touch-icon" sizes="120x120" href="touch-icon-iphone.png">
<link rel="apple-touch-icon" sizes="167x167" href="touch-icon-ipad-pro.png">
<link rel="apple-touch-icon" sizes="152x152" href="touch-icon-ipad.png">


이렇게 하면 iOS에서 앱 아이콘을 추가할 수 있다.


Frame 제거하기

이번에는 주소창 등 브라우저에서 기본으로 제공하는 Frame을 제거해보자.


Android Chrome & MS Edge

마찬가지로 manifest 파일에서 설정하는데, display 값을 변경해서 Frame을 제거할 수 있다.


{
  "display": "standalone"
}


지원하는 속성값은 4개다.

  1. fullscreen : 디스플레이 공간에서 사용 가능한 모든 공간을 사용하며, 유저 에이전트 chrome 도 표시되지 않는다.
  2. standalone : 일반 어플리케이션처럼 보인다. 여기서 유저 에이전트는 navigation을 위한 UI 요소를 제외하지만, status bar 같은 UI 요소는 포함한다.
  3. minimul-ui : standalone 모드와 비슷하지만 navigation을 위한 UI 요소를 포함한다. 구성 요소는 브라우저에 따라 다르다.
  4. browser : 그냥 브라우저다. (Default)

iOS

iOS에서는 meta 를 이용해 Frame을 제어한다. Android에서는 다양하게 제어 가능했지만, iOS는 standalone 혹은 browser 로만 대응 가능하다.


index.html

<meta name="mobile-web-app-capable" content="yes">


마무리

이번에는 Static resource를 서비스워커에 등록하고 Web App Manifest를 등록하는 아주 간단한 과정만 진행해보았다. 이어지는 다음 글에서는 App Shell을 이용해 동적 콘텐츠와 App Shell을 분리하여 유저가 다시 다운로드 받을 리소스를 줄이고, 핵심 데이터들은 Local Storage에 저장하여 오프라인 환경에서도 불러올 수 있도록 해보자.