FCM 원격 로그아웃이 안 되던 원인은 하나가 아니라 셋이었습니다
다른 기기에서 로그인하면 기존 기기가 자동으로 로그아웃되는 기능이 있습니다. 계정 공유를 막기 위한 최소한의 장치입니다. 어떤 급식서비스의 회원 앱에도 이 기능이 들어가 있었고, 코드는 제자리에 그대로 있었습니다. 그런데 운영에서 동작하지 않는다는 보고가 올라왔습니다.
이런 종류의 기능은 조용히 죽습니다. 로그아웃이 안 됐다고 앱이 튕기지도 않고, 서버에 에러 로그가 쌓이지도 않습니다. 한 계정을 여러 사람이 나눠 쓰고 있어도 서비스는 겉으로 멀쩡하게 돌아갑니다. 정책이 코드상으로는 살아 있는데 실제로는 아무 일도 하지 않고 있던 상태입니다.
원인이 하나일 것이라는 전제부터 틀렸습니다
처음에는 단일 문제로 봤습니다. 푸시 발송 설정이나 앱의 수신 핸들러 중 한쪽이 어긋났을 것이라고 짐작했습니다. 파보니 세 요인이 겹쳐 있었습니다.
도달 — 사일런트 푸시에 우선순위와 백그라운드 깨우기 플래그가 명시되지 않아, 백그라운드에 있는 기기에는 아예 닿지 않았습니다.
실행 — 도달한 경우에도 안드로이드에서는 앱 코드가 실행되지 않았습니다. 시스템 트레이에 알림만 꽂혔습니다.
완결 — 실행된 경우에도 토큰만 지워져서, 앱이 재개될 때 로그아웃이 아니라 비로그인 오판 상태가 만들어졌습니다.
이 구조가 문제를 오래 끄는 이유가 있습니다. 하나를 고치고 다시 테스트해도 결과는 여전히 실패입니다. 그 지점에서 방향을 잃기 쉽습니다. 방금 넣은 수정이 틀렸다고 판단하고 되돌린 뒤 다른 곳을 뒤지면, 맞는 수정을 지우고 다음 원인으로 넘어가게 됩니다. 도달과 실행과 완결을 각각 따로 확인해야 계층이 보입니다.

알림과 명령은 다른 푸시입니다
푸시에는 두 종류가 있습니다. 사람에게 보여주는 알림과, 앱에게 일을 시키는 명령입니다. 전자는 notification 메시지고 후자는 data-only 메시지입니다. 이 둘은 페이로드 모양만 다른 것이 아니라 전달 의미론 자체가 다릅니다.
notification 메시지는 편합니다. OS가 알림을 대신 그려주니 앱이 표시 코드를 갖고 있지 않아도 됩니다. 그 편리함에는 대가가 있습니다. 안드로이드에서 앱이 백그라운드에 있을 때 notification 메시지는 시스템 트레이까지만 갑니다. 사용자가 그 알림을 탭하기 전까지 앱 코드는 한 줄도 돌지 않습니다.
원격 로그아웃 같은 동작을 이 방식에 얹으면 어긋납니다. 알림은 정상적으로 떴는데 로그아웃은 안 되는 상태가 만들어집니다. 사용자가 알림을 보고 그냥 밀어서 지우면 그 명령은 영영 실행되지 않습니다. 서버 로그에는 발송 성공으로 찍혀 있습니다.
구분 | notification 메시지 | data-only 메시지 |
|---|---|---|
알림 표시 | OS가 대신 그려줍니다 | 앱이 직접 그려야 합니다 |
백그라운드 코드 실행 | 안드로이드는 탭 전까지 없습니다 | 수신 핸들러가 실행됩니다 |
맞는 용도 | 읽고 끝나는 소식 전달 | 원격 동작 지시 |
사일런트 푸시의 도달은 공짜가 아닙니다
data-only 로 보내기로 정했다고 끝이 아닙니다. 사일런트 푸시는 명시하지 않으면 조용히 버려집니다. 안드로이드는 우선순위를 높음으로 지정하지 않으면 배터리 절약 정책에 따라 전달을 유예하거나 폐기합니다. iOS는 content-available 플래그가 없으면 백그라운드 앱을 깨우지 않습니다.
이 설정이 빠졌을 때의 증상이 고약합니다. 개발자 기기처럼 앱이 화면에 떠 있는 상태에서는 잘 됩니다. 실제 사용자처럼 앱이 백그라운드에 오래 머문 기기에서만 실패합니다. 「가끔 안 됩니다」라는 보고의 단골 원인이 여기입니다.
백그라운드에서는 상태를 바꾸지 않고 의도만 기록합니다
세 번째 원인이 가장 손이 많이 갔습니다. 백그라운드 핸들러가 푸시를 받아 인증 토큰을 지우는 것까지는 하고 있었습니다. 문제는 그 다음입니다. 앱이 재개되면 네트워크 인터셉터가 토큰 없는 요청을 만나고, 이를 로그아웃으로 처리하는 대신 비로그인 상태로 판정합니다. 결과는 전 화면 조회 실패였습니다.
상태 전이가 완결되지 않고 부산물만 남은 형태입니다. 백그라운드 컨텍스트는 제약이 많습니다. 화면이 없고, 실행 시간이 짧게 제한되고, 앱의 상태 관리 계층이 온전히 살아 있다는 보장도 없습니다. 그 안에서 로그아웃처럼 여러 계층을 건드리는 전이를 끝까지 밀어붙이면 중간에서 끊깁니다.
그래서 역할을 나눴습니다. 백그라운드 핸들러는 로컬 알림을 표시하고 로그아웃 플래그를 안전 저장소에 기록하는 것까지만 합니다. 실제 토큰 삭제와 정식 로그아웃 처리는 앱이 시작되거나 재개되는 시점에 그 플래그를 소비하면서 수행합니다. 포그라운드에서 푸시를 받은 경로와 알림을 탭하고 들어온 경로도 같은 정리 루틴으로 수렴시켰습니다.

