FCM을 이용해 웹 푸시알림 구현 (웹 + PWA)

최호연·2024년 10월 3일
0
post-thumbnail

푸시알림이 오면 참 좋겠다~

회고 글쓰기 프로젝트 "Writon"을 진행하던 중, 푸시알림이 되면 좋겠다라는 의견이 있었다! "Writon"은 웹으로 개발되었고, 웹앱은 PWA로 작업을 진행해서 앱 화면처럼 구현을 해놨었다! 사실 앱처럼 구동은 하지만, 본질적으로 따지면 웹이기 때문에 과연 푸시알림이 가능할까? 라는 생각이 들었다. 흘려들었던 말로는 iOS가 작년부터 웹푸시를 허용해줬다 이런 소리는 들었었지만 그게 무슨 소리인지 잘 이해가 안갔었다.

항상 미루고 미뤘던 웹에서의 푸시알림을 개발해 서비스의 질을 향상시켜보고자 개발을 시작하게되었다!

a

1. 푸시알림은 어떻게 동작하는가?

앱을 사용하다보면 광고로 푸시알림이 오는 것을 많이 경험해 봤을 것이다. 그런 것들을 봤을 때 단순히 앱은 로컬기기에서 동작하기 때문에 뭔가 특별한 기능이 있는 줄 알았다. 하지만, 주변 개발자들의 이야기를 들었을 때, FCM?에 뭘 보내고 ~~ 이런 이야기들을 많이 하곤 했다.
FCM은 메시지를 안정적으로 무료 전송할 수 있는 크로스 플랫폼 메시징 솔루션이다. 간단하게 말해서, 푸시알림을 대신 해주는 서비스이다!

여기서 의문점이 든다. 푸시 메시지 전달 기능을 서버에서 직접 구현은 불가능하나?
-> 알림기능까지 추가한 서버를 운용하려면 많은 비용이 발생하고 환경이 복잡할 수 있기 때문에 구독과 푸시 메시지 전달 기능과 관련해서 클라이언트와 서버 사이에서 중간자 역할을 하는 것을 두는 것이 일반적이다!


a

1-1. 동작 순서

  1. 클라이언트 단에서 알림 허용 및 차단을 함과 동시에 FCM에 구독요청을 한다.
    (=== 클라이언트 코드에 설정된 FCM 인증을 거쳐 해당 프로젝트와 연관이 있다는 것을 보여주는 작업이다! + 자신에게 할당해 줄 디바이스 토큰을 요청한다.)
  2. FCM은 클라이언트가 요청한 구독에 대해 응답을 해준다.
    (===사용자의 요청정보를 바탕으로 확인을 한 후, 디바이스 토큰을 보내준다.)

  • 사실 여기까지만 해도 프론트 단에 넘어온 디바이스토큰을 바탕으로 FCM 내부에서 토큰을 가지고 푸시 메시지를 보내고 확인할 수 있다!
  1. 디바이스 토큰을 받고 난 후, 메인서버 단에 디바이스 토큰을 넘겨준다!
    (=== 마치 카카오 로그인과 비슷한 느낌이다!)
  2. 메인서버는 이 디바이스 토큰을 저장하고, 좋아요나 댓글이 달렸을 때 해당 메시지를 가공해서 디바이스 토큰과 함께 FCM에 메시지를 전송한다.
    (=== 디바이스 토큰과 사용자의 정보를 디비에 매핑 시켜놓는다.)
  3. FCM은 받은 디바이스 토큰을 바탕으로 전달해준 메시지를 토큰에 해당하는 기기로 푸시알림을 쏴준다.

내가 느꼈을 때, 푸시알림의 동작과정은 이게 끝이다!
앱이든, 웹이든 상관없이 똑같이 동작한다.

그럼, 여기서 발생하는 디바이스 토큰의 주기는 어떻게 될까?
한 번 뽑힌 디바이스 토큰은 다시는 갱신을 안해줘도 되는지 시기가 있는지 궁금한데,,


