[스타트업/기술] 알림함 아키텍처: Fan-out on Write vs Fan-out on Read | 비용을 '구독자 수'가 아닌 '활성 유저 수'에 비례시키기

 

PNG를 WebP로 바꿔 이미지 로딩을 최적화했던 게 '읽기'의 비용을 줄이는 일이었다면, 이번엔 '쓰기'의 비용을 줄이는 이야기다. 백만 명에게 알림 하나를 보낼 때, 그 알림함을 누가 — 서버가? 클라이언트가? — 만드느냐에 따라 청구서의 자릿수가 달라진다.

지난 몇 주간 슬루스의 메시징 서버에 인앱 알림함(Inbox) 기능을 설계했다. 별것 아닌 기능처럼 보이지만, 막상 들어가 보니 분산 시스템 설계의 고전적인 난제 하나를 정면으로 마주하게 됐다. 바로 Fan-out, 그러니까 "하나의 메시지를 여러 수신자에게 어떻게 펼쳐 넣을 것인가"의 문제다.

이번 글에서는 이 문제에 대해 진단하고, 관련된 기술에 대해 정리해보았다.


Problem — 푸시는 휘발된다

지금까지 슬루스는 사용자에게 소식을 전할 때 FCM 푸시 알림을 쏘고, 그 발송 이력을 운영자용 감사 로그에 남기는 게 전부였다. 문제는 푸시가 fire-and-forget, 즉 한 번 쏘고 잊어버리는 구조라는 점이다.

알림을 꺼둔 사용자, OS가 알림을 억제한 사용자, 그 순간 오프라인이던 사용자 — 이들은 메시지를 영구히 놓친다. 보상 안내든 신작 공지든, 푸시를 놓치는 순간 사용자 입장에선 그런 소식이 존재한 적도 없는 게 된다. 그래서 푸시 전달과 독립적으로, 사용자가 언제든 들어와 보고·읽음 처리하고·삭제할 수 있는 per-user 알림함이 필요했다.

여기까지는 평범하다. 진짜 문제는 "그럼 그 알림함 레코드를 언제 만들 것인가"에서 터졌다.

가장 나이브한 답은 이렇다. 알림을 보내는 순간, 수신자 한 명당 알림함 레코드 하나씩을 써넣는다. 100명에게 보내면 100개의 문서를 쓴다. 직관적이고 깔끔하다. 그런데 슬루스에는 all-users, lang-ko 같은 토픽 브로드캐스트가 있다. 만약 서비스 규모가 커진다면 구독자가 수만 명까지 갈 수 있는 발송이다. 이 방식대로라면 공지 하나 보낼 때마다 Firestore에 수만 건의 쓰기가 발생한다. 이로 인해 알림을 보낼 때마다 비용이 높아지는 건 아닌가 생각했다.

결국 질문은 이거였다. 알림함을 누가, 언제 만들 것인가?


Technology — Fan-out on Write vs Fan-out on Read

사실 이건 새로운 문제가 아니다. claude와 대화하며 찾아본 결과, 트위터 타임라인, 인스타그램 피드, 단톡방 — "한 명이 만든 콘텐츠를 다수의 수신자에게 어떻게 전달할 것인가"를 이미 고민해 본 서비스들이 있었다. 그리고 그들의 답은 크게 두 가지다.

Fan-out on Write (쓰기 시점 분배 · Push model)

메시지를 보내는 시점에, 미리 계산해서 모든 수신자의 받은함에 사본을 한 부씩 써넣는다. 수신자는 나중에 자기 받은함만 그대로 읽으면 된다.

  • 읽기가 극도로 싸고 단순하다. "내 알림함 주세요" 한 번의 쿼리로 끝. 실시간 구독(onSnapshot)을 걸기에도 완벽하다.
  • 쓰기가 비싸다. 비용이 수신자 수에 정비례한다. 1명이 보낸 메시지가 N개의 쓰기를 만든다.
  • 수신자 집합이 유한하고 작을 때 이상적이다.

Fan-out on Read (읽기 시점 조합 · Pull model)

