사용자 구독

첫 번째 단계는 사용자에게 푸시 메시지를 보내는 권한을 얻는 것입니다. 그런 다음 PushSubscription를 가져올 수 있습니다.

이를 위한 JavaScript API는 비교적 간단하므로 로직 흐름을 단계별로 살펴보겠습니다.

기능 감지

먼저 현재 브라우저가 실제로 푸시 메시지를 지원하는지 확인해야 합니다. 두 가지 간단한 검사로 푸시가 지원되는지 확인할 수 있습니다.

  1. navigator에서 serviceWorker를 확인합니다.
  2. 에서 PushManager를 확인합니다.
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;
}

서비스 워커와 푸시 메시지 모두에 대한 브라우저 지원이 빠르게 증가하고 있지만, 항상 두 가지 모두에 대해 기능 감지를 수행하고 점진적으로 개선하는 것이 좋습니다.

서비스 워커 등록

기능 감지를 통해 서비스 워커와 푸시가 모두 지원된다는 것을 알 수 있습니다. 다음 단계는 서비스 워커를 '등록'하는 것입니다.

서비스 워커를 등록하면 브라우저에 서비스 워커 파일의 위치를 알립니다. 이 파일은 여전히 JavaScript이지만 브라우저는 푸시를 비롯한 서비스 워커 API에 대한 '액세스 권한을 부여'합니다. 정확히 말하자면 브라우저는 서비스 워커 환경에서 파일을 실행합니다.

서비스 워커를 등록하려면 navigator.serviceWorker.register()를 호출하여 파일의 경로를 전달합니다. 다음과 같습니다.

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);
    });
}

이 함수는 브라우저에 서비스 워커 파일이 있고 그 위치가 어디인지 알려줍니다. 이 경우 서비스 워커 파일은 /service-worker.js에 있습니다. 백그라운드에서 브라우저는 register()를 호출한 후 다음 단계를 실행합니다.

  1. 서비스 워커 파일을 다운로드합니다.

  2. JavaScript를 실행합니다.

  3. 모든 것이 올바르게 실행되고 오류가 없으면 register()에서 반환된 약속이 확인됩니다. 어떤 종류의 오류가든 발생하면 프로미스가 거부됩니다.

register()가 거부되면 Chrome DevTools에서 JavaScript의 오타 / 오류를 다시 한번 확인합니다.

register()가 확인되면 ServiceWorkerRegistration을 반환합니다. 이 등록을 사용하여 PushManager API에 액세스합니다.

PushManager API 브라우저 호환성

브라우저 지원

  • Chrome: 42.
  • Edge: 17.
  • Firefox: 44
  • Safari: 16

소스

권한 요청

서비스 워커를 등록했으며 사용자를 구독할 준비가 되었습니다. 다음 단계는 사용자에게 푸시 메시지를 보내기 위한 권한을 얻는 것입니다.

권한을 가져오는 API는 비교적 간단하지만 최근에 콜백을 사용하는 것에서 Promise를 반환하는 것으로 변경되었습니다. 문제는 현재 브라우저에서 구현된 API 버전을 알 수 없으므로 두 버전을 모두 구현하고 처리해야 한다는 점입니다.

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() 호출입니다. 이 메서드는 사용자에게 메시지를 표시합니다.

데스크톱 및 모바일 Chrome의 권한 메시지

사용자가 허용, 차단을 누르거나 메시지를 닫아 권한 메시지와 상호작용한 후에는 결과가 문자열('granted', 'default' 또는 'denied')로 제공됩니다.

위 샘플 코드에서 askPermission()에서 반환된 프라미스는 권한이 부여되면 확인되고 그렇지 않으면 오류가 발생하여 프라미스가 거부됩니다.

