태그 예약 알림앱 알림 구현계정 전환리액트 네이티브expo-notifications안드로이드 알람
전체 글 보기

예약 알림

예약 알림은 계정이 아니라 기기에 남습니다 - 계정 전환 버그

로그아웃한 계정의 할일 이름이 새 계정 잠금 화면에 그대로 떴습니다. 예약 알림은 앱이 기기에 맡겨둔 것이라, 로그인 상태와 아무 상관이 없습니다.

2026년 8월 11일 5분

제 앱에서 계정을 바꿔봤습니다. 로그아웃하고, 다른 계정으로 로그인하고, 화면에 새 계정 데이터가 잘 뜨는지 확인했습니다. 잘 떴습니다.

그리고 다음 날 아침, 잠금 화면에 이런 알림이 떠 있었습니다.

이전 계정에서 만든 할일 이름

새 계정에는 그런 할일이 없습니다. 만든 계정은 이미 로그아웃했습니다. 그런데도 알림은 정확히 예정된 시각에 울렸습니다.

예약 알림은 앱의 데이터가 아닙니다

예약 알림을 어떻게 오해하고 있었는지부터 적겠습니다.

저는 알림을 “내 앱이 들고 있는 데이터” 라고 생각했습니다. 로그아웃하면 화면에서 데이터가 사라지듯, 알림도 같이 정리될 거라고 본 겁니다.

실제로는 반대입니다. 알림을 예약한다는 건 앱이 뭔가를 기억해 두는 게 아니라, 운영체제에 이렇게 부탁하는 것입니다.

8월 12일 오전 9시에 이 문구를 띄워주세요.

그 부탁을 받아 적어두는 쪽은 안드로이드나 iOS이고, 그 목록은 기기 단위로 관리됩니다. 앱이 꺼져 있어도, 앱을 강제 종료해도, 심지어 로그인하지 않은 상태여도 예정대로 울립니다. 그게 알림의 존재 이유이기도 합니다.

그러니까 로그아웃은 알림 입장에서 아무 사건도 아닙니다. 앱 안에서 사용자 정보가 바뀌었을 뿐, 운영체제가 들고 있는 예약 목록은 손댄 사람이 없습니다.

정리하면 이렇습니다.

어디에 있나로그아웃하면
할일 데이터서버 + 앱 메모리사라짐
화면 상태앱 메모리사라짐
예약 알림운영체제의 예약 목록 (기기 단위)그대로 남음

세 번째 줄만 성격이 다른데, 코드에서는 셋이 다 “우리 앱 것” 처럼 보입니다. 그래서 잘 안 보입니다.

그래서 계정을 끊는 순간에 지웁니다

고친 방법 자체는 단순합니다. 세션이 끊기는 시점에 예약된 것을 전부 취소합니다.

중요한 건 어디에 넣느냐입니다. 로그인 성공 후가 아니라, 이전 계정을 놓는 그 순간이어야 합니다. 새 로그인이 중간에 실패하거나 사용자가 취소해도 이전 계정의 알림은 이미 남의 것이기 때문입니다.

export async function cancelAllLocalNotifications(): Promise<void> {
  try {
    // ① 아직 안 울린 예약분
    await Notifications.cancelAllScheduledNotificationsAsync();
    // ② 이미 울려서 알림창에 쌓여 있는 것
    await Notifications.dismissAllNotificationsAsync();
  } catch (e) {
    log.error('[notifications] cancelAllLocal 실패', e);
  }
  // ③ 풀스크린 알람 - 위 두 개로는 안 잡힌다
  await fsAlarm.cancelAll();
}

여기서 갈래가 세 개인 게 핵심입니다. 처음에는 첫 줄만 넣었다가 다시 겪었습니다.

  • ①은 미래에 울릴 예약분입니다.
  • ②는 이미 울려서 알림창에 남아 있는 것입니다. 예약 목록에는 없지만 화면에는 있습니다.
  • ③은 이 앱이 따로 쓰는 전체화면 알람입니다. 채널이 달라서 앞의 두 개가 못 건드립니다.

같은 “알림” 이라는 말로 부르지만 사는 곳이 세 군데입니다. 하나라도 빼면 증상이 그대로 재현되는데, 눈에는 그게 “가끔 남는다” 로 보입니다.

조회할 수 없는 것은 장부에 적어둬야 합니다

세 번째 갈래에서 문제가 하나 더 있었습니다.

전체화면 알람은 안드로이드의 AlarmManager 를 직접 씁니다. 그런데 이쪽에는 “지금 예약된 알람을 전부 알려줘” 에 해당하는 기능이 없습니다. 예약과 취소는 되는데, 목록 조회가 안 됩니다.

지울 대상을 물어볼 수 없으니, 예약할 때 우리가 적어두는 수밖에 없습니다.

// AlarmManager 에는 "예약된 알람 전체 조회" 가 없다.
// 그래서 schedule / cancel 을 지나는 id 를 기기 저장소에 적어두고,
// 전체 취소는 그 장부만 보고 지운다.
function addToRegistry(fid: string): void {
  const cur = readRegistry();
  if (cur.includes(fid)) return;
  DeviceStore.set(CacheKeys.FS_ALARM_FIDS, [...cur, fid]);
}

장부가 실제와 어긋날 수 있습니다. 앱을 지웠다 다시 깔면 장부는 비는데 알람은 남을 수 있고, 반대 경우도 생깁니다.

