effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화

Firestore 리스너 하나가 Flutter 앱 청구서를 터뜨리는 이유: 읽기 100만 건당 $0.60의 함정

Firestore 리스너 하나가 Flutter 앱 청구서를 터뜨리는 이유: 읽기 100만 건당 $0.60의 함정

Firebase 콘솔의 사용량 그래프가 어느 날 갑자기 계단식으로 치솟아 있는 걸 본 적이 있다면, 원인은 거의 항상 코드 로직이 아니라 위젯 생명주기에 있다. 기능을 하나 추가한 것도 아니고 사용자가 폭증한 것도 아닌데 Firestore 읽기 횟수가 하루아침에 10배가 되는 사고는 Flutter 앱에서 유독 자주 발생한다. 이유는 단순하다. StreamBuilder.snapshots()는 쓰기 쉬운 만큼 리스너의 생명주기를 눈에 보이지 않게 만들고, 위젯이 다시 빌드될 때마다 리스너가 조용히 재연결되면서 이미 다 읽은 문서를 처음부터 다시 읽어들이기 때문이다.

이 글은 “Firestore가 비싸다”는 막연한 경고가 아니라, 정확한 단가로 계산했을 때 흔한 안티패턴 하나가 월 청구서에 얼마를 더 얹는지를 실제 숫자로 보여주는 글이다. 요금표, Flutter에서 반복되는 3가지 안티패턴, DAU 5,000/10만 스케일에서의 비용 곡선, 무료 일일 한도가 소진되는 임계점 계산법, 그리고 실전 대응 패턴과 대안 아키텍처까지 다룬다.

핵심 요약

Firestore Blaze 정확한 단가부터 박아두자

먼저 대략적인 인상이 아니라 정확한 숫자로 시작한다. 아래 표는 Firebase/Google Cloud 공식 가격 페이지(nam5·eur3 등 기본 멀티 리전 기준)에서 직접 확인한 값이다.

항목 무료 일일 한도 초과 시 단가
문서 읽기(Document Reads) 50,000건/일 $0.06 / 10만 건 (100만 건당 $0.60)
문서 쓰기(Document Writes) 20,000건/일 $0.18 / 10만 건
문서 삭제(Document Deletes) 20,000건/일 $0.02 / 10만 건
저장 용량(Stored Data) 1 GiB GiB당 월 단위 과금(리전별 상이)
네트워크 송신(Egress) 10 GiB/월 리전·목적지별 상이

여기서 실무자가 자주 놓치는 세부 규칙 세 가지를 짚어야 한다.

그리고 이 글의 핵심과 가장 직결되는 규칙이 하나 더 있다. 공식 문서는 실시간 리스너의 과금 방식을 이렇게 설명한다.

“쿼리 결과를 리스닝할 때, 결과 집합에 문서가 추가되거나 업데이트될 때마다 읽기로 과금됩니다. 이후 활성 리스너는 변경이 감지된 문서에 대해서만 과금됩니다. 단, 오프라인 퍼시스턴스가 비활성화된 상태에서 리스너가 끊겼다가 재연결되면, 완전히 새로운 쿼리를 실행한 것처럼 문서와 인덱스 항목 읽기로 과금됩니다.

.snapshots() 리스너는 “한 번 붙으면 그다음부터는 변경분만 싸게 받는” 효율적인 구조가 맞다. 문제는 그 리스너가 “계속 붙어있어야” 이 효율이 성립한다는 점이고, Flutter의 위젯 트리는 리스너를 계속 붙여두는 걸 기본값으로 보장해주지 않는다.

Flutter 앱에서 반복되는 3가지 안티패턴

Firebase 커뮤니티와 실제 프로덕션 인시던트에서 반복적으로 관찰되는 패턴은 크게 세 가지다. 셋 다 코드는 “동작”하기 때문에 리뷰에서 잘 걸러지지 않고, 청구서가 튀어야 비로소 발견된다.

안티패턴 1: 리스트 화면에서 항목마다 별도 문서 조회(N+1)

채팅 목록, 피드, 주문 내역처럼 리스트의 각 항목이 다른 컬렉션의 문서를 참조하는 화면에서 가장 흔하다.

// 안티패턴: 아이템 하나마다 별도 get() 호출
ListView.builder(
  itemCount: chats.length,
  itemBuilder: (context, index) {
    final chat = chats[index];
    return FutureBuilder<DocumentSnapshot>(
      future: FirebaseFirestore.instance
          .collection('users')
          .doc(chat.otherUserId)
          .get(), // 리스트 50개면 읽기 50건 추가, 스크롤·재빌드마다 반복
      builder: (context, userSnap) {
        final name = userSnap.data?.get('displayName') ?? '...';
        return ListTile(title: Text(name));
      },
    );
  },
)

