Change Data Capture

· 6분 읽기

Change Data Capture(CDC)는 데이터베이스의 변경 사항을 외부 시스템에 실시간으로 전파하는 기술이다. Outbox 패턴에서 폴링 대신 CDC를 사용하면 지연 없이 이벤트를 전달할 수 있다고 소개했다.

CDC가 필요한 이유

애플리케이션이 DB에 데이터를 저장하고, 이 변경을 다른 시스템에 알려야 하는 상황은 흔하다. 송금 완료를 알림 서비스에 전달하거나, 주문 생성을 검색 인덱스에 반영하는 경우가 그렇다.

가장 단순한 방법은 폴링이다. 주기적으로 DB를 조회하여 변경된 데이터를 가져간다. 하지만 폴링에는 두 가지 문제가 있다.

  • 지연: 폴링 간격만큼 전달이 늦어진다. 1초 간격이면 최대 1초 지연
  • DB 부하: 변경이 없어도 매번 쿼리를 실행한다. 트래픽이 높으면 폴링 쿼리 자체가 부담이 된다

CDC는 이 두 문제를 모두 해결한다. DB의 WAL(Write-Ahead Log) 을 감시하여, 변경이 발생하는 즉시 외부로 전달한다.

WAL 기반 CDC의 동작 원리

WAL이란

WAL은 DB가 데이터를 변경하기 전에 먼저 기록하는 로그이다. 트랜잭션의 Durability를 보장하기 위한 메커니즘으로, 모든 INSERT/UPDATE/DELETE가 WAL에 먼저 기록된 후 실제 데이터 파일에 반영된다.

  • PostgreSQL: WAL (Write-Ahead Log)
  • MySQL: Binlog (Binary Log)
  • MongoDB: Oplog (Operation Log)

이름은 다르지만 역할은 같다. DB에 일어난 모든 변경의 순서가 보장된 로그이다.

CDC Connector의 동작

CDC Connector(예: Debezium)는 이 WAL을 읽는 DB의 복제 클라이언트로 동작한다.

sequenceDiagram
    participant App as 애플리케이션
    participant DB as Database
    participant WAL as WAL/Binlog
    participant CDC as CDC Connector
    participant MQ as Kafka

    App->>DB: INSERT INTO outbox (...)
    DB->>WAL: 변경 기록
    DB->>App: OK
    WAL->>CDC: 변경 스트림 전달
    CDC->>MQ: 이벤트 발행
    CDC->>CDC: offset 저장
  1. 애플리케이션이 DB에 데이터를 쓴다
  2. DB는 WAL에 변경을 기록한다
  3. CDC Connector는 DB의 복제 프로토콜을 통해 WAL 변경 스트림을 수신한다
  4. 변경 내용을 Kafka 등 외부 시스템에 발행한다
  5. 발행 완료 후 현재까지 처리한 WAL 위치(offset) 를 저장한다

핵심은 CDC Connector가 애플리케이션과 완전히 분리되어 있다는 점이다. 애플리케이션은 DB에 쓰기만 하면 되고, CDC는 WAL을 통해 이를 감지한다.

복제 프로토콜

CDC Connector가 WAL을 읽는 방식은 DB마다 다르다.

  • PostgreSQL: Logical Replication 사용. Replication Slot을 생성하여 WAL 변경을 스트림으로 수신한다. 출력 플러그인(pgoutput, wal2json 등)이 WAL을 사람이 읽을 수 있는 형태로 디코딩한다
  • MySQL: Binlog Replication 사용. CDC Connector가 Replica처럼 동작하여 Master의 Binlog를 수신한다. binlog_format=ROW로 설정해야 행 단위 변경을 캡처할 수 있다

두 경우 모두 CDC Connector는 DB 입장에서 하나의 Replica로 취급된다.

Debezium 아키텍처

Debezium은 가장 널리 사용되는 오픈소스 CDC 플랫폼이다. Kafka Connect 위에서 동작한다.

flowchart LR
    subgraph "Kafka Connect Cluster"
        D1["Debezium\nConnector"]
        D2["Debezium\nConnector"]
    end
    DB1["PostgreSQL"] -->|Logical Replication| D1
    DB2["MySQL"] -->|Binlog| D2
    D1 --> K["Kafka"]
    D2 --> K
    K --> C1["Consumer A"]
    K --> C2["Consumer B"]