a

1-2. 웹 푸시알림 + PWA(웹앱) 푸시알림?

앞서 설명했던 것처럼 푸시알림은 FCM을 통해서 동작하게 된다. 푸시알림이란 것 자체가 사실 앱에서 동작하는건 많이 봤는데, 웹이나 웹앱으로 만든 PWA에서도 동작이 할까? 라는 의문점이 들었었다.
하지만, 역시나, 웹은 강력했다!

Web Push Notification?
웹 푸시 알림은 브라우저를 통해 웹 사이트에서 사용자의 기기로 전송되는 실행 가능한 메시지이다.
알림을 받기 위해 별도의 앱 설치나 이메일 등을 필요로 하지 않고 웹 사이트에서 알림 허용 버튼을 클릭하기만 하면 구독이 가능하여 접근성이 좋다.
알림을 구독한 후에는 웹 사이트가 닫혀 있는 백그라운드에서도 작동하여 알림을 수신할 수 있다.
대부분의 기기, OS, 브라우저에서 웹 푸시 알림 기술을 허용한다.
web-push는 HTTPS에서만 사용 가능하다(개발용인 localhost도 가능)

이렇게 간단하게 웹에서 푸시알림이 동작하게끔 구현을 할 수 있다!! 또한 PWA도 웹 기반이기 때문에 역시나 잘 동작한다! ( android는 원래부터 동작을 했지만, iOS는 원래 동작을 안했었다..)


a

1-3. iOS 웹앱 푸시알림?

Apple은 폐쇄적으로 iOS에서 Web Push를 지원하지 않고 있었다. iOS를 제외하고 대부분의 기기(PC, 모바일)와 크롬 등의 주요 브라우저에서 지원되고 있었다.
하지만, 2023년 iOS 16.4 베타버전부터 webPush를 지원한다! 지원은 했지만, 설정 -> Safari -> 고급 -> 실험적 기능 설정 안에 수십 개의 토글들과 함께 숨어있는 알림 기능을 ON 해야 사용이 가능했다.!
다행히 iOS 17부터는 옵션 설정이 더 이상 필요하지 않으며 관련 API가 기본적으로 활성화되어 있다.

여기서, Point
iOS에서 푸시알림을 설정하고 싶다면 safari에서 "홈화면에 추가" 를 해야 사용할 수 있다.!

난 아래 그림처럼 모바일에서, 데스크탑에서 알림을 구현했다! 이제 이걸 가능하게 했던 방법을 설명해보겠다!!

a

2. FCM을 이용해 웹 푸시알림 구현해보자!

1. FCM 연결
2. 프론트에서 알림 허용창 띄우기
3. 허용이 되면 FCM에 구독 요청 (서비스 워커 등록 먼저!)
4. FCM으로부터 디바이스 토큰 받기
5. device-token으로 테스트메시지 보내기

--> 여기까지만 설명하고 다음 포스트에서 서버와 연결하는 글을 작성해보겠다.
5. 서버연결~

2-1. FCM 연결

  • 우선, 파이어베이스에 접속해서 프로젝트를 생성한다!

  • 생성을 하면 아래와 같이 NPM이나 CDN 을 이용해서 해당 파이어베이스를 프로젝트에 세팅할 수 있도록 도와준다! 난 yarn을 사용하고 있었기에 yarn을 이용해서 파이어베이스를 설치했다.

명령어

yarn add firebase

그리고, settingFCM.ts 라는 파일을 하나 만들고 위 이미지의 내용들을 import해준다.

import { initializeApp } from "firebase/app";
import { getMessaging } from "firebase/messaging";

const firebaseConfig = {
  apiKey: "0000000000000000000000",
  authDomain: "0000000000000000000000",
  projectId: "0000000000000000000000",
  storageBucket: "0000000000000000000000",
  messagingSenderId: "0000000000000000000000",
  appId: "0000000000000000000000",
  measurementId: "G-00000000",
};

