본문 바로가기
Database2026년 9월 8일20분 읽기

CDC와 Debezium — DB 변경을 실시간 파이프라인으로 흘려보내기

YS
김영삼
조회 9
CDC와 Debezium — DB 변경을 실시간 파이프라인으로 흘려보내기

CDC는 애플리케이션 코드를 건드리지 않고 데이터베이스의 트랜잭션 로그(PostgreSQL의 WAL)를 읽어 INSERT/UPDATE/DELETE를 순서 그대로 이벤트 스트림으로 뽑아내는 기법이다. Debezium은 이걸 Kafka Connect 위에서 표준화해 주는 사실상의 오픈소스 표준이다.

캐시 무효화가 자꾸 어긋나고, 검색 인덱스와 DB가 미묘하게 안 맞고, 배치 폴링이 DB를 갉아먹는다면 답은 대개 CDC다. 이 글은 폴링과 로그 기반의 차이부터 WAL·logical decoding 원리, Debezium 설정, 아웃박스·스키마 변경 처리까지 실무에서 데인 것 위주로 정리한다.

몇 년 전에 이런 코드를 유지보수한 적이 있다. 상품 테이블이 바뀌면 updated_at > :last 조건으로 30초마다 폴링해서 Elasticsearch에 밀어넣는 배치. 처음엔 잘 돌았다. 문제는 삭제였다. DELETE된 행은 updated_at으로 잡히지 않으니 검색 결과에 유령 상품이 계속 떠 있었다. 게다가 트래픽이 늘자 30초 폴링이 DB의 IO를 야금야금 먹어 야간 배치와 충돌했다. 그때 CDC로 갈아탄 뒤로는 이 부류의 버그가 통째로 사라졌다. 그 경험을 이 글에 눌러 담았다.

핵심 수치메모
logical decoding 도입PostgreSQL 9.4+pgoutput은 10+ 내장
전달 보장at-least-once멱등 컨슈머 필수
전형적 종단 지연수백 ms ~ 수 초구성·부하 의존
기본 토픽 매핑테이블 1 : 토픽 1아웃박스로 도메인 단위 축약
필수 DB 설정(PG)wal_level=logical재시작 + REPLICATION 권한
항목폴링 기반로그 기반 CDC(Debezium)
지연(latency)폴링 주기(초~분)보통 100ms~수 초
DELETE 캡처불가(소프트 삭제 필요)가능(로그에 남음)
DB 부하반복 쿼리로 상시 부하로그만 읽어 낮음
순서 보장약함(같은 초 내 뒤섞임)커밋 순서 그대로
중간 변경 손실주기 사이 값은 최종값만모든 변경 캡처
앱 코드 수정쿼리/스케줄러 필요거의 없음(DB 밖에서)

CDC가 대체 뭐고, 폴링과 뭐가 다른가

CDC(Change Data Capture)는 데이터베이스에서 무엇이 언제 어떻게 바뀌었는지를 추출해 다른 시스템으로 전달하는 것이다. 방식은 크게 셋으로 나뉘는데, 실무 선택지는 사실상 두 개다.

  • 쿼리 기반(폴링): updated_at이나 버전 컬럼을 주기적으로 스캔. 구현이 쉽지만 삭제를 못 잡고, 폴링 주기 사이의 중간 변경이 뭉개진다.
  • 트리거 기반: 변경 시 트리거가 별도 감사 테이블에 기록. 삭제는 잡지만 쓰기마다 오버헤드가 붙고 트리거 관리가 지옥이 된다.
  • 로그 기반: DB가 복제와 복구를 위해 이미 남기는 트랜잭션 로그를 읽는다. 앱도 원본 테이블도 건드리지 않고, 커밋 순서 그대로 모든 변경을 얻는다. Debezium이 여기 속한다.

핵심 차이는 이거다. 폴링은 "지금 상태"를 반복해서 물어보는 것이고, 로그 기반은 "바뀐 사건"을 순서대로 받아 보는 것이다. 상태 스냅샷과 이벤트 스트림의 차이. 개인적으로는 어지간하면 로그 기반으로 시작하라고 말한다. 폴링으로 시작했다가 삭제 처리와 순서 문제로 결국 CDC로 옮겨오는 팀을 여럿 봤다.

