저번에 웹 푸시알림에 대해서 글을 남겼었다. 파이어베이스에서 제공해주는 FCM을 이용해서 웹 푸시알림을 구현하는 방법 + iOS에서 PWA 설치 후, 푸시알림 받는 법에 대해서 글을 작성했다. 여러 번 시도 끝에 이제 모든 것을 확실히 알게 되었다... 지금까지 잘못된 사실을 적었다는 것을,, 오늘 그걸 다시 한 번 잡고, 웹푸시 알림을 마무리 지어보겠다!
우선, 웹 푸시알림에 필요한 파일은(내 기준) 크게 3가지이다.!
- 푸시 알림을 요청하는 로직을 담은 파일 (컨트롤러)
- 백그라운드(알림은 앱이 실행되지 않아도)에서 돌아가게끔 도와주는 서비스 워커 파일
- 푸시알림을 요청을 start하는 파일 (ex. main페이지)
*추가적으로, FCM 설정하는 파일들도 필요하지만, 앞선, 블로그 글에서 정리했기 때문에 그건 패스하겠다! (이전 블로그 주소)
1. 푸시알림을 요청하는 로직을 담은 파일
//handleAllowNotification.ts
import { registerServiceWorker } from "@/utils/notification";
import { getToken } from "firebase/messaging";
import { messaging } from "./settingFCM";
import { postDeviceToken } from "@/apis/notification";
export async function handleAllowNotification() {
if (Notification.permission === "default") {
const status = await Notification.requestPermission(); //granted, denied, default
if (status === "denied") {
return "denied";
} else if (status === "granted") {
try {
// 서비스 워커 등록 완료를 기다림
await registerServiceWorker();
const token = await retryGetDeviceToken(3); // 최대 3번까지 재시도
await postDeviceToken(token);
return "granted";
} catch (error) {
console.error(error);
throw error;
}
} else {
return "default";
}
} else {
return "exist Notification type";
}
}
// getDeviceToken 재시도 로직 추가
async function retryGetDeviceToken(retries: number): Promise<string> {
try {
return await getDeviceToken();
} catch (error) {
if (retries === 0) {
console.error("최대 재시도 횟수 초과:", error);
throw error;
} else {
console.warn(`getDeviceToken 재시도 중... 남은 횟수: ${retries}`);
return retryGetDeviceToken(retries - 1);
}
}
}
async function getDeviceToken(): Promise<string> {
// 권한이 허용된 후에 토큰을 가져옴
const token = await getToken(messaging, {
vapidKey:
"@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@",
});
return token;
}
handleAllowNotification
함수가 사실 핵심 함수이다.
내부에는
const status = await Notification.requestPermission(); //granted, denied, default
이 Notification API가 있는데, 디바이스에게 직접 이 서비스에 대한 알림 권한에 대한 요청을 하는 API이다.
크게 2가지 기능을 지니고 있다.
Notification API
1.Notification.permission
현재 기기에 대한 알림이 어떻게 설정되어 있는지 확인할 수 있는 속성 값이다. (읽기 전용)
값은 크게 granted, denied, default 3가지로 이루어져 있다.
granted : 사용자가 이미 해당 서비스에 대한 알림을 허용한 경우.
denied : 사용자가 이미 해당 서비스에 대한 알림을 거부한 경우.
default : 사용자가 아직 해당 서비스에 대한 알림을 권한을 선택 안한 경우.(이때, 허용할래 말래 알림창이 뜨게 된다.!)
2.Notification.requestPermission()
이 메소드는 현재 처음인 상태, 즉 default일때, 발생시키면 사용자에게 알림 허용할래 말래 권한 부여 대화상자가 띄워준다.
(여기서, 중요한 점, 만약 이미 처음이 아닌 즉, default 상태가 아닌, denied나 granted인 상황에서 해당 코드를 실행하면 "denied"가 반환된다. 또한 대화상자 역시 뜨지않는다.! 대화상자는 무조건 "default"인 상황에서 발생하게 된다)
(mdn 사이트)
이 api를 이용해 현재 알림 권한 상태를 확인하고 "default"인 경우에만 Notification.requestPermission()
를 호출해서 대화상자를 띄우게 했다. 또한 여기서 선택되는 값들에 따라 허용일 경우, 서비스 워커를 실행시키고, 디바이스토큰을 발급받아서 백엔드한테 넘겨줬다.
그리고 난 이걸 해당 알림 권한 값을 return 해서 외부(화면)에서 사용자에게 알려주려고 로직을 이렇게 짰다.
retryGetDeviceToken
이 함수는 디바이스 토큰을 받아오는 함수를 총 3번에 걸쳐서 요청하는 함수이다. 디바이스토큰을 받아오는 조건은 단 하나! 바로 서비스워커가 등록이 되어 있어야하는 것이다. 왜냐하면 서비스워커는 백그라운드에서 돌아가는 로직인데, 이게 돌아가고 있어야 해당 기기에 대한 디바이스 토큰을 바탕으로 알림을 보낼 것 아닌가라는 생각이 들었다. 서비스워커가 늦게 실행되는건지, 서비스워커를 다운 받고 디바이스 토큰을 요청하면 계속 아래와 같은 에러가 뜨는 것이다.
Failed to execute 'subscribe' on 'PushManager': Subscription failed - no active Service Worker
그래서 나는 해당 에러를 구글링하고, 찾아본 결과, 아직까지도 해결을 못했다는 이야기들이 굉장히 많았다. 그래서 난 지피티와 함께 여러 시도 끝에 찾은 방법은 바로 서비스워커 등록 후, 디바이스 토큰 요청을 3번까지 시도하는 것이었다. 처음 여러가지 테스트하던 중, 디바이스 토큰 요청이 2번째부터는 제대로 되는 것이었다. 그래서 난 최대 3번까지 재요청하기로 하고 이렇게 로직을 짰다.
그래서 난 이 내용을 파이어베이스 이슈 댓글로 달아놓기도 했다,,ㅋㅋ
파이어베이스 깃허브 이슈 링크
2. 백그라운드에서 돌아가게끔 도와주는 서비스 워커 파일
앞서 계속 말했다시피 알림은 앱이 실행되지 않아도 백그라운드에서 실행이 되어야한다. 그러므로 백그라운드에서 돌아가는 파일 즉, 서비스워커가 필수적이다.
위에 설명 중, 디바이스 토큰을 받기 전 서비스워커가 등록되어야한다는 이야기를 봤을 것이다. 그래서 난 디바이스 토큰을 받아오기전에 registerServiceWorker
와 같은 함수를 만들어 서비스워커를 등록하게끔 해놨다.
//registerServiceWorker.ts
export async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register("firebase-messaging-sw.js");
console.log("Service Worker 등록 성공:", registration);
} catch (error) {
console.log("Service Worker 등록 실패:", error);
}
}
정말 이 함수는 단순히 서비스워커 파일 등록을 도와주는 함수이다. 메인 바로 아래에 서비스워커 파일이다.
//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,
data: resultData.data,
};
e.waitUntil(
self.registration.showNotification(notificationTitle, notificationOptions),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const urlToOpen = event.notification.data;
event.waitUntil(self.clients.openWindow(urlToOpen));
});
간단히 설명을 하자, 크게 install
, activate
, push
, notificationclick
4구역으로 나뉘게 된다.
install
: 서비스 워커 설치 시 실행, 주로 리소스 캐싱이나 초기화 작업.
activate
: 활성화 단계, 불필요한 리소스 정리.
push
: 푸시 알림 수신 시 실행, 사용자에게 알림 표시.
notificationclick
: 사용자가 알림을 클릭했을 때 실행, 특정 작업 수행.
이 파일에서는 말 그대로 알림에 대한 모든 것들을 처리하는 방식을 나타낸다. 서버에서 해당 디바이스 토큰을 바탕으로 알림을 쏘면 이곳이 동작해 사용자 기기에 알림을 주는 방식이다!
여기서 핵심은 push 이벤트의 객체가 notificationclick 이벤트 객체에 부모이다. 즉, 여기서 notificationclick 이벤트 객체에 값이 전이된다.
그래서 지금 코드에서 title과 body, data를 showNotification에 전달해주고 있다. 우선 title과 body를 바탕으로 푸시 알림이 꾸려지고, data에는 접속할 링크가 들어있다. 그래서 notificationOptions에 이 값들을 넣으면 notificationclick 이벤트 객체에서 이 값들을 확인할 수 있다. 여기서 url을 따와서 클릭시, 해당 디테일 페이지로 이동 할 수 있게끔 로직을 짜놨다.
url 전달 형태
-> https://www.writon.co.kr/detail/892 (x)
-> /detail/892 (o)
cf.) 이 파일에 alert이 들어가면 오류가 발생한다... 왜 그런지 모르겠는데, 이거때문에 한참을 헤맸었다. (gpt 왈_서비스 워커는 백그라운드 스레드에서 실행되기 때문에 사용자 인터페이스와 관련된 API를 사용할 수 없습니다.
)
3. 푸시알림을 요청을 start하는 파일 (ex. main페이지)
마지막으로 푸시알림을 로직을 실행시켜는 파일이 필요하다. 서비스마다 다르겠지만, writon 서비스같은 경우, 로그인을 거친 후, 메인페이지에 접속하게 된다. 그래서 메인페이지에 접속 후, 알림 권한을 받을 수 있게 했다!
-> 결론 : 메인페이지가 렌더링 되면 함수실행!
// MainPage.tsx 일부.
// pwa 일때만 푸시알림 허용 창 띄우기
const isPWA = () => {
return window.matchMedia("(display-mode: standalone)").matches;
};
//모바일일때만 푸시알림 허용 창 띄우기
const isTouchDevice = "ontouchstart" in window;
const notificationPermission = async () => {
document.body.style.overflowY = "hidden";
setIsLoading(true);
try {
const notificationResult = await handleAllowNotification();
if (notificationResult === "granted") {
setIsLoading(false);
document.body.style.overflowY = "scroll"; // granted의 로딩 후에 실행
setSnackBar({ ...snackBar, notificationSnackBar: true });
setTimeout(() => {
setSnackBar({ ...snackBar, notificationSnackBar: false });
}, 2000);
}
} finally {
setIsLoading(false);
document.body.style.overflowY = "scroll";
}
};
// 푸시알림 허용 창 띄우기 로직
useEffect(() => {
if (isPWA() && isTouchDevice) {
notificationPermission();
}
}, []);
보다시피, 나는 pwa상태일 때만 알림을 허용할 수 있게끔 해놨다. 그래서 해당 isPWA
, isTouchDevice
조건들로 막아놨다. 그리고 해당 함수를 실행시켜 권한요청 후, 디바이스 토큰이 등록될 때까지 로딩을 걸어놨다!
변경사항
위에 작성한 글을 보다시피, 메인페이지 접속과 동시에 useEffect 내부에 notificationPermission() 함수를 호출시키게끔 로직을 구현해놨다. 로컬로그인으로 테스트할 때까지만 해도 너무 잘 되었는데,,
배포를 진행하고, 카카오로그인으로 접속을 하니, 알림창이 뜨지 않는 것이다. 즉, 카카오로그인시, 외부 링크(카카오톡)를 갔다가 다시 메인페이지로 접속을 하니, handleAllowNotification 함수의 결과로 denied
가 나오는 것이다.쓰흡,, 그리고 다시 메인페이지를 새로고침하니, 그제서야 알림창이 뜨는 것이다,, 진짜 하루종일 이것의 원인을 찾아보려했지만, 찾을 수 없었고, 예전 게시글에 작성했던 사용자 클릭 이벤트로 인한 발생만이 해결법이라고 생각이 들었다,,(나중엔 꼭 해결하고 싶다..) 그래서 최종 로직은 아래와 같다.
카카오 로그인 접속 -> 자체서비스 모달창 (사용자 클릭 유도) -> handleAllowNotification() 호출 -> 알림설정 여부
자체서비스 모달창을 띄우는 기준은 Notification.permission이 default
일 때만 띄우도록 작업을 해놨다!
// 푸시알림 허용 창 띄우기 로직 (메인페이지 일부)
useEffect(() => {
if (Notification.permission === "default" && isPWA() && isTouchDevice) {
setModal((modal) => ({ ...modal, notificationPermissionModal: true }));
}
}, []);
// 모달창
const NotificationPermissionModal = () => {
const setIsLoading = useSetRecoilState(loadingState);
const setModal = useSetRecoilState(modalBackgroundState);
const [snackBar, setSnackBar] = useRecoilState(snackBarState);
const handleClick = async () => {
setModal((modal) => ({ ...modal, notificationPermissionModal: false }));
setIsLoading(true);
try {
const notificationResult = await handleAllowNotification();
if (notificationResult === "granted") {
setIsLoading(false);
document.body.style.overflowY = "scroll"; // granted의 로딩 후에 실행
setSnackBar({ ...snackBar, notificationSnackBar: true });
setTimeout(() => {
setSnackBar({ ...snackBar, notificationSnackBar: false });
}, 2000);
}
} finally {
setIsLoading(false);
document.body.style.overflowY = "scroll";
}
};
useEffect(() => {
document.body.style.overflowY = "hidden";
}, []);
return (
<Wrapper>
<Container>
<Text>
<Text_Bold>‘Writon’에서 알림을 보내고자 합니다.</Text_Bold>
<Text_light>댓글, 좋아요 알림</Text_light>
</Text>
<Buttons>
<Button
$type={true}
onClick={() => handleClick()}
>
알림 설정하기
</Button>
</Buttons>
</Container>
</Wrapper>
);
};
이렇게만 하면 사실 웹푸시알림은 끝이다!
프론트와 백이 서로 주고 받을 건, 크게 이 3가지 인 것 같다! 위에서 설명한대로 해놓는게 기본이다!
우선, 파이어베이스 어드민 SDK 파일을 전달해야한다. 이게 필요한 이유는 백엔드에서 짜놓은 로직대로 알림을 생성해서 이걸 파이어베이스에 전달하는 것이다. 그리고 파이어베이스는 이걸 받아서 우리 기기에 쏴주는 방식이다. 즉, 푸시알림은 백엔드에서 보내는 것이 아니라 파이어베이스를 통해서 우리에게 메시지를 보내는 것이다. 그러므로, 백엔드는 파이어베이스에 대한 주소가 있어야한다. 그게 바로 이 sdk 파일이다.
해당 프로젝트에 들어가서 프로젝트 설정을 들어간 후, 서비스 계정에서 새 비공개 키를 생성한다! 그리고 이걸 백엔드에게 전달을 하면 아마 패키지 파일에 넣어서 사용할 거다!
사실 이 부분은 내가 건드린게 아니라서 잘 모르겠다! 백엔드 친구에게 코드를 받았는데, 이거밖에 설정을 안했다고 한다. (알림 관련해서) 비즈니스 로직은 알아서 잘 구현한 것 같다!!
import { Injectable } from '@nestjs/common';
import { firebase } from '../config/firebase';
import { UserHelper } from 'src/domain/user/helper/User.Helper';
@Injectable()
export class AlarmService {
constructor(private readonly userHelper: UserHelper) {}
public async sendPushAlarm(
userId: number,
engineValues: string[],
title: string,
body: string,
targetUrl: string,
) {
console.log(targetUrl);
const message = {
tokens: engineValues,
webpush: {
notification: {
title: title,
body: body,
data: {
url: targetUrl,
},
},
},
};
try {
const response = await firebase.messaging().sendEachForMulticast(message);
console.log('Successfully sent message:', response);
const failedTokens = this.getFailedTokens(response, engineValues);
await this.handleFailedTokens(userId, failedTokens);
} catch (error) {
console.error('Error sending message:', error);
}
}
private async handleFailedTokens(
userId: number,
failedTokens: string[],
): Promise<void> {
if (failedTokens.length > 0) {
await this.removeTokensFromDatabase(userId, failedTokens);
}
}
private getFailedTokens(response: any, originalTokens: string[]): string[] {
return response.responses.reduce((acc, res, index) => {
if (
!res.success &&
res.error?.code === 'messaging/registration-token-not-registered'
) {
acc.push(originalTokens[index]);
}
return acc;
}, [] as string[]);
}
private async removeTokensFromDatabase(userId: number, tokens: string[]) {
console.log('Tokens to remove from database:', tokens);
await this.userHelper.executeDeleteFirebaseTokens(userId, tokens);
}
}
A 핸드폰에 알림이 울리는 상황
B가 A 게시글에 좋아요를 누름. -> 백엔드 비즈니스 로직을 거쳐 메시지를 제작함. -> 제작한 메시지와 디바이스 토큰을 함께 파이어베이스에게 전달 -> 파이어베이스는 해당 디바이스 토큰을 가진 기기에게 메시지를 보냄 -> A는 메시지를 받음.
메인페이지 첫접속 -> 알림 설정값 확인(default) -> (추가)서비스 자체 알림 모달 -> 알림 권한 대화상자 띄우기 -> 알림 허용 클릭시,) 서비스 워커 실행 및 설치 -> 디바이스 토큰 발급 -> 디바이스 토큰 서버에 전달 -> 이 과정이 끝나야만 사용자에게 알림 설정이 완료 되었다고 화면에 띄어줌.
메인페이지 첫접속 -> (추가)서비스 자체 알림 모달 -> 알림 권한 대화상자 띄우기 -> 알림 차단 클릭시,) 바로 메인화면으로 복귀
메인페이지 재접속 -> 알림 설정값 확인(denied, granted) -> 바로 return 메인화면으로 복귀
1. 리렌더링으로 인해 함수가 반복 실행
리렌더링 때문에 함수가 반복적으로 실행되어, 처음에는 API 호출에 문제가 있다고 오해했다. "denied"라는 값이 7번씩 나오고 그랬다. 알고보니, 해당 메인페이지가 총 7번 리렌더링 되고 있었던 것이었다. 문제는 api 다 받을 때까지 기다리게 로딩처리하던 UseIsFetching
이 문제였다.
2. 클릭시, Notification API 호출
이건, 저번 글에서 내가 API가 계속 그냥 denied가 된다고 말했던 것이었다. 그래서 항상 클릭이 있어야하는 줄 알고, 알림창 전에 라이톤 알림창을 띄워서 허용을 누르면 기기 알림창이 뜨게끔 구현을 해놨었다. (개고생) 근데, 근데, 백엔드 친구가 물어보는 것이었다. 그냥 그 모달창 없이는 안되냐고, 내가 생각해도 안된다고 생각했지만, 그거 때문에 크리티컬한 이슈들이 굉장히 많았다. db에 해당 값을 저장해놓는데, Pwa를 삭제 했다가 다시 까는 경우, 해당 값이 denied로 되어 있어서 실제로는 default 이지만, 디비의 값때문에 알림을 못띄운다는 크리티컬한 이슈가 있었다. 사용자 입장에서도 모달창이 2개뜬다는 건 자체가 불편한 점이라고 느꼈다. 근데 이렇게 다시 알림창을 하나로 바꾸고 크리티컬한 이슈를 해결해서 다행히다.왜 된건지는 잘 모르겠다. 내가 .ts 파일에서 함수를 호출하고, 이를 메인 페이지(.tsx)에 import한 상황이었는지 기억은 안나지만, 다시 되니까,, 럭키비키
삭선한 이유
처음에 로컬로 로그인 후, 바로 메인페이지로 접속했기 때문에, 알림 허용창이 잘 떴다! 그래서 난 내가 지금까지 잘못 개발했는 줄 알고 다시 모든 로직을 변경해서, 기존의 우리 서비스의 모달창(사용자의 클릭) 을 띄우는 로직을 다 제거했다. 그리고 배포를 하고 테스트를 진행했다. 카카오 로그인 시, 다른 외부 링크(카카오톡)를 갔다가 서비스로 접근(메인페이지) 하니, 알림창이 뜨지 않는것이다.(알림창이 뜨지 않고, denied가 계속 return 됐다.) 그래서 난 Notification.permission 값이 default일 때는 계속 함수를 돌리게끔 로직을 짜보았지만, 무용지물이었다,, 그래서 다시 기존의 우리 서비스를 먼저 띄우고 사용자가 클릭을 하면 모달창을 띄우게끔 로직을 변경하였다.. 아래는 최종 영상이다.