// Initialize Firebase
export const app = initializeApp(firebaseConfig);
export const messaging = getMessaging(app);
  • getAnalytics는 분석도구를 추가하는 것인데, 난 추가하지않았다! 그래서 지웠다. 그리고 Firebase에서 제공하는 클라우드 메시징 서비스를 사용하기 위해 getMessaging을 import해서 Firebase 앱 인스턴스를 초기화했다! 그리고 messaging을 export 하였다! (다른 파일에서 디바이스 토큰을 받아오기 위해 messaging이 필요하다!)

그리고 마지막으로 프로젝트 상단 파일 App.tsx, main.tsxsettingFCM.ts 을 import만 해주면 FCM 등록은 끝이난다.


import "@/core/notification/settingFCM";

const App = () => {
  
  // .. 생략
    return (
    <QueryClientProvider client={queryClient}>
      <RecoilRoot>
        <GlobalStyle />
        <Router />
      </RecoilRoot>
      <ReactQueryDevtools
        initialIsOpen={false}
        buttonPosition="bottom-right"
      />
    </QueryClientProvider>
  );
};

export default App;

이렇게 해서 FCM 등록을 완료했다. 이제 알림 허용창과 서비스워커를 등록해보자.!

2-2. 프론트에서 알림 허용창 띄우기

await Notification.requestPermission();

이코드는 웹 브라우저에서 제공하는 내장 API 중 "사용자의 푸시 알림 권한 요청"과 관련되어 있다. 이 코드를 실행시키면 아래와 같은 이미지로 사용자한테 웹브라우저로 뜨게 된다.

// 사용자의 푸시 알림 권한 요청
async function handleAllowNotification() {
const permission = await Notification.requestPermission();

  if (permission === 'granted') {
    console.log('알림 권한이 허용되었습니다.');
  } else if (permission === 'denied') {
    console.log('알림 권한이 거부되었습니다.');
  } else {
    console.log('사용자가 알림 권한을 결정하지 않았습니다.');
  }
}

handleAllowNotification();

이런식으로 permission에 응답 값을 담고 거기에 따라서 사용자 화면에 어떻게 보여줄지 선택할 수 있다.

2-3. 알림 허용이 되면 FCM에 구독(토큰) 요청 (근데 이제 서비스 워커 등록 먼저!)

위에서 푸시 알림에 대한 알림 허용 권한이 사용자로부터 'granted' 상태가 되면, 그때 Firebase Cloud Messaging (FCM) 에게 디바이스 토큰을 요청하는 방식으로 진행되게 된다!(여기서 받게 되는 디바이스 토큰은 이후에 해당 기기나 브라우저로 푸시 알림을 전송하는 데 사용된다.)

그리고 토큰요청을 할 때, 사용되는 vapidKey가 있는데 이건 위 사진에 나와있듯이 파이어베이스 프로젝트 설정에 들어가 클라우드메시징 창을 클릭한다. 그 후, 아래 웹푸시 인증서가 있는데, 여기에 있는 키쌍을 복사해서 vapidKey로 사용하면 된다.(없다면 새로 생성하면 된다!)

import { getToken } from "firebase/messaging";
import { messaging } from "./settingFCM";

async function handleAllowNotification() {
  await Notification.requestPermission();
  await getDeviceToken(); 
}

async function getDeviceToken() {
  // 권한이 허용된 후에 토큰을 가져옴
  await getToken(messaging, {
    vapidKey:
      "BEC7zsAOEMKJz2WH-0000000000000000000000001i48vfKiEPO7s-u758Kqhs1cCWjMk",
  })
    .then((currentToken) => {
      if (currentToken) {
        // 토큰을 서버로 전송하거나 UI 업데이트
        console.log("토큰: ", currentToken);
        alert("토큰: " + currentToken);
      } else {
        console.log("토큰을 가져오지 못했습니다. 권한을 다시 요청하세요.");
      }
    })
    .catch((err) => {
      alert(err);
      console.log("토큰을 가져오는 중 에러 발생: ", err);
    });
}

