[next.js, fcm] 웹 푸시 구현하기

소고기는레어·2023년 4월 19일
17

Front-end 🖥

목록 보기
18/19
post-thumbnail

애플이 전에 예고했던 웹 푸시 기능을 iOS와 iPadOS의 16.4 beta 1 버전부터 지원하기 시작했다. 다른 os에 비해 지원하기까지 너무 오랜 시간이 걸렸는데, 네이티브 앱 생태계를 중요시하는 애플에게 웹 앱은 아무래도 관심 밖이었지 않나 싶다.

iOS의 웹 푸시와 웹 앱

지원을 시작하기는 했지만 iOS에서 웹 푸시를 받기 위한 조건은 조금 까다로운 편에 속한다. 먼저 Safari에서 해당 웹사이트에 접속, 사이트를 홈 화면에 추가한 뒤 홈화면에서 아이콘을 클릭해 웹 앱 실행 후 푸시를 허용해야 한다.

참고로 이렇게 아이폰의 홈화면에 추가한 웹은 사이트가 PWA를 지원할 경우 브라우저 UI가 생략된 스탠드얼론 웹 앱으로 실행되고 지원하지 않을 경우 단순 safari 즐겨찾기의 역할을 하게 된다.

iOS의 웹 푸시에 대한 지원은 이제 막 시작했지만 웹 앱 대한 지원은 예전부터 해오고 있었다. 다만 관심이 없는 사람이라면 홈 화면에 추가라는 기능이 웹 앱을 설치하는 기능인지 몰랐을 것이다. 내가 본 iOS의 웹 앱 중 가장 신기했던 앱은 클라우드 게이밍 서비스 중 하나인 지포스 나우이다. 지포스 나우가 아이폰에서 네이티브 앱 대신 웹 앱으로만 클라우드 게이밍을 서비스한다는 점이 놀라웠다.

0. FCM 구현해보기

아무튼 드디어 이제 대다수의 기기와 브라우저, 그리고 os에서 웹 푸시를 받을 수 있게 되었다. 그 말은 구현해야 할 기능이 하나 늘었다는 뜻이다.

사실은 예전에도 한 번 구현을 시도해 본 적이 있다. 그 때는 삽질만 하다가 결국 중단하고 미래를 기약했었는데, 그 미래가 바로 지금이지 않나 싶다. 지금이 아니라도 언젠가는 구현해야 할 날이 올게 분명하니 미리 미리 구현해보고 나중을 대비하도록 하자.

실험 대상은 내 프로젝트들 중 가장 만만한 포폴 사이트로 결정했다.

구현 과정을 설명하기에 앞서 실험 대상의 대략적인 스펙은 다음과 같다.

  1. next.js로 만듦
  2. 왜인지는 몰라도 pwa 구현해둠 (next-pwa)
  3. 파이어베이스 연결해놨음 (방명록에만 쓰고 있음)
  4. 여러 번의 실험과 리뉴얼을 거듭해서 코드가 되게 지저분함
  5. 결론은 최적화 망한 사이트

1. FCM SDK 추가 및 초기화

FCM을 구현하기 위한 제일 첫번째 단계이다.

파이어베이스가 이미 프로젝트에 연결되어 있다는 전제 하에 기록하는 글이기 때문에 기존 파이어베이스를 초기화한 파일에서 FCM 관련 내용만 추가하면 된다.

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);
const messaging = getMessaging(app);

1-1. messaging/unsupported-browser 에러

초기화 시 다음과 같은 에러가 발생하는 경우가 있다.

This browser doesn't support the API's required to use the Firebase SDK. (messaging/unsupported-browser).

8버전으로 낮추면 된다는 이야기가 있는데 굳이 그럴 필요 없이 아래처럼 window.navigator 여부를 체크해주면 에러가 발생하지 않고 초기화도 정상적으로 작동한다.

if (typeof window !== "undefined" && typeof window.navigator !== "undefined") {
  const messaging = getMessaging(app);
}

2. 푸시 권한 / 토큰 생성

푸시 기능을 활성화하려면 먼저 브라우저에 권한을 요청 해야하며 푸시에 필요한 토큰을 발급받기 위해서도 우선적으로 푸시 권한이 필요하다.

2-1. 푸시 권한

Notification.requestPermission() 를 통해 푸시 권한을 요청할 수 있다.

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

