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

문제 푸시 알림은 놓치면 끝이라 사용자별 알림함이 필요했다. 그런데 모든 발송을 서버가 알림함에 펼쳐 쓰면 쓰기 건수가 수신자 수만큼 늘고, 언어별 토픽처럼 서버가 수신자를 모르는 발송은 펼칠 수조차 없다.
결정 서버가 수신자를 uid로 풀 수 있는 발송(uid 지정, 세그먼트, 동의 기반 토픽)은 발송 시점에 서버가 알림함에 쓰고, 풀 수 없는 언어 토픽(lang-*)과 가입 환영 공지만 앱이 열릴 때 클라이언트가 채운다.
대가 중복 방지, 마케팅 동의 재검증, 어디까지 채웠는지의 기록을 클라이언트가 떠안았다.
결과 클라이언트가 채우는 발송에 한해 비용이 구독자 수가 아니라 앱을 여는 사용자 수에 비례한다. 서버가 펼치는 발송은 여전히 수신자 수에 비례한다.

우리 서비스에서 마케터와 PM, 운영 담당자는 운영 콘솔에서 공지나 프로모션 푸시를 직접 보낸다. 개발자가 서버 스크립트를 돌려 주는 구조가 아니다(효율성을 위함). 콘솔은 발송 전에 수신자 수와 화면 미리보기를 보여 주고, 확인 토큰을 받은 발송만 받아들이고, 모든 발송을 감사 기록으로 남긴다. 그렇게 보낸 공지를 사용자가 못 보면 운영자는 같은 공지를 다시 보내야 한다. 그래서 푸시와 별개로, 사용자가 언제든 들어와 다시 볼 수 있는 알림함을 만들었다. 서버와 모바일 연동 명세는 내가 썼고, 모바일 구현은 클라이언트 담당 팀원이 맡았다.

이 글은 그 알림함 레코드를 서버가 만들지 클라이언트가 만들지 정한 과정이다. 분산 시스템에서 Fan-out(메시지 하나를 여러 수신자에게 펼쳐 넣는 일)이라고 부르는 문제다.


문제: 푸시는 휘발된다

슬루스는 사용자에게 소식을 전할 때 FCM(Firebase Cloud Messaging, 앱에 푸시를 보내는 Google 서비스)으로 푸시를 보내고 발송 이력을 운영자용 감사 로그에 남겼다. 푸시는 한 번 쏘고 잊는 구조여서, 알림을 꺼둔 사용자나 그 순간 오프라인이던 사용자는 메시지를 영영 놓친다. 보상 안내든 신작 공지든 푸시를 놓치면 사용자에게는 그런 소식이 없었던 것이 된다.

가장 단순한 알림함은 알림을 보내는 순간 수신자 한 명당 문서 하나를 써넣는 것이다. 100명에게 보내면 문서 100개를 쓴다. 이 방식은 구독자가 많은 발송에서 부담이 된다. 슬루스에는 lang-ko 같은 토픽 발송이 있다. 토픽은 앱이 구독해 두면 서버가 토픽 이름 하나로 구독자 전체에 푸시를 보내는 FCM 기능이다.

문제는 비용보다 앞에 있었다. 토픽은 구독자 명단을 FCM이 들고 있어서 서버가 누가 구독자인지 알 수 없다. Admin SDK는 구독자 수도 알려 주지 않아서 우리 서버는 토픽 발송의 수신자 수를 -1(알 수 없음)로 기록한다. 언어 토픽은 구독 여부가 기기의 언어 설정에 달려 있어 서버가 읽을 수 있는 데이터에도 없다. 알림함을 발송 시점에 펼치고 싶어도 누구에게 써야 할지 모른다.


제약

  • 팀에 서버 개발은 내가 주로 담당하고 있고, 인프라는 Firebase(Firestore, FCM)와 Cloud Run 위에 있다. 큐나 워커를 새로 들이면 운영할 사람도 나다.
  • Firestore는 문서 쓰기·읽기 건수로 과금된다. 스타트업이라 매달 청구서가 곧 런웨이다.
  • 모바일 앱은 이미 users/{uid} 문서를 읽고 토픽 구독을 스스로 관리하고 있었다.