처음 위 코드처럼 알림을 허용받고, FCM에 토큰 요청을 했는데 아래와 같은 에러가 발생하게 되었다.

토큰을 가져오는 중 에러 발생:  AbortError: Failed to execute 'subscribe' on 'PushManager': Subscription failed - no active Service Worker

-> 읽어보니 되게 간단한거였다!. 서비스워커가 필요하다는 뜻!

서비스워커란?
웹 브라우저에서 백그라운드에서 실행되는 스크립트로, 네트워크 요청 캐싱, 푸시 알림 수신 등 웹 애플리케이션의 오프라인 동작을 가능하게 한다. 웹 페이지와 독립적으로 실행되며, 페이지가 닫혀 있어도 푸시 메시지를 처리할 수 있다. 이를 통해 웹 애플리케이션은 오프라인 지원과 백그라운드 기능을 효율적으로 제공할 수 있다.

푸시알림은 백그라운드에서도 동작해야하기 때문에 백그라운드에서 실행되는 이 서비스워커가 꼭 필요하다는 것이다!

서비스 워커가 필수인 이유:
1. 푸시 알림의 백그라운드 처리: 푸시 알림은 웹 애플리케이션이 백그라운드 상태에 있을 때에도 수신하고 처리해야 한다. 서비스 워커는 페이지가 열려 있지 않거나 백그라운드에 있어도 푸시 메시지를 수신하고 처리할 수 있는 환경을 제공
2. PushManager는 서비스 워커와 연동: 푸시 알림은 PushManager API를 통해 관리되며, PushManager는 반드시 서비스 워커에 의해 처리된다. 서비스 워커가 없으면 PushManager는 구독을 설정할 수 없고, 푸시 메시지를 수신할 수 없다.

프로젝트 폴더 중, public 폴더 안에 firebase-messaging-sw.js
라는 서비스 워커 파일을 만든다.

// 서비스 워커 파일
self.addEventListener("install", function () {
  self.skipWaiting();
});

self.addEventListener("activate", function () {
  console.log("fcm sw activate..");
});
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));
});

이 서비스 워커 코드는 오로지 푸시 알림을 처리하기 위한 것이다.

install 이벤트에서 self.skipWaiting()을 호출해 즉시 활성화되도록 설정하고,
activate 이벤트에서 서비스 워커가 활성화될 때 콘솔로 출력을 한 번 해본다. 즉 여기서 콘솔이 출력되면 서비스워커가 돌아가고 있다는 뜻이다.
그리고 push 이벤트에서는 푸시 알림 데이터를 받아 알림을 표시하고, 제목과 내용을 브라우저에서 알림으로 보여준다.

서비스워커의 등록여부는 위 사진처럼 개발자도구 - 애플리케이션 - 2번째 탭에서 확인할 수 있다.


이제 이걸 실행시켜야하는데, 서비스워커실행은 아까 디바이스 토큰을 받아오기 전에 실행이 되어야한다. 그러므로, 나는 서비스 워커를 실행하는 함수를 하나 만들고 그걸 호출하는 방식으로 진행했다.

// 서비스 워커 실행 함수
function registerServiceWorker() {
  navigator.serviceWorker
    .register("firebase-messaging-sw.js")
    .then(function (registration) {
      console.log("Service Worker 등록 성공:", registration);
      alert(`Service Worker 등록 성공:, ${registration}`);
    })
    .catch(function (error) {
      console.log("Service Worker 등록 실패:", error);
      alert(`Service Worker 등록 실패:, ${error}`);
    });
}

// 알림 허용 및 디바이스 토큰 가져오기
import { getToken } from "firebase/messaging";
import { messaging } from "./settingFCM";

async function handleAllowNotification() {
  await Notification.requestPermission();
  registerServiceWorker(); // 서비스워커 실행
  await getDeviceToken(); 
}

