실시간 협업 앱을 WebSocket 없이 FCM 푸시로 만든 이유

가족이 함께 쓰는 장보기 리스트 앱을 만들었다. 핵심은 “한 명이 항목을 추가하면 다른 가족 화면에도 바로 반영”되는 실시간 협업이다. 보통 이럴 때 WebSocket을 떠올리지만, 나는 FCM 푸시 알림으로 실시간 동기화를 구현했다. 왜 그랬는지, 구조와 트레이드오프를 실제 결정 근거까지 정리한다.

흐름은 이렇다

  1. 사용자가 항목을 추가/체크 → 백엔드 API 호출
  2. 백엔드가 DB 갱신 → 그 바구니의 모든 멤버에게 FCM 푸시 발송
  3. 다른 멤버 앱이 푸시 수신 → 해당 화면을 자동 새로고침

즉 “실시간 동기화”를 지속 연결(WebSocket)이 아니라 “변경 이벤트를 푸시로 알리고, 받은 쪽이 갱신”하는 방식으로 구현했다. 백엔드는 Node.js/TypeScript + MySQL(Prisma)이고, 앱은 Flutter다.

알림 종류마다 템플릿을 둔다

푸시는 한 종류가 아니다. 이벤트마다 의미가 다르므로 템플릿을 나눴다.

  • 핵심 이벤트: 항목 추가·체크, 멤버 합류, 바구니 초대
  • 협업 갱신: 같은 바구니 참여자 전원에게 실시간 반영
  • 성장 이벤트: 친구 초대·추천 관련 알림

앱은 백그라운드 메시지 핸들러로 이 푸시를 받고, 종류에 따라 관련 화면만 골라 새로고침한다. 사용자가 앱을 보고 있으면 화면이 즉시 갱신되고, 꺼져 있으면 OS 알림으로 뜬다.

WebSocket 대신 FCM을 고른 이유

  • 오프라인/백그라운드에 강하다. 모바일 앱은 수시로 백그라운드로 내려간다. WebSocket은 연결이 끊기고 재연결·상태 복구 로직이 필요하다. FCM은 앱이 꺼져 있어도 OS가 전달한다.
  • 인프라가 단순하다. 지속 연결 서버(소켓 게이트웨이·핑퐁·스케일아웃 시 세션 공유)를 운영하지 않아도 된다. 단일 EC2로 MVP를 굴리는 1인 개발에 이건 큰 차이다.
  • 배터리·비용에 유리하다. 상시 연결은 배터리와 서버 리소스를 먹는다. 이벤트가 있을 때만 푸시를 쏘면 둘 다 아낀다.
  • 알림과 채널이 통합된다. “가족이 항목을 추가했어요” 알림을 어차피 FCM으로 보낸다. 실시간 갱신과 알림을 같은 채널로 처리하니 중복 인프라가 없다.

대가와 보완

공짜는 아니다. FCM은 지연·전달 보장이 WebSocket만큼 즉각적이지 않고, 초당 수십 번 바뀌는 협업(공동 문서 커서 같은)에는 안 맞는다. 전달이 몇 초 지연되거나 드물게 유실될 수도 있다. 그래서 앱은 포그라운드 복귀 시 서버와 한 번 더 동기화해, 푸시를 놓쳤어도 최신 상태를 맞춘다. 푸시는 “빠른 알림”, 포그라운드 동기화는 “정합성 보정” 역할을 나눠 맡는 셈이다.

교훈: “실시간”이라고 무조건 WebSocket이 아니다. 변경 빈도와 지연 허용치를 보고, 낮으면 푸시 기반이 훨씬 싸게 먹힌다.

장보기 리스트는 변경 빈도가 낮고 몇 백 ms 지연이 문제되지 않는다. 도메인 특성이 “느슨한 실시간”이라 FCM의 단순함이 정확히 맞았다. 기술 선택은 “가장 강력한 것”이 아니라 “이 도메인에 맞는 것”이어야 한다는 걸, 이 앱에서 다시 확인했다.

구현하며 겪은 판단들

FCM 방식에도 신경 쓸 지점이 있었다. 푸시가 유실될 수 있으니 “푸시 = 알림 트리거, 실제 데이터는 앱이 다시 조회”로 설계해, 푸시 본문에 최소 정보만 싣고 화면은 서버에서 최신 상태를 받아 그린다. 이러면 푸시가 순서 뒤바뀌어 와도 화면은 항상 서버 기준으로 정합적이다. 또 사용자가 자기 기기에서 한 변경은 굳이 자기에게 푸시하지 않도록 걸러, 불필요한 알림을 줄였다. “실시간처럼 보이되 정합성은 서버가 책임진다”는 원칙이 이 모든 판단의 기준이었다.

댓글 남기기

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.ㅇ