[React] PWA에서 알림 구현하기

PinkTopaz·2023년 7월 21일
8
post-thumbnail

들어가며

Tutice라는 과외관리 서비스에 웹 프론트엔드 개발자로 참여하게 되었다.

내가 맡은 가장 핵심적인 기능은 PWA 환경에서 푸시 알림을 구현하는 것이었는데, 처음에는 웹에서의 푸시 알림이 나조차도 아직은 낯설어서 어떻게 구현해야할지 막막했던 것 같다.

하지만 실제로 구현을 해보니 최근 IOS에서 웹 푸시 알림을 지원하게 되면서 생각보다는 수월하게 PWA에서 알림을 구현할 수 있었다.
PWA 푸시 알림을 구현한 과정을 5단계로 나누어서 정리해보려고 한다.

푸시알림의 기본 구조

푸시 알림을 구현할 때 다음과 같은 5단계를 거쳐 구현했다.

(1) FCM 연결
(2) FCM으로부터 유저의 deviceToken 받아오기
(3) deviceToken을 서버에게 post하기
(4) 서버가 FCM에 알림내용을 push하기
(5) FCM이 서버에게 받은 알림내용을 클라에게 push하기

이제 각각의 단계를 하나씩 톺아보자.

FCM 연결

FCM이란 무엇인가?

Firebase에서 지원하는 플랫폼에 종속되지 않고 푸시 메시지를 전송할 수 있는 메세징 솔루션이다.
PWA에서도 푸시 알림을 보내기 위해서는 FCM을 세팅해야한다.

Firebase에서 프로젝트를 추가하고

</> 버튼을 눌러서 firebase SDK 를 복사해온다.

복사해온 SDK를 나의 경우 src/core/notification/settingFCM.ts에 붙여넣기했다.

// ✅ src/core/notification/settingFCM.ts
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
import { getMessaging } from "firebase/messaging";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
export const firebaseConfig = {
  apiKey: import.meta.env.VITE_APP_FCM_API_KEY,
  authDomain: import.meta.env.VITE_APP_FCM_AUTH_DOMAIN,
  projectId: import.meta.env.VITE_APP_FCM_PROJECT_ID,
  storageBucket: import.meta.env.VITE_APP_FCM_STORAGE_BUCKET,
  messagingSenderId: import.meta.env.VITE_APP_FCM_MESSAGING_SENDER_ID,
  appId: import.meta.env.VITE_APP_FCM_APP_ID,
  measurementId: import.meta.env.VITE_APP_FCM_MEASUREMENT_ID,
};
// Initialize Firebase
export const app = initializeApp(firebaseConfig);
export const messaging = getMessaging(app);
const analytics = getAnalytics(app);

이렇게 FCM을 세팅했다면 알림을 허용하기 위한 함수를 작성한다.
Tutice의 경우 "알림 받기" 버튼을 누르면 알림 허용 함수를 호출하도록 했다.
또한 이와 동시에 서비스 워커를 연결해주어야 하는데

Service-Worker는 무엇인가?

서비스 워커는 웹 응용 프로그램, 브라우저, 그리고 (사용 가능한 경우) 네트워크 사이의 프록시 서버 역할을 한다.
네트워크 요청을 가로채서 네트워크 사용 가능 여부에 따라 적절한 행동을 취하는데, 이를 이용해 또한 푸시 알림을 제공하는 것이다.

src/utils/common/notification.ts에 서비스 워커를 연결하는 함수를 작성해서 알림을 허용하는 함수 내에서 호출했다.

// ✅ src/utils/common/notification.ts
export function registerServiceWorker() {
  navigator.serviceWorker
    .register("firebase-messaging-sw.js")
    .then(function (registration) {
      console.log("Service Worker 등록 성공:", registration);
    })
    .catch(function (error) {
      console.log("Service Worker 등록 실패:", error);
    });
}
// ✅ src/components/welcomeSignup/AlertSignup.tsx
  async function handleAllowNotification() {
    const permission = await Notification.requestPermission();

    registerServiceWorker();
    ...
  }

서비스 워커가 제대로 등록이 되었는지 확인하기 위해서 public/firebase-messaging-sw.js에 하단 코드를 등록했다.

self.addEventListener("install", function (e) {
  self.skipWaiting();
});

self.addEventListener("activate", function (e) {
  console.log("fcm sw activate..");
});

FCM으로부터 유저의 deviceToken 받아오기

FCM을 연결하고, 유저에게 알림 허용을 받고, 서비스 워커까지 연결했다면 FCM으로부터 유저의 Device Token을 받아서 서버에 등록해주어야한다.

FCM으로부터 유저의 Device Token을 받아 서버에 등록해주어야만 푸시 알림을 트리거해줘야하는 경우 서버에 요청을 보내면 서버에서 이 기기로 푸시 알림을 쏴주세요!라고 FCM에 요청을 보낼 수 있기 때문이다.