리스트 쿼리 자체는 1건(또는 페이지네이션 시 페이지 크기만큼)으로 끝나지만, 항목마다 상대방 프로필을 조회하느라 N개의 추가 읽기가 매번 화면을 그릴 때마다 발생한다. 스크롤로 위젯이 재구성되거나 화면을 재방문할 때마다 캐싱 없이 이 조회가 반복되면 실질 읽기 수는 순식간에 N의 몇 배로 불어난다.

해결책은 두 가지다.

  1. 배치 조회로 왕복 횟수를 줄인다. Firestore의 whereIn은 한 번에 최대 30개 값만 지원하므로 30개 단위로 청크를 나눠 조회하고, 결과는 반드시 Map에 캐싱해 재사용한다.
final userIds = chats.map((c) => c.otherUserId).toSet().toList();
final chunks = [
  for (var i = 0; i < userIds.length; i += 30)
    userIds.sublist(i, (i + 30).clamp(0, userIds.length)),
];
final userDocs = await Future.wait(chunks.map((chunk) =>
  FirebaseFirestore.instance
      .collection('users')
      .where(FieldPath.documentId, whereIn: chunk)
      .get(),
));
  1. 더 근본적으로는 비정규화(denormalization)로 조회 자체를 없앤다. otherUserName, otherUserAvatarUrl처럼 자주 쓰이는 필드를 chats/{chatId} 문서 안에 함께 저장해두고, 프로필이 바뀔 때만 Cloud Functions 트리거로 관련 문서를 갱신한다. 프로필 변경은 드물고 목록 조회는 빈번하므로, 쓰기 몇 건을 늘리는 대신 읽기 N건을 완전히 제거하는 전형적인 NoSQL 트레이드오프다.

안티패턴 2: 위젯 rebuild마다 StreamBuilder 리스너가 재연결된다

이게 가장 발견하기 어렵고 가장 비싼 패턴이다. .snapshots() 호출을 build() 메서드 안에 인라인으로 두면, build가 호출될 때마다 새로운 스트림 인스턴스가 만들어지고 이전 리스너는 버려진다. 앞서 인용한 공식 규칙대로, 리스너가 새로 붙으면 결과 집합 전체를 다시 읽기로 과금한다.

// 안티패턴: build()마다 새 스트림 생성 → 매번 리스너 재연결
class ChatRoomScreen extends StatelessWidget {
  final String roomId;
  const ChatRoomScreen({required this.roomId});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: FirebaseFirestore.instance
          .collection('rooms/$roomId/messages')
          .orderBy('timestamp', descending: true)
          .limit(50)
          .snapshots(), // 부모가 setState 할 때마다 이 스트림도 새로 생성됨
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

타이핑 인디케이터, 배지 카운트, 애니메이션 컨트롤러처럼 채팅 화면과 무관한 상태 변화가 부모 위젯을 rebuild시킬 때마다StreamBuilder도 함께 재생성되고, 이미 로드했던 최근 메시지 50건을 매번 처음부터 다시 읽는다.

해결책은 스트림 인스턴스를 위젯 생명주기와 분리해서 한 번만 만드는 것이다.

class ChatRoomScreen extends StatefulWidget {
  final String roomId;
  const ChatRoomScreen({required this.roomId});

  @override
  State<ChatRoomScreen> createState() => _ChatRoomScreenState();
}

class _ChatRoomScreenState extends State<ChatRoomScreen> {
  late final Stream<QuerySnapshot> _messagesStream;