로그 기반 CDC의 원리 — WAL과 logical decoding

로그 기반 CDC가 가능한 이유는 관계형 DB가 내구성(durability)과 복제를 위해 이미 변경 로그를 쓰고 있기 때문이다. PostgreSQL은 WAL(Write-Ahead Log), MySQL은 binlog, SQL Server는 트랜잭션 로그, Oracle은 redo log가 그 역할을 한다. CDC 도구는 이 로그를 복제 클라이언트인 척 구독한다.

PostgreSQL을 예로 보면 흐름은 이렇다. 원래 WAL은 바이너리라 사람이 읽으라고 만든 게 아니다. 그래서 PostgreSQL 9.4부터 logical decoding이 도입됐다. 물리적 WAL을 pgoutput 같은 플러그인이 논리적 변경(어느 테이블의 어떤 행이 어떻게)으로 디코딩해 준다. Debezium은 이 논리 복제 스트림을 replication slot을 통해 읽는다.

# postgresql.conf — logical decoding 활성화 (재시작 필요)
wal_level = logical            # 기본값은 replica. 이걸 바꿔야 한다
max_wal_senders = 10           # 복제 연결 수
max_replication_slots = 10     # Debezium이 슬롯을 하나 잡는다

# Debezium이 붙을 유저는 REPLICATION 권한 필요
CREATE ROLE debezium WITH REPLICATION LOGIN PASSWORD '...';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO debezium;
주의 replication slot은 Debezium이 어디까지 읽었는지를 DB가 기억하게 만든다. 이게 장점이자 함정이다. Debezium 컨슈머가 죽어서 며칠 방치되면, DB는 "아직 저 슬롯이 안 읽은 WAL"을 지우지 못하고 계속 쌓는다. 디스크가 꽉 차서 DB가 멈추는 사고가 실제로 흔하다. 안 쓰는 슬롯은 반드시 pg_drop_replication_slot()으로 지워라.

Debezium과 Kafka Connect는 어떻게 맞물리나

Debezium은 단독 데몬이 아니라 Kafka Connect의 소스 커넥터 형태로 도는 게 표준 구성이다. Kafka Connect가 커넥터의 생명주기·오프셋·재시작·스케일아웃을 관리하고, Debezium 커넥터는 그 위에서 DB 로그를 읽어 Kafka 토픽으로 변경 이벤트를 흘려보낸다. 테이블 하나당 토픽 하나가 기본 매핑이다.

동작 순서는 대략 이렇다. (1) 커넥터가 처음 뜨면 초기 스냅샷으로 대상 테이블 전체를 읽어 기준선을 만든다. (2) 스냅샷이 끝난 지점의 WAL 위치부터 스트리밍으로 전환해 이후 변경을 실시간으로 읽는다. (3) 각 변경을 before/after/op(c=생성, u=수정, d=삭제, r=스냅샷 read)/source 메타가 담긴 JSON 이벤트로 만들어 토픽에 넣는다.

// Kafka Connect REST API로 PostgreSQL 커넥터 등록
// POST http://connect:8083/connectors
{
  "name": "inventory-connector",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "postgres",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${file:/secrets:pg_pw}",
    "database.dbname": "shop",
    "topic.prefix": "shop",              // 토픽명: shop.public.products
    "plugin.name": "pgoutput",           // PG 10+ 기본 내장 플러그인
    "slot.name": "debezium_shop",
    "publication.autocreate.mode": "filtered",
    "table.include.list": "public.products,public.orders",
    "snapshot.mode": "initial",          // 최초 1회 전체 스냅샷 후 스트리밍
    "tombstones.on.delete": "true"       // 삭제 시 null 값 tombstone 발행
  }
}

이벤트 하나는 대충 이렇게 생겼다. UPDATE라면 before에 옛 값, after에 새 값이 다 들어온다. 이 before/after가 있어서 "가격이 얼마에서 얼마로 바뀌었나" 같은 걸 컨슈머가 그대로 알 수 있다.

