웹 푸시(Web Push Notification)를 사용해봅시다.

김정우·2023년 8월 31일
3
post-thumbnail
post-custom-banner

들어가며

회사에서 만들고 있는 서비스는 paid 마케팅을 하고 있지 않다. 따라서 앱 서비스에서 사용하는 푸시는 주요한 마케팅 수단이다. 유저에게 직접적으로 메시지를 전달할 수 있고, 무엇보다도 푸시 알림 sass 이용료 등을 제외하면 무료이기 때문이다.

그렇지만 현재 만들고 있는 서비스의 MAU에서 앱 사용자들이 차지하는 비율은 크지 않다. 상당수의 유저는 웹을 통해 우리 서비스를 사용하고 있다. 앞서 말했듯 앱 푸시를 주요 마케팅 수단으로 활용하고 있지만 훨씬 많은 유저를 대상으로 마케팅을 할 수 있다면 좋겠다는 생각이 들었다가 이전에 어떤 영상에서 잠깐 소개했던 웹 푸시가 떠올랐다.

앱 푸시 알림에 사용하는 원시그널이라는 서비스에서도 웹 푸시 기능을 지원하고 있는 만큼 실제 마케팅 수단으로 활용될 수 있는 기술이라는 생각이 있었고, WWDC 2022에서 발표된 바와 같이 mac os / ios도 지원하는 기술이라 잘만 파악해두면 우리 서비스에도 적용할 수도 있겠다는 생각이 들었다.

따라서 (1) 웹 푸시가 어떻게 동작하는지를 큰 틀에서 학습하고 (2) 여러 웹 푸시 sass 들을 살펴보며 실무에 웹 푸시를 사용할 수 있을지 생각해보고자 한다. 검색을 좀 해보니 PWA나 서비스 워커 등 다른 개념들에 대한 이해가 조금 더 필요하긴 한데, 이번 글에서는 학습한 내용 중 '어떻게 웹 푸시를 보낼 수 있는가?'에 해당하는 부분만 먼저 단순하게 정리해보고자 한다.

Overview

먼저 웹 푸시가 동작하는 큰 틀을 간단하게 이해할 필요가 있다.

    +----------------+      +--------------+       +-------------+
    |  UA (Browser)  |      | Push Service |       | Application |
    +----------------+      +--------------+       |   Server    |
        |                       |                   +-------------+
        |      Subscribe        |                      |
        |---------------------->|                      |
        | subscription resource |                      |
        |<=====================>|                      |
        |                       |                      |
        |          Distribute Push Resource            |
        |--------------------------------------------->|
        |                       |                      |
        :                       :                      :
        |                       |     Push Message     |
        |    Push Message       |<---------------------|
        |<----------------------|                      |
        |                       |                      |
  • 브라우저 (사용자)
  • 푸시 알림을 사용자에게 전달할 푸시 서비스
  • 푸시 알림을 발송할 서버

위와 같이 세 주체로 이루어져있는데 (1) 서버에서 대상(사용자)과 내용이 담긴 메시지 데이터를 푸시 서비스에 전달하면, (2) 푸시 서비스에서 사용자 정보를 식별 후 목적지(즉 브라우저)로 전달하는 것이 큰 흐름이다.

구체적으로 이 과정은 웹 푸시 프로토콜이라는 규약을 통해 진행된다.

(1) 유저가 푸시 서비스를 구독하면, 푸시 서비스는 그 유저에게 private한 구독 정보(subscription resource)를 전달한다.
(2) 유저는 서버에 이 구독 정보를 전달한다. 그럼 푸시를 보내는 주체(서버)는 해당 유저에게 푸시를 보내기 위해 필요한 유저 식별 값을 얻게 된다.
(3) 서버에서 푸시를 보낼 때는 메시지를 푸시 서비스에 보낸다. 이 때 (2)를 통해 얻은 유저 식별값을 함께 보낸다.
(4) 푸시 서비스는 서버로 부터 받은 유저 식별값을 가지고 어떤 유저에게 푸시를 전달해야 할 지 결정한다.

