[PWA] 웹 푸시 알림 동작 방식

Jaewon·2023년 4월 23일
0
post-thumbnail

How Push Works

Client Side

사용자는 push messaging 서비스를 구독해야 한다.

구독은 두 가지 단계가 필요하다.

  1. 푸시 메시지를 보내기 위한 권한 허용하기
  2. 브라우저로부터 PushSubscription 받기

PushSubscription 은 사용자에게 푸시 메시지를 보내기 위한 모든 정보들이 포함되어 있는 객체이다. 사용자를 식별하기 위한 ID라고 봐도 무방하다.

생성하고 나면 서버로 보낸다. 서버는 이를 데이터베이스에 저장하고 사용자에게 푸시 메시지를 보내는 데 사용한다.

Server Side

서버에서는 푸시 메시지를 사용자에게 보내기 위해서 Push Service에 API call을 해야 한다. API call은 보낼 데이터, 보낼 사용자 등의 정보를 포함하며 암호화된 데이터를 보내야 한다. 또한 이는 web push protocol을 준수해야 한다.

Who and what is the push service?

Push Service는 네트워크 요청을 받아 그것을 검증하고 푸시 메시지를 브라우저에 전달하는 역할을 한다. Push Service에 API call을 하기 위한 URL은 클라이언트가 전달해준 PushSubscription 객체의 endpoint 프로퍼티에 담겨 있다. endpoint는 사용자마다 unique한 값을 가진다.

{
  "endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/",
  "keys": {
    "p256dh": "BHb4NtuugDgctX7gS...",
    "auth": "lHu1biQfeKUWGcEAyGTyaQ"
  }
}

Client Side Again

클라이언트는 서비스 워커를 통해 push 이벤트를 선언한다. Push Service가 메시지를 클라이언트에 전달하면 이벤트가 트리거된다.

서비스 워커는 특별한 자바스크립트 파일이다. 브라우저는 이 자바스크립트를 페이지가 열려있지 않을 때에도 실행할 수 있으며 심지어 브라우저가 종료되어 있을 때에도 실행할 수 있다. 이러한 점을 이용하여 백그라운드 태스크를 실행하거나 애널리틱스 호출, 오프라인 페이지 캐싱 등 여러가지 일을 할 수 있다.

Subscribing a User

우선 브라우저가 push messaging을 지원하는지 확인해야 한다. 아래 두 가지 방법으로 확인 가능하다.

if (!('serviceWorker' in navigator)) {
  // Service Worker isn't supported on this browser, disable or hide UI.
  return;
}

if (!('PushManager' in window)) {
  // Push isn't supported on this browser, disable or hide UI.
  return;
}

Register a service worker

서비스 워커를 등록할 때 브라우저에게 서비스 워커 파일이 어디 있는지 말해주어야 한다. 브라우저는 이 파일을 서비스 워커 환경에서 실행한다.

function registerServiceWorker() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      console.log('Service worker successfully registered.');
      return registration;
    })
    .catch(function (err) {
      console.error('Unable to register service worker.', err);
    });
}

register() 함수가 resolve되면 후술할 ServiceWorkerRegistration 을 리턴한다.

Requesting permission

function askPermission() {
  return new Promise(function (resolve, reject) {
    const permissionResult = Notification.requestPermission(function (result) {
      resolve(result);
    });

    if (permissionResult) {
      permissionResult.then(resolve, reject);
    }
  }).then(function (permissionResult) {
    if (permissionResult !== 'granted') {
      throw new Error("We weren't granted permission.");
    }
  });
}

Notification.requestPermission() 함수가 사용자에게 권한을 요청하는 팝업을 띄울 것이다.

결과는 권한을 허용하면 'granted', 차단하거나 그냥 팝업을 닫으면 'default''denied' 문자열이 반환된다. 위 코드는 'granted'가 아니면 에러를 던져 프로미스를 reject한다.

한 가지 알아두어야 할 점은 사용자가 권한을 차단하면 다시는 권한 요청 팝업이 뜨지 않을 것이며, 브라우저 설정에 가서 직접 바꿔주어야 한다는 점이다. 이를 어떻게 UI로 보여줄지는 본인의 몫이다.

Subscribe a user with PushManager

서비스 워커를 등록하고 권한도 얻었으면 이제 registration.pushManager.subscribe()를 호출하여 사용자를 구독한다.

function subscribeUserToPush() {
  return navigator.serviceWorker.ready
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey:
          "BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U",
      };

      return registration.pushManager.subscribe(subscribeOptions);
    })
    .then(function (pushSubscription) {
      console.log(
        "Received PushSubscription: ",
        JSON.stringify(pushSubscription)
      );
      return pushSubscription;
    });
}

subscribeOptions는 두 가지 프로퍼티가 있는데,

userVisibleOnly

silent push 옵션, 개발자가 silent push를 사용하여 알림 없이 사용자 모르게 서비스 워커를 작동시켜 위치 정보를 주기적으로 수집하는 더러운 일들을 할 수 있다는 문제점 때문에 현재 true로 사용이 강제된다.

applicationServerKey