{
  "op": "u",
  "before": { "id": 42, "price": 12000, "stock": 5 },
  "after":  { "id": 42, "price": 9900,  "stock": 5 },
  "source": { "lsn": 34095216, "txId": 9987, "ts_ms": 1694600000000 },
  "ts_ms": 1694600000123
}

실전 1 — 캐시 무효화를 CDC로 정확하게

캐시 무효화가 자꾸 틀리는 근본 원인은 무효화 코드가 데이터 변경과 다른 경로에 있기 때문이다. 앱에서 UPDATEredis.del(key)를 부르는 방식은, 다른 서비스가 DB를 직접 고치거나 배치가 벌크 업데이트를 하면 그냥 새어 나간다. CDC는 "DB가 실제로 바뀐 순간"을 단일 진실의 원천으로 삼기 때문에 이 누수를 막는다.

구조는 간단하다. Debezium이 products 변경을 토픽에 흘리면, 작은 컨슈머가 그 이벤트의 PK로 캐시 키를 지운다. 앱 코드에는 무효화 로직이 아예 없어도 된다.

// 캐시 무효화 컨슈머 (의사코드 수준)
for (record of consumer.poll(topic="shop.public.products")) {
  const ev = JSON.parse(record.value);
  if (ev == null) continue;                 // tombstone 스킵
  const id = ev.after?.id ?? ev.before?.id;  // 삭제면 before에서
  await redis.del(`product:${id}`);
  // 목록 캐시처럼 파생 키도 함께 무효화
  await redis.del(`product:list:cat:${ev.after?.category_id}`);
}
참고 CDC 무효화는 캐시 갱신에 약간의 지연(수백 ms~초)이 붙는다. 그래서 재고 차감처럼 강한 읽기-쓰기 일관성이 필요한 경로에는 부적합하고, 상품 상세·목록처럼 살짝 늦어도 되는 곳에 잘 맞는다. 나는 이 둘을 섞어 쓴다. 결제/재고는 동기 무효화, 콘텐츠성 데이터는 CDC.

실전 2 — 검색 인덱스 동기화

DB와 Elasticsearch를 맞추는 문제는 CDC의 교과서적 활용처다. 앞서 말한 유령 상품 버그가 바로 이 케이스였다. 로그 기반 CDC는 DELETE를 이벤트로 주므로, 삭제된 문서를 인덱스에서도 지울 수 있다.

가장 깔끔한 건 Kafka Connect에 Elasticsearch 싱크 커넥터를 함께 붙이는 것이다. Debezium 소스가 토픽에 넣고, ES 싱크가 그 토픽을 읽어 색인한다. 코드를 거의 안 짜도 된다. 다만 Debezium 기본 이벤트는 before/after로 감싸여 있어서, ES에 넣기 전에 ExtractNewRecordState라는 SMT(Single Message Transform)로 after만 평탄화해야 한다. 이거 모르면 문서가 통째로 중첩돼 들어가서 한참 헤맨다.

// ES 싱크 커넥터 config 일부
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState",
"transforms.unwrap.drop.tombstones": "false",
"transforms.unwrap.delete.handling.mode": "rewrite",  // 삭제를 __deleted:true로
"behavior.on.null.values": "delete"                     // tombstone이면 문서 삭제

이렇게 하면 DB에서 상품이 지워지는 즉시 Debezium이 tombstone을 내고, ES 싱크가 그 문서를 인덱스에서 삭제한다. 폴링 배치로는 절대 안 되던 게 공짜로 된다.

실전 3 — 아웃박스 패턴, CDC의 진짜 킬러 유즈케이스

마이크로서비스에서 가장 흔한 실수가 "DB에 저장하고, 그다음 Kafka에 이벤트 발행"을 따로 하는 것이다. 이 둘은 한 트랜잭션이 아니라서, DB 커밋은 성공하고 Kafka 발행은 실패하면 이벤트가 유실된다. 반대면 없는 주문 이벤트가 나간다. 이게 그 악명 높은 dual-write 문제다.

