CDC와 Debezium으로 DB 변경 이벤트 수집하기

· 약 13분 읽기 · 조회 1 #CDC#Debezium#Kafka#PostgreSQL#MySQL#데이터 파이프라인

검색 색인, 분석 DB, 캐시가 원본 DB를 따라가려면 무엇이 바뀌었는지 빠짐없이 알아야 한다. 코드에 이벤트 발행을 끼워 넣으면 빠뜨리는 경로가 생기고, 테이블을 주기적으로 다시 읽으면 삭제를 놓친다. 이 글은 PostgreSQL WAL·MySQL binlog를 읽어 Kafka로 보내는 로그 기반 CDC를 Debezium으로 구성하는 순서와 운영에서 터지는 지점(슬롯 WAL 적체, 스키마 변경, 중복 이벤트)을 정리한다.

우체국 직원이 장부에 적는 줄을 황동 기계 팔이 카드로 옮겨 우편함 칸으로 보내고 집배원들이 카드를 꺼내 가는 장면

한눈에 보기

단계 하는 일 확인할 것
원본 DB 준비 변경 로그를 논리 형식으로 남기고, 읽을 계정·슬롯을 만든다 wal_level=logical, binlog_format=ROW
커넥터 등록 Kafka Connect에 Debezium 커넥터를 JSON으로 등록한다 대상 테이블 목록, 토픽 이름 규칙
초기 스냅샷 기존 행을 한 번 읽어 op=r 이벤트로 낸다 스냅샷 시간 동안 쌓이는 로그
스트리밍 변경 로그를 따라가며 c/u/d 이벤트를 낸다 슬롯 지연, 커넥터 지연 지표
소비 검색·분석·캐시가 기본 키 기준으로 반영한다 중복·순서·삭제 처리

PostgreSQL 테이블 변경이 WAL과 복제 슬롯을 거쳐 Debezium 커넥터로 읽히고 Kafka 토픽을 지나 검색 색인, 분석 적재, 캐시 무효화로 퍼지는 구성

폴링·트리거·로그 기반 CDC

변경을 잡는 방법은 크게 세 가지다.

방식 원리 놓치는 것 원본 DB 부담
폴링 updated_at > 마지막 시각으로 주기 조회 삭제, 한 주기 안의 중간 상태, 시계가 어긋난 행 조회마다 스캔
트리거 변경 시 감사 테이블에 행을 추가 트리거를 끈 일괄 작업 모든 쓰기 트랜잭션이 길어짐
로그 기반 커밋된 변경 로그를 읽음 거의 없음(로그 보존 기간 안이라면) 로그 보존과 디코딩 비용

로그 기반 CDC는 커밋 순서대로, 삭제까지 받고 애플리케이션 코드를 고치지 않아도 된다. 대신 읽는 쪽이 멈추면 원본 DB 디스크가 찬다.

테이블 변경을 그대로 내보내면 내부 스키마가 외부 계약이 된다. 다른 서비스에 업무 사건을 보낼 때는 Outbox 테이블을 CDC로 읽고 Debezium Outbox Event Router로 바꾸는 쪽이 낫다(Outbox·Inbox 패턴으로 연계 데이터 정합성 확보하기). 이 글은 검색·분석처럼 행 단위 복제가 목적인 경우를 다룬다.

PostgreSQL 준비

PostgreSQL은 WAL을 논리 디코딩해 변경을 꺼낸다. PostgreSQL 10부터 내장된 pgoutput 플러그인을 쓰면 확장 설치가 필요 없다.

-- postgresql.conf (재시작 필요)
-- wal_level = logical
-- max_replication_slots = 10
-- max_wal_senders = 10

CREATE ROLE debezium WITH LOGIN REPLICATION PASSWORD '...';
GRANT SELECT ON public.orders, public.order_items TO debezium;

CREATE PUBLICATION dbz_orders FOR TABLE public.orders, public.order_items;
  • 복제 슬롯은 소비자가 어디까지 읽었는지 DB가 기억하는 장치다. 그 이후 WAL은 지우지 않으므로 커넥터가 멈춰도 변경을 잃지 않지만, 오래 멈추면 WAL이 쌓인다.
  • publication은 내보낼 테이블 목록이다. DBA가 직접 만들고 커넥터의 자동 생성을 끄면 권한을 좁게 유지할 수 있다.
  • REPLICA IDENTITY: 기본값이면 수정·삭제의 before에 기본 키 정도만 담긴다. 이전 값이 필요한 테이블만 REPLICA IDENTITY FULL로 바꾼다. WAL이 늘기 때문이다.
  • 페일오버: PostgreSQL 16 이하에서는 논리 슬롯이 스탠바이로 복제되지 않는다. 17부터 슬롯 동기화가 들어왔으니 버전별 공식 문서를 확인한다.

