front-end / / 2024. 1. 8. 19:08

[PWA] Web Push Notification

웹 푸시의 장점

푸시 알림을 왜 사용해야 할까? 푸시 알림을 사용하면 앱(브라우저)가 닫혔을 때도 표시할 수 있다. 백그라운드 프로세스가 있어서 푸시알림을 받을 수 있는 service worker를 사용할 수 있다.

이는 이전에 Android, iOS의 네이티브 애플리케이션의 주요 특징이었지만, 지금은 웹 애플리케이션에서도 가능하다.

푸시 알림은 2가지로 구성되어 있다. 하나는 앱으로 전송하는 서버와 사용자에게 전송되는 알림이다. 우선 사용자의 Nofification을 활성화 할 필요가 있다. 그렇지 않으면 어떤 알림도 표시되지 않는다.

첫 번째 사용자에게 권한을 요청한다. 한번만 승인을 하면 된다. 원한다면 나중에 권한을 제거할 수 있다. 알림은 javascript로 실행이 된다. 사용자가 글을 작성하고 모든 사용자에게 알림을 보내야 한다고 생각하자.

웹앱에서 push subscription을 체크할 수 있다. 여기서 subscription은 장치의 브라우저를 나타낸다. 만일 Mac에서 Chrome을 사용한다면 이 브라우저로 subscribe 할 수 있고 같은 Mac에서 Firefox를 사용한다면 새로운 subscription을 할 수 있다. subscription은 브라우저를 나타낸다.

subscription은 javascript로 service worker에서 생성이 된다.

Push notification은 웹앱으로 푸시알림을 보내야 하므로 외부 서버가 필요하다. 그렇지 않으면 웹앱이 닫혔을 때 알림을 표시할 방법이 없다.

웹앱으로 푸시를 발송하기 위해 브라우저의 도움을 받아야 한다. 웹소켓과 같은 다른 기술로 만들 수도 있다. 하지만 웹앱이 열려있지 않은 경우에 알림을 받아야 하므로 서버가 따로 필요하다.

subscription은 브라우저 벤더의 정보를 포함한다. 즉, 각 브라우저 벤더는 각각의 푸시 서버를 가지고 있다. Google, Mozilla 각각 푸시서버를 가지고 있다.
javascript로 각 push subscription을 받으면 자동으로 푸시서버를 사용할 수 있다. endpoint는 간단한 URL이고 새로운 푸시 메시지를 전송할 수 있다. 브라우저 벤더에서 자동으로 푸시메시지를 발송한다.

푸시서버는 푸시를 전송할 수 있는지 체크할 수 있는 인증정보를 가지고 있어서 다른 사용자가 여러분의 앱에 푸시를 임의로 발송하지 못하도록 체크한다.

또한 우리가 가지고 있는 서버가 필요할 수도 있다.

subscription을 생성하는 service worker는 endpoint등의 정보를 가지고 있기 때문에 back-end 서버에 저장을 해야 한다.

그 다음 푸시를 발송한다. 푸시 발송이 브라우저 벤더로 api를 통해 발송된다. 이때 특정 인증정보를 푸시 메시지와 같이 보낸다. 브라우저 벤더는 웹앱으로 전송하고 "push" event를 발행함으로써 이를 service worker가 받는다. service worker는 웹앱에서 알림을 표시할 수 있다.

브라우저 지원

https://developer.mozilla.org/ko/docs/Web/API/Push_API

브라우저 권한 요청

알림을 발송받기 위해서는 브라우저 권한(permission)을 허용해야 한다. 화면에 웹푸시 권한 허용을 할 수 있는 버튼을 하나 만들자.

<a class="ui primary button" id="enableNotification">웹푸시</a>

그리고 해당 버튼이 이벤트를 연결하자.

[app.js]

if ("Notification" in window) {
  const notificationBtn = document.querySelector("#enableNotification");
  notificationBtn.addEventListener("click", askForNotificationPermission);
}

그리고 askForNotificationPermission에 대한 구현을 해보자.

[app.js]

function askForNotificationPermission() {
  Notification.requestPermission(function (result) {
    console.log("User Choice", result);
    if (result !== "granted") {
      console.log("No notification permission granted!");
    } else {
    // 알림 표시 (displayConfirmNotification)
    }
  });
}