참고로 Notification.requestPermission() 을 통한 권한 요청은 사용자의 클릭을 통해서 호출되도록 하는 것을 권장한다. 아무런 이벤트 없이 메소드를 호출할 경우 대부분 브라우저에서 작동하지 않거나 요청이 숨겨진다. 사용자를 귀찮게하는 알림창이 마구 뜨는 것을 방지하기 위함인 듯 하다. 따라서 푸시 허용 버튼을 만들고 사용자의 클릭을 유도하여 해당 메소드를 호출할 것을 권장한다.

2-2. 토큰 발급

여기서 말하는 토큰의 역할을 비유하자면 문자를 보낼 때 필요한 수령인의 번호와 같다고 생각하면 된다. 따라서 푸시 권한을 부여받고 토큰을 발급 받았다면 해당 토큰들을 꼭 한 곳에 저장해두어야 한다.

1) vapid key

토큰을 발급 받을 때는 vapid key를 통해 웹 푸시 서비스에 대한 보내기 요청을 승인 받아야 한다. vapid key는 파이어베이스 콘솔 - 프로젝트 설정 - 클라우드 메시징 - 웹 푸시 인증서 에서 발급받을 수 있다.

빨간색으로 하이라이트한 부분이 vapid key이다.

vapid key가 준비되었다면 아래와 같이 토큰을 발급받을 수 있다.

const messaging = getMessaging();

getToken(messaging, {
  vapidKey: process.env.NEXT_PUBLIC_VAPID_KEY,
})
  .then(async (currentToken) => {
    if (!currentToken) {
      // 토큰 생성 불가시 처리할 내용, 주로 브라우저 푸시 허용이 안된 경우에 해당한다.
    } else {
      // 토큰을 받았다면 호다닥 서버에 저장
    }
  })
  .catch((error) => {
    // 예외처리
  });

3. 서비스워커

이제 백그라운드에서 푸시를 받고 처리해 줄 서비스워커를 등록해야 한다.

next.js의 경우 public 디렉토리에 firebase-messaging-sw.js 파일을 생성한다.

public 디렉토리에서는 환경 변수를 불러올 수 없기 때문에 어쩔 수 없이 firebase api key를 하드코딩 해야한다.

애초에 firebase api key는 프로젝트를 식별하는 목적으로만 사용되며 api key를 코드에 직접 포함하고 repo에 올리더라도 치명적인 보안 위협을 초래하지 않도록 설계되어 있기 때문에 너무 걱정할 필요는 없다.

그래도 이 api key 설계 구조에 너무 의존하는 것 보다는 firebase의 보안 규칙이나 앱체크 등을 활용하여 클라이언트 요청의 무결성을 검증하고 데이터를 보호하는 것이 권장된다. 관련 내용은 여기서 더 자세히 알아볼 수 있다.

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: "a",
  authDomain: "p",
  projectId: "i",
  storageBucket: "k",
  messagingSenderId: "e",
  appId: "y",
});

const messaging = firebase.messaging();

self.addEventListener('push', function(event) {
	// 받은 푸시 데이터를 처리해 알림으로 띄우는 내용
});

self.addEventListener('notificationclick', 
	// 띄운 알림창을 클릭했을 때 처리할 내용
});

위 코드에서 푸시를 처리하는 내용은 일단 비워 두었는데, 발송한 푸시 데이터 구조에 따라 조금씩 달라질 수 있기 때문에 발송 코드를 짜면서 같이 작성하면 좋다.

4. 푸시 발송

FCM의 푸시를 발송할 수 있는 방법은 여러가지가 있다.

  1. Firebase Admin SDK
  2. Firebase Cloud Messaging API(V1)(HTTP 프로토콜)
  3. 기존 Cloud Messaging API(구버전 HTTP 프로토콜)

가장 권장되는 방법은 Firebase Admin SDK를 이용한 방법이고, 그 다음 권장은 Firebase Cloud Messaging API(V1)이다.

마지막 기존 Cloud Messaging API(구버전) 는 더 이상 사용되지 않고 마이그레이션을 권장하는 방법이다. 가장 큰 차이점은 OAuth2와 서비스 계정을 통한 별도의 인증 과정 없이 전송 요청이 가능하다는 점이며, 현재도 사용은 가능하지만 새롭게 생성하는 프로젝트에서는 더 이상 사용할 수 없도록 비활성화 되어있다. 따라서 과거에 활성화 시켜둔 프로젝트가 아니면 더 이상 사용할 수 없는 전송 방법이다.

내 경우 현재는 Firebase Admin SDK로 마이그레이션 했지만 처음에는 구버전을 이용한 푸시 발송을 구현했다. 과거에 구버전을 활성화 시켜놓았기 때문에 가능했다.

