Next.js에서 PWA 기반 FCM 푸시 알림 보내기

yesung·2024년 1월 26일
2

PWA 구현하기

PWA를 적용 시키면 모든 OS에 푸시 알림이 가는 줄 알았다. 이는 틀린 사실은 아니지만 IOS에서는 알림이 가지 않는 이슈가 있었다. 그래서 해결 방법을 서치하다가 결과적으로 현재는 React Native 개발 또는 PWA를 적용 시키고 FCM을 연동하지 않으면 앱 푸시 알림을 보낼 수 없다는 결론을 맞이했다.

AOS에서는 푸시 알림이 왔지만 IOS에서 푸시 알림을 받으려면 Safari에서 웹 사이트에 접속하고 가운데에 있는 공유 버튼 클릭 후, 홈 화면에 추가하기를 누른 뒤 추가한 아이콘을 클릭해서 실행하고 푸시 허용을 해야지만 알림이 가진다.

웹 푸시 기능을 iOS와 iPadOS의 16.4 beta 1 버전부터 지원하기 시작

아울러 사용자 경험 측면에서는 주문 전 알림 허용 유무 확인을 파악하는 UI를 가져가야할 거 같고 현재 IOS 푸시 알림이 이게 한계점인지 아니면 해결 방법이 있는 건지는 더 찾아봐야겠다.

✅ 결론 FCM을 구현해보자


FCM 구현해보기

설치를 해주고

npm install firebase
yarn add firebase

SDK 초기화

import { initializeApp } from 'firebase/app';
import { getMessaging, getToken } from 'firebase/messaging';

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_MESSAGING_ID,
  appId: process.env.NEXT_PUBLIC_APP_ID,
};

const app = initializeApp(firebaseConfig);

/**
 * FCM 토큰 발급
 */
export const setTokenHandler = async () => {
  const messaging = getMessaging(app);
  await getToken(messaging, {
    vapidKey: process.env.NEXT_PUBLIC_VAPID_KEY,
  })
    .then(async currentToken => {
      if (!currentToken) {
        // 토큰 생성 불가시 처리할 내용, 주로 브라우저 푸시 허용이 안된 경우에 해당한다.
      } else {
        // 토큰을 받았다면 여기서 DB에 저장하면 됩니다.
      }
    })
    .catch(error => {
      console.error(error);
    });
};

예전 프로젝트에서도 했듯이 파이어베이스 SDK를 초기화 해주고 env에 추가한 키 값을 꺼내오면 된다.

푸시 알림 허용

const clickPushHandler = () => {
  Notification.requestPermission().then(permission => {
    if (permission !== 'granted') {
      // 푸시 거부됐을 때 처리할 내용
      console.log('푸시 거부됨');
    } else {
      // 푸시 승인됐을 때 처리할 내용
      console.log('푸시 승인됨');
    }
  });
};

Notification.requestPermission() 을 사용해서 푸시 권한을 요청할 수 있다.

다만 해당 함수를 useEffect로 사용해서 페이지에 접근 하자마자 푸시 권한을 확인할 수도 있지만 클릭 이벤트로 처리 해야 하는 권장하는(?) 이유가 있다. 아무런 이벤트 없이 함수를 호출할 경우 브라우저마다 작동하지 않거나 요청 알림이 숨겨지기 때문이다. 사용자를 귀찮게하는 알림창이 사방팔방 뜨게 하는 것을 방지하기 위함인 느낌이다.

토큰 발급

토큰을 발급 받을 때는 vapid key를 통해 웹 푸시 서비스에 대한 보내기 요청을 승인 받아야 한다.

vapid key 발급 절차

  • 파이어베이스 콘솔 > 프로젝트 설정(톱니바퀴) > 클라우드 메세징 > 웹 푸시 인증서
    에서 발급 받으면 된다.
const VAPID_KEY = process.env.NEXT_PUBLIC_VAPID_KEY;