그런데 이쪽 어긋남은 손해가 적습니다. 없는 알람을 취소하면 아무 일도 안 일어나고, 장부에 없는 알람이 남으면 그건 원래 못 지우던 것과 같은 상태입니다. 목록 조회가 안 되는 상황에서 완벽한 장부를 만들 방법은 없으니, 틀렸을 때 덜 아픈 쪽을 고른 것입니다.

이 방식을 고른 이유가 하나 더 있습니다. 자바나 코틀린 같은 네이티브 코드를 안 건드리기 때문에, 스토어 심사 없이 앱에 바로 내보낼 수 있습니다.

같은 자리에서 반대 방향의 버그를 찾았습니다

여기까지가 “안 지워지는” 문제였습니다. 그런데 이걸 고치려고 알림 코드를 들여다보다가, 정확히 반대인 버그를 발견했습니다.

앱을 껐다 켜면 오늘 할일 알림이 통째로 사라지고 있었습니다.

원인은 이렇습니다. 앱을 켤 때마다 도는 정리 작업이 하나 있습니다. 지금 살아 있는 할일 목록을 받아서, 예약 목록 중에 거기 없는 것을 지우는 일입니다. 삭제한 할일의 알림이 계속 울리면 안 되니까요.

그런데 그 목록을 가져오는 부분이 잘못 호출돼 있었습니다. 기간을 안 넘겨줘서 데이터를 가져오는 쪽이 조기 종료해버렸고, 목록은 항상 빈 상태였습니다.

그 다음이 문제입니다. 정리 작업은 빈 목록을 받고 이렇게 판단했습니다.

살아 있는 할일이 하나도 없구나. 그럼 예약된 알림은 전부 지워야겠다.

에러는 없습니다. 경고도 없습니다. 코드는 시킨 일을 정확히 했습니다.

문제는 “아직 안 온 값” 과 “정말로 없음” 이 둘 다 빈 배열이라는 것입니다. 사람은 이 둘을 구분하지만 코드는 안 그렇습니다. 그리고 이 자리에서 두 해석의 결과는 정반대입니다. 하나는 아무것도 하지 말라는 뜻이고, 하나는 전부 지우라는 뜻입니다.

지우는 쪽 판단은 비대칭입니다

그래서 정리 작업의 규칙을 바꿨습니다.

// 이 알림이 언제 울릴 예정인지 못 읽으면 건드리지 않는다.
const fireMs = triggerFireMs(n.trigger);
if (fireMs == null || fireMs > windowEndMs) return Promise.resolve();

읽을 수 있는 것만, 그리고 우리가 실제로 확인한 기간 안에서 울릴 것만 지웁니다. 판단이 안 서면 그냥 둡니다.

이건 “안전하게 가자” 같은 막연한 말이 아니라, 두 방향의 손해를 실제로 비교한 결과입니다.

  • 지워야 할 알림을 안 지우면: 삭제한 할일 알림이 하루쯤 더 울립니다. 사용자는 “어 이거 지웠는데” 하고 넘어갑니다.
  • 지우지 말아야 할 알림을 지우면: 오늘 챙기려던 일이 통째로 안 울립니다. 사용자는 알림을 못 받았다는 사실조차 모릅니다.

두 번째가 훨씬 나쁘고, 무엇보다 티가 안 납니다. 울리지 않은 알림은 아무 흔적도 남기지 않습니다. 그래서 이 버그는 제보로 들어오지 않았고, 다른 걸 고치다가 우연히 잡혔습니다.

알림을 다루는 코드에서 확인할 것

정리하면 세 가지입니다.

첫째, 알림 목록은 계정이 아니라 기기에 붙어 있습니다. 로그아웃, 계정 전환, 탈퇴처럼 사용자가 바뀌는 지점마다 예약분을 정리하세요. 그리고 정리는 새 로그인이 성공한 뒤가 아니라 이전 세션을 놓는 순간에 하세요.

둘째, “알림” 은 한 곳에 있지 않습니다. 예약 대기 중인 것, 이미 떠 있는 것, 별도 채널로 예약한 것은 따로 지워야 합니다. 전부 지웠다고 생각했는데 하나가 남으면, 증상은 “가끔 그런다” 로 보여서 원인 찾기가 훨씬 어려워집니다.

셋째, 무언가를 지우는 코드에서는 빈 값을 의심하세요. 빈 목록이 “없음” 인지 “아직 안 옴” 인지 구분하지 않으면, 그 코드는 언젠가 전부 지웁니다. 그리고 지운 티가 안 나는 종류라면 몇 달이고 모릅니다.

같은 앱에서 3D 파일 용량의 99%가 안 쓰는 데이터였던 일도 성격이 같았습니다. 숫자가 조용하면 아무도 안 봅니다. 그래서 사람이 기억해서 확인하는 대신, 코드가 매번 확인하게 만드는 편이 낫습니다.

2026년 8월 11일 기준이고, Expo SDK 56 · expo-notifications · React Native 0.85 · 안드로이드 환경에서 확인했습니다. 전체화면 알람 쪽은 안드로이드의 AlarmManager 를 직접 쓰는 부분이라 iOS 에는 해당되지 않습니다. 앞의 두 갈래(예약분, 이미 떠 있는 것)는 양쪽 다 같습니다.

이전 글 근로장려금 지급일, 8월 27일에 안 들어와도 탈락이 아닌 이유