async function getDeviceToken() {
  // 권한이 허용된 후에 토큰을 가져옴
  await getToken(messaging, {
    vapidKey:
      "BEC7zsAOEMKJz2WH-0000000000000000000000001i48vfKiEPO7s-u758Kqhs1cCWjMk",
  })
    .then((currentToken) => {
      if (currentToken) {
        // 토큰을 서버로 전송하거나 UI 업데이트
        console.log("토큰: ", currentToken);
        alert("토큰: " + currentToken);
      } else {
        console.log("토큰을 가져오지 못했습니다. 권한을 다시 요청하세요.");
      }
    })
    .catch((err) => {
      alert(err);
      console.log("토큰을 가져오는 중 에러 발생: ", err);
    });
}

이렇게 하면 디바이스 토큰이 넘어오는게 정상인데,, 난 아까와 같은 에러가 계속 발생했다. 계속 getDeviceToken() 함수에서 서비스워커가 실행이 안되었다고 뜨는 것이다.

확인을 해보니, 서비스워커가 등록되고, activated 상태가 될때까지 시간이 꽤 걸린다! 하지만 실행과 동시에 activating 상태가 되고 이 상태일 때
getDeviceToken()함수가 실행되버리는 것이다! 실행되자마자, 에러가 나오니catch문으로 가서 에러를 발생시키는 것이다. 즉 흐름이 끝긴다.
그래서 재시도 로직을 구현하거나, 백오프 전략을 사용해야하지만, 난 간단하게 에러 처리가 없는 경우로 로직을 짰다. 즉, 비동기 함수 getToken이 내부적으로 실패하더라도 에러를 무시한 채로 계속해서 기다리게 하게끔 말이다! 에러 발생 여부를 체크하지 않으니 결과적으로 응답이 올 때까지 기다리는 상태가 되어 토큰이 넘어오게끔 구현을 했다.

export async function handleAllowNotification() {
  await Notification.requestPermission(); 
  registerServiceWorker();
  try {
    await getDeviceToken();
  } catch (error) {
    console.error(error);
  }
}

async function getDeviceToken() {
  // 권한이 허용된 후에 토큰을 가져옴
  const token = await getToken(messaging, {
    vapidKey:
      "BEC7999999999999999uFL50000000PO7s-u758Kqhs1cCWjMk",
  });
  console.log("토큰: ", token);
  alert("토큰: " + token);
}

이렇게 하면 이제 alert 창으로 토큰이 잘 넘어오게 된다.!

2-4. FCM으로부터 device-token으로 테스트메시지 보내기

앞서, 서비스워커도 등록하고, FCM으로부터 device-token도 받았다.
이제 모든 준비가 끝났다!
과연 메시지를 받을 수 있을지 테스트를 해보자! 우선, 콘솔창에 뜬 device-token을 복사하고, 파이어베이스 프로젝트로 들어온다.

프로젝트 안에 실행도구들 중, messaging이라는 도구가 있다. 거기에 들어간다.


내부에 새 캠페인 버튼이 존재하는데, 클릭 후, 알림을 선택한다!


알림제목과 알림텍스트를 작성하고 테스트 메시지 전송 버튼을 누른다.


그럼 이렇게 기기에서 테스트라는 모달창이 뜨게 된다. 여기서 "FCM 등록 토큰 추가" 를 클릭한 후, 이 곳에 아까 위에서 복사해놓은 device-token을 붙여넣고 테스트를 진행한다.

-> 그럼 몇초 있다가...

이렇게 알림이 오게된다!!!!!!! 웹에서 푸시알림을 구현했다!!!!

(mac 기준)혹시 알림이 안온다면,

시스템 설정에 들어가서 알림 탭에 있는 크롬 알림설정을 허용해줘야한다! (이거때문에 1시간 날림,,)

a

3. PWA(iOS)에서 적용하는 법?

위에서 웹에서 처음 작업했던 것처럼 자동으로 알림 허용 및 차단이 뜨길 바랬다.. 근데 계속해서

FirebaseError: Messaging: The notification permission was not granted and blocked instead. (messaging/permission-blocked)