Notification 객체를 통해 알림 요청을 사용자에게 하고 난 결과를 받을 수 있다.

Notification 표시

알림을 표시하는 로직을 구현해보자. 아래 코드에서 displayConfirmNotification() 함수이다.

[app.js]

function askForNotificationPermission() {
  Notification.requestPermission(function (result) {
    console.log("User Choice", result);
    if (result !== "granted") {
      console.log("No notification permission granted!");
    } else {
      displayConfirmNotification();
    }
  });
}

아래와 같이 displayConfirmNotification을 설정하고

사용자가 수락을 한다면 displayConfirmNotification()이 호출된다.

[app.js]

function displayConfirmNotification() {
  if ("serviceWorker" in navigator) {
    const options = {
      body: "You successfully subscribed to out Notification service!",
      icon: "/images/icons/app-icon-192x192.png"
      ],
    };
  }
  new Notification("Successfully subscribed! (from SW)", options);
}

다음으로 웹푸시 버튼을 눌러서 알림이 정상적으로 표시되는지 확인하자.

위와 같이 알림이 표시되는 것을 확인할 수 있다.

여기서 Notification으로 표시하는 것이 아니라 Service Worker 내의 Notification으로 표시를 해보자.

[app.js]

function displayConfirmNotification() {
  if ("serviceWorker" in navigator) {
    const options = {
      ...
  }
 // new Notification("Successfully subscribed!", options);
    navigator.serviceWorker.ready.then(function (swreg) {
      swreg.showNotification("Successfully subscribed! (from SW)", options);
    });
}

이렇게 하고 다시 웹푸시 버튼을 클릭해보자.

위와 같이 정상적으로 알림표시되는 것을 확인할 수 있다.

Notification 옵션

[app.js]

function displayConfirmNotification() {
    if ('serviceWorker' in navigator) {
        var options = {
            body: 'You successfully subscribed to our Notification service!',
            icon: '/src/images/icons/app-icon-96x96.png',
            image: '/src/images/sf-boat.jpg',
            dir: 'ltr',
            lang: 'en-US' ,// BCP 47
            vibrate: [100, 50, 200],
            badge: '/src/images/icons/app-icon-96x96.png',
            tag: 'confirm-notification',
            renotify:  true,
            actions: [
                { action: 'confirm', title: 'Okay', icon: '/src/images/icons/app-icon-96x96.png' },
                { action: 'cancel', title: 'Cancel', icon: '/src/images/icons/app-icon-96x96.png' }
            ]
        }

        navigator.serviceWorker.ready
        .then(function(swreg) {
            swreg.showNotification('Successfully subscribed (from SW)', options)
        })
    }
}
  • icon
    • icon의 경로
  • image
    • 이미지 경로
    • icon은 title 옆에 위치하지만 image는 content내에 위치한다.
    • 모든 장치에서 지원하는 것은 아니다. Mac용 크롬에서는 표시되지 않는다.
  • dir
    • 텍스트 방향
    • 기본값은 왼쪽에서 오른쪽
  • lang
    • 사용언어
  • vibrate
    • 어떤 진동모드를 사용할 지
    • [100, 50, 200] ==> 100(ms) vibration, 50(ms) pause, 200(ms) vibration
    • 모든 장치에서 지원하는 것은 아니다.
  • badge
    • badge 아이콘 표시
  • tag
    • 알림에 태그를 추가하는 기능
    • 태그는 알림의 ID 역할을 한다.
    • 동일한 태그로 많은 알림을 전송한다면 전부 표시가 되지 않는다. 동일한 태그를 가진 가장 최근 알림만 표시된다.
    • 두 개의 동일한 태그를 가진 알림이 전송되면 하나만 표시가 된다.
  • renotify
    • tag와 연계되어 새로운 알림이 표시될지 여부
    • true라면 새로운 알림이 표시된다.
    • false라면 새로운 알림이 표시되지 않는다.
  • action
    • 특정 액션을 지정한다. 알림 옆에 표시되는 버튼이다.
    • 장치에 따라 2개의 액션만 지원할 수도 있다. 3개를 지정하면 마지막은 무시된다.

action 구현

알림 옵션에서 actions에 두 개의 버튼을 추가하였는데 버튼을 눌렀을 때 반응을 하기 위해서는 아래와 같이 service worker에 이벤트를 추가해야 한다.

[app.js]

const options = {
       ..
        actions: [
            { action: 'confirm', title: 'Okay', icon: '/src/images/icons/app-icon-96x96.png' },
            { action: 'cancel', title: 'Cancel', icon: '/src/images/icons/app-icon-96x96.png' }
        ]
    }

[sw.js]

self.addEventListener("notificationclick", function (event) {
  const notification = event.notification;
  const action = event.action;

  console.log(notification);

  if (action === "confirm") {
    console.log("Confirm was chosen");
    notification.close();
  } else {
    console.log(action);
  }
});

self.addEventListener("notificationclose", function (event) {
  console.log("Notification was closed", event);
});

notificationclick 이벤트를 service worker에 추가하고 action에 따른 동작을 정의하면 된다.

닫기 이벤트는 notificationclose를 통해 이벤트를 받을 수 있다.

Notification 응답을 Push Message로 변환

function configurePushSubscription() {
  if (!("serviceWorker" in navigator)) {
    return;
  }

  let reg;
  navigator.serviceWorker.ready
    .then(function (swreg) {
      reg = swreg;
      return swreg.pushManager.getSubscription();
    })
    .then(function (subscription) {
      if (subscription === null) {
        // create a new subscription

        return reg.pushManager.subscribe({
          userVisibleOnly: true
        });
      } else {
        // we have a subscription
      }
    })
}

위의 코드는 pushService의 subscription에 추가하는 코드이다. 단순히 pushManager.subscribe()만 하면 된다. 이 코드는 해당 장치와 브라우저에서 새로운 subscription을 추가한다. 기존에 등록된 것이 있으면 예전꺼는 무시된다.

subscription은 push 메시지를 전송할 브라우저 벤더 서버의 endpoint를 포함한다. 만일 이 endpoint정보를 가진 사람이 서버로 메시지를 보낼 수 있고 웹푸시를 발송할 수 있다면 문제가 될 것이다. 그래서 이것을 방지하기 위한 2가지 방법이 있다. javascript 객체에 subscribe할 설정정보를 넣어준다.

reg.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: convertedVapidPublicKey,
});