아웃박스 패턴은 이걸 CDC로 우아하게 푼다. 이벤트를 별도 브로커에 쏘는 대신, 같은 DB 트랜잭션 안에서 outbox 테이블에 한 줄 INSERT한다. 비즈니스 데이터와 이벤트가 하나의 커밋으로 묶이니 원자적이다. 그다음 Debezium이 그 outbox 테이블을 CDC로 읽어 Kafka에 발행한다. DB 트랜잭션이 진실의 원천이 되는 것.

-- 주문 생성과 이벤트 기록을 한 트랜잭션으로
BEGIN;
  INSERT INTO orders(id, user_id, total) VALUES (1001, 7, 39000);
  INSERT INTO outbox(id, aggregate_type, aggregate_id, event_type, payload)
  VALUES (gen_random_uuid(), 'order', '1001', 'OrderCreated',
          '{"orderId":1001,"userId":7,"total":39000}');
COMMIT;
-- 커밋 순간 두 INSERT가 함께 WAL에 남는다 → Debezium이 outbox를 읽어 발행

Debezium은 이 패턴을 위한 전용 SMT(EventRouter)까지 제공한다. aggregate_type을 토픽으로, aggregate_id를 Kafka 파티션 키로 라우팅해 준다. 같은 주문의 이벤트가 같은 파티션으로 가서 순서가 보장된다. 나는 이벤트 기반 아키텍처에서 dual-write를 볼 때마다 아웃박스로 바꾸자고 제안한다. 이건 취향이 아니라 정합성 문제라서.

스키마가 바뀌면 어떻게 되나 (DDL 처리)

운영하다 보면 결국 컬럼을 추가하거나 타입을 바꾼다. 로그 기반 CDC의 좋은 점은 스키마 변경도 로그에 남는다는 것이다. Debezium은 테이블 구조 이력을 추적하다가, 어떤 시점의 변경 이벤트를 그 시점의 스키마로 해석한다. 그래서 컬럼 추가 정도는 대부분 매끄럽게 넘어간다.

문제는 컨슈머 쪽이다. Debezium은 잘 넘겨도, 그 이벤트를 받는 소비자가 새 필드에 놀라 깨질 수 있다. 그래서 실무에서는 Schema Registry(Avro/Protobuf)와 호환성 규칙을 함께 쓴다.

  • 안전한 변경: 기본값 있는 nullable 컬럼 추가 → 하위 호환. 컨슈머는 몰라도 안 깨진다.
  • 위험한 변경: 컬럼 삭제, 이름 변경, 타입 축소(예: bigint→int) → 컨슈머가 깨질 수 있어 순서를 지켜 배포해야 한다.
  • PG 특유의 함정: 컬럼을 DROP하지 말고 우선 안 쓰게 두는 expand-and-contract 방식이 안전하다. 또 REPLICA IDENTITYFULL로 안 두면 UPDATE/DELETE 이벤트의 before에 PK만 담기고 나머지가 비니, before 값이 필요하면 ALTER TABLE ... REPLICA IDENTITY FULL을 걸어야 한다.
참고 스키마 호환성은 Schema Registry에서 BACKWARD(기본), FORWARD, FULL 등으로 강제할 수 있다. 처음엔 BACKWARD로 두면 대부분의 안전한 변경만 통과되고 위험한 변경은 등록 단계에서 막힌다. 배포 사고를 코드가 아니라 레지스트리가 먼저 잡아 주는 셈이다.

운영에서 내가 데인 것들