사용자가 '차단' 버튼을 클릭하는 경우를 처리해야 하는 특이 사례가 하나 있습니다. 이 경우 웹 앱에서 사용자에게 다시 권한을 요청할 수 없습니다. 설정 패널에 있는 권한 상태를 변경하여 앱을 수동으로 '차단 해제'해야 합니다. 사용자에게 권한을 요청하는 방법과 시점을 신중하게 고려하세요. 사용자가 차단을 클릭하면 이 결정을 쉽게 번복할 수 없습니다.

다행히 대부분의 사용자는 권한 요청의 이유를 알고 있는 한 기꺼이 권한을 부여합니다.

나중에 인기 사이트에서 권한을 요청하는 방법을 살펴보겠습니다.

PushManager로 사용자 구독

서비스 워커가 등록되고 권한을 얻으면 registration.pushManager.subscribe()를 호출하여 사용자를 구독할 수 있습니다.

function subscribeUserToPush() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
        ),
      };

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

subscribe() 메서드를 호출할 때 필수 매개변수와 선택적 매개변수로 구성된 options 객체를 전달합니다.

전달할 수 있는 모든 옵션을 살펴보겠습니다.

userVisibleOnly 옵션

푸시가 브라우저에 처음 추가되었을 때 개발자가 푸시 메시지를 전송하고 알림을 표시하지 않아도 되는지에 대한 확신이 없었습니다. 이는 사용자가 백그라운드에서 무슨 일이 있었는지 알지 못하기 때문에 일반적으로 무음 푸시라고 합니다.

개발자가 사용자 모르게 지속적으로 사용자의 위치를 추적하는 등 불쾌감을 주는 일을 할 수 있다는 우려가 있었습니다.

이 시나리오를 방지하고 사양 작성자가 이 기능을 가장 잘 지원하는 방법을 고려할 시간을 확보하기 위해 userVisibleOnly 옵션이 추가되었으며 true 값을 전달하면 푸시가 수신될 때마다 웹 앱에 알림이 표시된다는 브라우저와의 상징적 동의입니다 (즉, 무음 푸시 없음).

현재는 true 값을 전달해야 합니다. userVisibleOnly 키를 포함하지 않거나 false를 전달하면 다음 오류가 발생합니다.

Chrome은 현재 사용자에게 표시되는 메시지가 발생하는 정기 결제에 대해서만 Push API를 지원합니다. 대신 pushManager.subscribe({userVisibleOnly: true})를 호출하여 이를 나타낼 수 있습니다. 자세한 내용은 https://goo.gl/yqv4Q4를 참고하세요.

현재로서는 Chrome에 전체 무음 푸시가 구현되지 않을 것으로 보입니다. 대신 사양 작성자는 웹 앱의 사용량에 따라 웹 앱에 특정 수의 무음 푸시 메시지를 허용하는 예산 API 개념을 모색하고 있습니다.

applicationServerKey 옵션

이전 섹션에서 '애플리케이션 서버 키'를 간단히 언급했습니다. '애플리케이션 서버 키'는 푸시 서비스에서 사용자를 구독하는 애플리케이션을 식별하고 동일한 애플리케이션이 해당 사용자에게 메시지를 보내고 있는지 확인하는 데 사용됩니다.

애플리케이션 서버 키는 애플리케이션에 고유한 공개 키와 비공개 키 쌍입니다. 비공개 키는 애플리케이션에 비밀로 유지해야 하며 공개 키는 자유롭게 공유할 수 있습니다.

subscribe() 호출에 전달된 applicationServerKey 옵션은 애플리케이션의 공개 키입니다. 브라우저는 사용자를 구독할 때 이를 푸시 서비스에 전달합니다. 즉, 푸시 서비스는 애플리케이션의 공개 키를 사용자의 PushSubscription에 연결할 수 있습니다.

아래 다이어그램은 이러한 단계를 보여줍니다.

  1. 웹 앱이 브라우저에 로드되고 subscribe()를 호출하여 공개 애플리케이션 서버 키를 전달합니다.
  2. 그런 다음 브라우저는 엔드포인트를 생성하고 이 엔드포인트를 애플리케이션 공개 키와 연결하고 엔드포인트를 브라우저에 반환하는 푸시 서비스에 네트워크 요청을 보냅니다.
  3. 브라우저는 이 엔드포인트를 PushSubscription에 추가하며, 이는 subscribe() 약속을 통해 반환됩니다.