이 때 푸시 메시지 또한 당연히 보안의 대상이기 때문에 암호화가 필요하다.

  • (1) 단계에서 유저는 푸시 서비스를 구독할 때 VAPID라는 인증방식의 공개 키를 전달한다(실제 구현에서 VAPID 인증 방식의 공개 키와 비공개 키는 서버에서 생성 후 유저에게 전달해줘야 한다).
  • (4) 단계에서 서버는 푸시 정보가 담긴 토큰을 VAPID의 비공개 키로 암호화하여 푸시 서비스에 전달한다.
  • (5)에서 푸시 서비스는 유저의 공개 키를 가지고 서버에서 받은 토큰을 복호화한다. 이를 통해 서버와 메시지에 대한 유효성 검사를 할 수 있게 된다.

웹 푸시 구현 환경 세팅

이번 글에서 Application Server에 해당 하는 서버는 구현하지 않는다. 주 학습 목표가 웹이기도 하고 실제 웹 푸시를 발송하고 확인하는 과정을 보다 간편하게 하기 위함이다.

  1. 구글의 코드랩 레포지토리 중 하나인 https://github.com/GoogleChromeLabs/web-push-codelab/tree/master 를 클론 받는다. 이 패키지에는 기본적인 아이콘이나 html 파일이 정의되어 있다.
  2. https://simplewebserver.org/ 에서 간단한 서버 실행 어플리케이션을 다운 받은 후 홈페이지에 나와 있는대로 서버를 실행시킨다. Folder path는 1을 통해 받은 디렉토리의 app 폴더를 설정하면 된다.

이러면 환경 세팅이 완료된다.

http://localhost:8080/로 접속하면 아래와 같은 화면이 보인다.

개발자 도구를 켠 뒤 Application -> Service Workers를 클릭한 뒤 이미지와 같이 Update on reload를 활성화 시켜주면 준비가 완료된다. Service Worker에 대해서는 바로 다음 절에서 설명한다.

Service worker 등록

앱 서비스를 생각해보면 앱 서비스를 사용하고 있지 않아도 푸시 메시지를 수신한다. 웹 푸시는 어떨까? 브라우저가 실행되고 있지 않아도 푸시 메시지를 수신할 수 있을까?

service worker(이하 서비스워커)는 이를 가능하게 해준다. 서비스워커는 워커의 일종으로 브라우저 단에서 클라이언트와 서버 사이를 중개하는 프록시 미들웨어로서 동작한다.

이러한 특성에 의거해 네트워크에 연결되지 않아 서버에 API 요청을 보낼 수 없더라도 서비스워커 단에서 특정 리소스들을 캐싱하고 있다가 마치 API 요청이 성공적으로 수행되고 응답을 받아온 것처럼 브라우저에 리소스를 전달할 수 있다. 이러한 특성은 PWA의 핵심 요소이다.

PWA에 대해서는 추후 별도의 포스트에서 다뤄보기로 하고, 웹 푸시에서 서비스워커는 푸시 서비스로서 동작한다. 따라서 우리는 푸시 서비스에 구독하듯이 서비스워커에 브라우저를 구독시켜야 한다. 이를 위해서는 먼저 서비스워커를 '등록'해야 한다.
(이번 글에서는 서비스 워커에 대해서도 웹 푸시를 보내는데 필요한 정도만 언급하고 자세한 내용은 추후 별도의 포스트에서 다룬다. 참고할 수 있는 좋은 자료는 다음과 같다: MDN, web dev)

구글의 코드랩 레포지토리를 잘 클론받았다면 app/sw.js 파일을 확인할 수 있을 것이다. 우리는 이 파일에 서비스워커에 관련된 코드들을 작성할 것이다.

우선은 html 로드 시 실행되는 scripts/main.js 파일에서 먼저 서비스워커를 등록해보자.