핵심 구성 요소

  • Source Connector: DB의 WAL을 읽어 Kafka 토픽에 발행하는 주체
  • Kafka Connect: Connector의 실행 환경. 분산 모드에서는 여러 Worker에 Connector를 분배한다
  • Offset Storage: 현재까지 처리한 WAL 위치를 저장한다. Kafka Connect의 내부 토픽(connect-offsets)에 저장된다
  • Schema Registry (선택): 변경 이벤트의 스키마를 관리한다. Avro/Protobuf 직렬화 시 사용

이벤트 구조

Debezium이 발행하는 이벤트에는 변경 전후의 데이터가 모두 포함된다.

  • before: 변경 전 행의 상태 (UPDATE/DELETE 시)
  • after: 변경 후 행의 상태 (INSERT/UPDATE 시)
  • op: 연산 종류 (c=create, u=update, d=delete, r=read/snapshot)
  • source: DB명, 테이블명, WAL 위치, 트랜잭션 ID 등 메타데이터

beforeafter가 모두 있기 때문에, Consumer 쪽에서 “어떤 필드가 어떻게 바뀌었는지”를 정확히 알 수 있다.

장애 시나리오와 대응

Connector가 죽은 경우

CDC Connector가 재시작되면 마지막으로 저장한 offset부터 WAL을 다시 읽는다. offset은 Kafka의 내부 토픽에 저장되어 있으므로 Connector 프로세스와 독립적이다.

  • 메시지 유실: 없다. offset 이후의 WAL을 다시 읽기 때문
  • 메시지 중복: 발생할 수 있다. 마지막 offset 저장 이후 ~ 장애 시점 사이의 이벤트가 재발행된다
  • Consumer는 멱등성 처리가 필요하다

WAL이 삭제된 경우

DB는 WAL을 무한히 보관하지 않는다. Connector가 오래 죽어있으면 아직 읽지 않은 WAL이 삭제될 수 있다.

PostgreSQL: Replication Slot이 있으면 해당 Slot이 읽지 않은 WAL은 삭제되지 않는다. 하지만 이는 디스크 사용량 폭증으로 이어질 수 있다. max_slot_wal_keep_size로 상한을 설정하거나, Slot 상태를 모니터링해야 한다.

MySQL: Binlog retention(binlog_expire_logs_seconds)이 지나면 삭제된다. Connector가 이 기간보다 오래 죽어있으면 WAL 연속성이 깨진다.

WAL이 유실되면 Debezium은 snapshot 모드로 전환하여 테이블 전체를 다시 읽는다. 이 과정에서 서비스 영향이 있을 수 있으므로, WAL 유실이 발생하지 않도록 모니터링하는 것이 중요하다.

DB에 부하를 주는가

CDC는 DB에 쿼리를 실행하지 않는다. WAL 스트림을 읽는 것이므로 쿼리 부하는 없다.

다만 두 가지 간접적 영향이 있다.

  • Replication Slot에 의한 WAL 보존: PostgreSQL에서 Slot이 있으면 WAL이 삭제되지 않아 디스크를 점유한다
  • 초기 Snapshot: Connector가 처음 시작하거나 WAL이 유실되면 테이블 전체를 SELECT한다. 이때는 DB에 부하가 발생한다

정상 운영 상태에서는 WAL 스트림만 읽으므로, 폴링 방식보다 DB 부하가 현저히 낮다.

Kafka가 죽은 경우

Debezium은 Kafka에 발행할 수 없으면 재시도를 반복한다. 이 동안 WAL은 계속 쌓이지만 DB의 Replication Slot이 WAL을 보존하므로 유실은 없다. Kafka가 복구되면 밀린 이벤트를 순서대로 발행한다.

다만 Kafka 장애가 길어지면 WAL 보존으로 인한 디스크 부족 위험이 있다.

Outbox 패턴에서의 CDC

이전 글에서 Outbox 패턴을 소개했다. CDC와 결합하면 다음과 같이 동작한다.

flowchart LR
    subgraph "하나의 DB 트랜잭션"
        W1["비즈니스 데이터 저장"]
        W2["outbox 테이블에 이벤트 저장"]
    end
    W1 --> W2
    W2 --> WAL["WAL"]
    WAL --> CDC["Debezium"]
    CDC --> K["Kafka"]
  1. 비즈니스 로직과 이벤트를 같은 트랜잭션으로 저장한다
  2. Debezium이 outbox 테이블의 변경을 WAL에서 감지한다
  3. Kafka에 이벤트를 발행한다

References