여기까지 오는 데 세 번 방향을 틀었습니다
도달만 보정했습니다. 사일런트 푸시에 우선순위와 깨우기 플래그를 넣었습니다. 로그아웃은 되는데 사용자에게 아무 안내가 없었습니다.
안내를 붙이려고 notification 으로 바꿨습니다. OS가 알림을 그려주니 간단해 보였습니다. 안드로이드에서 앱 코드가 실행되지 않는 벽에 막혔습니다.
data-only 사일런트로 되돌리되 안내 문구를 데이터 필드에 실었습니다. 알림 표시는 앱이 로컬 알림으로 직접 책임집니다.
결과적으로 알림 표시와 동작 실행이 분리된 구조가 됐습니다. 안드로이드 실기기에서 자동 로그아웃이 정상 동작하는 것을 확인했고, iOS는 원래도 동작하고 있었습니다. 같은 기능이 두 플랫폼에서 다른 이유로 갈리는 상황이라, 한쪽에서 되는 것을 근거로 다른 쪽을 판단하면 계속 어긋납니다.
테스트 발송 경로가 실운영과 달랐습니다
고치는 도중에 하나 더 걸렸습니다. 관리자 쪽에 붙어 있던 테스트 푸시 발송 코드가 실운영 발송과 다른 설정을 쓰고 있었습니다. 테스트로는 푸시가 잘 가는데 운영에서는 안 가는 상황이 여기서 만들어집니다.
검증 경로가 실경로와 다르면 검증이 거짓 안심을 만듭니다. 테스트 발송 코드를 실운영과 동일한 설정으로 맞췄습니다. 사일런트 여부와 데이터 필드 구성까지 같게 뒀습니다. 최종 확인은 시뮬레이터가 아니라 안드로이드 실기기로 했습니다. 백그라운드 전달 정책은 실기기에서만 제대로 재현됩니다.

이 방식에도 한계가 있습니다
data-only 사일런트도 전달 보장이 아닙니다. 도즈 모드나 배터리 정책에서 지연되거나 폐기될 수 있습니다. 앱 시작 시 서버에 세션 유효성을 확인하는 폴백 경로가 최종 안전망입니다.
로컬 알림은 앱이 강제 종료된 상태에서는 표시되지 않습니다. 이 사례처럼 다음 앱 시작 시점의 플래그 소비로 커버되는 시나리오에 맞습니다.
iOS 사일런트 푸시에는 발송 빈도 제한이 있습니다. 짧은 간격으로 계속 오가는 고빈도 원격 명령 채널로는 맞지 않습니다.
원격 동작 푸시를 붙이기 전에 볼 것들
이 푸시가 사람에게 보여주려는 알림인지, 앱에게 시키려는 명령인지 먼저 구분했는지
명령이라면 data-only 로 보내고 알림 표시는 앱이 책임지는 구조인지
사일런트 푸시에 우선순위와 백그라운드 깨우기 플래그를 명시했는지
백그라운드에서 상태를 직접 바꾸지 않고 플래그만 기록한 뒤 앱 활성 시점에 소비하는지
푸시가 유실됐을 때를 대비한 서버 상태 대조 경로가 있는지
테스트 발송 코드가 실운영 발송과 같은 설정을 쓰는지
마무리
한 증상의 원인이 셋이었습니다. 도달과 실행과 완결은 서로 다른 층이고, 각 층을 막는 주체가 다릅니다. 도달은 OS의 배터리 정책이 막고, 실행은 플랫폼의 메시지 전달 규칙이 막고, 완결은 우리 코드의 상태 전이 설계가 막습니다. 하나씩 나눠 확인하지 않으면 원인 사이를 오가며 같은 자리를 맴돌게 됩니다.
원격으로 앱에게 일을 시키는 기능은 겉보기보다 층이 많습니다. 푸시가 발송 성공으로 찍혔다는 사실은 그 일이 실행됐다는 뜻이 아닙니다. 계정 공유 방지든 캐시 무효화든 강제 동기화든, 실행 여부를 서버가 확인할 방법을 같이 설계해두면 조용히 죽는 기능을 줄일 수 있습니다.