MongoDB CDC

2편 · Full Dump의 한계와 CDC가 필요한 순간

  • Haram Lee
  • 2026-07-04
  • work / Daangn / data-team-notes

원문: 매번 다 퍼올 필요 없잖아? 당근의 MongoDB CDC 구축기 (2025-12-08)

MongoDB 전체 덤프 방식이 DB 부하와 2시간 SLO 사이에서 한계에 부딪히자, Flink CDC로 변경분만 캡처해 BigQuery에 반영하는 구조로 바꾼 이야기.

이 글이 당근 데이터 시스템에서 차지하는 위치

1편 DT Platform이 전송의 전체 지도였다면, 이 글은 특정 Source(MongoDB)를 어떻게 안정적으로 가져오는가라는 더 구체적인 문제를 다룬다.

text
MongoDB → Change Stream → Flink CDC → Raw Table → Final Table → BigQuery

즉 “서비스 DB에서 데이터를 어떻게 가져오는가” 중에서도, Full Dump의 한계를 CDC로 해결한 사례다.

당근이 마주한 문제 — Full Dump의 trade-off

당근은 MongoDB 데이터를 기존에 Spark Connector로 덤프하고 있었다. 데이터가 작을 땐 단순하고 안정적이지만, 서비스가 커지며 데이터도 커졌다. 그러자 두 요구사항을 동시에 만족하기 어려워졌다.

text
2시간 내 전달 SLO 를 맞추려면 → MongoDB를 세게 읽음 → DB CPU 부하 ↑
DB 부하를 낮추려면          → 천천히 읽음        → 2시간 SLO 못 맞춤

전체 데이터를 매번 퍼오는 방식으로는 한계가 온 것이다.

해결 구조 — 변경분만 가져오기(CDC)

**CDC(Change Data Capture)**는 DB에서 바뀐 데이터만 캡처하는 방식이다. 최초 1회 전체 스냅샷을 뜬 뒤, 이후에는 insert/update/delete 변경 이벤트만 읽어 최종 테이블에 병합한다. MongoDB는 변경 로그인 Oplog와 이를 구독하는 Change Stream으로 변경 이벤트를 읽을 수 있다.

당근은 여러 후보 중 Flink CDC를 택했다.

  • MongoDB Change Stream 네이티브 지원 (resume token ↔ Flink checkpoint 연결로 장애 후 마지막 지점부터 재개)
  • 강력한 checkpoint / 상태 관리 → exactly-once에 가까운 안정성
  • CDC → Transform → Sink를 하나의 Flink Job에서 처리 (Debezium 등은 이후 변환/적재 시스템이 별도로 필요)
  • TaskManager를 늘려 병렬 확장 용이

전체 아키텍처는 이렇다.

text
MongoDB 변경 발생 → Oplog 기록 → Change Stream 이벤트 생성
  → Flink CDC 구독 → 변환/가공 → BigQuery 적재

다만 최종 서빙은 완전 실시간이 아니라 hourly batch로 처리했다. 목표가 “2시간 내 전달"이었으니, 운영 복잡도가 높은 완전 실시간까지 갈 필요가 없었기 때문이다. (SLO를 만족하는 선에서 단순하게 가는 판단이 인상적인 지점.) hourly batch는 네 단계다.

text
1. Schema Evolution     (스키마 반영)
2. Extract CUD Latest   (최근 변경분 추출)
3. Merge to Raw         (raw 테이블 병합)
4. Materialize to Final (최종 테이블 구체화)

구축하며 마주한 고민들

① 트랜잭션 순서 보장INSERT → UPDATE → DELETE면 최종 테이블에 row가 없어야 하고, INSERT → UPDATE → UPDATE면 마지막 상태가 반영돼야 한다. Change Stream 이벤트에는 timestamp가 있어 순서 확인이 가능했고, fullDocument: updateLookup 옵션으로 update 이벤트에서도 전체 문서 최종 상태를 받아, primary key별 마지막 이벤트의 fullDocument만 처리하면 되는 구조를 만들었다.

② Initial Full Dump — CDC는 시작 이후 변경분만 잡으므로 기존 데이터는 한 번 전체로 가져와야 한다. Flink CDC의 Initial Snapshot도 있지만 Oplog 보존 기간 안에 끝내기 어렵고 부하가 컸다. 그래서 초기 전체 덤프는 기존 Spark Job, 이후 변경분은 Flink CDC로 역할을 나눴다.

③ Schema Evolution — MongoDB는 스키마가 자유롭지만 BigQuery는 명확한 스키마가 필요하다. 당근은 테이블을 2단계로 나눴다.

text
Stage 1: Raw 테이블   — MongoDB 문서를 JSON 원본 그대로 저장
Stage 2: Final 테이블 — 스키마 저장소 기반으로 JSON에서 필드 추출

이 구조 덕에 필드가 추가돼도 Full Dump를 다시 안 하고 Raw JSON을 다시 materialize하면 된다. 스키마 반영 시간이 2~3시간 → 20분 이내로 줄었다.

④ 정합성 체크 — 기존 Full Dump와 새 CDC 파이프라인을 **동시에 운영(dual write)**하며 레코드 수, 데이터 신선도, 중복 ID, checksum 기반 전체 필드 일치 여부를 비교했다. 전환 전 2주간 문제없음을 확인한 뒤 마이그레이션을 판단했다.

주요 개념 정리

Full Dump — 매번 전체 데이터를 읽는 방식. 단순하지만 데이터가 커질수록 DB 부하·처리 시간이 커지고 서비스 트랜잭션에도 영향.

Incremental Loadcreated_at/updated_at 같은 컬럼으로 “마지막 이후 변경분"만 가져오는 방식. 단, updated_at이 항상 갱신된다는 보장이 없고 delete를 잡기 어려운 한계.