subscribe 메서드에서 사용되는 공개 애플리케이션 서버 키의 그림입니다.

나중에 푸시 메시지를 전송하려면 애플리케이션 서버의 비공개 키로 서명된 정보가 포함된 승인 헤더를 만들어야 합니다. 푸시 서비스가 푸시 메시지 전송 요청을 수신하면 요청을 수신하는 엔드포인트에 연결된 공개 키를 조회하여 서명된 이 승인 헤더를 확인할 수 있습니다. 서명이 유효하면 푸시 서비스는 일치하는 비공개 키가 있는 애플리케이션 서버에서 전송된 것임을 알 수 있습니다. 기본적으로 다른 사용자가 애플리케이션 사용자에게 메시지를 보내지 못하도록 하는 보안 조치입니다.

메시지를 보낼 때 비공개 애플리케이션 서버 키가 사용되는 방식

엄밀히 말해서 applicationServerKey는 선택사항입니다. 하지만 Chrome에서 가장 쉬운 구현에는 이 속성이 필요하며 향후 다른 브라우저에도 필요할 수 있습니다. Firefox에서는 선택사항입니다.

애플리케이션 서버 키의 내용을 정의하는 사양은 VAPID 사양입니다. '애플리케이션 서버 키' 또는 'VAPID 키'를 언급하는 내용을 읽을 때마다 동일한 의미임을 기억하세요.

애플리케이션 서버 키를 만드는 방법

web-push-codelab.glitch.me을 방문하여 애플리케이션 서버 키의 공개 및 비공개 세트를 만들거나 다음을 실행하여 web-push 명령줄을 사용하여 키를 생성할 수 있습니다.

    $ npm install -g web-push
    $ web-push generate-vapid-keys

애플리케이션에 이러한 키를 한 번만 만들면 됩니다. 비공개 키는 비공개로 유지해야 합니다. (예, 방금 말씀드렸습니다.)

권한 및 subscribe()

subscribe()를 호출하면 한 가지 부작용이 있습니다. subscribe()를 호출할 때 웹 앱에 알림 표시 권한이 없는 경우 브라우저에서 권한을 요청합니다. 이 방법은 UI가 이 흐름을 사용하는 경우에 유용하지만 더 많은 제어를 원하는 경우 (대부분의 개발자가 그렇다고 생각함) 이전에 사용한 Notification.requestPermission() API를 사용하세요.

PushSubscription이란 무엇인가요?

subscribe()를 호출하고 일부 옵션을 전달하면 PushSubscription로 확인되는 약속이 반환되어 다음과 같은 코드가 생성됩니다.

