알림 트래킹이 왜 중요한가
알림(Notification)은 리텐션 개선 및 이탈 방지, 타게팅 오퍼를 통한 수익화 등 게임뿐만 아니라 모바일 앱 서비스 전반에서 가장 중요한 기능 중 하나입니다. 따라서 알림을 통해 유입된 유저·세션을 정확하게 트래킹하는 것은 필수적인 작업입니다.
알림은 개발적으로는 Local Notification과 Push Notification, 두 종류가 있습니다. 권한 모델이 다르고, 서비스하는 입장에서 드는 리소스가 다르다는 것이 근본적인 차이지만, 유저 체감은 사실상 같습니다.
게임은 대부분의 경우 두 종류의 알림을 모두 사용합니다. Local Notification은 스태미나 충전 알림이나 출석 리마인더처럼 클라이언트가 미리 예약해 두고 OS가 띄우는 용도(이걸 푸시로 구현하는 건 리소스 낭비입니다), Push Notification은 마케팅 캠페인이나 운영 공지처럼 서버가 임의 시점에 발송하는 용도로 씁니다.
로컬 알림 메타데이터 회수 구조
보통 Cross Platform 알림 라이브러리(Unity Mobile Notifications, react-native-push-notification 등)는 내부적으로 두 OS API를 추상화합니다.
- iOS:
UNUserNotificationCenter기반. 각 알림은Identifier,Title,Body,Trigger,UserInfo: Dictionary<string,string>을 가집니다. - Android:
NotificationManager+AlarmManager. 각 알림은Title,Text,FireTime,IntentData: string을 가집니다.
커스텀 메타데이터를 통해 알림 정보를 제공받고 종류를 구분할 수 있지만, 두 플랫폼의 메타데이터 형태가 다르다는 점에 주의해야 합니다. iOS는 Dict, Android는 단일 string입니다. 따라서 Android 쪽에서는 직접 직렬화 레이어를 만들어야 합니다.
아래와 같이 Local Notification을 schedule합니다.
- iOS:
notification.UserInfo[key] = value식으로 채움 - Android:
JSON.serialize(dict)→IntentData에 string으로 박음
이렇게 스케줄링한 알림으로 앱이 launch되었을 때 아래와 같은 방법으로 retrieve합니다.
- iOS:
GetLastRespondedNotification()→UserInfodict 회수 - Android:
GetLastNotificationIntent()→IntentDatastring 회수 →JSON.parse
주의점
iOS GetLastRespondedNotification, Android GetLastNotificationIntent 같은 API는 consumable이 아닌 사실상의 싱글톤 상태입니다. 한 세션 내에서 여러 번 호출해도 같은 값을 반환하고, 다음 cold launch에도 이전 탭 값이 살아 있을 수 있습니다. 이는 데이터 오염으로 이어지기 때문에 호출자 측에서 id 기반 dedup으로 막아야 합니다.
알림 객체에서 플랫폼별 가용 필드가 비대칭이기 때문에 FireTime 등의 정보가 한쪽에서 누락되는 경우가 흔하다고 합니다. 그냥 속 편하게 메타데이터에 필요한 정보를 직접 박아서 round-trip 자체로 양쪽 동일하게 만드는 것이 추천된다고 합니다.
그 외에:
- 플랫폼 SDK에 따라 다르지만
iOSNotification.UserInfo는 getter만 노출되어 있는 경우가 대부분이므로, 기존 dict를 mutate해야 합니다. iOSNotification.UserInfo에는 SDK 내부적으로 쓰는 예약 키들이 있어, 커스텀 키와 충돌하면 silent break입니다. prefix로 격리하는 것이 일반적인 접근이라고 합니다.- 당연한 얘기지만, 앱 업데이트 시점에서 이미 OS 스케줄러에 등록되어 있던 알림들은 구버전 포맷이기 때문에, 해당 데이터를 트래킹하고 싶다면 마이그레이션 로직이 있어야 합니다.
푸시 알림 데이터 회수 구조
노트
FCM 기준으로 설명합니다.
FCM은 양 플랫폼 기준으로 동일한 메시지 포맷입니다.
{
"notification": { "title": "...", "body": "..." },
"data": {
"category": "some_campaign_boost",
"campaign_id": "xmas_2024"
}
}data 블록은 iOS·Android 모두에서 같은 Dictionary<string,string>으로 클라에 들어옵니다. 로컬 알림처럼 플랫폼별 직렬화 레이어를 만들 필요가 없습니다.
FCM SDK는 두 콜백을 노출합니다.
FirebaseMessaging.TokenReceived += handler // 토큰 갱신
FirebaseMessaging.MessageReceived += handler // 메시지 도착 (foreground/launch tap 양쪽)MessageReceived의 Message.NotificationOpened == true면 사용자가 탭으로 앱을 열었다는 신호입니다.
주의점
FCM 같은 SDK는 subscriber가 있어야 buffered event를 deliver하는 경우가 많다고 합니다. 따라서 외부 SDK의 비동기 초기화 호출 전에 모든 콜백을 먼저 구독해야 합니다.
FirebaseMessaging의 콜백 dispatch가 비동기라 race condition이 발생할 수 있습니다.
FirebaseMessaging.MessageReceived += OnMessage;
var token = await FirebaseMessaging.GetTokenAsync();위와 같은 코드에서 메시지를 main thread pump 다음 frame에 dispatch하는 경우, GetTokenAsync가 이미 끝나서 다음 초기화 단계로 넘어가도 콜백은 아직 안 불릴 수 있습니다. (다음 단계에서 launchPayload를 조회하면 null이 됩니다.) 따라서 SDK 콜백을 await에 의존하지 말고, 이벤트 driven 트리거를 별도로 노출하고 구독해야 합니다.
NotificationOpened는 사용자가 알림을 탭해서 앱을 foreground로 가져왔으면 true입니다. 이 케이스는 cold launch에만 해당되지 않고, warm resume(백그라운드 복귀)의 케이스도 포함합니다. 따라서 warm resume에서도 동등한 이벤트 트래킹 절차를 구현해야 in-session 유입을 잃지 않습니다.
메시지 id는 transient합니다. 따라서 캠페인 단위 join을 하려면 별도 캠페인 식별자가 필수입니다.
통합 모듈
위에서 서술한 것처럼, 사용자나 데이터 분석자 입장에서 로컬 알림이나 푸시 알림은 그냥 똑같은 것입니다.
하나의 분석 이벤트로 통합하고, source 필드로 디버깅·세그먼트가 가능하게만 열어두는 것이 옳다고 생각합니다.
event_type: notification_open
fields:
source: enum {local, push}
category: string?
notification_id: string
campaign_id: string? # push only주의점
- 분석 이벤트 로깅 시스템에 의존성이 있으므로 호출 순서에 주의해서 작성해야 합니다.
- 소비가 미처 읽기 전에 두 번째 메시지가 들어오면 첫 번째 메시지는 손실됩니다. 여러 트리거가 같은 slot(payload)에 쓰는 경우, 자료 구조를 큐로 변경하거나 first-write-wins 가드를 두어 메시지 손실을 방어합니다.
- SDK 공식 문서에서 콜백 thread를 확인해야 합니다. dedup set 등을 콜백에서 만지는데 worker thread면 race입니다. 이럴 경우 lock 등을 추가 구현해야 합니다.