Fan-out on Write와 Fan-out on Read

새로운 문제는 아니다. Claude와 대화하며 사례를 찾아보니 트위터 타임라인, 인스타그램 피드, 단체 채팅방처럼 "한 명이 만든 콘텐츠를 다수에게 어떻게 전달할 것인가"를 먼저 고민한 서비스들이 있었고, 답은 크게 두 가지였다.

Fan-out on Write (쓰기 시점 분배, Push 모델)

메시지를 보내는 시점에 모든 수신자의 받은함에 사본을 한 부씩 써넣는다. 수신자는 나중에 자기 받은함만 읽으면 된다.

  • 읽기가 싸고 단순하다. "내 알림함" 쿼리 한 번이면 끝나고, 실시간 구독(onSnapshot)을 걸기도 쉽다.
  • 쓰기 비용이 수신자 수에 비례한다. 1건의 발송이 N건의 쓰기를 만든다.
  • 수신자 집합이 유한하고 서버가 알 수 있을 때 맞는다.

Fan-out on Read (읽기 시점 조합, Pull 모델)

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

  • 쓰기가 싸다. 수신자가 얼마든 발송당 문서 하나다.
  • 읽기가 무겁고 복잡하다. 클라이언트가 매번 "나한테 온 게 뭐지?"를 계산해야 한다.
  • 비용이 실제로 읽는 사람 수에 비례한다. 구독만 해두고 들어오지 않는 사람에겐 비용이 없다.

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

Architecture comparsion

핵심은 각각 trade-off가 있다는 점이다. 쓰기를 싸게 하면 읽기가 비싸지고, 반대도 마찬가지다. 그래서 트위터도 한 모델만 쓰지 않는다고 한다. 일반 유저의 트윗은 Write로 펼치고, 팔로워가 매우 많은 계정의 트윗만 Read로 당겨오는 하이브리드다. 결국 설계는 어디에 선을 긋느냐의 문제였다.





검토한 선택지

안 장점 단점 판단
전부 Write (모든 발송을 서버가 펼침) 클라이언트가 단순하다. 받은함 구독만 하면 된다 브로드캐스트 1건이 구독자 수만큼 쓰기. 무엇보다 순수 토픽은 서버가 수신자를 몰라 펼칠 수가 없다 기각
전부 Read (모든 발송을 클라이언트가 채움) 서버 쓰기가 발송당 1건으로 고정 uid 지정 발송도 클라이언트가 조회해야 해서 실시간 반영이 약해지고, 받은함마다 "나에게 온 개별 발송"을 찾는 쿼리가 필요하다 기각
동의 기반 토픽(all-users, marketing-opt-in, night-opt-in)을 FCM 토픽으로 보내고 받은함은 Read로 토픽 경로 하나로 통일 운영에서 토픽 발송이 sent로 기록됐는데 테스트 기기에는 푸시가 오지 않았다. OS가 FCM 토큰을 교체한 뒤 새 토큰으로 토픽을 다시 구독하지 않으면 토픽 명단에는 죽은 토큰만 남는다 기각 (5월 말)
서버가 수신자를 풀 수 있으면 Write, 못 풀면 Read 각 발송이 자기에게 맞는 모델을 쓴다 두 경로를 모두 유지해야 하고, Read 쪽 정합성을 클라이언트가 책임진다 채택

세 번째 안은 비용이 아니라 전달 신뢰성 때문에 버렸다. 동의 여부는 서버가 읽을 수 있는 users/{uid} 문서의 플래그로 정해지니, 서버가 직접 uid를 풀어 현재 토큰으로 멀티캐스트하는 쪽이 정확했다. 그렇게 바꾸고 나니 이 세 토픽은 수신자를 알 수 있는 발송이 되었고, 받은함도 자연스럽게 Write 쪽으로 넘어왔다. 언어 토픽(lang-*)만 브로드캐스트로 남았다. 언어는 기기에만 있는 상태라 서버가 풀 수 없기 때문이다.