메시지를 보낼 때는 원본 한 부만 써둔다. 그리고 각 수신자가 자기 화면을 열 때, 자신에게 해당하는 메시지들을 직접 쿼리해서 조합한다.

  • 쓰기가 싸다. 발송 한 번에 문서 하나. 수신자가 백만이든 천만이든 쓰기는 1회.
  • 읽기가 무겁고 복잡하다. 클라이언트가 매번 "나에게 온 게 뭐지?"를 계산해야 한다.
  • 비용이 읽는 사람(활성 유저) 수에 비례한다. 구독만 해두고 안 들어오는 사람에겐 비용이 0이다.

이 둘의 트레이드오프를 한 장으로 정리하면:

기준Fan-out on WriteFan-out on Read
쓰기 비용높음 (수신자 수에 비례)낮음 (발송당 1회)
읽기 비용낮음 (내 받은함만 읽기)높음 (매번 조합·필터)
실시간성강함 (받은함 구독)약함 (열 때 당겨옴)
비용이 비례하는 변수구독자(전체 수신자) 수활성(실제 열람) 유저 수
잘 맞는 상황타겟·소규모 발송대규모 브로드캐스트

Architecture comparsion

핵심은 각각 trade-off가 있다는 점이다. 쓰기를 싸게 하면 읽기가 비싸지고, 그 반대도 마찬가지다. 그래서 트위터조차 한 모델을 고집하지 않는다고 한다. 평범한 유저는 Write로 펼치고, 팔로워 수천만의 셀럽 트윗만 Read로 당겨오는 하이브리드를 쓴다. 진짜 설계는 "어느 하나를 고르는 것"이 아니라 "어디에 선을 긋느냐"다.


Solution — 기본은 Write, 브로드캐스트만 Read

그래서 슬루스의 알림함은 Fan-out on Write를 기본값으로 채택하되, 감당이 안 되는 구간만 Read로 떼어내는 구조로 설계했다. 선을 그은 기준은 단 하나, 수신자 집합의 크기(cardinality)다.


타겟 발송(특정 세그먼트·특정 uid) 은 수신자가 유한하다. 그래서 서버가 곧장 users/{uid}/inbox/{notifId} 경로에 수신자 한 명당 엔트리 하나씩을 펼쳐 쓴다(Write). 모바일 클라이언트는 그저 자기 받은함을 onSnapshot으로 구독하기만 하면, 새 알림이 10초 안에 화면에 뜨고 다른 기기에서 읽음 처리한 것까지 실시간으로 반영된다. 읽기 쪽이 더없이 단순해진다.

토픽 브로드캐스트 는 다르다. 여기서 서버가 Write로 펼치면 수백만 건 쓰기 폭발이 일어난다. 그래서 이 경우엔 서버가 원본 notifications/{id} 문서 하나만 써두고, 받은함 엔트리는 모바일이 앱을 켤 때(foreground) 직접 만들어 채워 넣는다(lazy backfill, Read).

이 전환의 핵심 효과는 이 한 문장에 있다.

비용이 '구독자 수'가 아니라 '활성 유저 수'에 비례하게 된다.

구독자 100만 명짜리 토픽이라도 그날 앱을 켠 사람이 5만 명이면, 발생하는 받은함 쓰기는 5만 건뿐이다. 그것도 서버가 아니라 각자의 단말이 나눠서 처리한다. 제품이 진짜로 신경 쓰는 비용 변수(=실제로 쓰는 사람)에 정확히 비례하도록 맞춘 셈이다.

Notification Flow


모델을 떼어낼 때 따라오는 숙제들

Read로 떼어내는 순간, Write 모델이 공짜로 보장해 주던 것들을 직접 처리해야 한다. 실제 구현에서 신경 쓴 지점들이다.

① 비정규화(Denormalization) — 받은함은 '그 순간'의 스냅샷이다. 받은함 엔트리에는 제목·본문·카테고리를 원본에서 복사해 박아둔다. 렌더링할 때 원본 notifications/{id}를 다시 읽지 않는다. 운영자가 나중에 원본을 수정하거나 삭제해도, 사용자가 그때 본 내용은 그대로 남아야 하기 때문이다. 받은함은 "사용자가 실제로 본 것"의 source of truth다.