Push Service에 API Call을 하는 서버의 식별자이다. application server key는 public, private 두 가지 키가 있으며 여기에는 public key가 들어간다. 브라우저는 이를 Push Service에 보내며, Push Service는 사용자의 PushSubscription에 application server key를 엮는다. 더 자세히 말하자면

  1. public applicaion server key를 넣어 subscribe()를 호출한다.
  2. 브라우저는 Push Service에 네트워크 요청을 보내고 Push Service는 endpoint를 생성하며 이 endpoint와 application public key를 엮어 리턴한다.
  3. 브라우저는 리턴받은 endpoint를 넣은 PushSubscription 객체를 만든다.

private key는 서버에서 Push Service에 API call을 하기 위해 보낼 데이터를 암호화할 때 사용한다 (암호화된 데이터를 endpoint로 보낸다는 걸 기억하자). Push Service는 서버의 요청을 받으면 암호화된 데이터를 복호화하기 위해 endpoint에 엮여 있던 public key를 사용하고 이를 통해 매칭되는 private key를 가진 동일한 서버가 보낸 데이터라는 것을 검증한다.

How to create application server keys

https://web-push-codelab.glitch.me/ 에서 생성하거나 아래에서 사용할 web-push 패키지 커맨드 라인으로 생성한다.

마지막으로 클라이언트는 subscribe() 함수의 결과로 리턴된 PushSubscription 객체를 서버에 보내야 한다. 서버는 이 객체의 값들을 데이터베이스에 저장한다.

Sending messages with web push libraries

Push Service에게 API call을 하는 과정은 web push protocol을 따라야 하기 때문에 매우 성가시다. 이를 위한 라이브러리들이 있고, 여기서는 web-push Node library를 쓴다. 다른 언어를 위한 라이브러리들도 존재한다.

web-push를 설치한다: npm install web-push --save

위 섹션에서 생성한 application server keys를 넣어 코드를 작성한다. vapidKeys는 프로토콜에서 application server keys를 부르는 명칭이다. 프로젝트 루트에 한 번만 선언하면 된다.

const vapidKeys = {
  publicKey:
    'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
  privateKey: 'UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls',
};

webpush.setVapidDetails(
  'mailto:web-push-book@gauntface.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey,
);

mailto 이메일 주소를 적어야 하는 이유는 만약 무슨 일이 생기면 Push Service가 sender(우리)에게 연락을 취할 수 있어야 하기 때문이다.

그러고 나서 Push Service에 아래와 같이 API call을 할 수 있다.

webpush.sendNotification(subscription, dataToSend).catch((err) => {
  if (err.statusCode === 404 || err.statusCode === 410) {
    console.log("Subscription has expired or is no longer valid: ", err);
    return deleteSubscriptionFromDatabase(subscription._id);
  } else {
    throw err;
  }
});

webpush.sendNotification()을 이용하여 호출을 하며 첫 번째 인자로 사용자의 Subscription 정보, 두 번째 인자로 보낼 데이터(plain string 또는 JSON data)를 넣는다. 프로미스가 반환되며 에러가 발생할 경우 위와 같이 처리할 수 있다.

Push events

메시지를 받으면 클라이언트의 서비스 워커의 push 이벤트가 트리거된다.

// service-worker.js

self.addEventListener('push', function(event) {
  if (event.data) {
  console.log('This push event has data: ', event.data.text());
  } else {
  console.log('This push event has no data.');
  }
});

self는 전역 스코프에서는 window를 의미하겠지만 서비스 워커 환경에서는 서비스 워커 자체를 의미한다.

// Returns string
event.data.text()

// Parses data as JSON string and returns an Object
event.data.json()

// Returns blob of data
event.data.blob()

// Returns an arrayBuffer
event.data.arrayBuffer()

서버에서 무슨 데이터를 보냈느냐에 따라 위와 같이 다르게 쓴다.

하지만 위 코드는 동작하지 않을 것이다.

Wait Until

서비스 워커에 대해 이해해야 할 것 중에 하나는 서비스 워커 코드가 실행될 때 우리는 하나의 작은 제어 권한을 가진다는 것이다. 브라우저는 서비스 워커를 작동할 시점과 종료할 시점을 결정한다. event.waitUntil() 메소드에 프로미스를 넘겨주면 브라우저는 프로미스가 완료될때까지 서비스 워커를 계속 실행시켜 둘 것이다.

위 코드에서 한 가지 요구사항이 더 있다면, 알림을 실제로 보여주어야 한다는 것이다.

self.addEventListener('push', function(event) {
  const promiseChain = self.registration.showNotification('Hello, World.');

  event.waitUntil(promiseChain);
});

self.registration.showNotification()는 사용자에게 알림을 보여주는 메소드이며 알림이 보여지고 나면 resolve되는 프로미스를 리턴한다.

아래는 네트워크 요청과 애널리틱스가 포함된 더 복잡한 예제이다.

self.addEventListener('push', function(event) {
  const analyticsPromise = pushReceivedTracking();
  const pushInfoPromise = fetch('/api/get-more-data')
  .then(function(response) {
    return response.json();
  })
  .then(function(response) {
    const title = response.data.userName + ' says...';
    const message = response.data.message;

    return self.registration.showNotification(title, {
	    body: message
    });
  });

  const promiseChain = Promise.all([
	  analyticsPromise,
	  pushInfoPromise
  ]);

  event.waitUntil(promiseChain);
});

pushReceivedTracking()은 애널리틱스 프로바이더에게 네트워크 요청을 하는 가상의 코드이며 그 아래 코드에서 보는 것과 같이 네트워크 요청도 가능하다.

출처: https://web.dev/notifications/

0개의 댓글