[나만의 무기] 푸시 알림 기능 구현기

SeHoony·2022년 7월 23일
7

1. 왜 푸시 기능인가?

  • 핵심 : 사용자가 다시 앱으로 돌아올 수 있게 하는 것

나만의 무기 프로젝트인 바로알바의 경우 아르바이트 구인구직 서비스를 제공한다. 따라서 사장님의 모집정보를 아르바이트생에게 빨리 전달하고, 아르바이트생의 알바 의사를 사장님께 빨리 전달하는 것이 중요하다.

즉, 사장님과 아르바이트생 즉 클라이언트와 클라이언트 사이에 정보를 바로바로 전달하기 위해서 푸시 기능을 도입하게 되었다.

2. 푸시란

2-1. 흐름

  1. USER가 특정 이벤트를 하면 SERVER에 데이터나 명령이 전달된다.(일반 CRUD)
  2. SERVER는 USER에서 전달된 이벤트에 따라 FCM에 명령을 전달한다.
  3. FCM에서는 SERVER에서 전달된 데이터 및 명령과 내부적으로 저장하고 있는 토큰 값을 가지고 매칭되는 기기에 푸시 알림을 전달한다.
    3,4. 위 과정의 반복

2-2. GCM은 뭔데?

푸시를 검색하면 FCM과 함께 GCM을 심심치 않게 볼 수 있었다.

GCM은 Google Cloud Messaging의 약자이며 간단히 말해서 모바일은 GCM, 웹은 FCM이다.
정확히 말하면 GCM은 Android와 iOS를 지원하고 FCM은 Android, iOS, Mobile Web을 모두 커버하는데 최근에는 FCM의 기능이 띄어나 FCM만 쓰면 된다는 말도 있었다.

2-3. FCM과 GCM은 어떤 차이가 있지?

  • 구독 로직 : FCM이 GCM 보다 간편하다. GCM은 직접 구현하지만 FCM은 개발자가 직접 구현할 필요가 없다.
  • FCM은 특정 타겟층에 푸시 할 수 있다.
  • 구글이 FCM을 밀고 있어, 이미 GCM은 사장된 분위기다(블로그에 올라온 글들 분위기가...)
  • GCM은 FCM의 부분집합이라고 생각하면 편하다.

2-4. Notification과 Push의 차이

이번 프로젝트는 PWA를 구현해서 오프라인에서도 접근이 가능한 장점이 있다. 하지만 그것도 사용자의 동작에 의존하는 것이다. 이 때 notification과 push를 통해 사용자에 의존하지 않고 정보를 전달할 수 있도록 하면 좋다.

  • notification :
    간단히 말해서 처음 방문하는 페이지에 방문했을 때 상단에 권한을 요청하는 alert가 뜨는데 그런 것을 notification이라고 한다.
    MDN에 따르면 사용자에게 새로운 정보를 보여주거나 업데이트 되었음을 알리는데 사용된다 한다.(약간 클라이언트 단에서만 구동되는 제한적인 느낌?)

  • push
    MDN에 따르면 클라이언트의 관여없이 서버로부터 앱에 어떤 정보를 전달하는데 사용되고 Service Worker에 의해 처리된다고 한다.

여기서 우리 프로젝의 경우, 클라이언트 사이드의 이벤트에 의해 서버로 데이터가 전달되고 서버에서 받은 데이터에 따라 분류하여 FCM에 푸시 알림의 종류를 선택하게 하는 플로우 따르기 때문에, 우리가 생각하는 기능이 'PUSH' 기능임을 알 수 있었다.

2-5. Foreground VS Background

  • Foreground : 우리가 제일 잘 아는 상태. 앱이 켜져 있고 우리가 보고 있는 중
  • Background : 창이 최소화되어 있거나, 보고 있지 않지만 다른 탭에 있는 경우
  • Terminated : 아예 앱을 꺼버린 상태