문서엔 잘 안 나오는데 실제로 아팠던 것들만 추린다.

  • 초기 스냅샷이 대형 테이블을 잠글 수 있다. 수억 행 테이블에 snapshot.mode=initial을 그냥 걸면 오래 걸리고 락 이슈가 난다. Debezium의 incremental snapshot(신호 테이블로 청크 단위 스냅샷)을 쓰면 스트리밍과 병행하며 나눠 읽을 수 있다.
  • replication slot 방치 = 디스크 폭발. 앞서 말한 그 사고. 모니터링으로 pg_replication_slotsconfirmed_flush_lsn 지연을 반드시 알람 걸어라.
  • at-least-once가 기본이다. 장애 복구 시 같은 이벤트가 두 번 올 수 있다. 컨슈머는 멱등(idempotent)하게 짜야 한다. PK 기준 upsert, 삭제는 존재 여부 무관하게 delete.
  • Kafka Connect 오프셋 토픽을 잃으면 재스냅샷이 돈다. connect-offsets 토픽은 replication factor를 넉넉히 두고 함부로 지우지 마라.

자주 묻는 질문

CDC를 쓰려면 애플리케이션 코드를 바꿔야 하나요?

로그 기반 CDC는 원칙적으로 앱 코드를 바꾸지 않는다. DB 밖에서 트랜잭션 로그를 읽기 때문이다. 다만 DB 설정(PostgreSQL의 wal_level=logical, 복제 권한)은 필요하고, 아웃박스 패턴처럼 이벤트 정합성까지 원하면 outbox 테이블에 INSERT하는 코드를 추가한다.

Debezium 없이 CDC를 구현할 수 있나요?

가능하다. 폴링(쿼리 기반)이나 트리거 기반으로도 CDC를 만들 수 있고, 클라우드 관리형(AWS DMS, GCP Datastream 등)도 있다. 하지만 로그 기반의 순서·삭제·저부하 이점을 오픈소스로 얻으려면 Debezium이 사실상 표준이다. Kafka가 부담되면 Debezium Server 모드로 Kinesis/Pulsar/Redis Stream 등에 직접 흘릴 수도 있다.

CDC 이벤트는 정확히 한 번 전달되나요?

기본 보장은 at-least-once, 즉 최소 한 번이다. 장애 복구 시 중복이 생길 수 있으므로 컨슈머를 멱등하게 설계하는 게 정석이다. exactly-once는 Kafka 트랜잭션 등으로 근사할 수 있지만 복잡도가 크게 올라가고, 대부분은 멱등 컨슈머로 충분하다.

초기 스냅샷 때문에 서비스가 멈추지 않나요?

대형 테이블에서 기본 스냅샷은 부하를 줄 수 있다. Debezium의 incremental snapshot을 쓰면 스트리밍을 계속하면서 테이블을 청크 단위로 나눠 읽어 락과 지연을 크게 줄인다. 이미 다른 곳에 데이터가 있다면 snapshot.mode=never로 스냅샷을 건너뛰고 스트리밍만 할 수도 있다.

MySQL에서도 똑같이 되나요?

된다. MySQL은 WAL 대신 binlog를 쓰고, binlog_format=ROWbinlog_row_image=FULL 설정이 필요하다. 커넥터 클래스만 MySqlConnector로 바뀔 뿐 이벤트 구조(before/after/op)와 아웃박스·SMT 활용은 동일하다. PostgreSQL의 replication slot에 대응하는 개념은 서버 ID 기반 binlog 위치 추적이다.

테이블마다 토픽이 하나씩 생기면 토픽이 너무 많아지지 않나요?

기본 매핑이 테이블당 토픽이라 그렇게 보일 수 있다. 하지만 아웃박스 패턴을 쓰면 outbox 테이블 하나만 CDC로 읽고 EventRouter SMT로 aggregate_type에 따라 몇 개의 도메인 토픽으로 정리하는 게 일반적이다. 그러면 내부 테이블 구조가 아니라 도메인 이벤트 단위로 토픽이 깔끔하게 유지된다.

캐시 무효화에 CDC를 쓰면 지연은 얼마나 되나요?

구성에 따라 다르지만 커밋부터 컨슈머 처리까지 보통 수백 ms에서 몇 초 수준이다. 상품 상세·목록처럼 약간의 지연이 허용되는 콘텐츠성 데이터에 적합하다. 재고 차감이나 결제처럼 즉시 일관성이 필요한 경로는 동기 무효화를 쓰고, 그 외에 CDC를 얹는 하이브리드가 현실적이다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록