근데 막상 블로그에 기록하려다 보니 이제 등록도 못하는 구버전 api의 사용법을 기록하는게 무슨 의미가 있나 싶어서 글 작성하다 말고 Firebase Admin SDK를 이용한 방법으로 마이그레이션 하였다.

4-1. Firebase Admin SDK를 이용한 푸시 발송 api

이 방법은 간단히 소개하면 서버측 api에서 프로젝트 서비스 계정 인증을 거친 후 서비스 계정의 권한으로 푸시를 발송하는 방법이다.

푸시 발송은 서버에서 처리하는 것이 권장된다. 클라이언트 측에서 푸시 발송을 직접 처리할 경우 앱에서 발생하는 네트워크 트래픽이 증가하고, 데이터 사용량과 배터리 소모를 증가시킬 수 있다는 등의 문제가 존재한다.

다행히 Next.js에는 API routes라는 기능이 존재하기 때문에 서버측 api를 간단하게 생성할 수 있다.

0. 사전 준비물

  • firebase-admin 설치
    $npm i firebase-admin --save

  • 서비스 계정 키 준비
    파이어베이스의 프로젝트 설정 -> 서비스 계정에서 새 비공개 키 생성 버튼을 클릭해 json 파일을 다운로드 할 수 있다. 파일에 포함된 항목 중 우리에게 필요한 항목은 privateKey, clientEmail 이다.

서비스 계정 키는 앞서 설명한 api key와 달리 비공개로 유지해야한다. 공개될 경우 치명적인 보안 위협에 그대로 노출될 수 있다!

1. API 파일 생성

pages/api/send-fcm.ts 파일을 생성한다.
파일명은 까먹지만 않도록 대충 편하게 지어도 된다.

2. 푸시 전송 함수 생성

api 파일 내에 푸시 전송 함수를 작성한다.

// 나중에 api 호출할 때 함께 전달할 데이터
interface NotificationData {
  data: {
    title: string;
    body: string;
    image: string;
    click_action: string;
  }
}

const sendFCMNotification = async (data: NotificationData) => {
  // Firebase Admin SDK 초기화
  const serviceAccount: ServiceAccount = {
    // 얘는 기존 파이어베이스 api 키
    projectId: process.env.NEXT_PUBLIC_PROJECT_ID,
    // 얘네는 새로 구해온 서비스 계정 비공개 키
    privateKey: process.env.NEXT_PUBLIC_FIREBASE_PRIVATE_KEY,
    clientEmail: process.env.NEXT_PUBLIC_FIREBASE_CLIENT_EMAIL,
  };

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

  // 토큰 불러오기
  // 앞서 푸시 권한과 함께 발급받아 저장해둔 토큰들을 모조리 불러온다. 
  // 본인에게 익숙한 방법으로 저장하고 불러오면 된다.
  // 내 경우 firestore에 저장하고 불러오도록 했다.
  let tokenList: Array<string> = []
  const docRef = doc(db, "subscribe", "tokens");
  
  await getDoc(docRef).then((doc) => {
    tokenList = doc?.data()?.list;
  });
  
  if (tokenList.length === 0) return;

  // 푸시 데이터
  // api 호출할 때 받아올 데이터와 방금 불러온 토큰
  const notificationData = {
    ...data,
    tokens: tokenList
  }

  // 푸시 발송
  // sendMulticast()는 여러개의 토큰으로 푸시를 전송한다.
  // 외에도 단일 토큰에 발송하는 등의 다양한 메소드 존재
  const res = await admin
    .messaging()
    .sendMulticast(notificationData);

  return res;
};

3. api 핸들러 생성

api 파일 내에 핸들러를 작성한다.
핸들러는 해당 api가 호출될 경우 함께 받아온 데이터를 앞서 작성한 푸시 함수에 전달하고 결과를 처리하는 역할을 하게 된다.

const handler = 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();
  }
};

4. api 호출

이제 클라이언트측 코드의 원하는 부분에서 api를 호출하면 된다.
나는 커스텀 훅을 만들고 방명록이 새로 등록될 때, 그리고 관리자 페이지에서 직접 발송할 때 훅을 호출하도록 하였다.

// hooks/useSendPush.ts

import axios from "axios";