② 멱등성(Idempotency) — 트랜잭션으로 create-only. 같은 알림이 서버 fan-out으로도, 모바일 backfill로도, 다른 기기의 backfill로도 동시에 들어올 수 있다. 이때 단순 set()으로 덮어쓰면 사용자의 readAt/deletedAt 상태가 날아간다. 그래서 트랜잭션 안에서 get → 이미 있으면 no-op → 없을 때만 set 하는 create-only 패턴으로 경쟁 상태를 막았다. 누가 먼저 쓰든 한 번만 만들어진다.

③ 커서(Cursor)는 now()가 아니라 '실제로 본 가장 최신값'까지만 전진시킨다. backfill 진척을 lastInboxBackfillAt에 기록하는데, 이걸 현재 시각으로 밀어버리면 쿼리와 커서 기록 사이에 도착한 알림이 영영 누락된다. 그래서 이번 패스에서 실제로 본 가장 최신 sentAt까지만 커서를 옮긴다. 본 게 없으면 커서는 안 움직인다.

④ 동의(Consent) 재검증 — 토픽 멤버십은 과거의 스냅샷이다. 프로모션이 나간 뒤 마케팅 수신 동의를 철회한 사용자가 그제서야 앱을 켜면? 토픽 구독은 발송 시점의 스냅샷일 뿐이라 그대로 두면 엔트리가 들어가 버린다. 그래서 backfill 시점에 마케팅 동의를 한 번 더 확인하고, 동의가 없으면 promo 엔트리를 건너뛴다.

⑤ 회원가입 환영(signup-welcome) — Read가 아니면 아예 불가능한 경우. 가입하면 주는 상시 혜택 공지는 아직 존재하지도 않는 미래의 가입자가 대상이다. 발송 시점에 수신자 집합이라는 게 애초에 없으니 Write로는 펼칠 방법이 자체가 없다. 서버는 원본만 게시하고, 클라이언트가 가입·앱 진입 시 자신의 firstSignupAt과 캠페인 조건(eligibleWithinDays 등)을 대조해 스스로 엔트리를 만든다. Fan-out on Read의 논리적 극단인 셈이다.

참고로 읽음·삭제 같은 변경(mutation) 은 전부 서버 API를 거친다. Firestore 보안 규칙이 클라이언트의 update/delete를 막고 create만 허용하기 때문에, 받은함의 상태는 항상 서버를 통해서만 바뀐다. 삭제도 하드 삭제가 아니라 deletedAt을 찍는 소프트 삭제라, 모든 렌더 쿼리는 deletedAt == null 필터를 깔아야 한다(분석용으로 데이터는 남긴다).




마무리

이번 설계에서 얻은 진짜 교훈은 "Write가 좋다 / Read가 좋다"가 아니었다. 비용을 올바른 변수에 비례시키는 것, 그리고 모델을 일률적으로 강요하지 않고 수신자 규모에 따라 선을 긋는 것이 핵심이었다.

WebP 때는 사용자가 보는 화면(읽기)의 비용을 줄였고, 이번엔 우리가 메시지를 펼치는 비용(쓰기)을 활성 유저 수에 묶었다. 스타트업이라 매 청구서가 곧 런웨이로 직결되는 만큼, "이 비용이 어떤 변수에 비례하는가"를 생각해지는게 점점 더 중요해진다고 느낀다. 기능 하나를 더 붙일 때마다, 그게 우리 비용 구조의 어디에 어떻게 걸리는지를 먼저 그려보는 중이다.


hyeon_B

안녕하세요! AI 기술을 이용해 더 나은 세상을 만들어 나가고 싶은 과기원생 Hyeon이라고 합니다. 저는 앞으로 인공지능 시대에는 지식을 '활용'하는 능력이 중요해질 것이라고 생각합니다. 대부분의 일들은 인공지능이 뛰어난 모습을 보이지만, 인공지능은 데이터로 부터 연관관계를 학습하기 때문에 지식들을 새로 통합해서 활용하는 능력이 부족합니다. 인공지능이 뉴턴 전에 만들어졌다면 사과가 떨어지는 이유에 대답하지 못했을 것이고, 아인슈타인 전에 만들어졌다면 중력이 어떻게 생기는지 설명하지 못했을 것입니다. 따라서 앞으로 우리는 '본질'을 탐구하고 그 본질로부터 다른 곳에 적용하며 인공지능을 현명하게 활용해야 할 것입니다. 함께 인공지능 시대를 준비합시다!

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2