// app/scripts/main.js
async function registerServiceWorker() {
  if (!("serviceWorker" in navigator)) return;

  console.log("Service Worker and Push are supported");

  swRegistration = await navigator.serviceWorker.getRegistration();
  if (!swRegistration) {
    swRegistration = await navigator.serviceWorker.register("sw.js");
    console.log("Service Worker is registered", swRegistration);
  } else {
    console.log("Service Worker is already registered", swRegistration);
  }
};
registerServiceWorker();

먼저 브라우저가 서비스워커를 지원하는지 확인한다.

지원한다면 navigator.serviceWorker.getRegistration 함수를 통해 서비스워커의 등록정보를 확인한다. 만약 아직 서비스워커가 등록되지 않았다면 navigator.serviceWorker.register("sw.js"); 코드를 통해 서비스워커를 등록하고 swRegistration 변수에 해당 등록 객체를 저장한다. 위에서도 언급했듯이 sw.js 파일을 통해 서비스워커의 동작을 정의한다.

버튼 활성화

우리의 웹 사이트에서 ENABLE PUSH MESSAGING 버튼은 비활성화 되어있다. 이를 활성화 시키는 코드를 작성하자.

마찬가지로 main.js 파일에 다음 두 함수를 추가한다.

async function initializeUI() {
  const subscription = await swRegistration.pushManager.getSubscription();
  isSubscribed = subscription;

  console.log(isSubscribed ? 'User iS subscribed.' : 'User is NOT subscribed.');

  updateBtn();
}

function updateBtn() {
  if (isSubscribed) {
    pushButton.textContent = 'Disable Push Messaging';
  } else {
    pushButton.textContent = 'Enable Push Messaging';
  }
  
  pushButton.disabled = false;
}

이후 initializeUI 함수를 registerServiceWorker 함수 몸체 최하단에 추가한 후 새로고침을 하면 웹 사이트가 다음과 같이 변한다.

또한 처음과 달리 개발자 도구에서 등록한 서비스워커를 확인할 수도 있다. 만약 디버깅 등의 이유로 등록해둔 서비스워커를 직접 해제하고 싶다면 이미지 우측의 Unregister 버튼을 클릭한 뒤 새로고침하면 된다.

서비스워커 구독하기

브라우저는 푸시 서비스를 구독해야 한다. 하지만 우리의 ENABLE PUSH MESSAGING 버튼은 활성화 되었지만 어떠한 동작도 하지 않는 상태이다. 해당 버튼을 클릭 시 구독을 수행해보자.

먼저 initializeUI 함수에서 버튼에 클릭 이벤트 리스너를 달아준다.

async function initializeUI() {
  pushButton.addEventListener('click', function() {
    pushButton.disabled = true;
    if (isSubscribed) {
      unsubscribeUser();
    } else {
      subscribeUser();
    }
  });

  const subscription = await swRegistration.pushManager.getSubscription();
  isSubscribed = subscription;
  updateSubscriptionOnServer(subscription);

  console.log(isSubscribed ? 'User IS subscribed.' : 'User is NOT subscribed.');

  updateBtn();
}

먼저 pushButton element에 클릭 이벤트를 달아준다. 구독되지 않았다면 subscribeUser 함수를 호출하여 브라우저를 구독한다.

async function subscribeUser() {
  const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
  const subscription = await swRegistration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: applicationServerKey
  })

  if (subscription) {
    console.log('User is subscribed.');
    updateSubscriptionOnServer(subscription);

    isSubscribed = true;
  } else {
    console.error('Failed to subscribe the user: ', error);
  }

  updateBtn();
}