  @override
  void initState() {
    super.initState();
    // initState는 위젯 생명주기 동안 단 한 번만 호출된다.
    _messagesStream = FirebaseFirestore.instance
        .collection('rooms/${widget.roomId}/messages')
        .orderBy('timestamp', descending: true)
        .limit(50)
        .snapshots();
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<QuerySnapshot>(
      stream: _messagesStream, // rebuild되어도 같은 스트림 재사용
      builder: (context, snapshot) { /* ... */ },
    );
  }
}

Riverpod을 쓴다면 StreamProvider.family로 같은 효과를 더 명시적으로 얻을 수 있다. 프로바이더가 roomId별로 스트림을 캐싱하고, 위젯이 rebuild되어도 ref.watch만으로는 스트림이 재생성되지 않는다.

final messagesProvider =
    StreamProvider.family<QuerySnapshot, String>((ref, roomId) {
  return FirebaseFirestore.instance
      .collection('rooms/$roomId/messages')
      .orderBy('timestamp', descending: true)
      .limit(50)
      .snapshots();
});

autoDispose를 붙일지 여부는 트레이드오프다. 채팅방을 나가자마자 리스너를 끊고 싶다면 autoDispose가 맞지만, 탭 전환처럼 화면을 잠깐 벗어났다 바로 돌아오는 패턴이 잦다면 autoDispose + keepAlive() 조합으로 짧은 유예 시간을 둬야 탭을 오갈 때마다 재연결 비용이 발생하는 걸 막을 수 있다.

안티패턴 3: 실시간 리스너 대신 get()을 폴링처럼 반복 호출

일부 팀은 .snapshots()의 실시간성이 필요 없다고 판단해 Timer.periodic으로 몇 초마다 .get()을 호출하는 방식을 쓴다. 언뜻 “리스너를 안 쓰니 더 저렴하겠다”고 생각하기 쉽지만 실제로는 반대다.

// 안티패턴: 5초마다 폴링 — 변경 여부와 무관하게 매번 전체 재조회
Timer.periodic(const Duration(seconds: 5), (_) async {
  final snap = await FirebaseFirestore.instance
      .collection('rooms/$roomId/messages')
      .orderBy('timestamp', descending: true)
      .limit(50)
      .get();
  updateUi(snap.docs);
});

.snapshots() 리스너는 최초 연결 이후 변경된 문서만 읽기로 과금되지만, .get() 폴링은 변경 여부와 무관하게 호출할 때마다 결과 집합 전체를 다시 읽는다. 1분에 12번(5초 간격) 폴링하고 결과가 50건이면, 아무 변화가 없어도 분당 600건, 시간당 36,000건의 읽기가 발생한다. 실시간성이 필요한 화면이라면 처음부터 .snapshots()를 쓰는 게 구조적으로 더 저렴하고, 정말 폴링이 필요한 경우(예: 배치성 동기화)라면 간격을 늘리고 limit을 최소화하거나 updated_at 커서 기반으로 변경분만 조회하도록 쿼리를 좁혀야 한다.

실제 계산 예시: DAU 5,000 채팅 앱이 안티패턴 하나로 늘어나는 비용

세 안티패턴 중 가장 파급력이 큰 **안티패턴 2(리스너 재연결)**를 기준으로 계산해본다. 아래 가정은 실제 채팅 앱 트래픽 패턴을 단순화한 모델이며, 정확한 벤치마크가 아니라 비용이 어떤 배수로 불어나는지 감을 잡기 위한 예시임을 먼저 밝힌다.

가정

계산

사용자 1명당 하루 추가 읽기 = 재연결 10회 × 50건 = 500건
DAU 5,000명 × 500건 = 하루 2,500,000건 추가 읽기
월간(30일) 추가 읽기 = 2,500,000 × 30 = 75,000,000건(7,500만 건)

추가 비용 = 75,000,000 / 100,000 × $0.06 = 750 × $0.06 = $45/월

이 앱이 DAU 10만으로 성장하면 어떻게 될까. 같은 안티패턴, 같은 사용자당 읽기 배수(500건/일)를 유지한다고 가정하면:

DAU 100,000명 × 500건 = 하루 50,000,000건 추가 읽기
월간 추가 읽기 = 50,000,000 × 30 = 1,500,000,000건(15억 건)
추가 비용 = 1,500,000,000 / 100,000 × $0.06 = 15,000 × $0.06 = $900/월

DAU가 20배 늘면 이 안티패턴으로 인한 추가 비용도 정확히 20배(월 $45 → $900)가 된다. 리스너 재연결로 인한 낭비는 사용자 수에 선형으로 비례하기 때문에 “적은 사용자일 땐 티가 안 나다가 스케일이 커지면 갑자기 무섭게 보이는” 전형적인 곡선을 그린다. 게다가 실제 앱에서는 안티패턴 하나만 존재하는 경우가 드물다. N+1 조회와 리스너 재연결이 동시에 있으면 두 낭비가 곱셈적으로 겹쳐서, 위 계산보다 훨씬 가파른 청구서를 만든다 — 리스트 화면의 항목 수(N), 항목별 리스너 수(L), 재연결 빈도(F)가 모두 곱해지는 구조이기 때문이다.

무료 일일 한도(5만 건)가 소진되는 임계점 역산하기

DAU 5,000 정도의 “아직 스케일이 크지 않은” 앱이라도, 무료 한도 5만 건은 생각보다 훨씬 빨리 바닥난다. 다음 공식으로 역산할 수 있다.

동시 접속자 수(C) × 세션당 평균 리스너 개수(L) × 리스너당 평균 초기 읽기 문서 수(R) × 재연결 배수(F)
= 하루 총 읽기 수

무료 한도를 넘기지 않는 최대 동시 접속자 수 C_max = 50,000 / (L × R × F)

예시 1: 재연결 안티패턴이 없는 “건강한” 상태 — 채팅 목록(리스너 1개, 결과 20건) + 현재 채팅방(리스너 1개, 결과 50건) 정도로 L=2, 평균 R=35, F=1(재연결 없음)이라면:

C_max = 50,000 / (2 × 35 × 1) = 50,000 / 70 ≈ 714명

동시 접속자 약 714명까지는 무료 한도 안에서 버틴다.

예시 2: 앞서 계산한 재연결 안티패턴이 섞인 상태 — 동일한 L=2, R=35이지만 세션 중 평균 재연결이 5회 발생(F=5)한다면:

C_max = 50,000 / (2 × 35 × 5) = 50,000 / 350 ≈ 142명

리스너 재연결 하나 때문에 무료 한도로 버틸 수 있는 동시 접속자 수가 714명에서 142명으로, 5분의 1로 줄어든다. “우리 앱은 아직 사용자가 몇백 명밖에 안 되니 무료 한도로 충분하다”는 가정이 실제로는 안티패턴 하나 때문에 정오도 되기 전에 깨지는 이유가 여기에 있다. 그리고 무료 한도는 프로젝트의 기본 데이터베이스 전체가 공유한다는 점도 기억해야 한다 — 스테이징 환경과 프로덕션이 같은 프로젝트를 쓰고 있다면 개발자의 핫 리로드 테스트조차 이 한도를 갉아먹는다.

비용을 줄이는 실전 패턴

앞서 다룬 개별 코드 수정 외에, 아키텍처 수준에서 적용할 수 있는 패턴을 정리한다.

final cached = await docRef.get(const GetOptions(source: Source.cache));
Query query = FirebaseFirestore.instance
    .collection('posts')
    .orderBy('createdAt', descending: true)
    .limit(20);

if (lastDocument != null) {
  query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();

Firestore 대안 요금 비교: Cloudflare D1, Supabase Postgres

마지막으로, “이 정도로 읽기가 많다면 애초에 다른 백엔드가 더 맞지 않을까”라는 질문에 답하기 위해 대안 두 곳의 공식 요금을 확인했다.

항목 Firestore (Blaze) Cloudflare D1 Supabase Postgres
무료 읽기 한도 1일 50,000건 1일 500만 행(Workers Free 플랜) API 요청 자체는 무제한(용량·성능 제한만 존재)
무료 저장 용량 1 GiB 5GB(계정 전체) 500MB
유료 읽기 초과 단가 10만 건당 $0.06 Workers Paid($5/월) 플랜에서 월 250억 행까지 포함, 초과분 100만 행당 $0.001 플랜 업그레이드로 저장·연결 수 확장(요청 단가 과금 없음)
유료 쓰기 초과 단가 10만 건당 $0.18 Workers Paid 플랜에서 월 5천만 행까지 포함, 초과분 100만 행당 $1.00 동일
네이티브 실시간 리스너 지원(WebSocket 기반 스트림, 이 글에서 다룬 비용 구조의 원인이기도 함) 기본 미지원(Durable Objects로 직접 구현 가능) Realtime 기능 제공(Postgres 논리적 복제 기반, 별도 한도 적용)

숫자만 보면 D1의 유료 초과 단가가 Firestore보다 압도적으로 저렴해 보이지만(100만 행당 $0.001 vs 100만 건당 $0.60), 이건 동일한 제품 비교가 아니라는 점을 짚어야 한다. D1은 SQL 행(row) 단위 과금이고 월 $5 Workers Paid 플랜 가입이 전제이며, Firestore가 기본 제공하는 실시간 리스너·오프라인 동기화·자동 스케일링을 그대로 대체하지 못한다. Supabase도 마찬가지로 Realtime 기능은 있지만 별도 동시접속·메시지 한도가 걸려 있어 Firestore의 .snapshots()와 1:1로 맞바꿀 수 있는 게 아니다.

실무적으로 합리적인 결론은 이렇다. 채팅 메시지나 협업 편집처럼 진짜 실시간 동기화가 필요한 부분은 Firestore(또는 동급 실시간 DB)에 남기고, 읽기는 많지만 실시간일 필요가 없는 나머지 데이터를 D1/Supabase 같은 저렴한 백엔드로 분리하는 것이다. 이 분리 하나만으로도 앞서 계산한 “안티패턴으로 인한 월 $45~$900” 수준의 낭비 대부분을 애초에 발생시키지 않을 수 있다.

마무리 체크리스트

Flutter+Firestore 조합의 청구서를 관리하려면 다음을 순서대로 점검하는 것을 권한다.

이 여섯 가지만 점검해도 “코드를 안 바꿨는데 청구서가 갑자기 튀는” 사고의 대부분은 사전에 막을 수 있다.