function subscribeUserToPush() {
  return navigator.serviceWorker
    .register('/service-worker.js')
    .then(function (registration) {
      const subscribeOptions = {
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array(
          'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U',
        ),
      };

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

PushSubscription 객체에는 해당 사용자에게 푸시 메시지를 전송하는 데 필요한 모든 정보가 포함되어 있습니다. JSON.stringify()를 사용하여 콘텐츠를 출력하면 다음과 같이 표시됩니다.

    {
      "endpoint": "https://some.pushservice.com/something-unique",
      "keys": {
        "p256dh":
    "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=",
        "auth":"FPssNDTKnInHVndSTdbKFw=="
      }
    }

endpoint는 푸시 서비스 URL입니다. 푸시 메시지를 트리거하려면 이 URL에 POST 요청을 보냅니다.

keys 객체에는 푸시 메시지와 함께 전송된 메시지 데이터를 암호화하는 데 사용되는 값이 포함됩니다(이 섹션의 뒷부분에서 설명).

만료를 방지하기 위한 정기적인 정기 결제

푸시 알림을 구독하면 nullPushSubscription.expirationTime이 자주 수신됩니다. 이론적으로는 정기 결제가 만료되지 않는다는 의미입니다 (정기 결제가 만료되는 정확한 시점을 알려주는 DOMHighResTimeStamp를 수신하는 경우와는 대조됨). 그러나 실제로는 브라우저에서 정기 결제가 만료되도록 허용하는 경우가 많습니다. 예를 들어 장시간 푸시 알림을 수신하지 못하거나 브라우저에서 사용자가 푸시 알림 권한이 있는 앱을 사용하지 않는 것으로 감지하는 경우 등이 여기에 해당합니다. 이를 방지하는 한 가지 패턴은 다음 스니펫과 같이 알림을 받을 때마다 사용자를 다시 구독하는 것입니다. 이를 위해서는 브라우저에서 정기 결제가 자동으로 만료되지 않도록 알림을 충분히 자주 전송해야 하며, 정기 결제가 만료되지 않도록 하기 위해 사용자에게 의도치 않게 스팸을 보내는 것과 합법적인 알림 필요성의 장단점을 매우 신중하게 고려해야 합니다. 결국, 오래 잊혀진 알림 구독으로부터 사용자를 보호하기 위해 브라우저와 싸우려고 해서는 안 됩니다.

/* In the Service Worker. */

self.addEventListener('push', function(event) {
  console.log('Received a push message', event);

  // Display notification or handle data
  // Example: show a notification
  const title = 'New Notification';
  const body = 'You have new updates!';
  const icon = '/images/icon.png';
  const tag = 'simple-push-demo-notification-tag';

  event.waitUntil(
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag
    })
  );

  // Attempt to resubscribe after receiving a notification
  event.waitUntil(resubscribeToPush());
});

function resubscribeToPush() {
  return self.registration.pushManager.getSubscription()
    .then(function(subscription) {
      if (subscription) {
        return subscription.unsubscribe();
      }
    })
    .then(function() {
      return self.registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY_HERE')
      });
    })
    .then(function(subscription) {
      console.log('Resubscribed to push notifications:', subscription);
      // Optionally, send new subscription details to your server
    })
    .catch(function(error) {
      console.error('Failed to resubscribe:', error);
    });
}

서버에 정기 결제 전송

푸시 구독이 있으면 서버로 전송해야 합니다. 구독 객체에서 필요한 모든 데이터를 가져오는 방법은 개발자가 결정하지만 JSON.stringify()를 사용하는 것이 좋습니다. 또는 다음과 같이 동일한 결과를 수동으로 조합할 수 있습니다.

const subscriptionObject = {
  endpoint: pushSubscription.endpoint,
  keys: {
    p256dh: pushSubscription.getKeys('p256dh'),
    auth: pushSubscription.getKeys('auth'),
  },
};

// The above is the same output as:

const subscriptionObjectToo = JSON.stringify(pushSubscription);

구독은 다음과 같이 웹페이지에서 전송됩니다.

function sendSubscriptionToBackEnd(subscription) {
  return fetch('/api/save-subscription/', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(subscription),
  })
    .then(function (response) {
      if (!response.ok) {
        throw new Error('Bad status code from server.');
      }

      return response.json();
    })
    .then(function (responseData) {
      if (!(responseData.data && responseData.data.success)) {
        throw new Error('Bad response from server.');
      }
    });
}

노드 서버는 이 요청을 수신하고 나중에 사용할 수 있도록 데이터를 데이터베이스에 저장합니다.

app.post('/api/save-subscription/', function (req, res) {
  if (!isValidSaveRequest(req, res)) {
    return;
  }

  return saveSubscriptionToDatabase(req.body)
    .then(function (subscriptionId) {
      res.setHeader('Content-Type', 'application/json');
      res.send(JSON.stringify({data: {success: true}}));
    })
    .catch(function (err) {
      res.status(500);
      res.setHeader('Content-Type', 'application/json');
      res.send(
        JSON.stringify({
          error: {
            id: 'unable-to-save-subscription',
            message:
              'The subscription was received but we were unable to save it to our database.',
          },
        }),
      );
    });
});

