앱 알림을 만들 때 처음에는 “푸시만 보내면 되지 않을까?”라고 생각하기 쉽다. 하지만 실제로 써보면 푸시는 알림의 입구일 뿐이다. 사용자가 앱을 열었을 때 지난 알림을 다시 볼 수 있어야 하고, 읽음 상태도 남아야 하며, 알림을 눌렀을 때 정확한 화면으로 이동해야 한다.
Strike Log에서는 이 문제를 FCM 푸시와 인앱 알림 DB를 함께 쓰는 방식으로 풀었다. FCM은 사용자를 다시 앱으로 불러오는 역할을 맡고, 인앱 알림은 기록과 읽음 상태를 책임지게 했다.

알림을 DB에 먼저 남긴 이유
Strike Log의 알림은 notifications 테이블에 먼저 저장된다. 여기에는 사용자 ID, 알림 타입, 제목, 본문, 이동 대상 ID, 읽음 여부가 들어간다.
예를 들어 클럽 가입 신청, 가입 승인, 체험판 만료 임박, 개인 최고 점수 갱신, 배지 획득 같은 이벤트는 모두 같은 알림 모델로 저장된다. 타입만 다르고, 사용자가 눌렀을 때 이동할 대상만 달라지는 구조다.
중요한 점은 푸시 전송보다 DB 기록을 먼저 만든다는 것이다. 서버에서는 알림을 저장한 뒤 FCM 전송을 비동기로 호출한다. 푸시 전송에 실패해도 앱 안의 알림 목록은 남는다. 네트워크나 FCM 상태 때문에 푸시가 유실되더라도 사용자가 앱을 열면 알림을 확인할 수 있다.
FCM 토큰은 upsert로 관리했다
푸시를 보내려면 디바이스별 FCM 토큰이 필요하다. Strike Log 서버에는 fcm_tokens 테이블이 있고, 앱은 시작, 로그인, 토큰 갱신, 앱 복귀 시점에 토큰 등록을 다시 시도한다.
여기서 토큰 등록은 단순 insert가 아니라 upsert로 처리했다. 같은 토큰이 이미 있으면 사용자 ID와 플랫폼 정보를 갱신한다. 사용자가 로그아웃하면 토큰을 완전히 지우지 않고 익명 상태로 다시 묶는다. 개인 알림은 받지 않지만, 시스템 공지 같은 전체 발송은 받을 수 있게 하기 위해서다.
앱 쪽에서는 토큰 등록 실패를 한 번으로 끝내지 않았다. 5초, 15초, 30초처럼 점점 늘어나는 백오프 재시도를 두었다. 권한 허용이 늦거나 네트워크가 잠깐 불안정해도 다음 기회에 다시 서버와 맞춰지도록 한 것이다.

포그라운드 알림은 로컬 알림으로 보여준다
앱이 켜져 있는 동안 FCM 메시지가 오면 운영체제 알림 배너가 자동으로 뜨지 않는 경우가 있다. 그래서 Strike Log는 포그라운드 메시지를 받으면 flutter_local_notifications로 로컬 알림을 직접 띄운다.
Android에서는 알림 채널도 따로 만들었다. 채널 ID는 strike_log_default_v2처럼 버전을 붙였다. Android 알림 채널은 한 번 만들어지면 중요도를 코드에서 마음대로 바꾸기 어렵기 때문이다. 예전에 낮은 중요도로 만들어진 채널이 남아 있으면 heads-up 알림이 뜨지 않는 문제가 생길 수 있다.
또 같은 알림 ID를 재사용하지 않고 시간 기반 고유 ID를 사용했다. 같은 ID를 쓰면 Android가 기존 알림을 덮어써서 마지막 알림 하나만 남을 수 있기 때문이다.
미읽음 뱃지는 서버 값과 낙관적 갱신을 함께 쓴다
홈 화면의 알림 아이콘에는 미읽음 수가 표시된다. 이 값은 서버의 /notifications/me/unread-count에서 가져오는 것이 기준이다. 다만 포그라운드에서 새 FCM을 받았을 때는 사용자가 즉시 변화를 느낄 수 있도록 앱에서 먼저 +1을 한다.
알림을 누르면 해당 알림을 읽음 처리하고 미읽음 수를 하나 줄인다. 전체 읽음 처리에서는 카운트를 0으로 만든다. 이렇게 서버 동기화와 낙관적 갱신을 같이 쓰면 체감 반응은 빠르고, 앱을 다시 열었을 때는 서버 기준으로 정리된다.

알림 타입과 화면 라우팅을 맞춘다
푸시에서 가장 중요한 데이터는 제목보다 type과 targetId다. 사용자가 알림을 눌렀을 때 어떤 화면으로 보낼지 결정하기 때문이다.
Strike Log에서는 알림 타입별로 라우팅을 나눴다. badge_earned는 배지 목록으로 이동하면서 해당 배지를 강조하고, new_best_score는 게임 상세 화면으로 이동한다. club_join_request는 클럽장의 가입 신청 관리 화면으로 보내고, 클럽 승인이나 체험판 관련 알림은 내 클럽 탭으로 이동시킨다.
반대로 거절 알림처럼 별도 목적지가 없는 경우도 있다. 이런 알림은 인앱 목록 자체가 종착지다. 앱은 이런 타입을 구분해서 불필요하게 화면을 또 쌓지 않도록 처리했다.
무효 토큰 정리는 자동으로 한다
FCM 토큰은 영구적이지 않다. 앱을 지우거나, 기기가 바뀌거나, Firebase가 토큰을 갱신하면 예전 토큰은 더 이상 쓸 수 없다.
서버는 FCM 전송 결과를 확인해서 registration-token-not-registered, invalid-registration-token, invalid-argument 같은 오류가 나온 토큰을 DB에서 삭제한다. 이 정리를 하지 않으면 시간이 갈수록 실패하는 토큰이 쌓이고, 전송 로그도 지저분해진다.
전체 공지처럼 모든 디바이스에 보내는 경우에는 FCM multicast 한도에 맞춰 500개씩 잘라서 보낸다. 지금은 작은 앱이지만, 이런 제한을 초기에 반영해두면 사용자가 늘어도 구조를 크게 바꾸지 않아도 된다.
마무리
Strike Log의 알림 구조는 “푸시를 보낸다”보다 “알림 경험을 유지한다”에 가깝다. 서버는 알림을 DB에 남기고, FCM은 사용자를 앱으로 다시 부른다. 앱은 토큰을 꾸준히 동기화하고, 미읽음 수를 관리하며, 알림 타입에 맞는 화면으로 이동시킨다.
배지와 출석 streak가 사용자가 다시 앱을 켜고 싶게 만드는 장치라면, FCM과 인앱 알림은 그 장치를 실제 행동으로 이어주는 연결선이다. 다음 편에서는 이 앱을 Railway와 Firebase 위에서 운영하면서 겪은 배포, 환경변수, 모니터링 구성을 정리해보려고 한다.
'APP' 카테고리의 다른 글
| 볼링 앱 제작기 마무리: 기능보다 중요한 운영 설계 (0) | 2026.07.09 |
|---|---|
| Railway와 Firebase로 Flutter 앱 백엔드 운영하기 (0) | 2026.07.08 |
| 배지와 출석 streak로 다시 켜고 싶은 앱 만들기 (0) | 2026.07.06 |
| 클럽 운영 기능 만들기: 가입 승인제와 1인 1클럽 정책 (0) | 2026.07.03 |
| Socket.IO로 클럽 실시간 볼링 경기방 만들기 (0) | 2026.07.01 |