const useSendPush = () => {
  const sendPush = async ({
    title,
    body,
    click_action,
  }: {
    title: string;
    body: string;
    click_action: string;
  }) => {
    const message = {
      data: {
        title,
        body,
        image: "/logos/favicon-196x196.png",
        click_action
      }
    };

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

  return sendPush;
};

export default useSendPush;

4-2. 서비스워커에서 푸시 처리

이제 서버에서 푸시 발송를 발송할 수 있게 되었다.
남은 것은 클라이언트에서 푸시를 받아서 처리하는 내용인데 아까 작성하다 말았던 서비스워커의 푸시 처리 코드가 이에 해당한다.

그 전에, FCM의 푸시 메세지 유형은 두가지로 나눌 수 있다.

1) 알림 메세지 (notification)
알림 메세지는 필드(키 모음)가 미리 정의되어 있다.

const message = {
  notification: {
    title: "푸시 제목",
    body: "푸시 내용",
  },
  tokens: tokenList
}

2) 데이터 메세지 (data)
데이터 메세지는 키-값 쌍을 마음대로 커스텀할 수 있는 구조이다.

const message = {
  data: {
    제목임: "제목",
    내용임: "내용",
    발송시간임: "발송 시간"
  },
  tokens: tokenList
}

간단하게 설명하면 위와 같다. 두 개를 섞어서 사용할 수도 있고 플랫폼별 메세지 유형 등 더 다양한 내용이 있지만 지금 설명할 내용은 아니기에 더 자세히 알고 싶다면 여기로.

본론으로 돌아와서 내가 푸시 전송 api를 작성할 때 정의한 메세지 타입은 데이터 메세지 구조였다.

interface NotificationData {
  data: {
    title: string; // 제목
    body: string; // 내용
    image: string; // 이미지(아이콘)
    click_action: string; // url
  }
}

그렇다면 서비스워커에서는 데이터 메세지 구조에 맞게 푸시를 받아서 처리해주면 된다.

// firebase-messaging-sw.js

// 초기화 과정은 생략

// 푸시 이벤트 처리
// 푸시 내용을 처리해서 알림으로 띄운다.
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);
});

4-3. 완성

이제 모든 코드 작성이 끝났으니 잘 작동하는지 테스트해보고 마무리하면 된다.

4-4. 근데 푸시가 두 번 출력된다면

전송은 한 번만 처리되었는데 푸시가 두 번 출력되는 문제가 발생할 수 있다.

내가 직접 겪었던 문제인데 빠르게 원인부터 말하자면 위에서 설명한 데이터 메세지와 알림 메세지 구조의 혼용 때문이었다. 다양한 원인이 있을 수 있겠지만 내 경우에는 그랬다.

문제를 일으켰던 메세지 구조(수정 전)는 아래와 같다.

interface NotificationData {
  notification: {
    title: string;
    body: string;
    icon: string;
  },
  data: {
    click_action: string;
  },
}

위의 notification 필드를 data로 통합한 뒤에는 푸시가 정상적으로 한 번만 출력되었다. 서비스워커에서 알림 메세지와 데이터 메세지를 중첩으로 처리해서 발생한 문제인가 싶다. 분명 공식 문서에서 두 유형을 섞어서 쓸 수 있다고 나와있는데 예외 사항이 있는걸까? 기억해뒀다가 다른 프로젝트에서 푸시를 구현할 일이 있다면 같은 문제가 발생하는지 한 번 체크해봐야겠다.


다 완성한 api를 마이그레이션한 것부터 시작해 푸시가 두 번 출력되는 버그, 글에는 생략됐지만 호스팅을 netlify에서 vercel로 변경하는 등 여러 우여곡절 덕분에 고생을 좀 했지만 그래도 결과적으로 정상 작동하는 웹 푸시 알림을 보니 구현해보길 잘했다는 생각이 든다.

profile
https://www.rarebeef.co.kr/

6개의 댓글

comment-user-thumbnail
2023년 5월 19일

잘 정리된 글 감사하니다.!! 호스팅을 netlify에서 vercel로 변경한 이유는 푸시알림과 관련이 있나요?

1개의 답글
comment-user-thumbnail
2023년 5월 31일

혹시 전체 소스를 공개해주실 수 있나요?

답글 달기
comment-user-thumbnail
2023년 9월 2일

아이폰에서 푸시알림이 잘 오나요? 아이폰에서 firebase로 부터 토큰을 못받아오는것 같아요

답글 달기
comment-user-thumbnail
2024년 9월 25일

안녕하세요! 블로그 보다가 질문이 있어 댓글 남깁니다!
웹 푸시알림 구현중인데, iOS의 푸시 알림이 잘 넘어오는지 궁금합니다.
저 같은 경우, pwa 로 받고 나서,
알림 허용 및 차단 창이 안뜨는데, 이 경우 어떻게 해결하셨는지 궁금합니다

1개의 답글