MySQL 준비

MySQL은 binlog를 읽는다. 행 단위 변경이 남아야 하므로 형식이 ROW여야 한다.

# my.cnf
server-id                  = 101
log_bin                    = mysql-bin
binlog_format              = ROW
binlog_row_image           = FULL
gtid_mode                  = ON
enforce_gtid_consistency   = ON
binlog_expire_logs_seconds = 604800   # 7일
  • 커넥터도 복제 클라이언트로 붙으므로 DB와 겹치지 않는 server id를 준다.
  • binlog 보존 기간이 커넥터가 멈춰 있어도 되는 최대 시간이다. 넘기면 이어 읽을 위치가 사라져 스냅샷부터 다시 한다.
  • Debezium은 binlog의 DDL을 스키마 이력 토픽에 기록해 재시작 때 테이블 구조를 복원한다. 이 토픽을 지우면 커넥터가 이전 이벤트를 해석하지 못한다.

Debezium 커넥터 등록과 이벤트 구조

Kafka Connect REST API(POST /connectors)에 설정을 올리면 커넥터가 시작된다. 속성 이름은 Debezium 2.x 기준이고(1.x는 topic.prefix 대신 database.server.name). 비밀번호의 ${file:…} 참조는 Connect 워커에 config.providers=file이 있어야 풀린다.

{
  "name": "orders-pg",
  "config": {
    "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
    "database.hostname": "pg-primary",
    "database.port": "5432",
    "database.user": "debezium",
    "database.password": "${file:/secrets/pg.properties:password}",
    "database.dbname": "shop",
    "topic.prefix": "shop",
    "plugin.name": "pgoutput",
    "slot.name": "debezium_orders",
    "publication.name": "dbz_orders",
    "publication.autocreate.mode": "disabled",
    "table.include.list": "public.orders,public.order_items",
    "snapshot.mode": "initial",
    "heartbeat.interval.ms": "10000",
    "tombstones.on.delete": "true"
  }
}

${file:...}은 워커에 FileConfigProvider를 설정해야 동작한다. 평문 비밀번호는 REST 조회에 그대로 보인다.

토픽은 shop.public.orders처럼 <topic.prefix>.<스키마>.<테이블>로 생기고, 메시지 키는 기본 키다. 값은 다음 envelope 구조다. before가 다 찬 것은 orders가 REPLICA IDENTITY FULL일 때다.

{
  "before": { "id": 1001, "status": "PENDING", "amount": 30000 },
  "after":  { "id": 1001, "status": "PAID",    "amount": 30000 },
  "source": { "db": "shop", "schema": "public", "table": "orders",
              "lsn": 24023128, "txId": 5541, "snapshot": "false", "ts_ms": 1762300800000 },
  "op": "u",
  "ts_ms": 1762300800412
}
  • op: c 생성, u 수정, d 삭제(after가 null), r 스냅샷 읽기(c와 같게 처리).
  • source.ts_ms는 DB에서 커밋된 시각, 바깥 ts_ms는 커넥터가 처리한 시각이다. 둘의 차이가 파이프라인 지연이다.
  • source.lsn(MySQL은 binlog 위치나 GTID)으로 같은 행의 이벤트 순서를 판별한다.

초기 스냅샷과 슬롯 WAL 적체

snapshot.mode=initial이면 커넥터는 슬롯을 만든 뒤 대상 테이블을 SELECT로 한 번 읽고, 끝나면 슬롯 위치부터 스트리밍한다. 수억 행이면 스냅샷이 몇 시간 걸리고 그동안 WAL이 쌓인다. 디스크 여유를 미리 계산하고, 큰 테이블은 시그널 테이블로 나눠 읽는 증분 스냅샷을 쓴다(설정은 버전별 문서 확인).

운영 중에는 슬롯이 붙잡고 있는 WAL 양을 감시한다.

SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn))         AS retained_wal,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn)) AS unconfirmed
FROM pg_replication_slots
WHERE slot_type = 'logical';
  • active = false가 몇 분 이어지거나 retained_wal이 기준(예: 디스크의 20%)을 넘으면 알림을 보낸다.
  • 대상 테이블은 조용하고 다른 테이블만 바쁘면 슬롯 위치가 안 움직인다. heartbeat.interval.ms와 하트비트 쿼리로 위치를 밀어 준다.
  • PostgreSQL 13부터 max_slot_wal_keep_size로 슬롯이 붙잡을 수 있는 WAL 상한을 둘 수 있다. 넘으면 디스크는 지키지만 슬롯이 무효가 되어 스냅샷부터 다시 한다. 디스크와 재적재 중 무엇을 잃을지 미리 정한다.
  • 커넥터 지연은 JMX 지표 MilliSecondsBehindSource로 본다.
  • 커넥터를 없앨 때는 슬롯도 pg_drop_replication_slot으로 지운다.