CDC (Change Data Capture) — DB 변경 로그를 읽어 insert/update/delete를 캡처. 스키마 의존성이 낮고 delete까지 안정적으로 잡을 수 있어 대용량에서 유리.

Oplog / Change Stream — Oplog는 MongoDB의 write 연산 기록 로그, Change Stream은 그 Oplog를 애플리케이션이 구독하게 해주는 고수준 API.

Checkpoint — 스트리밍 시스템이 처리 상태를 주기적으로 저장하는 기능. 장애 시 마지막 checkpoint부터 재개. CDC에선 “어디까지 처리했나"가 핵심이라 안정성의 근간.

Schema Evolution — 원천 스키마 변화를 분석 테이블에 안전하게 반영하는 문제. Raw(JSON 원본) / Final(스키마 적용) 분리로 해결.

Dual Write / 정합성 검증 — 기존·신규 파이프라인을 함께 돌리며 결과를 비교(레코드 수, 신선도, 중복 ID, checksum). 전환의 안전장치.

운영 모니터링

Flink Job 상태 · Throughput · MongoDB 읽기 부하 · BigQuery 적재 성공률 · Backpressure · Checkpoint 안정성을 관측한다. 장애 대응도 설계했다: Flink Job 실패 → Kubernetes Operator가 checkpoint부터 재시작 / MongoDB 연결 끊김 → exponential backoff 재연결 / BigQuery 적재 실패 → Dead Letter Queue 저장 후 알림·수동 재처리.

온보딩 관점 정리

이 글은 데이터 가치화팀이 운영 DB 부하와 데이터 신선도 사이의 균형을 어떻게 잡는지 보여준다. 빨리 가져오는 게 목표가 아니라, 서비스 DB에 부담을 주지 않으면서 신뢰할 수 있는 데이터를 전달하는 게 핵심이다. “언제 Full Dump가 한계가 되고 CDC가 필요한가"를 판단하는 감각을 잡기 좋은 사례다.

더 공부할 것

아래는 원문 밖에서 직접 찾아본 답변. 온보딩용 감을 잡는 수준이고, 실제 구현은 팀 코드로 확인 필요.

MongoDB Oplog와 Change Stream은 정확히 어떤 차이가 있을까? Oplog(local.oplog.rs)는 replica set의 모든 write를 idempotent 형태로 기록하는 내부 capped collection으로, 원래 목적은 secondary 복제다. 직접 tailing할 수 있지만 포맷이 내부 구현이라 버전에 따라 깨지기 쉽다. Change Stream(3.6+)은 그 Oplog 위에 얹은 공식 고수준 API로, aggregation 파이프라인 필터링, resume token 제공, updateLookup으로 전체 문서 조회, sharded cluster의 글로벌 순서 보장 등을 제공한다. 요약하면 Change Stream = Oplog의 안전한 추상화이고, 직접 oplog 파싱은 취약하다.

Flink checkpoint와 MongoDB resume token은 어떻게 연결될까? Change Stream의 각 이벤트에는 resume token(_id)이 붙는데, 이게 “여기까지 읽었다"는 북마크다. Flink는 checkpoint 시 소스 오퍼레이터의 state로 마지막 resume token을 스냅샷 저장한다. 장애가 나면 Flink는 마지막 성공 checkpoint로 롤백하고 저장된 token으로 resumeAfter/startAfter 재구독 → 이벤트 유실·중복 없이 정확히 그 지점부터 재개한다. 단 장애가 너무 길어 resume token이 oplog 보존 기간을 벗어나면 재개 불가 → 재스냅샷이 필요하다.

CDC에서 exactly-once를 보장한다는 것은 어떤 의미일까? 물리적으로 이벤트가 한 번만 전달된다는 뜻이 아니라, 재처리가 있어도 최종 결과가 “정확히 한 번 반영된 상태"와 같다는 의미다(effectively-once). 달성 방법은 checkpoint된 소스 오프셋(resume token) + 멱등적 sink(primary key upsert/merge)의 조합이다. CDC에선 이벤트에 PK가 있고 upsert로 적재하면 같은 이벤트가 두 번 와도 최종 상태가 같아진다 — 당근이 PK별 마지막 fullDocument만 반영하는 게 바로 이 idempotency 확보다.

Raw Table과 Final Table을 나누는 구조의 장단점은? 장점은 스키마 변경 시 재덤프 없이 Raw JSON을 다시 materialize하면 되고, 원본이 보존돼 감사·디버깅·재처리에 유리하며, Final은 소비자 친화적 정형 스키마를 유지할 수 있다는 점이다. 단점은 저장 비용이 사실상 2배(원본+정형), 파이프라인 단계가 늘어 운영 복잡도가 커지고, Raw JSON 직접 쿼리는 무겁다는 점이다. 스키마가 자주 변하는 document DB에선 재덤프 회피 이득이 저장 비용을 압도하기 때문에 이 트레이드오프가 성립한다.

CDC 정합성 검증에서 checksum은 어떻게 계산할까? row 단위로 모든 필드를 정규화(key 정렬, 타입·null·타임스탬프 정밀도 표준화)한 뒤 hash해서 row fingerprint를 만들고, PK 기준으로 두 파이프라인 결과를 join해 mismatch를 센다. BigQuery라면 TO_JSON_STRING + FARM_FINGERPRINT 조합이 흔하다. 핵심은 정규화 — 부동소수·JSON key 순서·null 표현이 두 파이프라인에서 다르면 실제로 같은 데이터인데도 오탐이 난다. 레코드 수·중복 ID 체크는 그 전 단계의 값싼 1차 필터로 쓴다.

Discussion