Debezium 기반의 CDC(Change Data Capture) 파이프라인을 운영하다 문득 다음과 같은 섬뜩한 의문이 들었습니다.
소싱하는 DB의 CASCADE가 발생하는 경우 변경 이벤트 감지를 할 수 있을까?
결론은 Debezium 자체는 CASCADE에 대해서 어떠한 처리도 하지 않습니다. 하지만 소싱 DB의 이벤트 저장 방식에 따라 변경 이벤트는 유실될 수도, 안될 수도 있습니다.
이 글에서는 MySQL, PostgreSQL, MongoDB 3개 DB에서 Debezium이 cascade를 어떻게 처리하는지를 확인해 보았습니다.
1. MySQL - Binlog에서 CASCADE를 표현하는 방식
InnoDB FK CASCADE의 내부 동작
MySQL InnoDB에서 FK CASCADE가 동작하는 흐름은 다음과 같습니다.
- 클라이언트가
DELETE FROM orders WHERE order_id = 1001실행 - InnoDB 엔진이 FK 제약조건을 확인
order_items에ON DELETE CASCADE가 있으므로, 해당 자식 행을 InnoDB가 내부적으로 DELETE- 이 내부 DELETE가 binlog에 별도의
DELETE_ROWS_EVENT로 기록됨
핵심은 "별도의 row event로 기록된다"는 점입니다. (⭐️⭐️⭐️)
MySQL binlog의 row event 형식에는 "이것이 cascade에 의한 삭제"를 나타내는 플래그나 메타데이터가 존재하지 않습니다. 그저 같은 트랜잭션(GTID) 내에서 부모 테이블 DELETE가 먼저 기록되고, 이어서 자식 테이블 DELETE가 기록될 뿐입니다.
Debezium 소스코드 분석
BinlogStreamingChangeEventSource.java의 handleDelete() 메서드를 보면 Debezium이 binlog의 DELETE 이벤트를 어떻게 처리하는지 알 수 있습니다.
protected void handleDelete(P partition, O offsetContext, Event event) throws InterruptedException {
// 단순히 handleChange에 Delete를 넘겨 호출합니다.
handleChange(partition, offsetContext, event, Envelope.Operation.DELETE, DeleteRowsEventData.class,
x -> schema.getTableId(x.getTableId()),
DeleteRowsEventData::getRows,
(tableId, row) -> eventDispatcher.dispatchDataChangeEvent(partition, tableId,
new BinlogChangeRecordEmitter<>(partition, offsetContext, clock, Envelope.Operation.DELETE, row, null, connectorConfig)),
(tableId, row) -> validateChangeEventWithTable(schema.tableFor(tableId), row, null));
}
이 코드에서 주목할 점은, Cascade로 인한 삭제인지 직접 삭제인지 구분하는 조건문이나 분기 로직이 전혀 없다는 점입니다.
(단편적으로 이 코드만 봐서 알 수는 없지만, 적어도 debezium 라이브러리에서 MySQL의 cascade 관련 처리 로직은 존재하지 않습니다.)
실제 이벤트를 비교해 보면
orders 테이블의 부모 행을 직접 DELETE 했을 때 발생하는 Debezium 이벤트:
{
"before": {
"order_id": 1001,
"customer_id": 42,
"total": 50000
},
"after": null,
"source": {
"connector": "mysql",
"db": "ecommerce",
"table": "orders",
"gtid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:42",
"file": "mysql-bin.000003",
"pos": 1081,
"thread": 15
},
"op": "d",
"ts_ms": 1711000000000
}
그리고 cascade로 자동 삭제된 order_items 행의 이벤트:
{
"before": {
"item_id": 5001,
"order_id": 1001,
"product_name": "키보드",
"price": 50000
},
"after": null,
"source": {
"connector": "mysql",
"db": "ecommerce",
"table": "order_items",
"gtid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:42",
"file": "mysql-bin.000003",
"pos": 1205,
"thread": 15
},
"op": "d",
"ts_ms": 1711000000000
}
두 이벤트를 나란히 놓고 비교하면, 차이점은 table, pos뿐입니다.
그리고 gtid와 thread는 동일하다는 것을 통해 같은 트랜잭션에서 발생했다는 것을 알 수 있습니다.
그러나 이 공통된 gtid와 thread만으로는 "cascade 관계인가"와 "같은 트랜잭션에서 실행된 별개의 DELETE인가"를 구분할 수 없습니다. 개발자가 하나의 트랜잭션 안에서 orders와 order_items를 명시적으로 순서대로 DELETE 했다면, 위와 이벤트 형태가 완전히 동일하기 때문입니다.
또한 source 블록 전체를 살펴봐도 "cascade" 여부를 나타내는 전용 필드가 존재하지 않습니다. MySQL binlog 자체가 해당 정보를 기록하지 않기 때문입니다.
2. PostgreSQL — WAL에서의 CASCADE 처리
WAL/pgoutput 기반 CDC
PostgreSQL도 MySQL과 마찬가지로, FK CASCADE로 인한 DELETE를 WAL에 별도 row 이벤트로 기록합니다.
그리고 Debezium은 pgoutput 논리 복제 플러그인을 통해 이 이벤트를 수신합니다.
PgOutputMessageDecoder.decodeDelete() 메서드는 WAL의 DELETE 메시지를 처리하며, MySQL과 동일하게 cascade 구분 플래그 없이 모든 DELETE를 동일하게 처리합니다.
예외: TRUNCATE CASCADE
단, Debezium 라이브러리에서 cascade를 검색하면 딱 하나 PostgreSQL에서 이를 파싱 하는 부분이 있습니다.
바로 TRUNCATE 처리 부분입니다.
PgOutputMessageDecoder.decodeTruncate()
// debezium-connector-postgresql/src/main/java/io/debezium/connector/postgresql/connection/pgoutput/PgOutputMessageDecoder.java:558-601
private void decodeTruncate(ByteBuffer buffer, TypeRegistry typeRegistry,
ReplicationMessageProcessor processor) throws SQLException, InterruptedException {
// As of PG11, the Truncate message format is as described:
// Byte Message Type (Always 'T')
// Int32 number of relations described by the truncate message
// Int8 flags for truncate; 1=CASCADE, 2=RESTART IDENTITY
// Int32[] Array of number of relation ids
int numberOfRelations = buffer.getInt();
int optionBits = buffer.get();
// ignored / unused
List<String> truncateOptions = getTruncateOptions(optionBits);
// ... 이하 각 테이블별 TRUNCATE 이벤트 생성
}
private List<String> getTruncateOptions(int flag) {
switch (flag) {
case 1: return Collections.singletonList("CASCADE");
case 2: return Collections.singletonList("RESTART IDENTITY");
case 3: return Arrays.asList("RESTART IDENTITY", "CASCADE");
default: return null;
}
}
이 코드에서 주목할만한 점은, 프로토콜의 T (Truncate) 메시지에 cascade 여부를 비트 플래그로 포함한다는 점입니다. (PG11+)
즉, 앞서 살펴본 MySQL과 달리 프로토콜 레벨에서 cascade 정보 자체는 제공한다는 것이죠.
또한 이에 대해 Debezium은 getTruncateOptions()로 이 플래그를 파싱 합니다.
하지만 바로 위의 주석 // ignored / unused처럼, 파싱 한 결과를 PgOutputTruncateReplicationMessage 생성 시 전달하지는 않습니다.
따라서 PostgreSQL이 유일하게 cascade 정보를 프로토콜 레벨에서 제공하지만, 현재 Debezium은 이를 활용하지 않고 있습니다.
아마도 PK CASCADE로 인한 DELETE를 WAL에 별도 row 이벤트로 기록하기 때문에 굳이 사용하진 않는 것으로 보입니다.
3. MongoDB — CASCADE라는 개념 자체가 없다
Document DB의 근본적 차이
MongoDB는 앞의 두 DB와 패러다임 자체가 다릅니다. MongoDB에는 FK 제약조건이 없기 때문에, CASCADE도 없습니다.
관련해서 조금 찾아본 바로는, Document DB에서 관련 테이블(Document)의 관리는 전적으로 애플리케이션의 책임이라고 합니다.
따라서 두 DB가 연관되어 있다면, 애플리케이션 레벨에서 두 테이블 각각에 대해 명시적으로 변경을 해야 한다는 것이죠.
4. 결론
1. Debezium은 DB 로그를 그대로 복제
DB 엔진에서 cascade를 일반 DML로 기록하면, Debezium도 일반 DML로 처리합니다.
따로 Debezium에서 cascade에 대한 별도의 처리가 존재하지 않습니다. 이는 Debezium의 설계 철학이기도 한 게, 로그에 기록된 것을 그대로 전달하는 것이 CDC의 기본 원칙입니다.
2. Cascade가 안 되는 것은 Debezium의 한계가 아니라 DB 로그의 한계
MySQL binlog와 PostgreSQL WAL 모두 DML 이벤트 레벨에서 cascade 여부를 기록하지 않습니다.
처음부터 Debezium이 cascade를 구분하기 위해선 DB 로그 자체가 해당 정보를 담고 있어야 합니다. 로그에 없는 정보를 Debezium이 만들어낼 수는 없습니다.
결론적으로 CDC 파이프라인을 설계할 때 cascade 동작을 고려해야 한다면, 원천 DB에서 이벤트를 남기는 포맷을 적절히 설정하는 것이 가장 현실적인 접근입니다. 예를 들어, MySQL의 binlog_format을 'row'로 설정하는 것처럼 말이죠.
'오픈소스' 카테고리의 다른 글
| Flink CDC는 어떻게 스냅샷을 병렬로 읽을까? (0) | 2026.04.12 |
|---|---|
| LSM Tree in Flink (0) | 2026.03.28 |
| Flink CDC MySQL Snapshot은 정말 중복 없이 동작할까? (0) | 2026.03.01 |
| [flink-cdc] Iceberg sink connector에서의 default value 지원 (0) | 2026.02.14 |
| [flink-cdc] VARIANT 타입과 PARSE_JSON 함수 (0) | 2026.01.31 |