본문으로 건너뛰기Hwanseok Sim

작성일

VisitCall 개발기 2 — 입차 푸시 알림과 중복 제거

방문 차량이 들어오면 세대 구성원의 휴대폰이 울리게 만든 FCM 푸시 경로와, 같은 알림이 두 번 뜨는 문제를 서버 캐시와 앱 메시지 ID 저장으로 막은 과정(2025년 7~8월).

VisitCall 개발기 시리즈 2/8편. 전체 목록은 visitcall 태그에서 볼 수 있다.

방문 차량이 단지에 들어오면 등록한 세대의 휴대폰에 알림이 가야 한다. 2025년 7월 중순부터 8월 20일까지 서버와 앱에서 이 경로를 만들었고, 마지막에는 같은 알림이 두 번 뜨는 문제를 막았다. 여기서 쓰는 입차 신호가 업체 DB에서 어떻게 오는지는 3편에 있다.

차량번호에서 세대로, 세대의 모든 계정으로

서버(notificationService)는 입차 신호로 차량번호와 입차 시각을 받는다. 그다음 순서는 이렇다.

  1. 차량번호로 가장 최근 방문 기록을 찾아 어느 세대의 방문인지 정한다.
  2. 그 세대에 속한 계정을 전부 조회한다. 세대주와 세대원이 같이 받는다.
  3. FCM 토큰이 있는 계정마다 푸시를 보낸다.

푸시 본문에는 화면에 보일 제목과 문구, 그리고 앱이 데이터로 읽을 값이 같이 들어간다.

services/notificationService.js
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일 하루에 서버와 앱 양쪽에 중복 제거를 넣었다.

서버는 차량번호_입차시각을 키로 하는 메모리 캐시를 둔다.

services/notificationService.js
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에 저장한다.

lib/main.dart
// 메시지 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 앱에서는 허용 목록 밖의 값을 무시한다.

이전·다음 글