export const getTokenHandler = async () => {
  const messaging = getMessaging(app);
  return await getToken(messaging, {
    vapidKey: VAPID_KEY,
  })
    .then(async currentToken => {
    if (!currentToken) {
      // 토큰 생성 불가시 처리할 내용, 주로 브라우저 푸시 허용이 안된 경우에 해당한다.
      console.error('토큰 생성 불가');
    } else {
      // 토큰을 받았다면 여기서 supabase 테이블의 저장하면 됩니다.
      console.log('currentToken', currentToken);
      return currentToken;
    }
  })
    .catch(error => {
    console.error('token error', error);
  });
};

서비스워커

이제부터 신기함을 경험해 보기 위한 백그라운드에서 푸시를 받고 처리해 줄 서비스워커를 등록해야 한다. (모든 앱 끄고 폰도 꺼놔도 알림이 오도록)

public 디렉토리에서는 process.env를 통해 환경변수를 불러올 수가 없기 때문에 파이어베이스 키를 어쩔 수 없이 공개를 해야 한다.

보통 private한 키들은 숨겨두는 게 좋지만 파이어베이스의 경우 데이터베이스 보안 규칙이나 앱 체크 등을 활용해서 서버를 보호하는 것이 권장되고 더 효과적이다.

하지만 이러한 설정을 하지 않았더라도 키의 공개로 인해서 보안에 치명적인 위협을 끼치지는 않도록 설계되어 있다. 공식 문서 참고

firebase-messaging-sw.js

importScripts('https://www.gstatic.com/firebasejs/9.0.2/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.0.2/firebase-messaging-compat.js');

firebase.initializeApp({
  apiKey: '---',
  authDomain: '---',
  projectId: '---',
  storageBucket: '---',
  messagingSenderId: '---',
  appId: '---',
});

const messaging = firebase.messaging();

// 푸시 내용을 처리해서 알림으로 띄운다.
self.addEventListener('push', function (event) {

});

// 클릭 이벤트 처리 - 알림을 클릭하면 사이트로 이동한다.
self.addEventListener('notificationclick', function (event) {

});

서버에서 처리를 하고 그 후에 이벤트로 처리를 해야 한다.

푸시 발송

공식 문서를 보면 FCM 공푸시 발송 방법은 크게 3가지가 있다.

  • Firebase Admin SDK
  • Firebase Cloud Messaging API(V1)(HTTP 프로토콜)
  • 기존 Cloud Messaging API(구버전 HTTP 프로토콜)

가장 권장되는 방법은 Firebase Admin SDK를 활용하는 방법이고 가장 적용하기 수월해서 해당 방법을 사용했다.

구현은 되게 간단하다. 서버 측 API에서 프로젝트 서비스 계정 인증을 거치고 서비스 계정 권한으로 푸시를 발송하는 방법이다.

Next.js를 사용하고 있어서 API 라우터를 사용할 예정이다.

설정

설치를 하자

npm install firebase-admin --save
yarn add firebase-admin --save 또는 yarn add firebase-admin

우리 프로젝트는 패키지 매니저가 yarn berry 여서 그런건지 --save 약어가 먹히지가 않았다... (문제점 저장..)

서비스 계정 키도 적용을 해야 한다.

파이어베이스 콘솔 > 프로젝트 설정 > 서비스 계정 > 새 비공개 키 생성
이 순서대로 가면 파일 하나가 다운로드 되는데 확장자를 json 파일로 변경하고 확인해보면 private_keyclient_email 이 있을 거다 그 2개의 값만 확인해서 환경변수에 저장해주면 된다.

푸시 전송 함수

import admin, { ServiceAccount } from 'firebase-admin';

// API 호출 시 전달할 데이터 타입
interface NotificationData {
  data: {
    title: string; // 제목
    body: string; // 내용
    image: string; // 이미지(아이콘)
    click_action: string; // url
    token: string // 토큰
  };
}
export const sendFCMNotification = async (data: NotificationData) => {
  const serviceAccount: ServiceAccount = {
    projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
    privateKey: process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
    clientEmail: process.env.NEXT_PUBLIC_FIREBASE_CLIENT_EMAIL,
  };

  if (!admin.apps.length) {
    admin.initializeApp({
      credential: admin.credential.cert(serviceAccount),
    });
  }

  // 푸시 알림 전송 대상 토큰
  const notificationData = { data };

  // 푸시 알림 전송
  const res = await admin.messaging().send(notificationData);

  return res;
};