Typescript 환경이라면 주의해야할 것이 deviceToken을 담을 state를 선언할 때 그냥 string 타입으로 선언하면 에러가 난다.
하단과 같은 객체 형식의 AppCheckTokenResult 타입으로 선언을 해주어야 한다.

  const [deviceToken, setDeviceToken] = useState<AppCheckTokenResult>({
    token: "",
  });

이를 토대로 디바이스 토큰을 받아오는 함수를 다음과 같이 작성해주었다.

async function getDeviceToken() {
    const token = await getToken(messaging, {
      vapidKey: import.meta.env.VITE_APP_VAPID_KEY,
    });

    setDeviceToken({
      token: token,
    });
  }

deviceToken을 서버에게 post하기

이제 기기를 서버에 등록하기 위해 서버에 deviceToken을 post해준다.
Tutice 서버의 경우 디바이스 토큰을 업데이트하는 api로 구성되어있어 patch 함수를 사용했어야했다.
이를 위해 patchDeviceToken 함수를 작성했다.

// ✅ src/api/patchDeviceToken.ts
export async function patchDeviceToken(token: string) {
  const data = await axios.patch(
    `${import.meta.env.VITE_APP_BASE_URL}/api/user/device-token`,
    { deviceToken: token },
    {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${import.meta.env.VITE_APP_TEACHER_TOKEN}`,
      },
    },
  );

  return data;
}

이 함수를 React-query의 useMutation을 이용해서 호출하여 디바이스 토큰을 업데이트해주었다.

  const { mutate: patchingDeviceToken } = useMutation(patchDeviceToken, {
    onSuccess: (res) => {
      console.log(res);
    },
    onError: (err) => {
      console.log(err);
    },
  });

디바이스 토큰을 받아와서 setState까지 완료가 되면 디바이스 토큰을 업데이트하기 위해 then을 사용했다.

  async function handleAllowNotification() {
    const permission = await Notification.requestPermission();

    registerServiceWorker();
    getDeviceToken().then(() => {
      patchingDeviceToken(deviceToken.token);
    });
  }

서버가 FCM에 알림내용을 push해주기

위에서 서버에 디바이스 토큰까지 보내주었다면 이제 서버가 해당 디바이스 토큰을 이용해 FCM에 이 기기로 푸시 알림 보내줘!라고 알림내용과 함께 Push해줄 것이다.

따라서 해당 단계에서 클라이언트에서 해주어야하는 별도의 처리는 없다.

FCM이 서버에게 받은 알림내용을 클라에게 push하기

public/firebase-messaging-sw.jspush 이벤트를 등록해서 FCM에서 받은 알림을 화면에 보일 수 있도록 하면 된다.

self.addEventListener("push", function (e) {
  if (!e.data.json()) return;

  const resultData = e.data.json().notification;
  const notificationTitle = resultData.title;

  const notificationOptions = {
    body: resultData.body,
  };

  console.log(resultData.title, {
    body: resultData.body,
  });

  e.waitUntil(self.registration.showNotification(notificationTitle, notificationOptions));
});

알림이 클릭되었을 때 어디로 이동할 것인지, 그리고 창은 어떻게 할 것인지에 대한 이벤트도 notificationclick로 등록하여 처리할 수 있다.

self.addEventListener("notificationclick", function (event) {
  const url = "/";
  event.notification.close();
  event.waitUntil(clients.openWindow(url));
});

마치며

이전에 FCM을 이용해서 푸시 알림을 구현하는 방법을 서치해봤을 때에는 생각보다 이해하기가 어려웠는데, IOS에서 웹 푸시 알림을 지원하기 시작하면서부터 그렇게 어렵지 않게 푸시알림을 구현할 수 있게 되었다.

구현이 크게 어렵지 않아서 앞으로 크게 복잡하지 않은 로직의 경우 네이티브 앱이 아니라 PWA를 이용하는 것이 리소스에 있어서 훨씬 효율적이라는 생각이 들었다.

농담식으로 웹이 미래다! 라는 말을 하고는 했는데 그 말이 어쩌면 정말이라는 생각을 하게 된 시간이었다.

profile
🌱Connecting the dots🌱

2개의 댓글

comment-user-thumbnail
2023년 7월 21일

이런 복잡한 주제를 이렇게 쉽게 설명해주셔서 감사합니다. 웹 푸시 알림이 이렇게 구현되는 것이라니 흥미롭네요. 특히 PWA를 이용하는 것이 리소스 측면에서 효율적이라는 부분에 공감이 가네요. 계속해서 좋은 글 부탁드립니다. 잘읽었습니다!

답글 달기
comment-user-thumbnail
2024년 2월 13일

혹시, 저 serviceWorker쪽을 만드실 때, self에서 eslintno-restricted-globals 라는 에러가 뜨지 않으셨나요?
전 serviceWorker를 만들었는데, 저 에러가 떠서 활용을 못하고 앞에 window.를 붙여서 활용했습니다.
어떻게 serviceWorker를 적용하셨는지 궁금하여 댓글 남깁니다! 좋은 글 잘 보고 갑니다!

답글 달기