큐를 두고 비동기로 서버 팬아웃을 돌리는 방식(Cloud Tasks 같은)은 이번에 따로 비교하지 않았다. 쓰기 건수 자체가 줄지 않고, 순수 토픽의 수신자를 모른다는 문제도 그대로라서다.




결정: 수신자를 알 수 있으면 Write, 모르면 Read

코드의 분기 기준은 수신자 수가 아니라 대상 유형이다. "N명을 넘으면 Read로 전환" 같은 숫자 임계값은 없다.

대상 받은함을 누가 만드나
uid 지정(최대 1,000명), 세그먼트 서버, 쓰기 시점
동의 기반 토픽(all-users 등) 서버, 쓰기 시점 (동의 플래그로 uid 해석)
순수 브로드캐스트 토픽(lang-*) 클라이언트, 앱이 열릴 때
가입 환영 공지(signup-welcome) 클라이언트, 가입 후 앱 진입 시

서버가 펼치는 쪽은 유저별 알림함 하위 컬렉션에 수신자 한 명당 문서 하나를 쓴다. 400건씩 배치로 묶고, 배치 5개를 동시에 커밋한다. 배치 하나가 실패해도 나머지는 계속 커밋하고, 성공한 uid와 실패한 uid를 나눠 돌려받는다. 모바일은 자기 받은함을 onSnapshot으로 구독만 하면 새 알림과 다른 기기에서 한 읽음 처리가 바로 반영된다(연동 명세의 QA 기준은 발송 후 10초 안에 목록에 뜨는 것으로 잡았다).

브로드캐스트는 서버가 원본 notifications/{id} 문서 하나만 쓴다. 받은함 엔트리는 앱이 포그라운드로 돌아오거나 로그인할 때 클라이언트가 직접 만들어 채운다(lazy backfill).

이렇게 나누면 브로드캐스트 비용이 구독자 수가 아니라 앱을 여는 사용자 수에 비례한다.

비용 비교의 근거

비용은 Firestore 연산 건수로 비교했다. 아래는 코드에서 센 것이고, 운영 측정값은 아니다.

서버 Write 클라이언트 Read (앱 진입 1회)
발송 시점 수신자 1명당 쓰기 1건 원본 문서 쓰기 1건
새 알림이 없을 때 해당 없음 사용자 문서 읽기 1 + 쿼리 1회(결과가 없어도 최소 1건 읽기로 과금)
새 알림 k건이 있을 때 해당 없음 위에 더해 쿼리 결과 읽기 k + 트랜잭션 확인 읽기 k + 받은함 쓰기 k + 커서 쓰기 1

가정으로 숫자를 넣어보면 이렇다. 구독자 100만 명짜리 토픽에 공지를 한 번 보내고 그날 앱을 연 사람이 5만 명이라면, Write는 쓰기 100만 건이고 Read는 쓰기가 약 10만 건(5만 명 × 받은함 1 + 커서 1)이다. 그것도 서버가 아니라 각자의 단말이 나눠서 처리한다.

표에서 보이듯 Read도 공짜는 아니다. 새 알림이 없어도 앱을 열 때마다 읽기 2건이 나간다. 그래서 정확히 말하면 비용은 "활성 유저 수 × 앱 진입 횟수"에 비례한다. 그래도 이 구조를 택한 건, 구독만 해두고 들어오지 않는 사용자에게 드는 비용을 0으로 만들 수 있어서다.

발송 유형별 알림 흐름: 서버 팬아웃과 클라이언트 백필




구현에서 걸린 것: 클라이언트가 떠안은 일들

Read로 떼어내면 Write가 공짜로 보장하던 것을 클라이언트가 챙겨야 한다. 이 중 두 가지만 적는다.

중복 방지. 같은 알림이 여러 경로로 동시에 들어올 수 있다. 같은 사용자의 다른 기기가 동시에 채울 수도 있고, 이전 시도가 중간에 끊긴 뒤 다시 돌 수도 있다. 이때 문서를 덮어쓰면 사용자가 이미 찍은 읽음과 삭제 표시가 사라진다. 그래서 엔트리마다 트랜잭션 안에서 문서가 있는지 읽어 보고, 있으면 아무것도 하지 않고 없을 때만 쓴다. 보안 규칙도 같은 방향이다. 알림함 문서는 본인만 만들 수 있고 수정과 삭제는 규칙에서 막았으며, 읽음과 삭제는 서버 API를 거친다.

