

1/1/1970
2일 만에 Firestore에서 Postgres로 마이그레이션하는 법
Firebase. 이 단어 하나가 더 저렴한 데이터베이스보다 10~20배의 비용을 쉽게 잡아먹을 수 있고, 데이터베이스 설계를 제약하고 복잡하게 만들 수 있어요.
그러니 제가 단 2일 만에 Firestore 마이그레이션을 끝냈을 때 얼마나 후련했을지 상상이 가시죠? 음, 초기 "PostgresSynchronizer"를 만드는 데 쓴 시간까지 합치면 3일이고요. 워크샵 시스템 마이그레이션까지 포함하면 3.5일이에요. 어쨌든, 빨랐어요. 정말 빨랐죠.
이 프로젝트에 대한 제 초기 예상은 무려 한 달의 작업이었어요! 그런데 멋진 LLM 프롬프트들과, CDC를 가르쳐준 친구 덕분에 훨씬 빠르게 끝낼 수 있었답니다.
pub/sub을 찾고 계신다면 Firebase나 Supabase가 굉장히 매력적으로 보일 수 있어요. 사실, pub/sub을 기본으로 지원하는 데이터베이스는 많지 않거든요. 하지만 아래의 이 간단한 아키텍처만 있어도 대부분의 소규모 비즈니스 사례에는 충분하고요 (그리고 초당 약 10k~100k ops까지 깔끔하게 확장될 거예요).
하지만 pub/sub이 어떻게 작동하는지 자세히 들어가기 전에, 제가 진행한 단계들을 순서대로 짚어볼게요.
먼저 Cursor에서 PostgresSynchronizer를 만들기 위한 프롬프트를 작성했어요. 이 클래스에는 handleWrite라는 함수가 있는데, Firestore 경로, 작업 유형(생성, 업데이트, 삭제), 그리고 데이터를 받아서 Postgres에 upsert해요. 그런 다음 Firestore 스토어 인스턴스를 래핑해서 Firestore에 일어나는 모든 변경이 handleWrite도 함께 호출하도록 했죠.
물론 이게 100% 완벽하진 않아요. 서버가 끊기거나 크래시되면 일부 사소한 변경 사항이 저장되지 않을 수도 있거든요. 하지만 Foony의 사용 사례에는 충분하고, Postgres 스키마는 Firestore보다 더 나은 데이터 무결성 보장(예: 외래 키 제약 조건)을 제공해요.
다음으로, 모든 Firestore 컬렉션을 순회하면서 각 문서에 대해 handleWrite를 호출하는 백필을 만들어 실행했어요. 이걸로 Firestore의 모든 과거 데이터를 가져올 수 있죠. 속도를 위해 등록된 사용자로만 제한했어요 (게스트분들 미안해요, 가입했어야죠). 잘 작동하고, 여러 번 실행해도 안전해요.
이제 Postgres에 데이터를 채우고 Firestore와 (거의) 동기화 상태로 유지할 방법이 생겼으니, 크고 무서운 문제인 pub/sub에 도전할 수 있게 되었어요.
도대체 어떻게 Postgres로 pub/sub을 해요? 아니, 그 어떤 SQL 데이터베이스든 마찬가지죠?
Postgres Change Data Capture (CDC)가 구하러 왔다!
CDC는 "데이터베이스에 일어난 변경을 읽어 pub/sub 시스템에 게시하는 것"을 의미하는 멋진 단어일 뿐이에요. 대규모 비즈니스에서는 Debezium + Kafka 같은 걸 쓸 수도 있겠죠. 하지만 Kafka는 설정이 골치 아프고, 우리에겐 이미 Redis가 있고, 데이터베이스는 초당 약 30 ops밖에 안 받아요. 우리 규모를 100배로 늘려서 동시 사용자 약 10만 명이 되어도 단일 Redis 인스턴스 하나에 충분히 들어갈 거예요. 그래서 그렇게 했죠.
(LISTEN/NOTIFY로도 Postgres에 pub/sub을 끼워 넣을 수 있긴 한데, 재연결 시 살아남지 못하고 실제로 팬아웃이 필요해지면 무너져요. CDC가 지루하지만 견고한 정답이에요.)
제 친구 Eric이 CDC를 가르쳐주고 자기 CDC 코드를 오픈소스로 공개했어요. 이 코드는 Postgres의 WAL(Write-Ahead Log, Postgres가 데이터베이스에 적용하는 모든 변경을 기록하는 곳)을 논리적 복제 슬롯을 통해 읽고, 싱크(예: Redis)에 영속화해요.
Cursor에서 간단하고 자세한 프롬프트로, 실시간 CDC와 gateway 코드를 거의 한 번에 뽑아낼 수 있었어요 (약간의 조정과 꼼꼼한 코드 리뷰는 거쳤고요). 여기엔 매우 단순한 두 개의 서비스가 포함돼요:
- WAL을 읽고 Redis Pub/Sub에 게시하는 싱글톤 CDC 서비스 (Streams로 더 높은 내구성을 얻을 수 있지만, 복잡성이 늘어나요)
- JWT 인증과 웹 클라이언트의 websocket 연결을 처리하는 수평 오토스케일링 gateway 서비스
두 서비스 모두 golang으로 작성됐고, 특히 오토스케일링 gateway 부분이 마음에 들어요. 웹 클라이언트는 예전에 Firestore에서 했던 것과 똑같은 방식으로 구독하고, 같은 형식의 데이터를 돌려받아요. 클라이언트는 "usersPublic/"과 "usersPrivate/"를 권한이 다른 별개의 두 컬렉션으로 보죠. Gateway는 그 요청을 내부 Postgres 테이블(이 경우 users)로 변환하고, 클라이언트가 해당 데이터에 권한이 있는지 검증할 책임을 져요.
이게 충격적일 정도로 잘 작동해요. 전체 pub/sub 시스템의 컴퓨팅 + egress 비용이 월 약 $0.50밖에 안 들고, 망가질 수 있는 움직이는 부품도 별로 없어요 (CDC, gateway, 클라이언트 코드 모두 Redis / Postgres 외에는 외부 의존성이 없는 단순한 코드예요).
DevEx 관점에서도 새로운 시스템이 오히려 더 단순하다고 할 수 있어요. 개발자들이 Firestore의 데이터 모델링 방식이나 Firestore 보안 규칙을 알 필요가 없거든요. SQL만 이해하면 schema.sql에 빠르게 수정을 가하고, all.go에서 라우트를 추가하거나 수정하면 끝이에요. 문서화도 충분히 잘 되어 있어서 LLM이 따라가며 변경하기도 쉽고요. 보안 관점에서도 이득이에요. 이제 새 인프라는 고정 비용이고 말도 안 되게 더 저렴해서, 위험한 denial-of-wallet (지갑 DoS) 공격에 노출되지 않거든요.
Postgres 인스턴스와 일일 S3 백업까지 포함해서, 데이터베이스 인프라 비용을 월 $550에서 단 $40로 줄이는 데 성공했어요. 클라이언트 번들 크기도 약 100KB 줄였는데, 이것도 기분 좋은 부분이죠.
새 시스템이 준비된 후, 로컬에서 모든 게 예상대로 작동하는지 테스트했어요. 그리고 실제 배포에 나섰죠. 문제가 생겨도 이 시점까지는 Firestore가 진실의 원천이기 때문에 클라이언트를 쉽게 롤백할 수 있어요.
라이브 전환
서버 컷오버는 좀 무서웠어요 (데이터베이스 마이그레이션은 항상 그래요). 이 시점까지 Firestore가 여전히 진실의 원천이었고, CDC pub/sub 시스템은 synchronizer가 최신 상태로 유지하는 Postgres 미러에서 읽기만 했죠. 새 게임 서버를 배포하는 것이 비로소 Postgres를 쓰기에 대한 권위 있는 데이터베이스로 전환하는 순간이었어요.
진행한 방식은 다음과 같아요:
- 파트너부터 먼저 업데이트. Foony는 FRVR 같은 파트너들과 통합되어 있어서, 새 CDC gateway를 사용할 새 클라이언트 빌드를 며칠 일찍 배포했어요.
- 플레이어에게 미리 알림. 컷오버 약 10분 전에, (바라건대) 짧은 점검 시간이 있을 거라고 공지했어요.
- 새 데이터베이스 백업 확보. 대규모 데이터베이스 마이그레이션에서는 항상 좋은 습관이죠. LLM이 이걸 "벨트와 멜빵"이라고 부르는데, 기본적으로 이중으로 조심하고 백업 계획을 갖는다는 뜻이에요.
- 두 클러스터를 동시에 배포. 이번엔 블루/그린은 안 했어요. 모든 클러스터가 같은 데이터베이스에 쓰도록 해서 잠재적 불일치를 피하고 싶었거든요.
롤백 계획도 마련해뒀는데, 아주 간단했어요. 뭔가 어긋나면 이전 클라이언트(여전히 Firestore에서 읽는)를 재배포하고, 서버를 재배포하고, 백필을 다시 시작한 다음 나중에 재시도하는 거예요. 이러면 서버 재배포 동안 약 5분의 다운타임이 생기고, Postgres를 Firestore로 다시 따라잡는 데 반나절 정도 걸렸을 거예요.
실제 컷오버는 약 1분의 다운타임만 발생했어요. 표면화된 유일하게 심각한 버그는 경험치 업데이트에서였는데, Postgres의 LOWER()가 bigint 컬럼에 대해 암묵적으로 text로 캐스팅하더라고요. 하아. 이건 bigint로 다시 캐스팅하는 간단한 수정으로 해결됐고, 서버를 한 번 더 배포해서 마이그레이션을 계속 진행했어요.
모든 게 매끄럽게 진행되는 것 같아서 충격이었어요. 문제를 보고한 사람도 거의 없었고, 있어도 다 매우 사소한 것들이었죠 (경험치 관련 건 빼고요). 특히 충격적이었던 건, 이 마이그레이션의 대부분이 vibe-coded였음에도 얼마나 매끄럽게 진행됐는가 하는 점이에요. 뉴스에서 읽을 수 있는 무서운 이야기들과는 꽤 다르죠.
워크샵 재작성
이제 Postgres가 쓰기에 대해 권위를 갖게 되면서, 코드베이스에서 Firestore에 여전히 얽혀 있는 큰 덩어리는 워크샵 시스템 하나뿐이었어요. 플레이어들이 Dino-Might Bomber Online에서 커스텀 맵을 공유하거나, Draw & Guess에서 단어 리스트를 공유하는 등에 사용하는 시스템이에요. Steam 워크샵 같은 건데, 우리 게임용이라고 보면 돼요. 클라이언트와 서버 양쪽에서 Firestore와 가장 얽혀 있던 기능이었고, Firestore 데이터 모델의 한계 때문에 어색한 구조를 갖고 있어서 단순화가 필요했어요.
시작하면서 Cursor에 이런 프롬프트를 줬어요 (Opus 4.7 high를 plan 모드에서 사용):
Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.
We'll need to update schema.sql to support the new workshop tables.
Think of the workshop sort of like Steam's workshop.
In other words, the workshop must:
- Support multiple games.
- Support different data formats (e.g. maps in dinomight, word lists in paintjob)
- Support favoriting
- Support like / dislike
- Support a description
Use best practices to ensure that the SQL is efficient.
After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).
Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.
As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.
You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).
Cursor 환경에서 Opus 4.7의 역량을 본 만큼, 이 마이그레이션이 밤사이 끝날 거라고 기대했어요.
원래 프롬프트에서 제가 놓쳐서 에이전트가 명확화를 요청한 큰 부분은, "지난 하루", "지난 주", "지난 달", "전체 기간" 정렬을 어떻게 효율적으로 처리할지였어요. 에이전트는 타임스탬프 버킷 방식을 (또는 타임스탬프 정렬을 아예 빼자고) 밀고 있었지만, 공동 창업자는 복잡성이 늘더라도 시간 기반 정렬을 유지하자고 고집했어요.
좀 고민한 끝에, 감쇠 계수를 이용한 간단한 해결책을 떠올렸어요. 각 워크샵 아이템에는 played_count_day, played_count_week, played_count_month, played_count_all 컬럼이 있고, 시간마다 도는 cron 작업이 rolling 컬럼을 각각 23/24, 167/168, (720-1)/720만큼 곱해줘요. 각 정렬 축에 부분 인덱스(WHERE private = false AND played_count_day >= 0.368)를 결합하면, 추가 인프라 없이도 "기간별 가장 인기 있는 항목" 쿼리를 아주 싸게 돌릴 수 있어요. 읽기와 쓰기 비용이 어마어마한 Firestore에서는 절대 안 했을 일이지만, Postgres에서는 사실상 공짜예요.
그리고 잠자리에 들었어요. 일어나서, 신나서 작업물을 확인했죠! 에이전트는 새로운 workshop_items, workshop_item_votes, user_subscriptions 테이블을 추가했고, 클라이언트가 CDC gateway를 통해 개별 아이템을 읽도록 연결했으며 (realtime.use('workshopItems/{id}')), 백엔드의 워크샵 액션 6개를 모두 Postgres와 직접 통신하도록 재작성했어요. 정리해야 할 마무리 작업이 몇 개 있었지만 (서버의 Firestore 쿼리 하나, Firestore에 데이터가 빠져 있어서 발생한 백필 오류 등), 전반적으로 코드는 거의 완벽했어요.
백필을 실행한 뒤, 로컬에서 모든 게 잘 작동하는지 테스트하고 변경 사항을 라이브에 배포했어요. 이로써 코드베이스는 마침내 Firestore 프리가 되었답니다. 아름답죠.
향후 작업
앞으로는 gateway에서 패치 지원도 추가하고 싶어요. 지금은 gateway가 모든 업데이트에 대해 전체 문서를 JSON으로 돌려줘요. 좀 낭비이긴 하지만, Hetzner 덕분에 egress가 사실상 무제한이에요. 지금 당장 구현할 수도 있겠지만, 아직은 추가 복잡성을 정당화할 수 없네요.