이러한 에러가 뜨는 것이다. (pwa에서 에러는 alert창으로 띄웠다.)

const permission = await Notification.requestPermission();

Notification.requestPermission 즉, 알림을 허용할지 말지 띄우는 모달창이 뜨지 않고, 바로 차단으로 redirect가 되어 notification이 block 되었다는 에러가 떴던 것이었다.

어떻게 해야 iOS에서 웹처럼 푸시알림 허용 및 차단 모달창을 띄울 수 있을까?
(진짜 이거 때문에 4시간을 투자했다,,)
GPT, 구글링, 심지어 https://developer.apple.com/ 애플 디벨로퍼 사이트도 뒤져가면서 찾아봤지만, 도저히 이해가 안되었다.

하지만, 여러 작업들을 해보다가 발견한 사실!

처음 Notification.requestPermission() 을 통한 권한 요청을 할 때, 웹이 실행과 동시에 허용 및 차단 창이 뜨게 끔 구현을 했었다. 이 같은 경우, 웹에서는 문제 없이 동작하였지만, 웹앱 즉, pwa 에서 처음 접속하였을때, 위와 같은 에러로 계속 denied가 났었다.

여러 시도 및 구글링을 통해 알아낸 사실 : 알림 권한 요청은 사용자의 클릭을 통해서 호출이 되도록 해야한다.

PWA 및 모바일 환경에서의 제한: 특히 PWA(Progressive Web App) 환경에서는 사용자 경험이 더 중요한데, 앱이 설치된 후 처음 실행되는 순간에 자동으로 알림 권한을 요청하면, 사용자가 이를 허용하거나 차단할 준비가 안 된 상태일 수 있습니다. 이런 상황을 방지하기 위해 iOS나 일부 브라우저는 사용자의 클릭 이벤트 없이 알림 권한을 요청하는 것을 금지합니다.

따라서 저 권한요청을 표시할 수 있게 사용자가 클릭 이벤트등을 통해 해당 메소드를 호출하게끔 해야한다.


// notificationFunc.ts
export async function handleAllowNotification() {
  await Notification.requestPermission(); 
  registerServiceWorker();
  try {
    await getDeviceToken();
  } catch (error) {
    console.error(error);
  }
}

async function getDeviceToken() {
  // 권한이 허용된 후에 토큰을 가져옴
  const token = await getToken(messaging, {
    vapidKey:
      "BEC7999999999999999uFL50000000PO7s-u758Kqhs1cCWjMk",
  });
  console.log("토큰: ", token);
  alert("토큰: " + token);
}

// login 페이지 (간단)
import { handleAllowNotification } from "@/core/notification/notificationFunc";

export const Login = ()=>{
	return(
    	<div onClick={handleAllowNotification}>로그인</div>
    )
}

이런 식으로 어떤 사용자의 이벤트(클릭)가 있을 때, handleAllowNotification 모달창을 띄우게끔 작업을 진행하였다!

그렇게 하니, 아래 이미지처럼 iOS에서도 알림 허용창이 뜨게 되었다.

마무리

  • 다음 글은 메인서버와 연결하여 직접 알림을 수신받는 로직에 대해 포스팅해보겠다!

참고자료

https://medium.com/@tellingme/frontend-fcm을-이용해-웹-푸시-알림-적용하기-368d90974b7

https://anywaydevlog.tistory.com/93

https://wonsss.github.io/PWA/web-push-notification/

https://dongsik93.github.io/til/2019/07/31/til-etc-fcm/

https://m.blog.naver.com/sssang97/222733667492

https://velog.io/@heather128/React-PWA에서-알림-구현하기

https://www.daangn.com/wv/faqs/3304

https://developer.apple.com/forums/thread/725772?answerId=750249022#750249022

https://velog.io/@drrobot409/next.js-fcm-웹-푸시-구현하기

https://rudaks.tistory.com/entry/PWA-iOS에서-web-push-발송

profile
하윙

0개의 댓글