서비스워커 등록 객체(swRegistration에는 pushManager 객체가 존재한다. 이 인터페이스의 subscribe 함수를 통해 구독할 수 있다.

applicationServerKey는 위에서 언급한 VAPID의 공개 키를 주입하면 된다. 이 사이트에서 임시로 공개 키와 비공개 키를 생성할 수 있다.

구독이 되면 푸시 서비스를 통해 받은 구독 정보를 서버에 전송한다. 하지만 이번 글에서는 서버를 구현하지 않으므로 실제 서버 API를 호출하지는 않고 구독 정보를 웹 사이트에 보여주도록 한다.

function updateSubscriptionOnServer(subscription) {
  // TODO: Send subscription to application server

  const subscriptionJson = document.querySelector('.js-subscription-json');
  const subscriptionDetails =
    document.querySelector('.js-subscription-details');

  if (subscription) {
    subscriptionJson.textContent = JSON.stringify(subscription);
    subscriptionDetails.classList.remove('is-invisible');
  } else {
    subscriptionDetails.classList.add('is-invisible');
  }
}

구독 해제도 실제 서버와 연결되지는 않기 때문에 subscribeUser와 거의 동일하다.

function unsubscribeUser() {
  swRegistration.pushManager.getSubscription()
  .then(function(subscription) {
    if (subscription) {
      return subscription.unsubscribe();
    }
  })
  .catch(function(error) {
    console.log('Error unsubscribing', error);
  })
  .then(function() {
    updateSubscriptionOnServer(null);

    console.log('User is unsubscribed.');
    isSubscribed = false;

    updateBtn();
  });
}

푸시 기능을 사용하기 위해서는 브라우저의 알림 권한이 필요하다. 버튼 클릭 시 알림 권한이 없다면 구독되어선 안될 것이므로 다음과 같이 updateBtn 함수를 수정하자.

function updateBtn() {
  if (Notification.permission === 'denied') {
    pushButton.textContent = 'Push Messaging Blocked';
    pushButton.disabled = true;
    updateSubscriptionOnServer(null);
    return;
  }

  if (isSubscribed) {
    pushButton.textContent = 'Disable Push Messaging';
  } else {
    pushButton.textContent = 'Enable Push Messaging';
  }

  pushButton.disabled = false;
}

푸시 이벤트 핸들링

이제 서비스워커의 동작을 정의해야 한다. 서버에서 푸시 메시지를 전송하면 서비스워커는 브라우저의 Notification API를 사용하여 해당 푸시 메시지를 알림의 형태로 바꿔준다.

// sw.js
self.addEventListener('push', function(event) {
  console.log('[Service Worker] Push Received.');
  console.log(`[Service Worker] Push had this data: "${event.data.text()}"`);

  const title = 'Push Codelab';
  const options = {
    body: 'Yay it works.',
    icon: 'images/icon.png',
    badge: 'images/badge.png'
  };

  event.waitUntil(self.registration.showNotification(title, options));
});

서비스워커의 동작을 정의한 파일에서 self 객체는 서비스워커를 의미한다.
여기에서는 여러가지 이벤트에 대한 리스너를 정의할 수 있는데, 그 중 push 이벤트의 이벤트 리스너를 작성하여 서버에서 푸시 요청이 왔을 때 알림을 띄우려고 한다.

이는 registration.showNotification 함수를 통해서 실행한다.
event.waitUntil는 서비스워커의 생명주기와 관련된 함수이다. 이 글에서는 서비스워커에 대한 글이 아니지만 간단하게 설명하자면, waitUntil 함수는 Promise 객체를 인자로 받아서 Promise가 처리될 때까지 서비스워커가 동작하도록 한다.

여기까지 코드를 작성했다면 새로고침 후 개발자도구의 서비스워커 탭에서 push 이벤트를 직접 트리거하여 테스트해볼 수 있다.

위 이미지와 같이 Push 부분에 푸시 메시지를 작성하고 옆의 버튼을 클릭하면 다음과 같이 웹 푸시가 발송되고 콘솔 창에서 입력한 푸시 메시지를 확인할 수 있다!

https://app.slack.com/t/modoodoc/login/z-app-168678765859-6018836049047-40f0de192e0520c4095cfdbd470f5e4aeb709732d1824274256bd14d0d1e76b4?s=slack&x=x-p168678765859-2255575342918-6033372434498

참고자료

profile
hello world!
post-custom-banner

0개의 댓글