작성일
VisitCall 개발기 2 — 입차 푸시 알림과 중복 제거
방문 차량이 들어오면 세대 구성원의 휴대폰이 울리게 만든 FCM 푸시 경로와, 같은 알림이 두 번 뜨는 문제를 서버 캐시와 앱 메시지 ID 저장으로 막은 과정(2025년 7~8월).
VisitCall 개발기 시리즈 2/8편. 전체 목록은 visitcall 태그에서 볼 수 있다.
방문 차량이 단지에 들어오면 등록한 세대의 휴대폰에 알림이 가야 한다. 2025년 7월 중순부터 8월 20일까지 서버와 앱에서 이 경로를 만들었고, 마지막에는 같은 알림이 두 번 뜨는 문제를 막았다. 여기서 쓰는 입차 신호가 업체 DB에서 어떻게 오는지는 3편에 있다.
차량번호에서 세대로, 세대의 모든 계정으로
서버(notificationService)는 입차 신호로 차량번호와 입차 시각을 받는다. 그다음 순서는 이렇다.
- 차량번호로 가장 최근 방문 기록을 찾아 어느 세대의 방문인지 정한다.
- 그 세대에 속한 계정을 전부 조회한다. 세대주와 세대원이 같이 받는다.
- FCM 토큰이 있는 계정마다 푸시를 보낸다.
푸시 본문에는 화면에 보일 제목과 문구, 그리고 앱이 데이터로 읽을 값이 같이 들어간다.
const title = '차량 입차 알림';
const body = `차량(${plate})이 입차했습니다.`;
const data = {
plate: plate,
enterTime: enterTime,
screen: '/notification_list', // 앱이 열 화면
};
발송 쪽(pushNotificationService)은 Firebase Admin SDK를 쓰고, Android는 우선순위 high, iOS는 content-available과 기본 소리를 켰다. 앱이 꺼져 있거나 백그라운드일 때도 푸시가 도착해야 하기 때문이다. 로그에는 FCM 토큰 앞 15자만 남겼다. 토큰은 특정 기기로 푸시를 보낼 수 있는 주소라 로그에 통째로 둘 이유가 없었다.
이 흐름의 약점은 1번이다. 차량번호만으로 "가장 최근 방문 기록"을 고르면 같은 차량을 여러 세대가 예약했을 때 엉뚱한 세대로 알림이 갈 수 있다. 이 문제는 12월에 운영 중 드러났고 6편에서 다룬다.
계정 하나에 기기 하나
같은 기기에서 다른 계정으로 로그인하면 두 계정이 같은 토큰을 가질 수 있고, 그러면 이전 계정 앞으로 간 알림이 지금 기기에 뜬다. 그래서 8월 20일에 단일 로그인 기능을 넣었다. 로그인할 때마다 이전 세션을 무효로 만들고, 한 토큰은 한 계정에만 연결되게 한다.
앱은 로그인 직후, 그리고 자동 로그인으로 들어올 때도 토큰을 서버에 다시 보낸다(7월 15일 커밋). 토큰은 앱을 지웠다 깔거나 OS가 갱신하면 바뀌기 때문에 한 번 등록하고 끝낼 수 없다.
알림이 두 번 오는 문제
알림이 두 번 뜨는 원인은 서버와 앱 두 곳에 있었다. 서버가 같은 입차를 두 번 처리할 수 있고, 앱도 포그라운드 수신(onMessage), 알림을 눌러 여는 경로(onMessageOpenedApp), 종료 상태에서 알림으로 시작하는 경로(getInitialMessage)가 따로여서 같은 메시지를 두 번 만질 수 있었다. 앱 코드는 세 경로를 terminated, background, foreground라는 출처 이름만 달리해 한 함수로 모았다. 8월 20일 하루에 서버와 앱 양쪽에 중복 제거를 넣었다.
서버는 차량번호_입차시각을 키로 하는 메모리 캐시를 둔다.
const recentNotifications = new Map();
const CACHE_DURATION = 60000; // 1분
const notificationKey = `${plate}_${enterTime}`;
if (recentNotifications.has(notificationKey)) {
// 이미 처리된 알림 → 푸시를 보내지 않고 종료
return;
}
recentNotifications.set(notificationKey, Date.now());
만료된 키는 5분마다 정리한다. 캐시가 살아 있는지는 상태 확인 API(/health)에 캐시 크기를 같이 내보내 눈으로 볼 수 있게 했다. 프로세스 메모리에 있으므로 서버를 재시작하면 사라지고, 1분 안의 중복만 막는다. 같은 입차 신호가 짧은 간격으로 다시 들어오는 경우를 막는 용도라서 이 정도로 시작했고, 이 한계를 알고도 그대로 출시했다.
앱은 처리한 메시지 ID를 기억한다. 포그라운드 핸들러와 백그라운드 핸들러 모두에서, 그리고 앱을 껐다 켜도 남도록 SharedPreferences에 저장한다.
// 메시지 ID: FCM이 준 ID가 없으면 차량번호 + 입차시각 + 데이터 해시로 만든다
final messageId = message.messageId ??
'${plateNo}_${enterTime}_${message.data.hashCode}';
final processedIds = prefs.getStringList('processed_message_ids') ?? [];
if (processedIds.contains(messageId)) {
return; // 이미 처리한 메시지
}
processedIds.add(messageId);
if (processedIds.length > 100) {
processedIds.removeAt(0); // 최대 100개만 유지
}
await prefs.setStringList('processed_message_ids', processedIds);
백그라운드 핸들러는 앱 본체와 다른 실행 환경(isolate)에서 돈다. 그래서 메모리의 Set을 공유할 수 없고, 저장소를 통해 맞출 수밖에 없었다. 이 점을 놓치고 메모리 Set 하나로만 처리하면 백그라운드에서 받은 메시지가 앱을 연 뒤 다시 처리된다.
푸시로 화면 데이터를 갱신하기
7월 30일 앱 커밋부터 푸시를 데이터 전달에도 썼다. 푸시의 plate와 enterTime을 읽어 예약 목록의 해당 차량에 입차 시각을 바로 채워 넣는다. 사용자가 앱을 열었을 때 서버에 다시 묻지 않아도 "입차 11:42" 같은 상태가 보인다. 서버에도 방문자의 입차 시각을 갱신하는 엔드포인트가 따로 생겼다(7월 31일).
2.0 앱도 두 값이 모두 있을 때만 갱신한다. screen은 앱이 아는 화면일 때만 연다. 푸시 데이터는 앱이 신뢰해서는 안 되는 입력이므로, 2.0 앱에서는 허용 목록 밖의 값을 무시한다.