ADR-0010: OS 앱 아이콘 배지를 앱에서 “알림함 미읽음 + 채팅 미읽음”으로 합산 계산한다
- Status: Accepted
- Date: 2026-09-15
- 관련: HMH-9319 (이번 작업) · 알림함 도입 에픽 HMH-9004 · 관련 API HMH-9273(MessagingAPI unread-count)
Context
알림함(HMH-9004) 도입 전까지 OS 앱 아이콘 배지는 사실상 채팅(Sendbird) 미읽음만 반영했다. 근거:
- 앱은
NotificationProvidermount에서notifee.setBadgeCount(0)으로 무조건 0 클리어만 했고, 양수 세팅/증가는 어디에도 없었다. - 우리 백엔드 푸시(알림함·기타·카페공지)는
aps.badge를 넣지 않는다(확인). 배지 숫자의 유일한 출처는 Sendbird APNs 설정has_unread_count_badge(iOSaps.badge = 채팅 총 미읽음). - 배지 감소(
notifee.decrementBadgeCount)는 웹뷰 카페 글 읽음(DECREASE_NOTIFICATION_COUNT)에서만, 네이티브 채팅 읽음은 무반응.
결과: 알림함 미읽음은 배지에 전혀 안 뜨고, 앱을 한 번 열면 배지가 0으로 사라져 자주 비고 실제와 어긋났다.
핵심 제약(이 결정을 지배):
- iOS 백그라운드/종료 상태에서 아이콘 배지는 오직 푸시
aps.badge(서버 설정)로만 갱신된다 — 그 순간 앱 JS가 안 돈다. - 채팅 푸시는 Sendbird → APNs/FCM 직접 발송(앱이
registerAPNS/FCMPushTokenForCurrentUser로 토큰 등록)이고, 알림함 푸시는 우리 백엔드 발송 → 발송자가 둘.aps.badge는 절대값이라 두 발송자가 각자 쓰면 마지막 푸시가 이겨 합산이 깨진다. - 앱은 MAU 절감을 위해 Sendbird에 상시 연결하지 않는다(채팅 화면에서만 연결,
ChatConnectProvider) → 앱이 채팅 미읽음을 상시 알 수 없다.
검토한 대안
| 대안 | 채택 안 한 이유 |
|---|---|
A. 발송자 단일화 — Sendbird has_unread_count_badge 끄고 group_channel:message_send 웹훅으로 채팅 푸시를 우리 백엔드가 발송, 백엔드가 모든 푸시에 aps.badge = 알림함+채팅 설정 | iOS 백그라운드까지 완전 일관되지만 백엔드 대공사(채팅 푸시 발송·DND·번역 떠안음). 지금 필요 대비 과함 — 후속 확장으로 남긴다. |
| C. 앱 전역 Sendbird 상시 연결로 앱이 실시간 합산 | MAU 비용 증가(화면에서만 연결하는 정책과 충돌), iOS 백그라운드 갱신은 여전히 불가. |
Decision
앱에서 배지 = 알림함 미읽음 + 채팅 총 미읽음(메시지 수)를 합산 계산한다(백엔드 무변경). 두 숫자를 각각 MMKV에 영속하고 배지는 항상 저장된 알림함 + 저장된 채팅으로 설정한다. 배선(apps/native-app/src/lib/appIconBadge.ts):
- 알림함 미읽음:
refreshInboxUnreadBadge()가MessagingAPI.getUnreadNotificationCount(서버)를 조회해 성공 시에만 저장값을 갱신하고 배지를 반영한다. 실패 시 마지막 저장값을 유지(배지를 낮추지 않음). 인증된 컨텍스트(포어그라운드/앱이 살아있는 상태)에서 호출한다. - 채팅 미읽음:
setChatUnreadFromPush()(Sendbird 푸시 payloaddata.sendbird의unread_message_count) 또는setChatUnread()(채팅 화면 SDKgetTotalUnreadMessageCount보정). 둘 다 저장 + 배지 반영을 네트워크 없이 한다.
이 “각각 영속 + 실패 시 미하향” 구조가 핵심이다 — 앱 종료 헤드리스 FCM 핸들러에는 fetcher·인증 토큰이 없어(registerNativeApiFetcher/restoreSession 모두 _layout 전용, index.js 헤드리스는 lib/push만 로드) 알림함 조회가 실패하는데, 그때 배지를 0으로 낮추면 기존 값을 지워 현재보다 나빠진다. 저장값 유지로 이를 막는다.
호출 지점: NotificationProvider(앱 열 때·포어그라운드 복귀 — 인증 O), lib/push.ts(수신·Android 헤드리스·탭 → setChatUnreadFromPush + best-effort inbox 조회, 알림함 읽음), NotificationsScreen(인앱 읽음), ChatConnectProvider(연결 중 채팅 수신/읽음 → debounce 후 setChatUnread). 기존 setBadgeCount(0)은 폐기. packages/chat에 getTotalUnreadMessageCount 래퍼 추가. payload 가정 검증용으로 Sendbird 푸시인데 unread 키가 없으면 Logger.addAction('badge_sendbird_unread_missing') 로 관측한다.
Consequences
좋아지는 것
- 알림함 미읽음이 배지에 드러나고, 앱 열 때 0으로 사라지지 않고 실제 합산을 보인다.
- 저장값 기반이라 어느 소스를 못 갱신해도(헤드리스 등) 배지를 잘못 낮추지 않는다.
- 채팅분은 푸시 payload로 네트워크 없이 갱신 → 헤드리스에서도 즉시 반영.
- 백엔드 변경 0으로 즉시 개선.
감수하는 것
- iOS 백그라운드에서 알림함 푸시 도착 즉시 배지가 안 오른다 —
aps.badge(서버)가 없어 앱 JS가 그때 못 돎. 앱을 다음에 열 때 보정(self-heal). 그래도 현재(=알림함 아예 미반영)보다 항상 낫다. - 알림함 미읽음은 인증된 컨텍스트(포어그라운드/살아있는 백그라운드)에서만 갱신 — 앱 종료 헤드리스에선 조회가 실패해 저장값 유지, 다음 포어그라운드에서 최신화. (Android 헤드리스에서도 알림함분 실시간이 필요하면 백엔드가 push data에
badge_count를 실어야 한다 — 후속.) - iOS 백그라운드에서 채팅 푸시가 도착하면 Sendbird가
aps.badge를 채팅 수로 덮어써 알림함분이 잠깐 빠진다 — 앱 열 때 보정. (완전 일관성은 대안 A 필요.) - 채팅 미읽음은 연결이 없을 때 마지막 푸시 기준이라 stale 가능(다른 기기·web에서 읽으면 다음 푸시나 연결된 방 진입까지 반영 안 됨).
이 결정을 다시 열어야 할 때
- iOS 백그라운드에서 알림함까지 실시간 배지가 필요해지면 → 대안 A(Sendbird webhook → 백엔드 단일 발송,
aps.badge = 합산)로 확장한다. 그때 이 ADR을Superseded로 전환. - 채팅 stale이 체감 문제가 되면 → 앱 진입 시 값싼 Sendbird unread 조회(MAU 트레이드오프) 재검토.