스키마 변경·tombstone·멱등 소비

스키마 변경. 열 추가는 대체로 문제없지만 열 삭제·이름·타입 변경은 소비자를 깨뜨린다. 스키마 레지스트리의 호환성 규칙(BACKWARD 등)으로 깨지는 변경을 등록 단계에서 막고, 소비자 반영 후에 열을 지운다. 이 약속을 검사로 만드는 방법은 데이터 정합성 검증 자동화와 Data Contract 설계에 있다.

tombstone. 삭제(op=d) 뒤에는 같은 키에 값이 null인 메시지가 하나 더 온다. Kafka 로그 압축용 표시다. 값을 바로 역직렬화하는 소비자는 여기서 멈추니, null이면 건너뛴다.

멱등 소비. Debezium은 오프셋을 주기적으로 커밋하므로 재시작하면 마지막 커밋 이후 이벤트를 다시 보낸다. 전달은 최소 한 번이다(Exactly-Once Processing은 정말 가능한가 참고). 키가 기본 키라 같은 행은 같은 파티션에서 순서대로 오지만, 재전송된 옛 이벤트가 새 상태를 덮을 수는 있다. 그래서 반영할 때 원본 위치를 같이 저장하고 더 오래된 것은 버린다.

INSERT INTO order_view (order_id, status, amount, source_lsn)
VALUES (:id, :status, :amount, :lsn)
ON CONFLICT (order_id) DO UPDATE
SET status = EXCLUDED.status,
    amount = EXCLUDED.amount,
    source_lsn = EXCLUDED.source_lsn
WHERE order_view.source_lsn < EXCLUDED.source_lsn;

행을 바로 지우면 위치 기록이 사라져 늦게 온 옛 이벤트가 행을 되살린다. 삭제 표시와 source_lsn을 남기는 소프트 삭제로 처리하고 정리는 배치로 한다. 분석 DB로 보내는 흐름은 OLTP와 OLAP 아키텍처 분리 및 데이터 동기화에서 이어 다룬다.

자주 하는 실수

커넥터를 지우고 슬롯은 남긴다. 아무도 읽지 않는 슬롯이 WAL을 붙잡아 몇 주 뒤 디스크가 찬다.

binlog 보존을 하루로 둔다. 금요일 밤에 커넥터가 죽으면 월요일엔 전체 스냅샷을 다시 한다.

테이블 CDC 토픽을 다른 팀의 계약으로 연다. 열 이름 하나 바꾸는 리팩터링이 다른 팀 장애가 된다. 외부 공개는 Outbox로 한다.

소비자가 tombstone을 모른다. 삭제 한 건에 소비자가 멈추고, 오프셋이 안 넘어가 같은 메시지에서 계속 실패한다.

재전송을 고려하지 않고 덮어쓴다. 커넥터 재시작 직후 옛 값이 새 값을 덮어 검색 결과가 과거로 돌아간다.

점검표

  • PostgreSQL wal_level이 logical이고, MySQL binlog_format이 ROW다.
  • CDC 계정은 복제 권한과 대상 테이블 SELECT만 가진다.
  • publication(또는 table.include.list)에 필요한 테이블만 들어 있다.
  • 초기 스냅샷 시간 동안 쌓일 WAL·binlog를 디스크 여유와 비교했다.
  • 슬롯 retained_wal과 active에 알림이 걸려 있다.
  • binlog 보존 기간이 커넥터 최대 중단 허용 시간보다 길다.
  • 커넥터를 지우는 절차에 슬롯 삭제가 들어 있다.
  • 소비자가 op=r, op=d, 값이 null인 tombstone을 모두 처리한다.
  • 반영 쿼리가 source_lsn(또는 binlog 위치)으로 옛 이벤트를 버린다.
  • 열 삭제·이름 변경은 소비자 반영 후에 하도록 배포 순서가 정해져 있다.

로그 기반 CDC는 변경을 빠짐없이 순서대로 주는 대신 원본 DB에 "읽을 때까지 지우지 말라"는 부담을 지우므로, 슬롯·보존 기간 감시와 멱등 소비까지가 Debezium 도입의 범위다.

댓글 0개

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

댓글 쓰기

← 목록으로 돌아가기