서버에 있는 PushSubscription 세부정보를 사용하면 언제든지 사용자에게 메시지를 보낼 수 있습니다.

만료를 방지하기 위한 정기적인 정기 결제

푸시 알림을 구독하면 nullPushSubscription.expirationTime이 자주 수신됩니다. 이론적으로는 정기 결제가 만료되지 않는다는 의미입니다 (정기 결제가 만료되는 정확한 시점을 알려주는 DOMHighResTimeStamp를 수신하는 경우와는 대조됨). 그러나 실제로는 브라우저에서 정기 결제가 만료되도록 허용하는 경우가 많습니다. 예를 들어 오랫동안 푸시 알림을 수신하지 못했거나 브라우저에서 사용자가 푸시 알림 권한이 있는 앱을 사용하지 않는 것으로 감지한 경우 등이 여기에 해당합니다. 이를 방지하는 한 가지 패턴은 다음 스니펫과 같이 알림을 받을 때마다 사용자를 다시 구독하는 것입니다. 이렇게 하려면 브라우저에서 정기 결제가 자동으로 만료되지 않도록 알림을 충분히 자주 전송해야 하며, 정기 결제가 만료되지 않도록 하기 위해 사용자에게 스팸을 보내는 것과 정당한 알림 필요성의 장단점을 매우 신중하게 고려해야 합니다. 결국, 오래 잊혀진 알림 구독으로부터 사용자를 보호하기 위해 브라우저와 싸우려고 해서는 안 됩니다.

/* In the Service Worker. */

self.addEventListener('push', function(event) {
  console.log('Received a push message', event);

  // Display notification or handle data
  // Example: show a notification
  const title = 'New Notification';
  const body = 'You have new updates!';
  const icon = '/images/icon.png';
  const tag = 'simple-push-demo-notification-tag';

  event.waitUntil(
    self.registration.showNotification(title, {
      body: body,
      icon: icon,
      tag: tag
    })
  );

  // Attempt to resubscribe after receiving a notification
  event.waitUntil(resubscribeToPush());
});

function resubscribeToPush() {
  return self.registration.pushManager.getSubscription()
    .then(function(subscription) {
      if (subscription) {
        return subscription.unsubscribe();
      }
    })
    .then(function() {
      return self.registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: urlBase64ToUint8Array('YOUR_PUBLIC_VAPID_KEY_HERE')
      });
    })
    .then(function(subscription) {
      console.log('Resubscribed to push notifications:', subscription);
      // Optionally, send new subscription details to your server
    })
    .catch(function(error) {
      console.error('Failed to resubscribe:', error);
    });
}

FAQ

이 시점에서 사람들이 자주 묻는 질문은 다음과 같습니다.

브라우저에서 사용하는 푸시 서비스를 변경할 수 있나요?

아니요. 푸시 서비스는 브라우저에서 선택하며, subscribe() 호출에서 보았듯이 브라우저는 푸시 서비스에 네트워크 요청을 보내 PushSubscription을 구성하는 세부정보를 가져옵니다.

각 브라우저는 서로 다른 푸시 서비스를 사용합니다. API가 서로 다르지 않나요?

모든 푸시 서비스는 동일한 API를 예상합니다.

이 공통 API는 웹 푸시 프로토콜이라고 하며 애플리케이션에서 푸시 메시지를 트리거하기 위해 실행해야 하는 네트워크 요청을 설명합니다.

데스크톱에서 사용자를 구독하면 휴대전화에서도 구독되나요?

아니요. 사용자는 메시지를 수신하려는 각 브라우저에서 푸시를 등록해야 합니다. 또한 사용자는 각 기기에서 권한을 부여해야 합니다.

다음에 수행할 작업

Codelab