현재 어플리케이션이 어떤 상황인지에 따라 쓰여지는 푸시 함수도 달라진다. 그리고 그 전에 푸시 함수에 들어오는 데이터의 형태가 notification인지 data인지 아님 둘 다 인지에 따라서도 사용되는 푸시 함수가 달라지니 유의해야한다.


우리는 data를 키로 하여 데이터를 전달했다. 실제로 구현한 푸시함수를 살펴보자.

[포그라운드 메시징 함수]

(App.tsx)

firebaseMessaging.onMessage((payload: any) => {
    const { title, body } = payload.data;
    const data = JSON.parse(body);
    console.log("START", title, "DATA", data);

    if (title === "알바천사 콜") {
      if (
        window.confirm(
          title + " : " + data["store_name"] + "에서 알바천사 호출하셨습니다."
        )
      ) {
        sessionStorage.setItem("angel_id", data["angel_id"]);
        window.location.assign(
          `${process.env.REACT_APP_ROUTE_PATH}/worker/AngelResult`
        );
      }
    } else if (title === "알바천사 결과") {
      if (data["result"] === "success") {
        if (
          window.confirm(
            title +
              " : " +
              "알바천사 " +
              data["worker_name"] +
              "님이 수락하셨습니다."
          )
        ) {
          sessionStorage.setItem("angel_id", data["angel_id"]);
          window.location.assign(
            `${process.env.REACT_APP_ROUTE_PATH}/owner/angel`
          );
        }
      }
    } else if (title === "면접 신청") {
      alert(`
        ${data["worker_name"]}님이 면접 신청하셨습니다.
        `);
    } else if (title === "면접 신청결과") {
      alert(
        `${data["store_name"]}에서 면접 신청을 ${
          data["result"] === "accept" ? "수락" : "거절"
        }했습니다.`
      );
    } else if (title === "화상면접 개설") {
      if (
        window.confirm(
          data["store_name"] +
            "와의 화상 면접이 곧 시작합니다. 화상면접에 입장해주세요."
        )
      ) {
        window.location.assign(
          `${process.env.REACT_APP_ROUTE_PATH}/worker/mypage`
        );
      }

[백그라운드 메시징 함수]

(firebase-messaging-sw.js)

messaging.onBackgroundMessage(function (payload) {
  console.log(
    "[firebase-messaging-sw.js] Received background message ",
    payload
  );
  
  const notificationTitle = payload.data.title;
  const notificationOptions = {
    body: payload.data.body,
  };
  worker_id = JSON.parse(payload.data.body).worker_id;
  angel_id = JSON.parse(payload.data.body).angel_id;

  self.registration.showNotification(notificationTitle, {
    body: `${
      notificationOptions.body.split('"')[3]
    }에서 알바생을 급하게 찾고 있습니다!`,
  });
});

self.addEventListener("notificationclick", function (event) {
  const url = `https://heobo.shop/worker/AngelResult?worker_id=${worker_id}&angel_id=${angel_id}`;
  event.notification.close();
  event.waitUntil(clients.openWindow(url));
});

포그라운드, 백그라운드 환경에 대해서 이번에 처음 알게 되었다. 더욱 프로그래밍에 흥미를 느꼈고 이런 것을 가능케하는 service worker가 도대체 뭔지 궁금해서 프로젝트 끝나고 빨리 공부해보고 싶다.

3. 삽질의 역사

위의 개념은 내가 push 개념이 정리되 이후에 작성된 글이다. 시간 관계상 개념을 잡고 기능 구현에 나선 것이 아니기 때문에 기능을 구현하는데 매우 삽질은 많이 할 수 밖에 없었다.

3-1. 복잡한 파일 구조

처음에 경험했던 난관은 다소 복잡한 파일 구조였다.
어느 파일에 어떤 함수를 넣어야 하는 지 확신이 없어서 계속 해맸다.
그냥 블로그나 공식문서에서 하라고 하는대로 하면되는 걸 왜 이렇게 돌아왔나 싶다.

푸시 기능을 위해 우리는 총 3개의 파일이 필요하다.

firebase.js, firebase-messaging-sw.js, App.js

  • firebase.js
    이 놈은 src 폴더 내의 최상단에 적어두면 된다.
    역할은 firebase의 기능을 쓰기 위한 설정파일이라고 생각하면 된다.
    안에 구성요소는 firebaseConfig와 firebase.initializeApp(firebaseConfig)하는 부분이 있는데 요 두 개만 잘 넣어주면 잘 작동하더라 자세한 내용은 이 영상을 참고하면 좋을 거 같다.

  • firebase-messaging-sw.js
    얘는 public 폴더에 넣어줘야 하고 내가 쓴 파일명 그대로 적어줘야 한다고 한다.
    이 파일이 중요한 것은 여기서 Background messaging을 구현하기 때문이다.
    나는 onBackgroundMessage 함수를 써서 백그라운드 메시징을 구현했다.
    중요한 게 이후 백그라운드 푸시를 클릭했을 때 리다이렉팅을 시키는 기능을 추가할 때도 이 파일에서 처리하면 된다.

  • App.js
    여기가 중요한데 애플리케이션에 푸시 permission을 보내고 onMessage 함수를 통해 Foreground messaging을 구현할 수 있다.

3-2. 라이브러리 간 버전 충돌

FCM은 현재 8버전, 9버전을 지원하고 있다.
이것도 공식문서에 떡하니 있는 내용인데 왜 당시에는 보이지 않았는지.... 이번 기회를 통해 공식문서를 보는 눈이 확실히 성장한 거 같다.

FCM을 구현할 때 여러 블로그들을 참고했고 그러다보니 각 개발자들이 구현한 방식이 버전별로 상이했다. 그런 차이점을 인지하지 못해 계속 PUSH를 띄우지 못하고 있었다.

해결은

몇몇 라이브러리들의 버전을 조정해주는 것으로 맞추었고, 우리 프로젝트는 Firebase 버전 8을 선택했다.

Firebase version : 9.9.1 -> 8.7.1
react-script 버전 : 5.0.1 -> 4.0.3

dependency를 바꾸면서 살짝의 이슈도 있었다.

firebase와 react-script 버전만 바꾸고 다시 실행시켜보니 CSS가 싹다 빠져 있었다. 컴퓨터를 껐다 켜도 이 상태 그대로였는데, 결국 node-module과 package-lock.json을 지우고 다시 npm install하고 난 후 정상화되었다.

혹시 버전 수정 후 이런 상태가 발생해도 놀라지말자.

3-3. HTTPS는 안된다고 말 안... 했네?

FCM은 service worker를 사용해 작동하기 때문에 HTTPS가 전제된다.
그것을 모르고 localhost:3000에서 계속 FCM을 구동시키려 해서 아래의 에러를 계속 내뿜었다.

해결방법 :


ngrok으로 해결할 수 있었다. ngrok은 PWA를 구현할 때 production, https 모두 지원하기 위해 사용했다. ngrok http 4000 명령어를 치고 위의 창에서 https 주소를 긁어와 사용했다.

3-4. importScript is not defined

에러가 너무 각양각색으로 나와서 조금 지쳤다.
이번에는 importScript가 인식되지 않는다고 한다. importScipt는 firebase-messaging-sw.js에서 가장 상단에 firebase 관련 버전을 import 해오는 부분이다.

해결 방법은

하단의 코드를 index.html에 추가하는 것으로 해결할 수 있었다.

<script src="/firebase-messaging-sw.js"></script>
<script>
      if ('serviceWorker' in navigator) {
        window.addEventListener('load', () => {
          navigator.serviceWorker.register('/firebase-messaging-sw.js');
        });
      }
</script>

3-5. 그저 벽... 리다이렉팅

드디어 포그라운드 푸시와 백그라운드 푸시를 구현 완료했다.
하지만 푸시 기능의 백미는 사이트로 재접속하게 하는 것인데 푸시 메시지를 클릭해도 아무런 반응이 일어나지 않았다. 이 부분에서 시간을 많이 썼다.

  • Foreground 해결
if (title === "알바천사 콜") {
      if (
        window.confirm(
          title + " : " + data["store_name"] + "에서 알바천사 호출하셨습니다."
        )
      ) {
        sessionStorage.setItem("angel_id", data["angel_id"]);
        window.location.assign(
          `${process.env.REACT_APP_ROUTE_PATH}/worker/AngelResult`
        );
      }
    } else if ...

어려웠던 점은 Foreground Message와 깊은 관계에 있는 onMessage 함수가 App.js 즉 애플리케이션의 최상단에 존재해서 React Router를 쓸 수 없다는 점이었다.
푸시 메시지를 클릭하면 다른 페이지로 이동을 해야하는데 React에서 페이지 이동의 목적으로 사용되는 React Router를 사용할 수 없었다. 그래서 리다이렉팅 방법에 대해서 검색해서 나오는 것들은 다 확인해봤다.

1. window.location.href=(실패)
2. window.location.href()(실패)
3. window.location.replace()(실패)
4. return <Redirect to="/owner/mypage" />(실패)
5. window.location.assign("https://heobo.shop/worker/AngelResult");/> (성공)
  • Background 해결
[showNotification 함수의 문제]

self.registration.showNotification(notificationTitle, {
    body: `${
      notificationOptions.body.split('"')[3]
    }에서 알바생을 급하게 찾고 있습니다!`,
  });


[notificationclick 이벤트로 문제 해결]
self.addEventListener("notificationclick", function (event) {
  const url = `https://heobo.shop/worker/AngelResult?worker_id=${worker_id}&angel_id=${angel_id}`;
  event.notification.close();
  event.waitUntil(clients.openWindow(url));
});

내가 찾아본 바에 따르면 showNotification은 앞서 말했듯이 notification이기 때문에 어떤 인터렉션을 구현할 수는 없는 거 같다. 실제로 저 함수 내부에서 리다이렉팅을 시도했지만 결국 실패했다.
이번에도 여러 구글링의 결과 notificationclick이라는 함수를 찾게 되었다.

이 함수를 통해 리다이렉팅은 가능해졌으나, 현재 우리 애플리케이션은 처음에 로그인 페이지를 꼭 걸치게 되어 있는데, 푸시 메시지를 통해 리다이렉팅을 할 경우 이동한 페이지에 어떻게 현재 푸시 메시지가 담고 있는 데이터를 옮겨줄 지 고민이었다.

이 때, URL에 query로 데이터를 넘기고 이동한 페이지에서 request query로 받는 방법으로 문제를 해결했으나 여전히 보안상의 문제는 존재할 거 같다.

4. 더 공부할 것

5. 참고 자료

[FCM과 GCM의 차이] https://joshua1988.github.io/web-development/fcm-gcm-difference/
[Notification과 Push의 차이] https://developer.mozilla.org/ko/docs/Web/Progressive_web_apps/Re-engageable_Notifications_Push
[Foreground vs Background] https://blog.logrocket.com/push-notifications-react-firebase/
[FCM, 자바스크립트 클라이언트에서 메시지 수신] https://firebase.google.com/docs/cloud-messaging/js/receive?authuser=0
[FCM] https://firebase.google.com/docs/cloud-messaging/js/client?authuser=0
[FCM 구현 영상] https://www.youtube.com/watch?v=m6zI8vgq_xM
[FCM 구현 자료] https://geundung.dev/94
[FCM 관련 삽질기록] https://velog.io/@katanazero86/PWA-%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-push-notification-%EA%B5%AC%ED%98%84-%ED%9B%84%EA%B8%B0feat.-FCM

profile
두 발로 매일 정진하는 두발자, 강세훈입니다. 저는 '두 발'이라는 이 단어를 참 좋아합니다. 이 말이 주는 건강, 정직 그리고 성실의 느낌이 제가 주는 분위기가 되었으면 좋겠습니다.

1개의 댓글

comment-user-thumbnail
2024년 3월 18일

업무에 큰 도움이 되었습니다. 감사해요.

답글 달기