이는 userVisibleOnly가 true일 경우만 가능하다. 이는 서비스를 통해 발송된 푸시 알림은 이 사용자에게만 보여진다는 것을 나타낸다. 이는 보안 매커니즘은 아니다. 보안 매커니즘은 웹푸시 서버에 있다. 푸시를 발송하는 유일한 서버가 맞는지 확인하여 다른 사용자가 endpoint로 메시지를 발송하는 경우 푸시메시지가 발송못하게 해야 한다. IP로 차단하는 방법을 우회하는 방법도 있지만 안전하지 못하다. 대신 vapid라는 접근방법을 사용하자.

vapid는 두 가지 키를 가지고 있는 접근방법이다. (public/private key)
public key는 공개되어 있는 것을 나타낸다. 이 키는 javascript에서 사용할 것이다. 이 키는 숨길 수 없다는 의미이다.
private key는 public과 관련이 있지만 public key로 부터 나온것은 아니고 web push 서버에만 저장되어야 한다. 외부에서 접근을 하지 못한다.
public key만으로는 전송을 하지 못한다. vapid는 JSON Web Token을 사용하고 base64로 변환된다.

vapid 키를 생성하는 방법은 여러가지가 있는데 여기서는 web push라는 javascript library를 사용해 볼 것이다.

Subscription 저장

npm 모듈이 저장되어 있는 위치에서 web push를 설치를 해볼 것이다.

npm install web-push

그리고 나서 vapid key를 생성하자.

package.json에 web-push를 추가하자.

 "scripts": {
    "web-push": "web-push"
  }

그리고 아래 명령어를 실행한다.

npm run web-push generate-vapid-keys

그러면 아래와 같이 키가 생성되는 것을 확인할 수 있다.

=======================================

Public Key:
BLsdY98UucmTdF72-z_7BA2jkCxxxxxxxx-xxxxxxxxxxxxxxxxxxxx