커서. 어디까지 채웠는지는 사용자 문서의 마지막 백필 시각 필드에 기록한다. 이 값이 커서다. 커서를 현재 시각으로 밀면 쿼리와 기록 사이에 도착한 알림을 다음 번에 건너뛴다. 그래서 이번에 실제로 본 가장 최신 발송 시각까지만 옮긴다. 쿼리는 200건씩 가져오고, 밀린 알림이 더 있으면 다음 진입 때 이어서 받는다. 커서가 없는 사용자의 시작점은 명세와 구현이 달랐다. 내가 쓴 명세는 처음부터 읽는 것이었는데 모바일 팀원은 첫 구현부터 30일 전으로 잡았고, 며칠 뒤 가입 시점보다 앞으로 내려가지 않게 보정했다. 첫 진입에 과거 브로드캐스트가 전부 들어오는 것을 막는 쪽이 현실적이라 구현을 따랐다.

마케팅 알림은 채울 때 마케팅 수신 동의와 마케팅 알림 설정이 모두 켜져 있는지 다시 확인한다. 발송 뒤에 동의를 철회한 사용자의 알림함에 프로모션이 들어가지 않게 하려는 것이다.


측정

아직 없다. 운영 환경의 브로드캐스트 발송 횟수, 토픽 구독자 수, 백필이 앱 진입에 더하는 지연을 재지 않았다. 토픽 구독자 수는 Admin SDK가 알려주지 않아서 다른 측정 방법이 필요하다. 위의 비용 비교는 코드 경로에서 센 연산 수와 가정한 숫자다.


남은 한계

  • 동의 기반 토픽의 1만 명 상한. 서버가 한 번에 해석하는 수신자는 1만 명까지다. 넘으면 1만 명에서 잘리고, 미리보기에서 운영자에게 실제 매칭 수와 빠지는 인원을 경고한다. 전체 사용자가 1만 명에 가까워지면 페이지 단위 팬아웃으로 바꾸거나 all-users를 Read 쪽으로 옮겨야 하고, 이것이 이 설계를 다시 열어볼 첫 번째 신호다.
  • 기기 간 커서 역행. 두 기기가 동시에 채우면 더 이른 시각을 본 기기가 나중에 커서를 써서 커서가 뒤로 갈 수 있다. 엔트리가 중복되지는 않지만 같은 구간을 다시 읽는 비용이 든다.
  • 클라이언트가 만든 엔트리는 서버가 검증하지 않는다. 규칙은 본인 알림함에 문서를 만드는 것만 허용하고 필드는 검사하지 않으므로, 자기 알림함에 가짜 알림을 넣을 수 있다. 남에게는 영향이 없고 알림함 엔트리를 근거로 돈이 움직이는 경로가 없어서 받아들였다.
  • 채우기가 실패하면 로그만 남기고 다음 진입 때 다시 시도하므로, 알림이 늦게 보일 수 있다.


마무리

이번 설계에서 정리한 건 두 가지다. 비용이 어떤 변수에 비례하는지를 먼저 보는 것, 그리고 한 모델을 모든 발송에 강요하지 않고 발송마다 선을 긋는 것. 막상 선을 그어 보니 그 선은 수신자 수보다 "서버가 수신자를 아는가"에 가까웠다.

WebP 때는 사용자가 보는 화면(읽기)의 비용을 줄였고, 이번엔 메시지를 펼치는 비용(쓰기)을 앱을 여는 사용자 수에 묶었다. 스타트업이라 비용이 곧 런웨이로 이어지다 보니, 기능 하나를 붙일 때마다 그게 비용 구조의 어디에 걸리는지를 먼저 그려보게 된다. 다음엔 여기서 비워 둔 측정 칸을 채워볼 생각이다.

hyeon_B

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

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2