여기서 나만 랜덤 형태인지는 몰라도 \n과 별 이상한 문자열이 껴 있어서 빌드 타임 때, 계속 파싱 에러가 났었다.

다른 오류인 줄 알았으나 결과적으로는 서버 콘솔을 확인해 보니 private_key가 아니다라는 에러를 뿜어냈고,

혹시나 해봐서 .replace 로 접근해보니 자동완성이 되서 정규식으로 불필요한 문자들을 치환했더니 빌드가 잘 됐다.

API 라우터 핸들러

const sendFCMHandler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method === 'POST') {
    const { message } = req.body;
    await sendFCMNotification(message)
      .then(result => res.status(200).json({ result }))
      .catch(error => console.log(error));
  } else {
    res.status(405).end();
  }
};

API 핸들러 파일을 별도로 pages에서 만들어서 앞서 받아온 데이터를 푸시 전송 함수에 전달하고 결과를 처리하는 역할을 한다.

API 호출 Hook

const useSendPush = () => {
  const sendPush = async ({ title, body, click_action, token }: { title: string; body: string; click_action: string, token: string }) => {
    const message = {
      data: {
        title,
        body,
        image: '/public/icons/manifest/icon-192x192.png',
        click_action,
      },
    };

    axios.request({
      method: 'POST',
      url: window?.location?.origin + '/api/fcm',
      data: { message },
    });
  };

  return sendPush;
};

클라이언트 측에 원하는 부분에서 API를 호출하면 되고 커스텀 훅을 이용해서 주문 완료 버튼을 눌렀을 때, 호출해 줄 것이다.

서비스워커에서 푸시 처리

self.addEventListener('push', function (event) {
  if (event.data) {
    // 알림 메세지일 경우엔 event.data.json().notification;
    const data = event.data.json().data;
    const options = {
      body: data.body,
      icon: data.image,
      image: data.image,
      data: {
        click_action: data.click_action, // 이 필드는 밑의 클릭 이벤트 처리에 사용됨
      },
    };

    event.waitUntil(self.registration.showNotification(data.title, options));
  } else {
    console.log('This push event has no data.');
  }
});

// 클릭 이벤트 처리
// 알림을 클릭하면 사이트로 이동한다.
self.addEventListener('notificationclick', function (event) {
  event.preventDefault();
  // 알림창 닫기
  event.notification.close();

  // 이동할 url
  // 아래의 event.notification.data는 위의 푸시 이벤트를 한 번 거쳐서 전달 받은 options.data에 해당한다.
  // api에 직접 전달한 데이터와 혼동 주의
  const urlToOpen = event.notification.data.click_action;

  // 클라이언트에 해당 사이트가 열려있는지 체크
  const promiseChain = clients
    .matchAll({
      type: 'window',
      includeUncontrolled: true,
    })
    .then(function (windowClients) {
      let matchingClient = null;

      for (let i = 0; i < windowClients.length; i++) {
        const windowClient = windowClients[i];
        if (windowClient.url.includes(urlToOpen)) {
          matchingClient = windowClient;
          break;
        }
      }

      // 열려있다면 focus, 아니면 새로 open
      if (matchingClient) {
        return matchingClient.focus();
      } else {
        return clients.openWindow(urlToOpen);
      }
    });

  event.waitUntil(promiseChain);
});

위 서비스워커에서 정의했던 함수 내용부를 채워줬다.


결과물

크.. 너무 신기했다. 테스트는 HTTPS 프로토콜에서만 가능해서 직접 vercel에 배포하고 테스트를 했었고 저 알림 하나를 받으려고 많은 코드를 작성했지만 오는 순간 너무 짜릿했다 ㅋㅋㅋㅋ 그저 뿌듯.

profile
Frontend Developer

1개의 댓글

comment-user-thumbnail
2024년 2월 7일

굿....

답글 달기