Private Key:
3AdYsobzMKoZdAali6QpAxxxxxxxxxxxxxxxxxx

=======================================

public key는 클라이언트에서 사용하고 private key는 서버에서 사용할 것이다.

아래의 configurePushSubscription 함수 내에 로직을 추가해보자. 그리고 subscription 정보는 firebase에 저장을 할 것이다.

[app.js]

function configurePushSubscription() {
  if (!("serviceWorker" in navigator)) {
    return;
  }

  let reg;
  navigator.serviceWorker.ready
    .then(function (swreg) {
      reg = swreg;
      return swreg.pushManager.getSubscription();
    })
    .then(function (subscription) {
      if (subscription === null) {
        // create a new subscription
        const vapidPublicKey = "<public key>";
        const convertedVapidPublicKey = urlBase64ToUint8Array(vapidPublicKey);
        return reg.pushManager.subscribe({
          userVisibleOnly: true,
          applicationServerKey: convertedVapidPublicKey,
        });
      } else {
        // we have a subscription
      }
    })
    .then(function (newSubscription) { // 여기서 subscription을 저장한다.
      return fetch(
        "https://<firebase-url>.firebasedatabase.app/subscriptions.json",
        {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Accept: "application/json",
          },
          body: JSON.stringify(newSubscription),
        },
      );
    })
    .then(function (res) {
      if (res.ok) {
        displayConfirmNotification();
      }
    })
    .catch(function (err) {
      console.log(err);
    });
}
function urlBase64ToUint8Array(base64String) {
  var padding = "=".repeat((4 - (base64String.length % 4)) % 4);
  var base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");

  var rawData = window.atob(base64);
  var outputArray = new Uint8Array(rawData.length);

  for (var i = 0; i < rawData.length; ++i) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

위와 같이 구현하고 다시 웹푸시를 눌러 subscription을 생성해보자.

정상적으로 실행되면 subscription정보가 생성이 된다.

Subscription 정보는 아래와 같은 형식이다.

{
  "endpoint":"https://fcm.googleapis.com/fcm/send/xxxxxxxxxxxxxxxxxxxxxxxx",
  "expirationTime":null,
  "keys":{
    "p256dh":"xxxxxxxxxxxxx","auth":"xxxxxxxxxxxx"
  }
}

서버에서 push message 전송

서버에서 푸시를 발송을 하는 방법은 java를 사용하였다. (Spring-boot)

private List<Subscription> subscriptions = new ArrayList<>();

@PostConstruct
private void initSubscription() {
  // 여기에서 subscriptions 정보를 추가하는 코드를 넣어준다.
  // 이 전에서 subscription 받은 subscription정보를 가져다가 subscriptions에 추가하면 된다.
  // subscription 정보는 firebase에 저장되어 있으니 firebase api를 사용하면 된다.
}

@PostMapping("push")
public void sendNotification(@RequestBody PushMessage pushMessage) {
    subscriptions.stream().forEach(subscription -> {
        HttpEntity response = sendPush(subscription, getMessage(pushMessage));
    });
}

private HttpEntity sendPush(Subscription subscription, String message) {
    try {
        return pushService.send(new Notification(subscription, message));
    } catch (GeneralSecurityException | IOException | JoseException | ExecutionException
             | InterruptedException e) {
        e.printStackTrace();
    }
    return null;
}

위의 코드와 내용은 다르지만 아래 소스를 참고하면 도움이 된다.

https://github.com/naturalprogrammer/webpush-java-demo

push 수신

서버에서 전송한 푸시를 수신하려면 service worker에서 수신을 받아야 한다. 푸시를 받기 위해서는 push 이벤트를 수신해야 한다.

[sw.js]

self.addEventListener("push", function (event) {
  console.log("Push Notification received", event);
  if (event.data) {
    console.log("data text", event.data.text());
    data = JSON.parse(event.data.text());
  }

  const options = {
    body: data.content,
    icon: "/images/icons/app-icon-96x96.png",
    badge: "/images/icons/app-icon-96x96.png",
  };

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

위와 같이 push 리스너를 추가한다.

이렇게 하고 알림이 잘 뜨는지 확인하자. (PC, 모바일 모두)

참고

https://www.udemy.com/course/progressive-web-app-pwa-the-complete-guide

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유