구글에서 제공하는 푸시 알림 인프라
expo-notifications 라이브러리를 통해 FCM을 사용.푸시 알림 클릭 시 앱의 특정 화면으로 이동하게 해주는 데이터.
알림에는 단순히 텍스트말고도 data에 { type, target } 같이 정보를 담아 보냄.
{ type: 'ORDER', target: '12345' } → 해당 주문 상세 페이지 열기Firebase 프로젝트 생성
안드로이드 앱 등록 google-services.json 다운로드
iOS 앱 등록 GoogleService-Info.plist 다운로드
expo-notifications, expo-device, expo-constants 설치
app.json에 FCM 관련 설정 추가 (Expo가 빌드 시 포함시켜줌)
앱에서 FCM 토큰을 발급(registerForPushNotificationsAsync), 기기정보 수집(getDeviceInfo)
↓
사용자 로그인 시 백엔드에게 전달(sendNotificationToken)
↓
백엔드에서는 이벤트 발생 시 특정 혹은 전체 사용자에 대해 FCM 서버에 요청보냄
↓
FCM 서버는 안드로이드 기기에는 직접 전달 iOS 기기에는 APNs를 거쳐서 전달
registerForPushNotificationsAsync)FCM 토큰을 expo-notifications로 발급받음
이때 EAS 프로젝트 ID가 필요하기 때문에 expo-constants로 설정값을 읽어서 같이 넘겨줌
이 토큰을 알아야 서버가 특정 사용자 기기만 골라서 알림을 보낼 수 있음
export async function registerForPushNotificationsAsync() {
let token;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
}
if (Device.isDevice) {
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Failed to get push token for push notification!');
return;
}
token = await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
});
} else {
console.log('Must use physical device for Push Notifications');
}
return token;
}
getDeviceInfo)expo-device 로 디바이스 정보 가져옴
// 기기 정보 가져오기
export async function getDeviceInfo() {
const deviceType = await Device.getDeviceTypeAsync();
const deviceName = Device.deviceName;
const osName = Device.osName;
const osVersion = Device.osVersion;
const brand = Device.brand;
const manufacturer = Device.manufacturer;
const modelName = Device.modelName;
// 고유 식별자 생성
const uniqueId = `${brand}-${modelName}-${osVersion}-${deviceName}`
.replace(/\s+/g, '-')
.toLowerCase();
return {
installationId: uniqueId,
deviceName,
deviceType,
osName,
osVersion,
brand,
manufacturer,
modelName,
};
}
sendNotificationToken)이제 로그인 할때 fcmToken이랑 deviceInfo를 백엔드에게 보내줌
export const sendNotificationToken = async (body: {
fcmToken: string;
deviceInfo: string;
}) => {
return await baseInstance.post('/api/notification/token', body);
};
그럼 백엔드에서 FCM API를 호출해서 앱기기로 알림을 전달함
알림을 받을 때 앱이 가지는 상태는 총 세가지임
알림이 도착했을 때 실행할 함수(onNotificationReceived),
알림을 클릭했을 때 실행할 함수(onNotificationResponseReceived)
export function setupNotificationListeners(
onNotificationReceived: (notification: Notifications.Notification) => void,
onNotificationResponseReceived: (
response: Notifications.NotificationResponse,
) => void,
) {
// 안드로이드 cold-start
Notifications.getLastNotificationResponseAsync().then((lastResponse) => {
if (lastResponse) {
onNotificationResponseReceived(lastResponse);
}
});
const notificationListener = Notifications.addNotificationReceivedListener(
onNotificationReceived,
);
const responseListener =
Notifications.addNotificationResponseReceivedListener(
onNotificationResponseReceived,
);
return () => {
Notifications.removeNotificationSubscription(notificationListener);
Notifications.removeNotificationSubscription(responseListener);
};
}
// app/_layout.tsx
const unsubscribe = setupNotificationListeners(
(notification: any) => {
console.log('알림 수신:', notification.request.content.data);
},
(response: any) => {
console.log('알림 클릭:', response.notification.request.content.data);
const deepLinkData = extractDeepLinkData(response);
if (deepLinkData) {
const currentTabDeepLinkHandler = (global as any)[
`handleDeepLinkFromNotification_${currentTab}`
];
if (currentTabDeepLinkHandler) {
currentTabDeepLinkHandler(deepLinkData);
} else {
console.log(
`${currentTab} 탭의 딥링크 처리 함수가 등록되지 않았습니다.`,
);
}
} else {
console.log('딥링크 데이터가 없습니다.');
}
},
);
onNotificationReceived 실행됨
사용자가 알림 클릭 시 onNotificationResponseReceived 실행됨
알림은 OS가 표시
onNotificationReceived 실행X
사용자가 알림 클릭 시 onNotificationResponseReceived 실행됨
iOS
알림은 OS가 표시
onNotificationReceived 실행X
사용자가 알림 클릭 시 onNotificationResponseReceived 실행됨
Android
알림은 OS가 표시
onNotificationReceived 실행X
사용자가 알림 클릭 → 앱 실행되지만, listener(onNotificationResponseReceived)는 자동 실행되지 않음
아래 이 코드를 호출해서 마지막 알림 응답을 수동으로 꺼내와야 함
Notifications.getLastNotificationResponseAsync().then((lastResponse) => {
if (lastResponse) {
onNotificationResponseReceived(lastResponse);
}
});
알림이 도착했을때 실시간 UI반영이 필요없다면 onNotificationReceived 는 없어도됨.(마켓D도 필요없음)
푸쉬알림을 클릭했을 때
백엔드에서 보내준 데이터를 받아 현재 탭의 웹뷰에 postMessage로 전달해서 해당 페이지를 열도록 해야됨
푸쉬알림 클릭
↓
extractDeepLinkData 로 데이터 뽑아냄
↓
현재 탭에 맞는 딥링크처리함수를 전역객체(global)에서 꺼내옴 ex)handleDeepLinkFromNotification_Home
↓
해당 함수 실행 → useDeepLink 내부의 handleDeepLinkFromNotification 동작
↓
이미 처리된 알림(lastHandledId)인지 검사. (홈탭일때 이미 처리한 알림인데 다른 탭에서
새로운 알림이면 메세지 객체 생성
↓
WebView 준비 여부 확인
준비됨(앱이 백그라운드에 있거나, 켜져있을 경우) → webviewRef.postMessage() 로 곧바로 전달
준비 안 됨(앱이 완전히 꺼져있을 경우) → pendingDeepLink에 임시 저장
↓
WebView가 준비 완료 신호(setIsWebviewReady(true)) 보내면 pendingDeepLink 실행
앱이 켜져있거나, 백그라운드에 있을때
앱이 켜져있으니, 웹뷰도 이미 READY 상태 → 푸쉬알림 클릭 →handleDeepLinkFromNotification_home 호출 → isWebviewReadyRef.current === true 라서 바로 웹뷰로 postMessage 전송
앱이 꺼져있을때
푸쉬알림 클릭 → 앱실행 →앱코드에서 useDeepLink 실행 → 아직 READY 상태 아님 → pendingDeepLink에 저장 → 앱이 열리면서 웹쪽에서 WEB_MSG.READY 메시지 보냄 → 아래 코드 실행
if (pendingDeepLink) {
webviewRef.current?.postMessage(JSON.stringify(pendingDeepLink));
}
const useDeepLink = (webviewRef: React.RefObject<WebView>, type: string) => {
const isWebviewReadyRef = useRef<boolean>(false);
const {
lastHandledId,
setLastHandledId,
pendingDeepLink,
setPendingDeepLink,
} = useDeepLinkStore();
const handleDeepLinkFromNotification = (deepLinkData: any) => {
if (!deepLinkData || !deepLinkData.target) return;
// 이미 처리된 딥링크 무시
if (lastHandledId === deepLinkData.target) return;
// 즉시 lastHandledId 설정하여 중복 처리 방지
setLastHandledId(deepLinkData.target);
const message = {
type: APP_MSG.DEEP_LINK,
data: {
type: deepLinkData.type,
target: deepLinkData.target,
},
};
if (isWebviewReadyRef.current) {
webviewRef.current?.postMessage(JSON.stringify(message));
setPendingDeepLink(null);
} else {
// 앱 꺼져있을 경우
setPendingDeepLink(message);
}
};
// 현재 탭(type)에 맞는 딥링크 처리 함수를 global 객체에 등록
useEffect(() => {
(global as any)[`handleDeepLinkFromNotification_${type}`] =
handleDeepLinkFromNotification;
return () => {
delete (global as any)[`handleDeepLinkFromNotification_${type}`];
};
}, [type, lastHandledId]);
return {
pendingDeepLink,
setIsWebviewReady: (ready: boolean) => {
isWebviewReadyRef.current = ready;
},
};
};
// home탭
const { pendingDeepLink, setIsWebviewReady } = useDeepLink(
webviewRef,
'home',
);
// order탭
const { setIsWebviewReady } = useDeepLink(webviewRef, 'order');
// myPage탭
const { setIsWebviewReady } = useDeepLink(webviewRef, 'myPage');
// setting탭
const { setIsWebviewReady } = useDeepLink(webviewRef, 'setting');
푸시 알림은 _layout.tsx처럼 앱 최상위에서 수신함
여기서는 단순히 알림이 왔고, 딥링크데이터가 뭔지 정도만 확인 가능함
하지만 딥링크 실행 위치는 각 탭임. 그러나 _layout.tsx는 각 탭의 WebView ref를 직접 알지못함.
즉 알림은 위에서 받는데 실행은 아래서 해야하기 때문에 전역에 함수를 등록해놓는거임
각 탭은 자기 webview ref를 알고 있으니까 전역에 자기 전용함수를 등록해놓는거임.
(global as any)[`handleDeepLinkFromNotification_Home`] = (deepLinkData) => {
homeWebviewRef.current?.postMessage(JSON.stringify(deepLinkData));
};
layout.tsx는 알림이 오면 현재 탭 이름 확인하고, 전역에서 `handleDeepLinkFromNotification${currentTab}`를 꺼내 실행
탭마다 독립적인 웹뷰를 가지고 있어서 알림 클릭 시 어떤 탭의 웹뷰로 메세지를 보낼지 구분해야됨
근데 만약 탭마다 구분안하고 공통으로 써버리면, 마지막탭에서 등록된 함수가 덮어씌워버림
ex)
홈탭이 열려있어도 딥링크가 mypage로 가버림
웹뷰가 아직 준비되지 않았는데 postMessage를 보내버려서 작동하지 않았었음.
웹뷰 준비 전이면 ref객체에 딥링크정보 저장해둠으로써 해결
알림 응답 객체(NotificationResponse)는 한번 쓰고 사라지지 않고, 앱이 살아있는 동안 유지됨
새 listener가 등록되면, Expo가 이 객체를 또 전달함
앱이 꺼져있는 상태에서 알림이 도착 → 알림 클릭 → 앱 실행. 홈탭 실행 → 딥링크 페이지 이동→
다시 다른 탭 클릭 시 딥링크 페이지 또 이동 → 무한반복
다른탭의 웹뷰 로드되면서 useDeepLink("MyPage") 훅이 자기 전용 핸들러 등록
여전히 남아있는 알림 응답 객체 때문에 listener가 다시 불림
그래서 중복처리 해줘야함
구글에서 제공하는 푸시 알림 인프라
expo-notifications 라이브러리를 통해 FCM을 사용.푸시 알림 클릭 시 앱의 특정 화면으로 이동하게 해주는 데이터.
알림에는 단순히 텍스트말고도 data에 { type, target } 같이 정보를 담아 보냄.
{ type: 'ORDER', target: '12345' } → 해당 주문 상세 페이지 열기Firebase 프로젝트 생성
안드로이드 앱 등록 google-services.json 다운로드
iOS 앱 등록 GoogleService-Info.plist 다운로드
expo-notifications, expo-device, expo-constants 설치
app.json에 FCM 관련 설정 추가 (Expo가 빌드 시 포함시켜줌)
앱에서 FCM 토큰을 발급(registerForPushNotificationsAsync), 기기정보 수집(getDeviceInfo)
↓
사용자 로그인 시 백엔드에게 전달(sendNotificationToken)
↓
백엔드에서는 이벤트 발생 시 특정 혹은 전체 사용자에 대해 FCM 서버에 요청보냄
↓
FCM 서버는 안드로이드 기기에는 직접 전달 iOS 기기에는 APNs를 거쳐서 전달
registerForPushNotificationsAsync)FCM 토큰을 expo-notifications로 발급받음
이때 EAS 프로젝트 ID가 필요하기 때문에 expo-constants로 설정값을 읽어서 같이 넘겨줌
이 토큰을 알아야 서버가 특정 사용자 기기만 골라서 알림을 보낼 수 있음
export async function registerForPushNotificationsAsync() {
let token;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
}
if (Device.isDevice) {
const { status: existingStatus } =
await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Failed to get push token for push notification!');
return;
}
token = await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
});
} else {
console.log('Must use physical device for Push Notifications');
}
return token;
}
getDeviceInfo)expo-device 로 디바이스 정보 가져옴
// 기기 정보 가져오기
export async function getDeviceInfo() {
const deviceType = await Device.getDeviceTypeAsync();
const deviceName = Device.deviceName;
const osName = Device.osName;
const osVersion = Device.osVersion;
const brand = Device.brand;
const manufacturer = Device.manufacturer;
const modelName = Device.modelName;
// 고유 식별자 생성
const uniqueId = `${brand}-${modelName}-${osVersion}-${deviceName}`
.replace(/\s+/g, '-')
.toLowerCase();
return {
installationId: uniqueId,
deviceName,
deviceType,
osName,
osVersion,
brand,
manufacturer,
modelName,
};
}
sendNotificationToken)이제 로그인 할때 fcmToken이랑 deviceInfo를 백엔드에게 보내줌
export const sendNotificationToken = async (body: {
fcmToken: string;
deviceInfo: string;
}) => {
return await baseInstance.post('/api/notification/token', body);
};
그럼 백엔드에서 FCM API를 호출해서 앱기기로 알림을 전달함
알림이 도착했을 때 실행할 함수(onNotificationReceived),
알림을 클릭했을 때 실행할 함수(onNotificationResponseReceived)
export function setupNotificationListeners(
onNotificationReceived: (notification: Notifications.Notification) => void,
onNotificationResponseReceived: (
response: Notifications.NotificationResponse,
) => void,
) {
// 안드로이드 cold-start
Notifications.getLastNotificationResponseAsync().then((lastResponse) => {
if (lastResponse) {
onNotificationResponseReceived(lastResponse);
}
});
const notificationListener = Notifications.addNotificationReceivedListener(
onNotificationReceived,
);
const responseListener =
Notifications.addNotificationResponseReceivedListener(
onNotificationResponseReceived,
);
return () => {
Notifications.removeNotificationSubscription(notificationListener);
Notifications.removeNotificationSubscription(responseListener);
};
}
// app/_layout.tsx
const unsubscribe = setupNotificationListeners(
(notification: any) => {
console.log('알림 수신:', notification.request.content.data);
},
(response: any) => {
console.log('알림 클릭:', response.notification.request.content.data);
const deepLinkData = extractDeepLinkData(response);
if (deepLinkData) {
const currentTabDeepLinkHandler = (global as any)[
`handleDeepLinkFromNotification_${currentTab}`
];
if (currentTabDeepLinkHandler) {
currentTabDeepLinkHandler(deepLinkData);
} else {
console.log(
`${currentTab} 탭의 딥링크 처리 함수가 등록되지 않았습니다.`,
);
}
} else {
console.log('딥링크 데이터가 없습니다.');
}
},
);
onNotificationReceived 실행됨
사용자가 알림 클릭 시 onNotificationResponseReceived 실행됨
알림은 OS가 표시
onNotificationReceived 실행X
사용자가 알림 클릭 시 onNotificationResponseReceived 실행됨
iOS
알림은 OS가 표시
onNotificationReceived 실행X
사용자가 알림 클릭 시 onNotificationResponseReceived 실행됨
Android
알림은 OS가 표시
onNotificationReceived 실행X
사용자가 알림 클릭 → 앱 실행되지만, listener(onNotificationResponseReceived)는 자동 실행되지 않음
아래 이 코드를 호출해서 마지막 알림 응답을 수동으로 꺼내와야 함
Notifications.getLastNotificationResponseAsync().then((lastResponse) => {
if (lastResponse) {
onNotificationResponseReceived(lastResponse);
}
});
알림이 도착했을때 실시간 UI반영이 필요없다면 onNotificationReceived 는 없어도됨.(마켓D도 필요없음)
푸쉬알림을 클릭했을 때
백엔드에서 보내준 데이터를 받아 현재 탭의 웹뷰에 postMessage로 전달해서 해당 페이지를 열도록 해야됨
푸쉬알림 클릭
↓
extractDeepLinkData 로 데이터 뽑아냄
↓
현재 탭에 맞는 딥링크처리함수를 전역객체(global)에서 꺼내옴 ex)handleDeepLinkFromNotification_Home
↓
해당 함수 실행 → useDeepLink 내부의 handleDeepLinkFromNotification 동작
↓
이미 처리된 알림(lastHandledId)인지 검사. (홈탭일때 이미 처리한 알림인데 다른 탭에서
새로운 알림이면 메세지 객체 생성
↓
WebView 준비 여부 확인
준비됨(앱이 백그라운드에 있거나, 켜져있을 경우) → webviewRef.postMessage() 로 곧바로 전달
준비 안 됨(앱이 완전히 꺼져있을 경우) → pendingDeepLink에 임시 저장
↓
WebView가 준비 완료 신호(setIsWebviewReady(true)) 보내면 pendingDeepLink 실행
앱이 켜져있거나, 백그라운드에 있을때
앱이 켜져있으니, 웹뷰도 이미 READY 상태 → 푸쉬알림 클릭 →handleDeepLinkFromNotification_home 호출 → isWebviewReadyRef.current === true 라서 바로 웹뷰로 postMessage 전송
앱이 꺼져있을때
푸쉬알림 클릭 → 앱실행 →앱코드에서 useDeepLink 실행 → 아직 READY 상태 아님 → pendingDeepLink에 저장 → 앱이 열리면서 웹쪽에서 WEB_MSG.READY 메시지 보냄 → 아래 코드 실행
if (pendingDeepLink) {
webviewRef.current?.postMessage(JSON.stringify(pendingDeepLink));
}
const useDeepLink = (webviewRef: React.RefObject<WebView>, type: string) => {
const isWebviewReadyRef = useRef<boolean>(false);
const {
lastHandledId,
setLastHandledId,
pendingDeepLink,
setPendingDeepLink,
} = useDeepLinkStore();
const handleDeepLinkFromNotification = (deepLinkData: any) => {
if (!deepLinkData || !deepLinkData.target) return;
// 이미 처리된 딥링크 무시
if (lastHandledId === deepLinkData.target) return;
// 즉시 lastHandledId 설정하여 중복 처리 방지
setLastHandledId(deepLinkData.target);
const message = {
type: APP_MSG.DEEP_LINK,
data: {
type: deepLinkData.type,
target: deepLinkData.target,
},
};
if (isWebviewReadyRef.current) {
webviewRef.current?.postMessage(JSON.stringify(message));
setPendingDeepLink(null);
} else {
// 앱 꺼져있을 경우
setPendingDeepLink(message);
}
};
// 현재 탭(type)에 맞는 딥링크 처리 함수를 global 객체에 등록
useEffect(() => {
(global as any)[`handleDeepLinkFromNotification_${type}`] =
handleDeepLinkFromNotification;
return () => {
delete (global as any)[`handleDeepLinkFromNotification_${type}`];
};
}, [type, lastHandledId]);
return {
pendingDeepLink,
setIsWebviewReady: (ready: boolean) => {
isWebviewReadyRef.current = ready;
},
};
};
//home탭
const { pendingDeepLink, setIsWebviewReady } = useDeepLink(
webviewRef,
'home',
);
//order탭
const { setIsWebviewReady } = useDeepLink(webviewRef, 'order');
//myPage탭
const { setIsWebviewReady } = useDeepLink(webviewRef, 'myPage');
//setting탭
const { setIsWebviewReady } = useDeepLink(webviewRef, 'setting');
푸시 알림은 _layout.tsx처럼 앱 최상위에서 수신함
여기서는 단순히 알림이 왔고, 딥링크데이터가 뭔지 정도만 확인 가능함
하지만 딥링크 실행 위치는 각 탭임. 그러나 _layout.tsx는 각 탭의 WebView ref를 직접 알지못함.
즉 알림은 위에서 받는데 실행은 아래서 해야하기 때문에 전역에 함수를 등록해놓는거임
각 탭은 자기 webview ref를 알고 있으니까 전역에 자기 전용함수를 등록해놓는거임.
(global as any)[`handleDeepLinkFromNotification_Home`] = (deepLinkData) => {
homeWebviewRef.current?.postMessage(JSON.stringify(deepLinkData));
};
layout.tsx는 알림이 오면 현재 탭 이름 확인하고, 전역에서 `handleDeepLinkFromNotification${currentTab}`를 꺼내 실행
탭마다 독립적인 웹뷰를 가지고 있어서 알림 클릭 시 어떤 탭의 웹뷰로 메세지를 보낼지 구분해야됨
근데 만약 탭마다 구분안하고 공통으로 써버리면, 마지막탭에서 등록된 함수가 덮어씌워버림
ex)
홈탭이 열려있어도 딥링크가 mypage로 가버림
웹뷰가 아직 준비되지 않았는데 postMessage를 보내버려서 작동하지 않았었음.
웹뷰 준비 전이면 ref객체에 딥링크정보 저장해둠으로써 해결
알림 응답 객체(NotificationResponse)는 한번 쓰고 사라지지 않고, 앱이 살아있는 동안 유지됨
새 listener가 등록되면, Expo가 이 객체를 또 전달함
앱이 꺼져있는 상태에서 알림이 도착 → 알림 클릭 → 앱 실행. 홈탭 실행 → 딥링크 페이지 이동→
다시 다른 탭 클릭 시 딥링크 페이지 또 이동 → 무한반복
다른탭의 웹뷰 로드되면서 useDeepLink("MyPage") 훅이 자기 전용 핸들러 등록
여전히 남아있는 알림 응답 객체 때문에 listener가 다시 불림
그래서 중복처리 해줘야함