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

Firebase 콘솔의 사용량 그래프가 어느 날 갑자기 계단식으로 치솟아 있는 걸 본 적이 있다면, 원인은 거의 항상 코드 로직이 아니라 위젯 생명주기에 있다. 기능을 하나 추가한 것도 아니고 사용자가 폭증한 것도 아닌데 Firestore 읽기 횟수가 하루아침에 10배가 되는 사고는 Flutter 앱에서 유독 자주 발생한다. 이유는 단순하다. StreamBuilder와 .snapshots()는 쓰기 쉬운 만큼 리스너의 생명주기를 눈에 보이지 않게 만들고, 위젯이 다시 빌드될 때마다 리스너가 조용히 재연결되면서 이미 다 읽은 문서를 처음부터 다시 읽어들이기 때문이다.
이 글은 “Firestore가 비싸다”는 막연한 경고가 아니라, 정확한 단가로 계산했을 때 흔한 안티패턴 하나가 월 청구서에 얼마를 더 얹는지를 실제 숫자로 보여주는 글이다. 요금표, Flutter에서 반복되는 3가지 안티패턴, DAU 5,000/10만 스케일에서의 비용 곡선, 무료 일일 한도가 소진되는 임계점 계산법, 그리고 실전 대응 패턴과 대안 아키텍처까지 다룬다.
핵심 요약
- Firestore Blaze 요금(nam5/eur3 리전 기준, 공식 문서 확인)은 읽기 10만 건당 $0.06, 쓰기 10만 건당 $0.18, 삭제 10만 건당 $0.02다. 읽기 100만 건이면 $0.60 — 숫자만 보면 작아 보이지만 리스너 재연결이 겹치면 이 단가가 무섭게 곱해진다.
- 무료 일일 한도는 읽기 5만 건·쓰기 2만 건·삭제 2만 건·저장 1GiB이며, 이 한도는 프로젝트당 기본 데이터베이스 하나에만 적용된다. 앱 하나가 아니라 프로젝트 전체 트래픽이 이 한도를 공유한다.
- 실시간 리스너는 최초 연결 시 결과 집합의 모든 문서를 1회씩 읽기로 과금하고, 이후에는 변경된 문서만 과금한다. 문제는 오프라인 퍼시스턴스가 꺼져 있으면 리스너가 끊겼다 다시 붙을 때마다 완전히 새 쿼리로 취급되어 전체 문서를 다시 읽는다는 점이다. Flutter에서 이 재연결은 위젯 rebuild 한 번으로도 쉽게 발생한다.
- 리스트 화면에서 항목마다 별도 문서를 조회하는 N+1 패턴, 위젯 rebuild마다 새 스트림을 생성하는 리스너 재연결, 실시간 리스너 대신
.get()을 반복 호출하는 폴링 패턴, 이 세 가지가 Flutter+Firestore 조합에서 청구서를 터뜨리는 가장 흔한 원인이다. - 대응책은 리스너 인스턴스 캐싱, 커서 기반 페이지네이션, 비정규화를 통한 N+1 제거, 그리고 읽기 위주 저변동성 데이터를 Cloudflare D1/KV 같은 훨씬 저렴한 백엔드로 분리하는 하이브리드 구조다.
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/월 | 리전·목적지별 상이 |
여기서 실무자가 자주 놓치는 세부 규칙 세 가지를 짚어야 한다.
- 쿼리는 결과가 0건이어도 최소 1회 읽기로 과금된다. “존재하지 않는 데이터를 조회하면 공짜”라는 가정은 틀렸다.
- 복잡한 쿼리는 스캔한 인덱스 항목 1,000개당 1회 읽기로 추가 과금된다.
count()같은 집계 쿼리도 동일한 규칙을 따르며, 인덱스 항목을 전혀 읽지 않아도 최소 1회는 과금된다. - 무료 한도는 프로젝트의 기본(default) 데이터베이스 하나에만 적용된다. 같은 프로젝트에 데이터베이스를 추가로 만들면 그 데이터베이스는 무료 한도 없이 처음부터 과금된다.
그리고 이 글의 핵심과 가장 직결되는 규칙이 하나 더 있다. 공식 문서는 실시간 리스너의 과금 방식을 이렇게 설명한다.
“쿼리 결과를 리스닝할 때, 결과 집합에 문서가 추가되거나 업데이트될 때마다 읽기로 과금됩니다. 이후 활성 리스너는 변경이 감지된 문서에 대해서만 과금됩니다. 단, 오프라인 퍼시스턴스가 비활성화된 상태에서 리스너가 끊겼다가 재연결되면, 완전히 새로운 쿼리를 실행한 것처럼 문서와 인덱스 항목 읽기로 과금됩니다.”
즉 .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의 몇 배로 불어난다.
해결책은 두 가지다.
- 배치 조회로 왕복 횟수를 줄인다. 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(),
));
- 더 근본적으로는 비정규화(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(리스너 재연결)**를 기준으로 계산해본다. 아래 가정은 실제 채팅 앱 트래픽 패턴을 단순화한 모델이며, 정확한 벤치마크가 아니라 비용이 어떤 배수로 불어나는지 감을 잡기 위한 예시임을 먼저 밝힌다.
가정
- DAU(일간 활성 사용자) 5,000명
- 사용자 1명당 하루 평균 1회 채팅방 세션, 세션 중 부모 위젯 rebuild로 인한 리스너 재연결이 평균 10회 발생(타이핑 인디케이터, 읽음 표시 등 채팅과 무관한 상태 변화가 원인)
- 재연결마다 최근 메시지 50건(
limit(50))을 처음부터 다시 읽음
계산
사용자 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로 줄어든다. “우리 앱은 아직 사용자가 몇백 명밖에 안 되니 무료 한도로 충분하다”는 가정이 실제로는 안티패턴 하나 때문에 정오도 되기 전에 깨지는 이유가 여기에 있다. 그리고 무료 한도는 프로젝트의 기본 데이터베이스 전체가 공유한다는 점도 기억해야 한다 — 스테이징 환경과 프로덕션이 같은 프로젝트를 쓰고 있다면 개발자의 핫 리로드 테스트조차 이 한도를 갉아먹는다.
비용을 줄이는 실전 패턴
앞서 다룬 개별 코드 수정 외에, 아키텍처 수준에서 적용할 수 있는 패턴을 정리한다.
- 클라이언트 캐싱을 명시적으로 활성화한다.
cloud_firestore는 기본적으로 오프라인 퍼시스턴스가 켜져 있지만(모바일 기준), 캐시 크기와 정책을 명시적으로 확인하고, 실시간성이 필요 없는 화면은GetOptions(source: Source.cache)로 서버 왕복 자체를 건너뛴다.
final cached = await docRef.get(const GetOptions(source: Source.cache));
- 리스트 화면에는 예외 없이 커서 기반 페이지네이션을 적용한다.
limit()없이 컬렉션 전체를 구독하는 코드는 컬렉션이 커질수록 초기 읽기 비용이 함께 커진다.
Query query = FirebaseFirestore.instance
.collection('posts')
.orderBy('createdAt', descending: true)
.limit(20);
if (lastDocument != null) {
query = query.startAfterDocument(lastDocument);
}
final snapshot = await query.get();
- 부분 업데이트와 비정규화로 N+1을 근본적으로 줄인다.
.set()으로 문서 전체를 덮어쓰는 대신.update()로 바뀐 필드만 쓰고, 자주 조회되는 연관 데이터는 위에서 다룬 것처럼 상위 문서에 함께 저장한다. - 리스너 생명주기를 위젯 트리가 아니라 상태 관리 레이어가 소유하게 한다.
initState에서 스트림을 한 번만 생성하거나, Riverpod/Bloc의 프로바이더 레벨에서 스트림을 캐싱해 rebuild와 리스너 재연결을 분리한다. 이게 이 글에서 다룬 안티패턴 중 가장 임팩트가 큰 부분을 원천 차단하는 방법이다. - 읽기 위주·저변동성 데이터는 Firestore 밖으로 뺀다. 실시간 동기화가 진짜로 필요한 데이터(채팅 메시지, 프레즌스, 협업 편집 상태)만 Firestore에 남기고, 정적 설정값·상품 카탈로그·리더보드처럼 자주 읽히지만 실시간일 필요는 없는 데이터는 Cloudflare Workers + D1(또는 KV) 같은 훨씬 저렴한 백엔드로 옮기는 하이브리드 구조를 검토한다. Flutter 앱은 이런 데이터를 Firestore SDK 대신 REST 호출로 Worker 엔드포인트에 요청하면 된다.
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 조합의 청구서를 관리하려면 다음을 순서대로 점검하는 것을 권한다.
-
.snapshots()호출이build()메서드 안에 인라인으로 들어가 있지 않은가 (initState또는 프로바이더 레벨로 옮겼는가) - 리스트 화면에서 항목마다 별도
get()을 호출하고 있지 않은가 (배치 조회 또는 비정규화로 대체했는가) -
Timer.periodic+.get()폴링이 남아있지 않은가 (실시간 리스너로 대체 가능한가) - 모든 리스트/컬렉션 구독에
limit()과 페이지네이션 커서가 적용되어 있는가 - 스테이징과 프로덕션이 같은 Firebase 프로젝트(=같은 무료 한도)를 공유하고 있지 않은가
- 실시간성이 필요 없는 읽기 위주 데이터를 Firestore 밖으로 분리할 여지가 있는가
이 여섯 가지만 점검해도 “코드를 안 바꿨는데 청구서가 갑자기 튀는” 사고의 대부분은 사전에 막을 수 있다.