<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Suhwanc</title>
    <link>https://suhwanc.tistory.com/</link>
    <description>프로그래밍 관련 글을 올려요</description>
    <language>ko</language>
    <pubDate>Thu, 14 May 2026 09:38:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>suhwanc</managingEditor>
    <image>
      <title>Suhwanc</title>
      <url>https://tistory1.daumcdn.net/tistory/3230888/attach/72358621eb0b402483dfbdcbb1adb34a</url>
      <link>https://suhwanc.tistory.com</link>
    </image>
    <item>
      <title>Flink CDC는 어떻게 스냅샷을 병렬로 읽을까?</title>
      <link>https://suhwanc.tistory.com/222</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;CDC 작업을 수행할 때 Flink CDC 라이브러리를 사용하면 얻게되는 장점은 &lt;b&gt;parallelism을 2이상으로 높일 수 있다는 것입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 Debezium 기반 Kafka Connect는 &lt;b&gt;단일 태스크로만 동작&lt;/b&gt;하기 때문에 병렬성(tasks.max)가 1로 고정되는 단점이 존재합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 문득&amp;nbsp;&lt;b&gt;Flink CDC도 내부적으로 Debezium을 사용&lt;/b&gt;하는데 &lt;b&gt;왜 Flink CDC는 병렬 소싱이 가능하고, Kafka Connect는 불가능한지&lt;/b&gt; 구조적인 차이가 궁금해 조사해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론부터 말하면&lt;/b&gt;, 차이의 핵심은 Debezium 자체가 아니라 &lt;b&gt;그 위에 올라가는 실행 프레임워크(Kafka Connect vs Flink)의 아키텍처&lt;/b&gt;에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Debezium 기반 Kafka Connect - 병렬성이 1인 이유&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka Connect의 태스크 모델&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka Connect는 &lt;b&gt;Connector&lt;/b&gt;와 &lt;b&gt;Task&lt;/b&gt;라는 두 가지 개념으로 동작합니다. Connector가 설정을 관리하고, 실제 데이터를 읽는 건 Task입니다. 일반적인 Source Connector(예: JDBC Source)는 &lt;code&gt;tasks.max&lt;/code&gt;를 높여서 여러 태스크를 병렬로 실행할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 &lt;b&gt;Debezium의 CDC 커넥터는 &lt;code&gt;tasks.max=1&lt;/code&gt;로 고정&lt;/b&gt;됩니다. MySQL, PostgreSQL, SQL Server 할 것 없이 모두 단일 태스크입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단일 태스크일 수밖에 없는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이유는 CDC의 근본적인 메커니즘에 있습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DB&lt;/th&gt;
&lt;th&gt;CDC 메커니즘&lt;/th&gt;
&lt;th&gt;제약&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Binlog&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;하나의 binlog 스트림은 순차적이며, 읽기 위치(offset)가 단일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Replication Slot&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;하나의 슬롯은 동시에 하나의 컨슈머만 사용 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL Server&lt;/td&gt;
&lt;td&gt;&lt;b&gt;CT(Change Tracking)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;변경 캡처 인스턴스가 단일 읽기 지점을 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The MySQL connector always uses a single task and therefore does not use this value. &amp;mdash; &lt;a href=&quot;https://docs.confluent.io/kafka-connectors/debezium-mysql-source/current/mysql_source_connector_config.html&quot;&gt;Confluent Documentation&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 &lt;b&gt;데이터베이스의 변경 로그(binlog, WAL 등)는 본질적으로 단일 스트림&lt;/b&gt;이라는 것입니다. 하나의 파일에 순서대로 기록되는 로그를 여러 태스크가 나눠 읽으면, 이벤트 순서가 보장되지 않습니다. Debezium은 이 순서 보장을 위해 단일 태스크를 강제합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스냅샷 단계도 마찬가지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;변경 로그는 단일 스트림이니까 그렇다 치고, 스냅샷은 병렬로 할 수 있지 않을까?&quot;라고 생각할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Debezium의 Kafka Connect 구현에서는 &lt;b&gt;스냅샷과 binlog 읽기가 하나의 태스크 안에서 순차적으로 이루어집니다&lt;/b&gt;. 스냅샷 완료 후 binlog 읽기로 전환하는 과정에서 정확한 오프셋을 넘겨야 하기 때문에, 이 둘을 분리하기 어려운 구조입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Debezium에서 &lt;code&gt;snapshot.max.threads&lt;/code&gt; 설정을 통해 &lt;b&gt;여러 테이블에 대한&amp;nbsp;병렬 스냅샷&lt;/b&gt;은 지원하지만, &lt;b&gt;하나의 테이블을 여러 태스크로 쪼개서 읽는 것은 불가능&lt;/b&gt;합니다.&lt;/p&gt;
&lt;!-- IMAGE: Debezium Kafka Connect의 단일 태스크 구조
     하나의 Kafka Connect Worker 안에 Debezium Task 1개.
     Task 내부에서 Phase 1(Snapshot: 테이블 A → B → C 순차 읽기)과
     Phase 2(Binlog 스트리밍)가 직렬로 연결되는 흐름.
     화살표: DB → [Task] → Kafka Topics 방향.
     참고: 가로 방향 흐름도, 너비 약 800px --&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Flink CDC: 같은 Debezium인데 어떻게 병렬로?&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Flink CDC와 Debezium의 관계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC는 Debezium을 &lt;b&gt;라이브러리(Embedded Engine)&lt;/b&gt; 형태로 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Embedded Engine&lt;/b&gt;이란 Debezium이 제공하는 별도 API로 Kafka Connect 없이, 아무 Java 애플리케이션 안에서 Debezium의 CDC 기능만 꺼내 쓸&amp;nbsp;수 있는 옵션을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Flink CDC의 내부 코드를 보면 단순히 Debezium 의존성을 추가한 것이 아니라, binlog 추출 같은 Debezium의 CDC 핵심 로직을 추출해 Flink 코드 위에 올린 형태로 이루어져 있습니다. &lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/debezium/reader/BinlogSplitReader.java&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;FLIP-27 Source API: 병렬 읽기의 기반&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC 2.0부터는 Flink의 &lt;b&gt;FLIP-27 Source API&lt;/b&gt; (&lt;a href=&quot;https://issues.apache.org/jira/browse/FLINK-10740&quot;&gt;FLINK-10740&lt;/a&gt;)를 기반으로 동작합니다. 이 API의 핵심은 두 가지 역할의 분리입니다:&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;구현 클래스&lt;/th&gt;
&lt;th&gt;하는 일&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;SplitEnumerator&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/enumerator/MySqlSourceEnumerator.java&quot;&gt;&lt;code&gt;MySqlSourceEnumerator&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;테이블을 chunk로 분할하고, 각 SourceReader에 할당&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;SourceReader&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/reader/MySqlSourceReader.java&quot;&gt;&lt;code&gt;MySqlSourceReader&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;할당받은 chunk를 실제로 읽음 (병렬 실행)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SplitEnumerator는 &lt;b&gt;JobManager&lt;/b&gt;에서 실행되고, SourceReader는 &lt;b&gt;TaskManager&lt;/b&gt;에서 parallelism 수만큼 실행됩니다. 이 분리 덕분에 &quot;무엇을 읽을지 결정하는 것&quot;과 &quot;실제로 읽는 것&quot;을 독립적으로 스케일링할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 &lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/MySqlSource.java&quot;&gt;&lt;code&gt;MySqlSource.java&lt;/code&gt;&lt;/a&gt;의 클래스 Javadoc을 보면, 이 세 가지 특성이 명시되어 있습니다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;The MySQL CDC Source based on FLIP-27 and Watermark Signal Algorithm which supports parallel reading snapshot of table and then continue to capture data change from binlog.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;The source supports &lt;b&gt;parallel capturing table change.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;The source &lt;b&gt;supports checkpoint in split level when read snapshot data&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;The source &lt;b&gt;doesn't need apply any lock&lt;/b&gt; of MySQL.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 조사하면서 알게된 사실인데 스냅샷 과정에서 &lt;b&gt;chunk 단위로 체크포인트를 찍는다&lt;/b&gt;고 하는건 조금 새로웠습니다.&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Incremental Snapshot: 테이블을 Chunk로 쪼개기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC는 라이브러리 버전 2.0부터 &lt;b&gt;Incremental Snapshot&lt;/b&gt; 알고리즘을 통해 병렬 소싱을 지원하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원리는 &lt;a href=&quot;https://suhwanc.tistory.com/216&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전에 제 블로그 글&lt;/a&gt;에서 언급한 Netflix의 &lt;a href=&quot;https://arxiv.org/abs/2010.12597&quot;&gt;DBLog 논문 &lt;/a&gt;기반의 알고리즘인데요, 다음과 같이 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: Chunk 분할&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/assigner/splitter/ChunkSplitter.java&quot;&gt;&lt;code&gt;ChunkSplitter&lt;/code&gt;&lt;/a&gt;가 테이블의 &lt;b&gt;Chunk&amp;nbsp;Key&lt;/b&gt; 범위를 기준으로 데이터를 여러 chunk로 나눕니다. (Chunk Key는 PK가 될수도 있고, 따로 지정할 수도 있습니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 인터페이스의 핵심 메서드는 아래과 같습니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;/** Generates all snapshot splits (chunks) for the give data collection. */
Collection&amp;lt;SnapshotSplit&amp;gt; generateSplits(TableId tableId) throws Exception;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL의 경우 &lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/assigners/MySqlChunkSplitter.java&quot;&gt;&lt;code&gt;MySqlChunkSplitter&lt;/code&gt;&lt;/a&gt;가 이를 구현합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;예시: users 테이블 (PK: id, 1000만 건)

Chunk 1: id [1, 100000)
Chunk 2: id [100000, 200000)
Chunk 3: id [200000, 300000)
...
Chunk 100: id [9900000, 10000000]
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;chunk 크기는 &lt;code&gt;scan.incremental.snapshot.chunk.size&lt;/code&gt; (기본 8,096행)로 설정할 수 있습니다. PK가 auto increment인 경우 evenly로 판단되어 균등 분할이 되며, 그렇지 않은 경우엔 unevenly로 판단되어 자체적인 분할 방식으로 처리합니다. (unevenly인 경우 성능 차이가 꽤나 존재하여 조심해야 합니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: 병렬 읽기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-cdc-base/src/main/java/org/apache/flink/cdc/connectors/base/source/assigner/SnapshotSplitAssigner.java&quot;&gt;&lt;code&gt;SnapshotSplitAssigner&lt;/code&gt;&lt;/a&gt;가 생성된 chunk들을 여러 &lt;code&gt;SourceReader&lt;/code&gt;에 분배합니다. 내부적으로 비동기 스레드(&lt;code&gt;snapshot-splitting&lt;/code&gt;)에서 테이블을 chunk로 분할하고, &lt;code&gt;getNext()&lt;/code&gt; 호출 시 &lt;code&gt;remainingSplits&lt;/code&gt;에서 하나씩 꺼내 각 SourceReader에 할당합니다. parallelism=4로 설정했다면, 4개의 SourceReader가 각각 다른 chunk를 동시에 읽습니다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;// 개념적 구조 (실제 코드 단순화)
// SourceReader 1: SELECT * FROM users WHERE id &amp;gt;= 1 AND id &amp;lt; 100000
// SourceReader 2: SELECT * FROM users WHERE id &amp;gt;= 100000 AND id &amp;lt; 200000
// SourceReader 3: SELECT * FROM users WHERE id &amp;gt;= 200000 AND id &amp;lt; 300000
// SourceReader 4: SELECT * FROM users WHERE id &amp;gt;= 300000 AND id &amp;lt; 400000
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3단계: Binlog 전환&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 chunk의 스냅샷이 완료되면, &lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/assigners/MySqlHybridSplitAssigner.java&quot;&gt;&lt;code&gt;MySqlHybridSplitAssigner&lt;/code&gt;&lt;/a&gt;가 내부의 &lt;code&gt;SnapshotSplitAssigner&lt;/code&gt;에서 &lt;code&gt;BinlogSplitAssigner&lt;/code&gt;로 전환됩니다. 이후의 변경 이벤트는 &lt;b&gt;단일 binlog reader&lt;/b&gt;가 처리합니다. 여기서 Debezium의 binlog 읽기 코드가 그대로 활용됩니다.&lt;/p&gt;
&lt;!-- IMAGE: Flink CDC의 병렬 스냅샷 아키텍처
     상단: JobManager 안의 SplitEnumerator가 테이블을 Chunk 1~N으로 분할.
     중단: TaskManager들에 있는 SourceReader 1~4가 각각 다른 chunk를 병렬로 읽음.
     화살표: SplitEnumerator → (chunk 할당) → SourceReader 1, 2, 3, 4.
     각 SourceReader에서 DB로 SELECT 쿼리 화살표.
     하단: 모든 chunk 완료 후 단일 Binlog Reader로 전환되는 흐름.
     참고: 세로 방향 3단계 흐름도, 너비 약 800px --&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Watermark 기반 일관성 보장 (Lock-Free)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 궁금한 점은 &quot;스냅샷을 읽는 동안 데이터가 변경되면 어떡하지?&quot; 라는 생각이 들 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC는 &lt;b&gt;Watermark 알고리즘&lt;/b&gt;으로 이 문제를 해결합니다. 각 chunk를 읽을 때 다음 과정을 거칩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;chunk 읽기 &lt;b&gt;시작 전&lt;/b&gt;: 현재 binlog 위치를 &lt;b&gt;Low Watermark&lt;/b&gt;로 기록&lt;/li&gt;
&lt;li&gt;chunk의 &lt;code&gt;SELECT&lt;/code&gt; 쿼리 실행 (스냅샷 데이터 읽기)&lt;/li&gt;
&lt;li&gt;chunk 읽기 &lt;b&gt;완료 후&lt;/b&gt;: 현재 binlog 위치를 &lt;b&gt;High Watermark&lt;/b&gt;로 기록&lt;/li&gt;
&lt;li&gt;Low~High Watermark 사이의 binlog 이벤트 중 &lt;b&gt;이 chunk 범위에 해당하는 변경&lt;/b&gt;을 추후에 보정(back-fill)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식 덕분에 MySQL의 &lt;b&gt;Global Read Lock&lt;/b&gt;이 필요 없습니다. Debezium의 기존 스냅샷 방식은 데이터 일관성을 위해 이 글로벌 락을 사용했는데, 이는 아무래도 운영 DB에 부하가 갈 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 언급했던 것처럼&amp;nbsp;&lt;a style=&quot;background-color: #e6f5ff; color: #0070d1; text-align: center;&quot; href=&quot;https://nightlies.apache.org/flink/flink-cdc-docs-master/docs/connectors/flink-sources/mysql-cdc/&quot;&gt;Apache Flink CDC docs 중&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot;&gt;scan.incremental.snapshot.enabled&lt;/span&gt; 부분에&lt;/a&gt;&amp;nbsp;해당 내용이 명확히 나와있습니다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Compared to the old snapshot mechanism, the incremental snapshot has many advantages, including: (1) MySQL CDC Source can be parallel during snapshot reading (2) MySQL CDC Source can perform checkpoints in the chunk granularity during snapshot reading (3) &lt;b&gt;MySQL CDC Source doesn't need to acquire global read lock before snapshot reading.&lt;/b&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구조 비교: 한눈에 보기&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Debezium + Kafka Connect&lt;/th&gt;
&lt;th&gt;Flink CDC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Debezium 사용 방식&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Kafka Connect Source Connector&lt;/td&gt;
&lt;td&gt;Embedded Engine (라이브러리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;실행 프레임워크&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Kafka Connect&lt;/td&gt;
&lt;td&gt;Apache Flink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;스냅샷 병렬성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단일 태스크 (테이블 간 병렬만 가능)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Chunk 기반 다중 SourceReader&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;스냅샷 단위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;테이블 전체&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Chunk (PK 범위 기반)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;글로벌 락 필요&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;필요 (FLUSH TABLES WITH READ LOCK)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;불필요 (Watermark 알고리즘)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;체크포인트 단위&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;테이블 단위&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Chunk 단위&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;장애 복구&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;처음부터 다시 스냅샷&lt;/td&gt;
&lt;td&gt;마지막 미완료 chunk부터 재개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;변경 이벤트 읽기&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;단일 태스크&lt;/td&gt;
&lt;td&gt;단일 Binlog Reader (스냅샷 완료 후)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;!-- IMAGE: 성능 비교 — Debezium vs Flink CDC 스냅샷
     좌측: Debezium 단일 태스크가 테이블을 순차 읽기하는 모습 (시간 축이 길게).
     우측: Flink CDC 4개 SourceReader가 chunk를 병렬 읽기하는 모습 (시간 축이 짧게).
     양쪽 모두 같은 크기의 테이블(예: 1억 건)을 처리.
     시간 축 비교로 병렬 처리의 이점을 시각적으로 표현.
     참고: 좌우 비교 다이어그램, 너비 약 800px --&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;그렇다면 Binlog Stream 단계는?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC의 병렬성은 &lt;b&gt;스냅샷 단계에서만&lt;/b&gt; 효과적입니다. 스냅샷이 끝나고 binlog stream로 전환되면, &lt;b&gt;단일 Binlog Reader&lt;/b&gt;가 변경 이벤트를 처리하기 때문에 Debezium과 동일하게 Parallelism이 1로 설정됩니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, &lt;b&gt;Flink CDC&lt;/b&gt;가 Debezium 기반 Kafka Connect에 비해 &lt;b&gt;스냅샷 단계에서 병렬성이 높은 이유&lt;/b&gt;는 &lt;b&gt;Debezium 자체의 차이가 아니라, 실행 프레임워크의 아키텍처 차이&lt;/b&gt;입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Kafka Connect&lt;/b&gt;는 CDC 커넥터를 단일 태스크로 실행하며, 이는 binlog의 순차적 특성 때문에 불가피한 설계입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Flink CDC&lt;/b&gt;는 Source API의 SplitEnumerator/SourceReader 분리 모델을 활용하여, 스냅샷 단계를 &lt;b&gt;chunk 단위로 분할하고 병렬 실행&lt;/b&gt;합니다. Debezium은 binlog 읽기와 데이터 변환에만 활용합니다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>오픈소스</category>
      <category>CDC</category>
      <category>Debezium</category>
      <category>Flink CDC</category>
      <category>FLIP-27</category>
      <category>Kafka Connect</category>
      <category>snapshot</category>
      <category>병렬처리</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/222</guid>
      <comments>https://suhwanc.tistory.com/222#entry222comment</comments>
      <pubDate>Sun, 12 Apr 2026 16:44:49 +0900</pubDate>
    </item>
    <item>
      <title>LSM Tree in Flink</title>
      <link>https://suhwanc.tistory.com/218</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Apache Flink에서 주로 State Backend로 사용되는 &lt;b&gt;RocksDB&lt;/b&gt;는 &lt;b&gt;쓰기 속도가 빠르다&lt;/b&gt;고 알려져 있는데요, 그 동안 왜 빠른지에 대해서는 한 번도 찾아본 적이 없어 찾아보게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정답부터 말하면 &lt;b&gt;LSM Tree(Log-Structured Merge-Tree)&lt;/b&gt;를 선택했기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 LSM Tree의 아키텍처와 동작 원리를 1996년 원본 논문을 기반으로 살펴보고, 이후 Flink에서 이걸 어떻게 활용하는지 간단히 정리합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 왜 LSM Tree가 필요했을까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;B-Tree의 쓰기 비용 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LSM Tree를 이해하려면 먼저 &lt;b&gt;B-Tree의 한계&lt;/b&gt;를 알아야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.cs.umb.edu/~poneil/lsmtree.pdf&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;1996년 Patrick O'Neil 등이 발표한 원본 논문 &quot;The Log-Structured Merge-Tree (LSM-Tree)&quot;&lt;/a&gt; 에서는 다음과 같은 문제를 제기합니다. (1p)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Standard disk-based index structures such as the B-tree will effectively double the I/O cost of the transaction to maintain an index in real time, increasing the total system cost up to fifty percent.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B-Tree 인덱스를 실시간으로 유지하면 트랜잭션의 I/O 비용이 두 배가 되어, 전체 시스템 비용이 최대 50%까지 증가합니다. -&amp;gt; 흠 B-Tree가 이렇게나 비효율적인 자료구조였다는 게 이해가 되지 않습니다.  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B-Tree에 레코드 하나를 삽입하는 과정을 보면 이해가 됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;해당 leaf 페이지(보통 4KB~16KB)를 디스크에서 읽고&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;메모리에서 페이지를 수정&lt;/b&gt;한 뒤&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수정된 페이지 전체를 디스크에 다시 쓰고&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;WAL(Write-Ahead Log)에도 기록&lt;/b&gt;합니다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10바이트를 수정하는데 16KB 페이지 전체를 다시 쓰게 됩니다. 논문에서 언급되진 않지만, 이러한 현상을 &lt;b&gt;Write Amplification(쓰기 증폭)&lt;/b&gt;이라고 종종 부릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 원인&lt;/b&gt;은 &lt;b&gt;B-Tree의 삽입이 &lt;span style=&quot;color: #ee2323;&quot;&gt;랜덤 위치&lt;/span&gt;에 발생&lt;/b&gt;한다는 점입니다. 연속된 삽입이 트리의 이곳저곳에 흩어지면서, 매번 디스크 암이 물리적으로 이동해야 합니다. 여기서 &lt;b&gt;랜덤 I/O는&amp;nbsp;&lt;/b&gt;&lt;b&gt;디스크에서 가장 비싼 연산입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;순차 쓰기와 랜덤 쓰기&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZRI9i/dJMcabDEjRm/LLmdeSne26HGa09EBktk7k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZRI9i/dJMcabDEjRm/LLmdeSne26HGa09EBktk7k/img.png&quot; data-alt=&quot;순차 쓰기 vs 랜덤 쓰기&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZRI9i/dJMcabDEjRm/LLmdeSne26HGa09EBktk7k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZRI9i%2FdJMcabDEjRm%2FLLmdeSne26HGa09EBktk7k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;430&quot; height=&quot;235&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;순차 쓰기 vs 랜덤 쓰기&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;택배 상하차로 비유해보자면 순차 쓰기는 들어오는 택배를 순서대로 쌓으면 되지만, 랜덤 쓰기는 들어오는 택배의 주소지를 알파벳 순으로 정렬해서 맞춰야 합니다. 쓰기 속도가 느릴 수밖에 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;논문이 제시한 해결 아이디어&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;논문의 핵심 아이디어는 한 문장으로 요약됩니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;The LSM-tree uses an algorithm that defers and batches index changes, cascading the changes from a memory-based component through one or more disk components in an efficient manner reminiscent of merge sort.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LSM 트리의 알고리즘은 인덱스 변경을 지연(defer)하고 일괄 처리(batch)해서, &lt;b&gt;메모리 컴포넌트에서 디스크 컴포넌트&lt;/b&gt;로 &lt;b&gt;머지 소트와 유사한 방식으로 전파&lt;/b&gt;하는 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단히 말하면 &quot;&lt;b&gt;인덱스 변경을 즉시 반영하지 말고, 모아서 한꺼번에 순차적으로 쓰자&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 이것이 논문에서 제시한 LSM Tree의 근본 원리입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 아키텍처&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;논문의 원래 설계: C0와 C1&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Jz1my/dJMcaa5Oddu/PkNxOUIdxfJjoCWioYFrCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Jz1my/dJMcaa5Oddu/PkNxOUIdxfJjoCWioYFrCk/img.png&quot; data-alt=&quot;https://www.cs.umb.edu/~poneil/lsmtree.pdf&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Jz1my/dJMcaa5Oddu/PkNxOUIdxfJjoCWioYFrCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJz1my%2FdJMcaa5Oddu%2FPkNxOUIdxfJjoCWioYFrCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;430&quot; height=&quot;144&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;310&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://www.cs.umb.edu/~poneil/lsmtree.pdf&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본 논문에서 LSM Tree는 &lt;b&gt;두 개의 컴포넌트로 구성&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;C0 (&lt;b&gt;메모리&lt;/b&gt; 컴포넌트): 메모리에 저장된 작은 트리. 모든 새 레코드는 먼저 여기에 삽입됩니다.&lt;/li&gt;
&lt;li&gt;C1 (&lt;b&gt;디스크&lt;/b&gt; 컴포넌트): 디스크에 저장된 큰 트리. 완전히 채워진 페이지 노드들로 구성됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1224&quot; data-origin-height=&quot;732&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/N6dPi/dJMcadOVOV5/D2SxBa9sdLEgQa79PQ0Ndk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/N6dPi/dJMcadOVOV5/D2SxBa9sdLEgQa79PQ0Ndk/img.png&quot; data-alt=&quot;https://www.cs.umb.edu/~poneil/lsmtree.pdf&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/N6dPi/dJMcadOVOV5/D2SxBa9sdLEgQa79PQ0Ndk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FN6dPi%2FdJMcadOVOV5%2FD2SxBa9sdLEgQa79PQ0Ndk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;359&quot; data-origin-width=&quot;1224&quot; data-origin-height=&quot;732&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;https://www.cs.umb.edu/~poneil/lsmtree.pdf&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 컴포넌트는 자기가 위치한 저장 매체의 특성에 맞게 최적화됩니다. C0는 메모리 접근 속도에, C1은 디스크의 순차 I/O에 맞춰 튜닝됩니다. (예를 들어, C0는 Red-Black Tree나 Skip List를 사용합니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C0가 임계값에 도달하면, &lt;b&gt;Rolling Merge&lt;/b&gt;라는 &lt;b&gt;프로세스가 C0의 데이터를 C1으로 병합&lt;/b&gt;합니다. 논문에서는 이 과정을 다음과 같이 묘사합니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;The rolling merge process has a conceptual cursor which slowly circulates in quantized steps through equal key values of the C0 tree and C1 tree components, drawing indexing data out from the C0 tree to the C1 tree on disk.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념적인 커서가 C0와 C1의 &lt;b&gt;동일한 키 범위를 순환&lt;/b&gt;하면서, &lt;b&gt;C0에서 데이터를 꺼내 C1의 디스크로 병합하는 것&lt;/b&gt;입니다. 이때 multi-page block 단위로 I/O를 수행하기 때문에 seek time이 거의 발생하지 않습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다중 컴포넌트 확장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;논문은 여기서 멈추지 않고, K+1개의 컴포넌트로 일반화합니다. C0(메모리), C1, C2, ..., CK(디스크) 순으로 크기가 커지고, 인접한 컴포넌트 쌍마다 Rolling Merge가 동작합니다. (C1-C2, C5-C6 ...)&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;C0 (Memory): 가장 작지만 가장 빠른 메모리&lt;/li&gt;
&lt;li&gt;C1, C2, ..., Ck (Disk): 뒤로 갈수록 용량이 커지는 디스크. Ck가 가장 큰 최종 저장소가 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;논문의 Theorem 3.1에 따르면, 모든 인접 컴포넌트의 크기 비율이 동일한 값 r일 때 전체 I/O가 최소화됩니다. 즉 S1/S0 = S2/S1 = ... = SK/SK-1 = r이 되어야 합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;현대적 구현: MemTable + SSTable + Level&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대의 LSM Tree 구현체(RocksDB, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;LevelDB&lt;/span&gt; 등)는 논문의 아이디어를 따라가지만, 용어와 구조가 좀 더 구체화되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1262&quot; data-origin-height=&quot;1220&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dj4nmK/dJMcaibIx40/n5vKS0FavCf1MltBPZY2NK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dj4nmK/dJMcaibIx40/n5vKS0FavCf1MltBPZY2NK/img.png&quot; data-alt=&quot;용어 정리로 앞에 WAL 부분은 생략했습니다. 밑에 나올 예정&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dj4nmK/dJMcaibIx40/n5vKS0FavCf1MltBPZY2NK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdj4nmK%2FdJMcaibIx40%2Fn5vKS0FavCf1MltBPZY2NK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;580&quot; data-origin-width=&quot;1262&quot; data-origin-height=&quot;1220&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;용어 정리로 앞에 WAL 부분은 생략했습니다. 밑에 나올 예정&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;논문 용어&lt;/th&gt;
&lt;th&gt;현대 용어&lt;/th&gt;
&lt;th&gt;설명&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;C0&lt;/td&gt;
&lt;td&gt;MemTable&lt;/td&gt;
&lt;td&gt;메모리의 정렬된 버퍼 (Skip List / Red-Black Tree)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C1, C2, ...&lt;/td&gt;
&lt;td&gt;Level 0, 1, 2, ...&lt;/td&gt;
&lt;td&gt;디스크의 SSTable 파일 계층&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rolling Merge&lt;/td&gt;
&lt;td&gt;Compaction&lt;/td&gt;
&lt;td&gt;하위 레벨 &amp;rarr; 상위 레벨로 데이터 병합&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;MemTable&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쓰기 요청이 들어오면 &lt;b&gt;먼저 MemTable에 기록&lt;/b&gt;됩니다. MemTable은 메모리 내의 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;정렬된&lt;/b&gt;&lt;/span&gt; 자료구조로, 보통 &lt;b&gt;Skip List로 구현&lt;/b&gt;됩니다. &lt;b&gt;삽입과 검색 모두 O(log N)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MemTable은 반드시 &lt;b&gt;WAL(Write-Ahead Log)과 함께 동작&lt;/b&gt;합니다. (⭐️)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 쓰기는 MemTable에 넣기 전에 WAL에 먼저 기록되어, 프로세스 crash 시 복구할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SSTable (Sorted String Table)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MemTable이 임계값(보통 64MB)에 도달하면, &lt;b&gt;내용 전체를 디스크에 정렬된 상태로 flush&lt;/b&gt;합니다. (순차 쓰기)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 만들어진 파일이 SSTable입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSTable의 핵심 특성 세 가지:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;불변(Immutable)&lt;/b&gt;: 한 번 쓰면 절대 수정하지 않습니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정렬(Sorted)&lt;/b&gt;: 키 순서대로 정렬되어 있습니다&lt;/li&gt;
&lt;li&gt;&lt;b&gt;순차 쓰기(Sequential Write)&lt;/b&gt;: 디스크에 처음부터 끝까지 순서대로 씁니다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSTable 내부는 다음과 같이 구성됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1406&quot; data-origin-height=&quot;900&quot;&gt;&lt;a href=&quot;https://medium.com/@dwivedi.ankit21/lsm-trees-the-go-to-data-structure-for-databases-search-engines-and-more-c3a48fa469d2&quot; target=&quot;_blank&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/exL6zM/dJMcacWPPM0/dhIE3DlcJVJErfZt7Xr3e0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FexL6zM%2FdJMcacWPPM0%2FdhIE3DlcJVJErfZt7Xr3e0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;384&quot; data-origin-width=&quot;1406&quot; data-origin-height=&quot;900&quot;/&gt;&lt;/a&gt;&lt;figcaption&gt;https://medium.com/@dwivedi.ankit21/lsm-trees-the-go-to-data-structure-for-databases-search-engines-and-more-c3a48fa469d2&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 동작 원리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;쓰기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쓰기의 흐름을 따라가 보겠습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;쓰기 연산 발생&lt;/li&gt;
&lt;li&gt;WAL에 Append 수행 (순차 쓰기, 1회 디스크 I/O)&lt;/li&gt;
&lt;li&gt;MemTable 삽입 (메모리 연산)&lt;/li&gt;
&lt;li&gt;완료&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디스크 I/O는 &lt;b&gt;WAL의 순차적 append 딱 1회&lt;/b&gt;입니다. B-Tree가 매번 랜덤 위치의 페이지를 읽고-수정하고-다시 쓰는 것과 비교하면, 이 차이가 LSM Tree를 쓰는 가장 큰 이유라고 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;논문에서는 이 효율성을 Batch-Merge Parameter M이라는 개념으로 정량화합니다. C0와 C1의 크기 비율이 클수록 &lt;b&gt;한 번의 디스크 페이지에 더 많은 항목을 배치 병합&lt;/b&gt;할 수 있어 효율이 높아진다는 것입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;읽기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쓰기를 최적화한 대가로, 읽기는 조금 복잡해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최신 데이터가 어디에 있는지 모르기 때문에 &lt;b&gt;위에서부터 아래로 순서대로&lt;/b&gt; 찾아야 합니다. (C0, C1, ..., Ck)&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;MemTable 검색&lt;/li&gt;
&lt;li&gt;Level 0 SSTable 검색&lt;/li&gt;
&lt;li&gt;Level 1 SSTable 검색&lt;/li&gt;
&lt;li&gt;Level 2, 3, ...&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최악의 경우 모든 레벨을 탐색&lt;/b&gt;해야 합니다. &lt;b&gt;LSM Tree의 최대 단점&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 실제로는 &lt;b&gt;몇 가지 장치&lt;/b&gt;가 이 비용을 크게 줄여줍니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Bloom Filter: 각 SSTable에 존재하며 &quot;이 키가 여기 없다&quot;는 것을 O(1)에 판별&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;Block Cache: 자주 읽히는 블록은 메모리에 캐싱&lt;/li&gt;
&lt;li&gt;Index Block: SSTable 내 binary search 지원&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Compaction&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시간이 지나면 SSTable 파일이 계속 쌓입니다. &lt;b&gt;같은 키에 대한 여러 버전이 여러 파일에 흩어지게 됩니다.&lt;/b&gt; 이걸 정리하는 과정이 &lt;b&gt;Compaction&lt;/b&gt;이고, 논문에서의 Rolling Merge에 해당합니다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Compaction 전:
  Level 0:  [a:1, c:3, f:6]  [b:2, c:5, d:4]   &amp;larr; 같은 키 c가 두 파일에
                    │
                    ▼  merge sort + 중복 제거
Compaction 후:
  Level 1:  [a:1, b:2, c:5, d:4, f:6]            &amp;larr; 최신값(c:5)만 유지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;삭제는 어떻게?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 이 부분이 꽤 재미있었는데요, LSM Tree와 같은 불변 구조에서는 파일을 수정할 수 없으니 &lt;b&gt;삭제도 &quot;쓰기&quot;로 처리합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DELETE(id=1) 요청이 오면, MemTable에 (id=1, TOMBSTONE)이라는 특수한 마커를 삽입합니다. 이후 읽기 시 tombstone을 만나면 &quot;이 키는 삭제되었다&quot;고 판단합니다. &lt;b&gt;실제 물리적 삭제는 compaction 때 일어납니다. &lt;/b&gt;INSERT, UPDATE, DELETE 모두 결국 같은 쓰기 경로를 탄다는 점이 중요합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Flink에서 사용하는 LSM Tree&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink에서는 상태 저장소로 RocksDB(LSM Tree 기반)를 사용합니다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Flink + RocksDB&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink의 EmbeddedRocksDBStateBackend은 operator state를 RocksDB에 저장합니다. 스트림 처리에서 이 조합이 잘 맞는 이유는 세 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. &lt;b&gt;스트림 처리는 쓰기 중심&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;// 이벤트마다 상태를 읽고 쓴다
public void processElement(Event event, Context ctx, Collector&amp;lt;Result&amp;gt; out) {
Long count = countState.value();   // 1회 읽기
    countState.update(count + 1);      // 1회 쓰기
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초당 수십만 이벤트가 들어오면, 상태 쓰기 횟수도 초당 수십만 회입니다. 이는 Flink 상태 처리에서 매우 일반적인 상황입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;b&gt;메모리보다 큰 상태&lt;/b&gt;를 다룰 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HashMapStateBackend은 모든 상태를 JVM 힙에 올립니다. 상태가 수십 GB를 넘으면 GC 지옥에 빠져 헤어나오지 못합니다.   RocksDB는 MemTable과 Block Cache만 메모리에 두고 나머지는 디스크에 두기 때문에, TB 규모의 상태도 안정적으로 처리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. &lt;b&gt;Incremental Checkpoint&lt;/b&gt;와 궁합이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SSTable은 불변이기 때문에, 마지막 체크포인트 이후 새로 생긴 SSTable 파일만 업로드하면 됩니다. Flink에서 RocksDB를 쓰는 가장 큰 이유입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;LSM Tree를 사용하는 다른 시스템들&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LSM Tree를 사용하는 다른 시스템들에는 아래가 있습니다. 이 밖에도 엄청 많습니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;시스템&lt;/th&gt;
&lt;th&gt;사용 맥락&lt;/th&gt;
&lt;th&gt;LSM 구현체&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Apache Cassandra&lt;/td&gt;
&lt;td&gt;분산 NoSQL DB&lt;/td&gt;
&lt;td&gt;자체 구현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apache HBase&lt;/td&gt;
&lt;td&gt;대용량 칼럼형 스토어&lt;/td&gt;
&lt;td&gt;자체 구현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kafka Streams&lt;/td&gt;
&lt;td&gt;상태 저장소&lt;/td&gt;
&lt;td&gt;RocksDB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LSM Tree에 대해 찾아보기 전까지는 단순히 옆에 메모리 하나 더 두는 개념으로 생각했었는데요,&amp;nbsp;그 안에서는 WAL로 데이터를 먼저 쓰고, MemTable에 데이터를 모으고, SSTable로 flush하고, Compaction으로 정리하는 일을 반복하고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 핵심은&amp;nbsp;&lt;b&gt;&quot;랜덤 쓰기를 순차 쓰기로 변환한다&quot;&lt;/b&gt;는 아이디어를 바탕으로 쓰기 속도를 극단적으로 높였고, 블룸 필터 등으로 읽기 속도를 개선했다고 볼 수 있습니다. 특히 최근 NoSQL, 시계열 데이터베이스가 성장하면서 이렇게 쓰기 효율을 높이는 방법이 많이 등장하고 있다고 합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고 자료&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;O'Neil, Cheng, Gawlick, O'Neil. &quot;The Log-Structured Merge-Tree (LSM-Tree)&quot;, Acta Informatica, 1996 (&lt;a href=&quot;https://www.cs.umb.edu/~poneil/lsmtree.pdf&quot;&gt;PDF&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://en.wikipedia.org/wiki/Log-structured_merge-tree&quot;&gt;Log-structured merge-tree - Wikipedia&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/facebook/rocksdb/wiki&quot;&gt;RocksDB Wiki&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-docs-stable/docs/ops/state/state_backends/&quot;&gt;Apache Flink - State Backends&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>오픈소스</category>
      <category>flink</category>
      <category>LSM Tree</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/218</guid>
      <comments>https://suhwanc.tistory.com/218#entry218comment</comments>
      <pubDate>Sat, 28 Mar 2026 22:39:53 +0900</pubDate>
    </item>
    <item>
      <title>CASCADE DELETE, Debezium은 알고 있을까?</title>
      <link>https://suhwanc.tistory.com/217</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Debezium 기반의 CDC&lt;/b&gt;(Change Data Capture) 파이프라인을 운영하다 문득 다음과 같은 섬뜩한 의문이 들었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소싱하는 DB의 CASCADE가 발생하는 경우 변경 이벤트 감지를 할 수 있을까?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론은 &lt;b&gt;Debezium 자체는 CASCADE에 대해서 어떠한 처리도 하지 않습니다.&lt;/b&gt; 하지만 소싱 DB의 이벤트 저장 방식에 따라 변경 이벤트는 유실될 수도, 안될 수도 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 MySQL, PostgreSQL, MongoDB 3개 DB에서 Debezium이 cascade를 어떻게 처리하는지를 확인해 보았습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. MySQL - Binlog에서 CASCADE를 표현하는 방식&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;InnoDB FK CASCADE의 내부 동작&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL InnoDB에서 FK CASCADE가 동작하는 흐름은 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;클라이언트가 &lt;code&gt;DELETE FROM orders WHERE order_id = 1001&lt;/code&gt; 실행&lt;/li&gt;
&lt;li&gt;InnoDB 엔진이 FK 제약조건을 확인&lt;/li&gt;
&lt;li&gt;&lt;code&gt;order_items&lt;/code&gt;에 &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;가 있으므로, 해당 자식 행을 InnoDB가 &lt;b&gt;내부적으로 DELETE&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;이 내부 DELETE가 binlog에 &lt;b&gt;별도의 &lt;code&gt;DELETE_ROWS_EVENT&lt;/code&gt;&lt;/b&gt;로 기록됨&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심은 &quot;별도의 row event로 기록된다&quot;는 점입니다.&lt;/b&gt; (⭐️⭐️⭐️)&lt;br /&gt;MySQL binlog의 row event 형식에는 &quot;이것이 cascade에 의한 삭제&quot;를 나타내는 플래그나 메타데이터가 &lt;b&gt;존재하지 않습니다&lt;/b&gt;. 그저 같은 트랜잭션(GTID) 내에서 부모 테이블 DELETE가 먼저 기록되고, 이어서 자식 테이블 DELETE가 기록될 뿐입니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Debezium 소스코드 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/debezium/debezium/blob/5ccd9cd791e764fab692b40ddcd76284af0cd4e8/debezium-connector-binlog/src/main/java/io/debezium/connector/binlog/BinlogStreamingChangeEventSource.java#L897-L911&quot;&gt;&lt;code&gt;BinlogStreamingChangeEventSource.java&lt;/code&gt;&lt;/a&gt;의 &lt;code&gt;handleDelete()&lt;/code&gt; 메서드를 보면 Debezium이 binlog의 DELETE 이벤트를 어떻게 처리하는지 알 수 있습니다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;protected void handleDelete(P partition, O offsetContext, Event event) throws InterruptedException {
    // 단순히 handleChange에 Delete를 넘겨 호출합니다.
    handleChange(partition, offsetContext, event, Envelope.Operation.DELETE, DeleteRowsEventData.class,
            x -&amp;gt; schema.getTableId(x.getTableId()),
            DeleteRowsEventData::getRows,
            (tableId, row) -&amp;gt; eventDispatcher.dispatchDataChangeEvent(partition, tableId,
                    new BinlogChangeRecordEmitter&amp;lt;&amp;gt;(partition, offsetContext, clock, Envelope.Operation.DELETE, row, null, connectorConfig)),
            (tableId, row) -&amp;gt; validateChangeEventWithTable(schema.tableFor(tableId), row, null));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 주목할 점은, Cascade로 인한 삭제인지 직접 삭제인지 구분하는 조건문이나 분기 로직이 전혀 없다는 점입니다.&lt;br /&gt;(단편적으로 이 코드만 봐서 알 수는 없지만, 적어도 debezium 라이브러리에서 MySQL의 cascade 관련 처리 로직은 존재하지 않습니다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실제 이벤트를 비교해 보면&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;orders&lt;/code&gt; 테이블의 부모 행을 직접 DELETE 했을 때 발생하는 Debezium 이벤트:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;before&quot;: {
    &quot;order_id&quot;: 1001,
    &quot;customer_id&quot;: 42,
    &quot;total&quot;: 50000
  },
  &quot;after&quot;: null,
  &quot;source&quot;: {
    &quot;connector&quot;: &quot;mysql&quot;,
    &quot;db&quot;: &quot;ecommerce&quot;,
    &quot;table&quot;: &quot;orders&quot;,
    &quot;gtid&quot;: &quot;aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:42&quot;,
    &quot;file&quot;: &quot;mysql-bin.000003&quot;,
    &quot;pos&quot;: 1081,
    &quot;thread&quot;: 15
  },
  &quot;op&quot;: &quot;d&quot;,
  &quot;ts_ms&quot;: 1711000000000
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 cascade로 자동 삭제된 &lt;code&gt;order_items&lt;/code&gt; 행의 이벤트:&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;before&quot;: {
    &quot;item_id&quot;: 5001,
    &quot;order_id&quot;: 1001,
    &quot;product_name&quot;: &quot;키보드&quot;,
    &quot;price&quot;: 50000
  },
  &quot;after&quot;: null,
  &quot;source&quot;: {
    &quot;connector&quot;: &quot;mysql&quot;,
    &quot;db&quot;: &quot;ecommerce&quot;,
    &quot;table&quot;: &quot;order_items&quot;,
    &quot;gtid&quot;: &quot;aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee:42&quot;,
    &quot;file&quot;: &quot;mysql-bin.000003&quot;,
    &quot;pos&quot;: 1205,
    &quot;thread&quot;: 15
  },
  &quot;op&quot;: &quot;d&quot;,
  &quot;ts_ms&quot;: 1711000000000
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 이벤트를 나란히 놓고 비교하면, 차이점은 &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;pos&lt;/code&gt;뿐입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 &lt;code&gt;gtid&lt;/code&gt;와 &lt;code&gt;thread&lt;/code&gt;는 동일하다는 것을 통해 &lt;b&gt;같은 트랜잭션에서 발생&lt;/b&gt;했다는 것을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 이 공통된 &lt;code&gt;gtid&lt;/code&gt;와 &lt;code&gt;thread&lt;/code&gt;만으로는 &lt;b&gt;&quot;cascade 관계인가&quot;와&lt;/b&gt; &lt;b&gt;&quot;같은 트랜잭션에서 실행된 별개의 DELETE인가&quot;&lt;/b&gt;를 구분할 수 없습니다. 개발자가 하나의 트랜잭션 안에서 &lt;code&gt;orders&lt;/code&gt;와 &lt;code&gt;order_items&lt;/code&gt;를 명시적으로 순서대로 DELETE 했다면, 위와 이벤트 형태가 완전히 동일하기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;code&gt;source&lt;/code&gt; 블록 전체를 살펴봐도 &lt;b&gt;&quot;cascade&quot; 여부를 나타내는 전용 필드가 존재하지 않습니다. MySQL binlog 자체가 해당 정보를 기록하지 않기 때문입니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. PostgreSQL &amp;mdash; WAL에서의 CASCADE 처리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;WAL/pgoutput 기반 CDC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PostgreSQL도 MySQL과 마찬가지로, FK CASCADE로 인한 DELETE를 WAL에 &lt;b&gt;별도 row 이벤트로 기록&lt;/b&gt;합니다.&lt;br /&gt;그리고 Debezium은 &lt;code&gt;pgoutput&lt;/code&gt; 논리 복제 플러그인을 통해 이 이벤트를 수신합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;&lt;a href=&quot;http://(https://github.com/debezium/debezium/blob/5ccd9cd791e764fab692b40ddcd76284af0cd4e8/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/connection/pgoutput/PgOutputMessageDecoder.java#L525)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;PgOutputMessageDecoder.decodeDelete()&lt;/a&gt;&lt;/code&gt;&amp;nbsp;메서드는 WAL의 DELETE 메시지를 처리하며, MySQL과 동일하게 &lt;b&gt;cascade 구분 플래그 없이 모든 DELETE를 동일하게 처리&lt;/b&gt;합니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;예외: TRUNCATE CASCADE&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, Debezium 라이브러리에서 &lt;code&gt;cascade&lt;/code&gt;를 검색하면 딱 하나 PostgreSQL에서 이를 파싱 하는 부분이 있습니다.&lt;br /&gt;바로 TRUNCATE 처리 부분입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/debezium/debezium/blob/5ccd9cd791e764fab692b40ddcd76284af0cd4e8/debezium-connector-postgres/src/main/java/io/debezium/connector/postgresql/connection/pgoutput/PgOutputMessageDecoder.java#L558&quot;&gt;&lt;code&gt;PgOutputMessageDecoder.decodeTruncate()&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;// 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&amp;lt;String&amp;gt; truncateOptions = getTruncateOptions(optionBits);
    // ... 이하 각 테이블별 TRUNCATE 이벤트 생성
}

private List&amp;lt;String&amp;gt; getTruncateOptions(int flag) {
    switch (flag) {
        case 1:  return Collections.singletonList(&quot;CASCADE&quot;);
        case 2:  return Collections.singletonList(&quot;RESTART IDENTITY&quot;);
        case 3:  return Arrays.asList(&quot;RESTART IDENTITY&quot;, &quot;CASCADE&quot;);
        default: return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 주목할만한 점은, 프로토콜의 &lt;code&gt;T&lt;/code&gt; (Truncate) 메시지에 cascade 여부를 &lt;b&gt;비트 플래그로 포함한다는 점&lt;/b&gt;입니다. (PG11+)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉, 앞서 살펴본 MySQL과 달리 프로토콜 레벨에서 cascade 정보 자체는 제공한다는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 이에 대해 Debezium은 &lt;code&gt;getTruncateOptions()&lt;/code&gt;로 이 플래그를 파싱 합니다.&lt;br /&gt;하지만 바로 위의 주석 &lt;code&gt;// ignored / unused&lt;/code&gt;처럼, 파싱 한 결과를 &lt;code&gt;PgOutputTruncateReplicationMessage&lt;/code&gt; 생성 시 전달하지는 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 PostgreSQL이 유일하게 cascade 정보를 프로토콜 레벨에서 제공하지만, 현재 Debezium은 이를 활용하지 않고 있습니다.&lt;br /&gt;아마도 PK CASCADE로 인한 DELETE를 WAL에 &lt;b&gt;별도 row 이벤트로 기록&lt;/b&gt;하기 때문에 굳이 사용하진 않는 것으로 보입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. MongoDB &amp;mdash; CASCADE라는 개념 자체가 없다&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Document DB의 근본적 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MongoDB는 앞의 두 DB와 패러다임 자체가 다릅니다. MongoDB에는 FK 제약조건이 없기 때문에, CASCADE도 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;관련해서 조금 찾아본 바로는, Document DB에서 관련 테이블(Document)의 관리는 전적으로 &lt;b&gt;애플리케이션의 책임&lt;/b&gt;이라고 합니다.&lt;br /&gt;따라서 두 DB가 연관되어 있다면, 애플리케이션 레벨에서 두 테이블 각각에 대해 명시적으로 변경을 해야 한다는 것이죠.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Debezium은 DB 로그를 그대로 복제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 엔진에서 cascade를 일반 DML로 기록하면, Debezium도 일반 DML로 처리합니다.&lt;br /&gt;따로 Debezium에서 cascade에 대한 별도의 처리가 존재하지 않습니다. 이는 Debezium의 설계 철학이기도 한 게, 로그에 기록된 것을 그대로 전달하는 것이 CDC의 기본 원칙입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Cascade가 안 되는 것은 Debezium의 한계가 아니라 DB 로그의 한계&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL binlog와 PostgreSQL WAL 모두 DML 이벤트 레벨에서 cascade 여부를 기록하지 않습니다.&lt;br /&gt;처음부터 Debezium이 cascade를 구분하기 위해선 DB 로그 자체가 해당 정보를 담고 있어야 합니다. 로그에 없는 정보를 Debezium이 만들어낼 수는 없습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 CDC 파이프라인을 설계할 때 cascade 동작을 고려해야 한다면, 원천 DB에서 이벤트를 남기는 포맷을 적절히 설정하는 것이 가장 현실적인 접근입니다. 예를 들어, MySQL의 &lt;code&gt;binlog_format&lt;/code&gt;을 'row'로 설정하는 것처럼 말이죠.&lt;/p&gt;</description>
      <category>오픈소스</category>
      <category>Cascade</category>
      <category>Debezium</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/217</guid>
      <comments>https://suhwanc.tistory.com/217#entry217comment</comments>
      <pubDate>Sat, 21 Mar 2026 12:30:46 +0900</pubDate>
    </item>
    <item>
      <title>Flink CDC MySQL Snapshot은 정말 중복 없이 동작할까?</title>
      <link>https://suhwanc.tistory.com/216</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;의문의 시작&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC의 MySQL snapshot은 청크 단위로 &lt;code&gt;SELECT&lt;/code&gt;문을 이용해 데이터를 복사하고, &lt;code&gt;SHOW MASTER STATUS&lt;/code&gt;문을 이용해 GTIDs 값을 기록합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 binlog streaming 단계로 전환할 때, 모든 청크의 GTIDs 값 중 &lt;b&gt;가장 낮은(오래된) 값&lt;/b&gt;부터 binlog event를 읽기 시작합니다. 이걸 보는데 문득 이런 생각이 들었습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;snapshot에서 이미 가져온 데이터가 binlog에서 다시 나와서, sink가 upsert 모드가 아니면 중복 insert가 쌓이는 것 아닐까?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면, &lt;b&gt;중복은 발생하지 않았습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC는 Netflix의 &lt;a href=&quot;https://arxiv.org/pdf/2010.12597v1.pdf&quot;&gt;DBLog 논문&lt;/a&gt;에 기반한 2단계 중복 방지 메커니즘을 갖고 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 그 과정을 설명합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1단계: Chunk 정규화 (Normalization)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 chunk를 읽을 때는 3개의 step이 순차적으로 실행됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/debezium/task/MySqlSnapshotSplitReadTask.java&quot;&gt;MySqlSnapshotSplitReadTask.java&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// Step 1: Low Watermark 기록
final BinlogOffset lowWatermark = DebeziumUtils.currentBinlogOffset(jdbcConnection);

// Step 2: SELECT 실행 - chunk 데이터 읽기
createDataEvents(ctx, snapshotSplit.getTableId());

// Step 3: High Watermark 기록
highWatermark = DebeziumUtils.currentBinlogOffset(jdbcConnection);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;currentBinlogOffset()&lt;/code&gt;은 내부적으로 &lt;code&gt;SHOW MASTER STATUS&lt;/code&gt;를 실행하여 현재 binlog file, position, GTID set을 가져옵니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그다음이 &lt;b&gt;정규화의 핵심&lt;/b&gt;으로, &lt;a href=&quot;https://github.com/apache/flink-cdc/blob/master/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/debezium/reader/SnapshotSplitReader.java&quot;&gt;&lt;b&gt;SnapshotSplitReader.java&lt;/b&gt;&lt;/a&gt;에서 backfill 과정이 이어집니다&lt;/p&gt;
&lt;pre class=&quot;smali&quot;&gt;&lt;code&gt;// Step 1: execute snapshot read task
SnapshotResult&amp;lt;MySqlOffsetContext&amp;gt; snapshotResult = snapshot(sourceContext);

// Step 2: read binlog events between low and high watermark and backfill changes into snapshot
backfill(snapshotResult, sourceContext);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;backfill은 Low~High Watermark 사이의 binlog을 읽어 snapshot 데이터에 &lt;b&gt;PK 기준 upsert 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 &lt;code&gt;SnapshotSplitReader.pollWithBuffer()&lt;/code&gt; 메서드의 주석이 이를 명확히 설명하고 있습니다&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;// data input:  [low watermark event][snapshot events][high watermark event]
//              [binlog events][binlog-end event]
// data output: [low watermark event][normalized events][high watermark event]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;pollWithBuffer()&lt;/code&gt; 메서드 수행 후 정규화가 되면서 binlog events가 snapshot events에 upsert 되고 normalized events로 바뀐 모습을 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실제 정규화 코드&lt;/b&gt; (&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/c15563860c6ded2c01d515e309927c1027a6e640/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/debezium/reader/SnapshotSplitReader.java#L346-L355&quot;&gt;SnapshotSplitReader.java&lt;/a&gt;):&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;if (!reachBinlogStart) {
    // snapshot 데이터를 PK 기준 Map에 저장
    snapshotRecords.put((Struct) record.key(), Collections.singletonList(record));
} else {
    // binlog 이벤트로 snapshot 데이터를 upsert
    RecordUtils.upsertBinlog(snapshotRecords, record, ...);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;RecordUtils.upsertBinlog()&lt;/code&gt;는 binlog 이벤트의 operation 타입에 따라 동작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/c15563860c6ded2c01d515e309927c1027a6e640/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/utils/RecordUtils.java#L121-L167&quot;&gt;RecordUtils.java L:121-167&lt;/a&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;INSERT(CREATE)&lt;/b&gt;: 해당 PK의 snapshot 레코드를 교체&lt;/li&gt;
&lt;li&gt;&lt;b&gt;UPDATE&lt;/b&gt;: AFTER 이미지로 교체 (단, &lt;b&gt;Split 범위 밖이라면 warn log를 뱉고 처리하지 않음&lt;/b&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;DELETE&lt;/b&gt;: 해당 PK의 레코드를 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 거치면 각 chunk는 &lt;b&gt;High Watermark 시점의 일관된 스냅샷&lt;/b&gt;으로 정규화됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2단계: Binlog Streaming의 Per-Chunk 필터링&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 snapshot chunk가 완료되면 binlog streaming이 시작됩니다. 시작 위치는 모든 chunk의 High Watermark 중 &lt;b&gt;가장 낮은 값&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/c15563860c6ded2c01d515e309927c1027a6e640/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/utils/RecordUtils.java#L374-L387&quot;&gt;RecordUtils.java L:375-386&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;public static BinlogOffset getStartingOffsetOfBinlogSplit(
        List&amp;lt;FinishedSnapshotSplitInfo&amp;gt; finishedSnapshotSplits) {
    BinlogOffset startOffset = finishedSnapshotSplits.get(0).getHighWatermark();
    for (FinishedSnapshotSplitInfo finishedSnapshotSplit : finishedSnapshotSplits) {
        if (finishedSnapshotSplit.getHighWatermark().isBefore(startOffset)) {
            startOffset = finishedSnapshotSplit.getHighWatermark();
        }
    }
    return startOffset;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 맨 처음 의문이 든 &lt;b&gt;&quot;가장 낮은 값부터 시작하면 중복 아니냐?&quot;&lt;/b&gt;라는 질문이 나오게 되었는데, 이를 아래 코드에서 명쾌하게 해결하는 걸 알 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;답은 &lt;code&gt;BinlogSplitReader.shouldEmit()&lt;/code&gt; 메서드입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/c15563860c6ded2c01d515e309927c1027a6e640/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/debezium/reader/BinlogSplitReader.java#L244-L305&quot;&gt;&lt;b&gt;BinlogSplitReader.java L:250-291&lt;/b&gt;&lt;/a&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;/**
* Returns the record should emit or not.
*
* &amp;lt;p&amp;gt;The watermark signal algorithm is the binlog split reader only sends the binlog event that
* belongs to its finished snapshot splits. For each snapshot split, the binlog event is valid
* since the offset is after its high watermark.
*
* &amp;lt;pre&amp;gt; E.g: the data input is :
*    snapshot-split-0 info : [0,    1024) highWatermark0
*    snapshot-split-1 info : [1024, 2048) highWatermark1
*  the data output is:
*  only the binlog event belong to [0,    1024) and offset is after highWatermark0 should send,
*  only the binlog event belong to [1024, 2048) and offset is after highWatermark1 should send.
* &amp;lt;/pre&amp;gt;
*/
private boolean shouldEmit(SourceRecord sourceRecord) {
    // ...
    Object[] chunkKey = SplitKeyUtils.getSplitKey(splitKeyType, nameAdjuster, target);

    FinishedSnapshotSplitInfo matchedSplit =
            SplitKeyUtils.findSplitByKeyBinary(finishedSplitsInfo.get(tableId), chunkKey);

    return matchedSplit != null &amp;amp;&amp;amp; position.isAfter(matchedSplit.getHighWatermark());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;BinlogSplitReader.shouldEmit()&lt;/code&gt;에서는 어떤 binlog 이벤트에 대해 메인 스트림에 내보낼지 여부를 결정합니다. 따라서 만약 값이 false라면 타겟에 반영되지 않는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동작 원리&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;binlog 이벤트의 &lt;b&gt;PK값&lt;/b&gt;으로 어떤 snapshot chunk에 속하는지 binary search&lt;/li&gt;
&lt;li&gt;해당 chunk의 &lt;b&gt;개별 High Watermark 이후&lt;/b&gt;인 이벤트만 emit&lt;/li&gt;
&lt;li&gt;&lt;b&gt;이전이면 버림 (이미 chunk 정규화에서 반영된 데이터)&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구체적 예시&lt;/h2&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;chunk-0: PK [0, 1000)    &amp;rarr; highWatermark = 150
chunk-1: PK [1000, 2000) &amp;rarr; highWatermark = 280
chunk-2: PK [2000, 3000] &amp;rarr; highWatermark = 350

binlog streaming 시작 위치: min(150, 280, 350) = 150&lt;/code&gt;&lt;/pre&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;binlog pos&lt;/th&gt;
&lt;th&gt;이벤트&lt;/th&gt;
&lt;th&gt;PK&lt;/th&gt;
&lt;th&gt;속한 chunk&lt;/th&gt;
&lt;th&gt;HW&lt;/th&gt;
&lt;th&gt;판정&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;155&lt;/td&gt;
&lt;td&gt;INSERT&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;chunk-0&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;155 &amp;gt; 150 &amp;rarr; &lt;b&gt;emit&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;160&lt;/td&gt;
&lt;td&gt;INSERT&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;chunk-1&lt;/td&gt;
&lt;td&gt;280&lt;/td&gt;
&lt;td&gt;160 &amp;lt; 280 &amp;rarr; &lt;b&gt;drop&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;290&lt;/td&gt;
&lt;td&gt;UPDATE&lt;/td&gt;
&lt;td&gt;1500&lt;/td&gt;
&lt;td&gt;chunk-1&lt;/td&gt;
&lt;td&gt;280&lt;/td&gt;
&lt;td&gt;290 &amp;gt; 280 &amp;rarr; &lt;b&gt;emit&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;position 160의 PK=1500 INSERT의 경우 position이 highWatermark보다 낮기 때문에 이미 반영된 것으로 판단 후 버립니다.&lt;br /&gt;이처럼 &lt;b&gt;chunk별 개별 High Watermark 기준 필터링&lt;/b&gt; 덕분에 중복이 발생하지 않는 것을 보장할 수 있습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;예외: backfill skip 옵션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/c15563860c6ded2c01d515e309927c1027a6e640/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/debezium/task/MySqlSnapshotSplitReadTask.java#L178-L186&quot;&gt;MySqlSnapshotSplitReadTask.java&lt;/a&gt;에서 &lt;code&gt;scan.incremental.snapshot.backfill.skip&lt;/code&gt; 옵션이 true이면 backfill을 건너뛰고 High Watermark를 Low Watermark와 동일하게 설정하는 경우가 있습니다.&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;if (isBackfillSkipped) {
    // Directly set HW = LW if backfill is skipped. Binlog events created during snapshot
    // phase could be processed later in binlog reading phase.
    //
    // Note that this behaviour downgrades the delivery guarantee to at-least-once. We can't
    // promise that the snapshot is exactly the view of the table at low watermark moment,
    // so binlog events created during snapshot might be replayed later in binlog reading
    // phase.
    highWatermark = lowWatermark;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우에는 중복 가능성이 존재합니다. 다만 기본값은 false이므로 정상 사용 시 exactly-once가 보장됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;궁금해서 이 옵션이 왜 존재하는지 한 번 찾아봤는데요, 아래와 같은 이슈와 동기를 찾을 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/issues/2553&quot;&gt;https://github.com/apache/flink-cdc/issues/2553&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이슈 &amp;amp; 동기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Exactly-once를 보장하는 &lt;b&gt;이런 Backfill Log 과정&lt;/b&gt;이 때에 따라서는 무겁고, 소스에 부하가 많이 가는 작업일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이슈에서 예시로 든 Postgres의 경우 변경 데이터를 읽기 위해 &lt;b&gt;Replication Slot과 연결이 필요&lt;/b&gt;한데, Parallelism 수에 맞게 개별적인 연결을 유지하면서 + 동시에 스냅샷을 읽는 도중 데이터가 바뀌면 보정(Backfill) 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Parallelism을 4로 두면, 일단 4개의 커넥터가 필요하고, Enumerator에서 관리자용 연결이 또 하나 필요합니다. (일단 5개)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 DB 소스가 10개라면 5 * 10 = 50개나 되어버리는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;b&gt;이 작업은 At-least-once로 처리해 부하를 줄이자!라는&lt;/b&gt; 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어차피 타겟 DB에서 upsert 옵션이 되어있다면, 같은 이벤트가 여러 번 가도 멱등성을 보장하기 때문에 정합성에 문제가 없다는 판단입니다. 이러면 backfill 과정도 생략되며 계속 연결을 유지해야 하는 커넥션의 수도 줄어듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink에서는 Exactly-once를 위해 더 정교한 작업을 처리하려다 보니 이런 이슈도 발생할 수 있는 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 굳이 필요 없다면 &lt;code&gt;scan.incremental.snapshot.backfill.skip&lt;/code&gt; 옵션을 true로 두는 것도 충분히 괜찮은 선택 같네요.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;단계&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;th&gt;핵심 코드&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chunk 정규화&lt;/td&gt;
&lt;td&gt;snapshot + binlog merge로 HW 시점 일관성 확보&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SnapshotSplitReader.pollWithBuffer()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-Chunk 필터링&lt;/td&gt;
&lt;td&gt;PK 기반으로 chunk별 HW 이후 이벤트만 통과&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BinlogSplitReader.shouldEmit()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;가장 낮은 High Watermark부터 binlog을 읽지만, 각 레코드가 속한 chunk의 개별 HW 이후 이벤트만 emit 한다.&quot; 이것이 exactly-once를 달성하는 DBLog 알고리즘의 핵심입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 알고리즘은 &lt;a href=&quot;https://debezium.io/blog/2021/10/07/incremental-snapshots/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Debezium의 Incremental Snapshot&lt;/a&gt;이 동작하는 원리와 동일합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드를 보다 보니 이 부분에 대한 설명이 주석에 잘 적혀있는 것을 보았습니다.&lt;br /&gt;혹시 좀 더 깊게 보고 싶으신 분들은 링크에 표기된 코드 본문을 따라가면 블로그 글에 기재된 내용 이상의 무언가(?)을 얻으실 수도!&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://arxiv.org/pdf/2010.12597v1.pdf&quot;&gt;DBLog: A Watermark Based Change-Data-Capture Framework (Netflix, 2020)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>오픈소스</category>
      <category>flink</category>
      <category>flink-cdc</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/216</guid>
      <comments>https://suhwanc.tistory.com/216#entry216comment</comments>
      <pubDate>Sun, 1 Mar 2026 12:44:12 +0900</pubDate>
    </item>
    <item>
      <title>Flink CDC에서 Upsert가 재처리를 가능하게 하는 이유</title>
      <link>https://suhwanc.tistory.com/215</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 개요&lt;/h2&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;Flink CDC 파이프라인은 &quot;재처리&quot;를 피할 수 없다.&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산 스트리밍 시스템은 이론적으로는 아름답지만, 현실은 변수가 많습니다. 소싱하는 Kafka 토픽의 파티션이 갑자기 늘어나고, 노드가 OOM으로 죽고, 디스크가 가득 차고, GC로 인해 타임아웃이 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apache Flink 기반의 CDC 파이프라인도 마찬가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현시점 기준으로 스트리밍 파이프라인 중 운영 환경에서 장애를 고려하지 않은 시스템은 아마 없을 것이라 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink는 이러한 장애에 대응하기 위해 &lt;b&gt;체크포인트(Checkpoint)&lt;/b&gt; 메커니즘을 제공하고 있습니다. 주기적으로 스트림 처리 상태를 스냅샷으로 저장하고, 장애 발생 시 마지막으로 성공한 체크포인트로부터 복구합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단, 이번 포스팅에서 이야기하고 싶은 내용은 여기서 발생하는 핵심적인 문제입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;체크포인트 이후에 처리되었지만 아직 다음 체크포인트에 포함되지 않은 이벤트들은, 체크포인트 복구 후 &lt;b&gt;다시 한 번 전달&lt;/b&gt;됩니다. 즉, &lt;b&gt;같은 이벤트가 싱크(Sink)에 두 번 이상 도착할 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것은 Flink만의 문제는 아닌 게, Kafka Consumer의 오프셋 리셋, Debezium CDC 소스 커넥터의 binlog position 재설정이 이런 문제를 야기할 수 있고, 심지어 &quot;그냥&quot; 잘못 보내줬던 케이스가 존재할 수도 있어서 &lt;b&gt;실 서비스에서&amp;nbsp;같은 데이터가 두 번 이상 처리되는 상황은 자연스러운 현상입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 항상 섬뜩한 생각이 따라오는데,,&amp;nbsp;&lt;b&gt;&quot;같은 이벤트가 두 번 싱크되면 데이터가 꼬이지 않을까?&quot;라는&lt;/b&gt; 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 답변이 있을 수 있겠지만, 이번 글에서는 Flink CDC 코드베이스의 실제 구현을 통해 이 질문에 대한 답을 알아보려 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론부터 말하면 &lt;b&gt;Upsert 기반의 멱등한 쓰기(idempotent write)&lt;/b&gt;가 그 해답입니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 배경 - Delivery Semantics 3종 비교&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 분산 메시징 시스템에서 메시지 전달 보장(Delivery Semantics)은 세 가지 수준으로 나뉘게 됩니다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;보장 수준&lt;/th&gt;
&lt;th&gt;의미&lt;/th&gt;
&lt;th&gt;비용&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;At-most-once&lt;/td&gt;
&lt;td&gt;유실 가능, 중복 없음&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;At-least-once&lt;/td&gt;
&lt;td&gt;유실 없음, 중복 가능&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exactly-once&lt;/td&gt;
&lt;td&gt;유실 없음, 중복 없음&lt;/td&gt;
&lt;td&gt;높음 (2PC 등 필요)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;At-most-once (최대 한 번)&lt;/b&gt;: 가장 단순하게, 메시지를 보내고 확인하지 않는 방법입니다. 유실은 될 수 있지만 중복은 허용하지 않습니다. 로그 수집처럼 일부 유실이 허용되는 경우에 적합하지만, &lt;b&gt;CDC 파이프라인에서는 데이터 유실이 곧 데이터 불일치이므로 선택지가 될 수 없습니다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Exactly-once (정확히 한 번):&lt;/b&gt; &lt;b&gt;모든 시스템이 원하는 이상적인 방법&lt;/b&gt;입니다. 메시지 유실도 없고, 중복도 없습니다. 이를 달성하려면 2-Phase Commit(2PC)이나 트랜잭셔널 싱크 같은 무거운 메커니즘이 필요하게 됩니다. 분산 환경에서 2PC는 코디네이터 장애, 타임아웃, 참여자 간 불일치 등 수많은 엣지 케이스를 처리해야 하며, 처리량(throughput)도 크게 떨어지게 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;At-least-once (최소 한 번):&lt;/b&gt; 많은 CDC 시스템이 선택하는 &lt;b&gt;현실적인 타협점&lt;/b&gt;입니다. 메시지 유실은 막되, 중복은 허용합니다. Flink에서는 Exactly-once를 제공한다고 하지만, 이는 Flink State의 일관성이고 Flink CDC에서는 동일한 레코드의 Sink가 여러 번 될 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 Flink CDC의 주요 싱크 커넥터들(Iceberg, Paimon, Doris, Fluss..)은 대부분 이 현실적인 선택을 따릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-cdc-docs-release-3.4/docs/connectors/pipeline-connectors/iceberg/#usage-notes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 문서&lt;/a&gt;에서도 아래와 같은 문구가 명시되어 있습니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;Not support exactly-once. The connector uses at-least-once + primary key table for idempotent writing.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;단, 여기서 눈여겨봐야 할 점은 Exactly-once를 구현하지 않아도 upsert + PK 기반 멱등성으로 동일한 결과를 달성할 수 있다는 것입니다. &lt;/b&gt;언뜻 보면 말장난 같은데, 이게 어떻게 가능한지 살펴봅니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 멱등성(Idempotency)이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;멱등성(Idempotency)&lt;/b&gt; 은 수학 용어입니다. 함수 &lt;code&gt;f&lt;/code&gt;가 멱등하다는 것은 다음을 만족하게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;f(f(x)) = f(x)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;같은 연산을 한 번 적용하든 두 번 적용하든, 결과가 동일하다는 뜻입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 절댓값 함수의 경우 &lt;code&gt;||-3|| = |-3| = 3&lt;/code&gt;&amp;nbsp;처럼 여러 번 씌워도 결과는 동일합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 성질이 분산 시스템에서 왜 중요한지는 DB 테이블에 발생 가능한 DML을 통해 직관적으로 이해할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;INSERT는 멱등하지 않습니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;INSERT INTO users VALUES (1, 'Alice')&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;를 두 번 실행하면, 첫 번째는 성공하지만 두 번째는 Primary Key 충돌 에러가 발생합니다. (PK가 없다면 같은 행이 두 개 삽입됩니다.) 어느 쪽이든 &quot;한 번 실행한 것과 같은 결과&quot;가 아니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;UPSERT는 멱등합니다.&lt;/span&gt;&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;UPSERT INTO users VALUES (1, 'Alice')&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;를 두 번 실행하면, 첫 번째는 행을 삽입하고, 두 번째는 이미 존재하는 행을 같은 값으로 덮어씁니다. 결과는 동일하게 &lt;/span&gt;&lt;code style=&quot;letter-spacing: 0px;&quot;&gt;(1, 'Alice')&lt;/code&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 한 행이 남습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;DELETE도 멱등합니다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;/b&gt; &lt;code&gt;DELETE FROM users WHERE pk = 1&lt;/code&gt;을 두 번 실행하면, 첫 번째는 행을 삭제하고, 두 번째는 삭제할 행이 없으므로 아무 일도 하지 않는다. (단, 에러를 던지지 않아야 합니다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주목할 점은 당연하게도 &lt;b&gt;UPSERT&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;UPSERT&lt;/b&gt;의 의미를 한 문장으로 정리하면&amp;nbsp;&lt;b&gt;&quot;존재하면 덮어쓰고, 없으면 삽입한다.&quot;라는&lt;/b&gt; 것이고, 이 연산은 본질적으로 멱등합니다. 같은 PK에 같은 값을 몇 번을 써도 최종 상태는 항상 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;바로 이 성질을 이용해 Flink CDC에서는 at-least-once 환경에서의 중복 전달 문제를 해결합니다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Flink CDC 커넥터별 구현&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 Sink 커넥터들은 이를 어떻게 구현하고 있는지 살펴봅니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. Iceberg: Equality Delete + Checkpoint Deduplication&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Iceberg 싱크 커넥터는 두 단계로 멱등성을 보장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1단계: 이벤트를 Upsert 시맨틱으로 변환&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/2f59f35b9d16d34e1091fa45d986c345518fc082/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/utils/RowDataUtils.java#L32-L53&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;code&gt;RowDataUtils.java&lt;/code&gt;&lt;/a&gt;는 CDC의 &lt;code&gt;DataChangeEvent&lt;/code&gt;를 Iceberg가 이해하는 &lt;code&gt;RowData&lt;/code&gt;로 변환하는 역할을 합니다. 여기서 핵심은 &lt;b&gt;INSERT, UPDATE, REPLACE를 모두 &lt;code&gt;RowKind.INSERT&lt;/code&gt;로 매핑한다는 점&lt;/b&gt;입니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// RowDataUtils.java (Lines 36-53)
// flink-cdc-pipeline-connector-iceberg/.../utils/RowDataUtils.java
switch (dataChangeEvent.op()) {
    case INSERT:
    case UPDATE:
    case REPLACE:
        {
            recordData = dataChangeEvent.after();
            kind = RowKind.INSERT;
            break;
        }
    case DELETE:
        {
            recordData = dataChangeEvent.before();
            kind = RowKind.DELETE;
            break;
        }
    default:
        throw new IllegalArgumentException(&quot;don't support type of &quot; + dataChangeEvent.op());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히&amp;nbsp;UPDATE를 INSERT로 변환해 버리면 기존 행이 남는다고 생각할 수 있는데요, 여기서 Iceberg의 &lt;b&gt;equality-delete&lt;/b&gt; 메커니즘이 등장합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/2f59f35b9d16d34e1091fa45d986c345518fc082/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-iceberg/src/main/java/org/apache/flink/cdc/connectors/iceberg/sink/v2/IcebergWriter.java#L133-L147&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;code&gt;IcebergWriter.java&lt;/code&gt;&lt;/a&gt;에서 &lt;code&gt;RowDataTaskWriterFactory&lt;/code&gt;를 생성할 때, 테이블 스키마의 &lt;code&gt;identifierFieldIds&lt;/code&gt;(= PK 필드 ID 목록)을 같이 전달하게 됩니다.&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;// IcebergWriter.java (Lines 133-147)
// flink-cdc-pipeline-connector-iceberg/.../v2/IcebergWriter.java
private RowDataTaskWriterFactory getRowDataTaskWriterFactory(TableId tableId) {
    Table table = catalog.loadTable(TableIdentifier.parse(tableId.identifier()));
    RowType rowType = FlinkSchemaUtil.convert(table.schema());
    RowDataTaskWriterFactory rowDataTaskWriterFactory =
            new RowDataTaskWriterFactory(
                    table,
                    rowType,
                    DEFAULT_MAX_FILE_SIZE,
                    FileFormat.fromString(DEFAULT_FILE_FORMAT),
                    new HashMap&amp;lt;&amp;gt;(),
                    new ArrayList&amp;lt;&amp;gt;(table.schema().identifierFieldIds()),
                    true);
    rowDataTaskWriterFactory.initialize(taskId, attemptId);
    return rowDataTaskWriterFactory;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;table.schema().identifierFieldIds()&lt;/code&gt;는 Iceberg 테이블 스키마에서 PK(식별자 필드)들의 ID 목록을 가져옵니다. 이 목록이 비어 있지 않으면, Iceberg는 내부적으로 &lt;code&gt;PartitionedDeltaWriter&lt;/code&gt;를 생성하게 됩니다. 이 Writer는 INSERT 행을 기록할 때, 같은 PK를 가진 기존 행을 무효화하는 &lt;b&gt;동등 삭제(equality-delete) 파일&lt;/b&gt;을 함께 생성합니다. 따라서 UPDATE를 &lt;code&gt;RowKind.INSERT&lt;/code&gt;로 내려보내도 equality-delete 파일이 함께 생성되어 올바른 upsert 시맨틱이 보장되는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단, 같은 PK(pk=1)에 대해 UPSERT가 두 번 도착하는 경우엔 두 가지 상황이 존재할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동일한 체크포인트에 포함되지 않는 경우
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;동등 삭제 파일의 경우 이전 체크포인트(커밋)된 데이터 파일에 대해서만 적용이 되기 때문에 두 번째 UPSERT의 데이터만 남게 되어 중복 적용의 영향이 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;두 개의 UPSERT가 동일한 체크포인트에 포함되는 경우
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Writer는 이미 삽입한 키를 insertedRows Map으로 추적하고 있기 때문에 position delete 기록 후 가장 마지막 UPSERT의 데이터만 남깁니다. 결과적으로 둘 다 중복 적용의 영향이 없습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2단계: Checkpoint ID 기반 중복 커밋 방지&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Writer 수준의 멱등성에 더해, Committer 수준에서도 중복된 체크포인트 커밋을 막습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;IcebergCommitter.java&lt;/code&gt;는 커밋 전에 현재 테이블의 스냅샷 히스토리를 조회하여, 같은 Checkpoint ID로 이미 커밋이 되었는지 확인합니다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;// IcebergCommitter.java (Lines 114-127)
// flink-cdc-pipeline-connector-iceberg/.../v2/IcebergCommitter.java
Snapshot snapshot = table.currentSnapshot();
if (snapshot != null) {
    Iterable&amp;lt;Snapshot&amp;gt; ancestors =
            SnapshotUtil.ancestorsOf(snapshot.snapshotId(), table::snapshot);
    long lastCheckpointId =
            getMaxCommittedCheckpointId(ancestors, newFlinkJobId, operatorId);
    if (lastCheckpointId == checkpointId) {
        LOGGER.warn(
                &quot;Checkpoint id {} has been committed to table {}, skipping&quot;,
                checkpointId,
                tableId.identifier());
        continue;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋이 실제로 실행될 때는 Checkpoint ID를 스냅샷 메타데이터에 기록합니다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;// IcebergCommitter.java (Lines 183-192)
private static void commitOperation(
        SnapshotUpdate&amp;lt;?&amp;gt; operation,
        String newFlinkJobId,
        String operatorId,
        long checkpointId) {
    operation.set(SinkUtil.MAX_COMMITTED_CHECKPOINT_ID, Long.toString(checkpointId));
    operation.set(SinkUtil.FLINK_JOB_ID, newFlinkJobId);
    operation.set(SinkUtil.OPERATOR_ID, operatorId);
    operation.commit();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 Iceberg 싱크는 &lt;b&gt;두 단계로 멱등성을 보장합니다.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;step 1 (레코드 수준):&lt;/b&gt; Upsert 시맨틱 + equality-delete로 같은 PK의 중복 레코드가 와도 최종 상태가 동일하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;step 2 (커밋 수준):&lt;/b&gt; Checkpoint ID를 스냅샷에 기록하여, 같은 체크포인트의 파일을 두 번 커밋하는 것 자체를 방지한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. Paimon: LSM-Tree Merge&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apache Paimon은 LSM-Tree 기반의 테이블 스토어로, PK 테이블에서 같은 키의 다중 레코드는 &lt;b&gt;compaction 시 merge됩니다.&lt;/b&gt;&amp;nbsp;Paimon의 merge 전략에서 기본값은 &lt;b&gt;&quot;deduplicate&quot;&lt;/b&gt; -&amp;gt; 즉, &lt;b&gt;같은 PK의 가장 최신 레코드를 유지&lt;/b&gt;하는 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;PaimonWriterHelper.java&lt;/code&gt;는 두 가지 변환 메서드를 제공하는데요. 첫 번째는 &lt;b&gt;단순 변환&lt;/b&gt;으로, &lt;b&gt;Iceberg과 동일하게 INSERT/UPDATE/REPLACE를 모두 &lt;code&gt;RowKind.INSERT&lt;/code&gt;로 매핑하는 방식입니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// PaimonWriterHelper.java (Lines 210-236)
// flink-cdc-pipeline-connector-paimon/.../v2/PaimonWriterHelper.java
public static GenericRow convertEventToGenericRow(
        DataChangeEvent dataChangeEvent, List&amp;lt;RecordData.FieldGetter&amp;gt; fieldGetters) {
    GenericRow genericRow;
    RecordData recordData;
    switch (dataChangeEvent.op()) {
        case INSERT:
        case UPDATE:
        case REPLACE:
            {
                recordData = dataChangeEvent.after();
                genericRow = new GenericRow(RowKind.INSERT, recordData.getArity());
                break;
            }
        case DELETE:
            {
                recordData = dataChangeEvent.before();
                genericRow = new GenericRow(RowKind.DELETE, recordData.getArity());
                break;
            }
        ...
    }
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째는 full changelog 변환으로, UPDATE를 &lt;code&gt;UPDATE_BEFORE&lt;/code&gt; + &lt;code&gt;UPDATE_AFTER&lt;/code&gt; 쌍으로 분리하는 방식입니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// PaimonWriterHelper.java (Lines 239-280)
public static List&amp;lt;GenericRow&amp;gt; convertEventToFullGenericRows(
        DataChangeEvent dataChangeEvent,
        List&amp;lt;RecordData.FieldGetter&amp;gt; fieldGetters,
        boolean hasPrimaryKey) {
    List&amp;lt;GenericRow&amp;gt; fullGenericRows = new ArrayList&amp;lt;&amp;gt;();
    switch (dataChangeEvent.op()) {
        case INSERT:
            {
                fullGenericRows.add(
                        convertRecordDataToGenericRow(
                                dataChangeEvent.after(), fieldGetters, RowKind.INSERT));
                break;
            }
        case UPDATE:
        case REPLACE:
            {
                if (hasPrimaryKey) {
                    fullGenericRows.add(
                            convertRecordDataToGenericRow(
                                    dataChangeEvent.before(),
                                    fieldGetters,
                                    RowKind.UPDATE_BEFORE));
                }
                fullGenericRows.add(
                        convertRecordDataToGenericRow(
                                dataChangeEvent.after(), fieldGetters, RowKind.UPDATE_AFTER));
                break;
            }
        case DELETE:
            {
                if (hasPrimaryKey) {
                    fullGenericRows.add(
                            convertRecordDataToGenericRow(
                                    dataChangeEvent.before(), fieldGetters, RowKind.DELETE));
                }
                break;
            }
        ...
    }
    return fullGenericRows;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 주목할 점은 &lt;code&gt;hasPrimaryKey&lt;/code&gt; 조건입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PK가 있는 테이블에서는 UPDATE_BEFORE + UPDATE_AFTER 쌍을 생성하고, PK가 없으면 UPDATE_AFTER만 생성합니다. PK 테이블의 경우 &lt;code&gt;UPDATE_BEFORE&lt;/code&gt;와 &lt;code&gt;UPDATE_AFTER&lt;/code&gt;가 중복 적용되어도, Paimon의 LSM-Tree merge 과정에서 같은 키의 레코드들은 &quot;가장 최신 값&quot;으로 merge되기 때문에 Compaction이 완료되면 PK당 하나의 최종 행만 남으므로 결과적으로 멱등합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PK가 없는 테이블의 경우 어떤 행을 삭제할지 식별할 키가 없기 때문에 보통 append-only로 쓰입니다. 따라서 UPDATE_AFTER만 생성하게 됩니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. Fluss: PK 유무에 따른 자동 분기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fluss 싱크 커넥터는 테이블에 Primary Key가 있는지 없는지에 따라 완전히 다른 Writer를 생성합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/blob/2f59f35b9d16d34e1091fa45d986c345518fc082/flink-cdc-connect/flink-cdc-pipeline-connectors/flink-cdc-pipeline-connector-fluss/src/main/java/org/apache/flink/cdc/connectors/fluss/sink/v2/FlussSinkWriter.java#L49&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;code&gt;FlussSinkWriter.java&lt;/code&gt;&lt;/a&gt;를 보면&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;// FlussSinkWriter.java (Lines 115-124)
// flink-cdc-pipeline-connector-fluss/.../v2/FlussSinkWriter.java
Table table = connection.getTable(tablePath);
TableWriter writer;
if (table.getTableInfo().hasPrimaryKey()) {
    writer = table.newUpsert().createWriter();
} else {
    writer = table.newAppend().createWriter();
}
tableMap.put(tablePath, table);
writerMap.put(tablePath, writer);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PK가 있으면 &lt;code&gt;UpsertWriter&lt;/code&gt;를, 없으면 &lt;code&gt;AppendWriter&lt;/code&gt;를 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당연하게도 실제 쓰기 시에 Writer 타입에 따라 연산이 분기됩니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;// FlussSinkWriter.java (Lines 160-191)
private CompletableFuture&amp;lt;?&amp;gt; write(
        TableWriter writer, FlussOperationType opType, InternalRow row, TablePath tablePath)
        throws IOException {
    if (writer instanceof UpsertWriter) {
        UpsertWriter upsertWriter = (UpsertWriter) writer;
        if (opType == FlussOperationType.UPSERT) {
            return upsertWriter.upsert(row);
        } else if (opType == FlussOperationType.DELETE) {
            return upsertWriter.delete(row);
        } else {
            throw new UnsupportedOperationException(
                    String.format(
                            &quot;Unsupported operation type: %s for primary key table %s&quot;,
                            opType, tablePath));
        }
    } else if (writer instanceof AppendWriter) {
        AppendWriter appendWriter = (AppendWriter) writer;
        if (opType == FlussOperationType.APPEND) {
            return appendWriter.append(row);
        } else {
            throw new UnsupportedOperationException(
                    String.format(
                            &quot;Unsupported operation type: %s for log table %s&quot;,
                            opType, tablePath));
        }
    } else {
        throw new UnsupportedOperationException(...);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;UpsertWriter&lt;/code&gt;는 &lt;b&gt;upsert와 delete 두 가지 연산만 지원&lt;/b&gt;한다. 위에서 언급했듯이 upsert는 멱등하고 delete도 멱등하므로, 중복 적용에 안전합니다. 반면 &lt;code&gt;AppendWriter&lt;/code&gt;는 &lt;b&gt;append 연산만 지원&lt;/b&gt;합니다. &lt;b&gt;PK가 없는 append-only 테이블에서는 upsert가 불가능하고, 중복 이벤트가 도착하면 같은 행이 두 번 삽입됩니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Paimon에서도 그렇듯, PK가 없는 경우엔 upsert 기반 멱등성의 한계를 보여주게 됩니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Upsert가 재처리를 할 수 있게 하는 3가지 조건&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금까지 Flink CDC에서 여러 싱크 커넥터가 재처리에 대한 중복 적용 위험에 대해 UPSERT 시멘틱을 통해 해결하는 것을 살펴보았습니다. 마지막으로 UPSERT가 재처리를 가능하게끔 하는 필수 요건에 대해 살펴보고 마무리합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PK가 반드시 필요합니다.&lt;/li&gt;
&lt;li&gt;DELETE 연산 시, 없는 레코드를 삭제해도 에러가 발생하면 안 됩니다.&lt;/li&gt;
&lt;li&gt;같은 키에 여러 번 업데이트가 발생했을 때, 가장 마지막에 쓰인 값이 최종 값이 되어야 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;글을 작성하며 여러 공식 문서를 살펴보았는데요, &lt;b&gt;가장 중요한 전제 조건은 &lt;/b&gt;&lt;/span&gt;&lt;b&gt;Primary Key의 존재입니다&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;PK가 없는 테이블에서는 upsert가 불가능하기 때문인데요, 이 밖에도 초기 스냅샷 시 PK가 없다면 정합성이 어긋날 위험도 있는 등 CDC 연동 과정에서 반드시 필요한 필드입니다. 만약 CDC 파이프라인 연동 과정에서 PK가 존재하지 않는 테이블이라면.. 다시 생각해 볼 필요가 있겠습니다.  &lt;/span&gt;&lt;/p&gt;</description>
      <category>CDC</category>
      <category>flink-cdc</category>
      <category>iceberg</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/215</guid>
      <comments>https://suhwanc.tistory.com/215#entry215comment</comments>
      <pubDate>Wed, 18 Feb 2026 11:03:34 +0900</pubDate>
    </item>
    <item>
      <title>[flink-cdc] Iceberg sink connector에서의 default value 지원</title>
      <link>https://suhwanc.tistory.com/214</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4277&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/apache/flink-cdc/pull/4277&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1771029863447&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;[FLINK-39055] [Iceberg] Support default column values in Iceberg sink connector by suhwan-cheon &amp;middot; Pull Request #4277 &amp;middot; apache/&quot; data-og-description=&quot;Summary In the Iceberg table version 3, default value support for columns https://iceberg.apache.org/spec/#version-3-extended-types-and-capabilities Add default column value support for Iceberg...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/apache/flink-cdc/pull/4277&quot; data-og-url=&quot;https://github.com/apache/flink-cdc/pull/4277&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/c568SB/dJMb81GTnMz/XQa6910DKnVFVMlPrLEJTk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/da7Opl/dJMb82eJeAe/sA4Xr5o6l0qZXtKT3LjYSk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4277&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/apache/flink-cdc/pull/4277&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/c568SB/dJMb81GTnMz/XQa6910DKnVFVMlPrLEJTk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/da7Opl/dJMb82eJeAe/sA4Xr5o6l0qZXtKT3LjYSk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[FLINK-39055] [Iceberg] Support default column values in Iceberg sink connector by suhwan-cheon &amp;middot; Pull Request #4277 &amp;middot; apache/&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Summary In the Iceberg table version 3, default value support for columns https://iceberg.apache.org/spec/#version-3-extended-types-and-capabilities Add default column value support for Iceberg...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Iceberg 1.8.0부터 소개된 table format 3에는 &lt;b&gt;칼럼에 대해서 default value를 지원하는 기능이 포함&lt;/b&gt;되어 있습니다. 이에 대응하여 &lt;b&gt;flink-cdc에서도 default value를 지원할 수 있도록 수정&lt;/b&gt;한 내용을 소개합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;배경&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;왜 Default Values가 필요한가?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 CDC(Change Data Capture) 파이프라인을 운영하다 보면, 소스 데이터베이스에서 스키마 변경이 발생하는 것은 피할 수 없습니다. 특히 &lt;span style=&quot;background-color: #9feec3;&quot;&gt;ALTER TABLE ADD COLUMN... DEFAULT 'value'&lt;/span&gt;처럼 &lt;b&gt;기본값이 있는 컬럼을 추가하는 경우, 싱크 테이블에서도&amp;nbsp;이&amp;nbsp;기본값이&amp;nbsp;올바르게&amp;nbsp;반영되어야&amp;nbsp;합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Flink CDC의 Iceberg 싱크 커넥터&lt;/b&gt;는 스키마 변경(컬럼 추가, 삭제, 타입 변경 등)은 지원했지만, &lt;b&gt;칼럼의 기본값(default value)&lt;/b&gt;은 무시하고 있었습니다. 이로 인해 다음과 같은 문제가 발생할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/biBG3i/dJMcahcg5Kh/fykclx0d2ghPiq7WNLNGVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/biBG3i/dJMcahcg5Kh/fykclx0d2ghPiq7WNLNGVK/img.png&quot; data-alt=&quot;AI로 만들어 보았어요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/biBG3i/dJMcahcg5Kh/fykclx0d2ghPiq7WNLNGVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbiBG3i%2FdJMcahcg5Kh%2Ffykclx0d2ghPiq7WNLNGVK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;730&quot; height=&quot;399&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;559&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;AI로 만들어 보았어요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;소스 DB에서 DEFAULT 'active'로 정의된 칼럼이 Iceberg에는 기본값 없이 생성됨 (스키마가 동일하지 않음)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;새로 추가된 컬럼에 값이 없으면 null로 처리되어 소스와 싱크 간 데이터 불일치 발생&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 Iceberg에서 제공하는 API를 이용해 CDC 적재 시 메타데이터를 변경하여 해결해 보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결 방법&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 기본값 파싱 및 변환&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink CDC에서는 default value를 파싱 하는 함수는 기본적으로 지원하는데요, 이를 Iceberg가 이해할 수 있는 타입별 Literal &amp;lt;?&amp;gt; 객체로 변환하는 로직이 추가로 필요했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1771053349308&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Nullable
public static Literal&amp;lt;?&amp;gt; parseDefaultValue(
        @Nullable String defaultValueExpression, DataType cdcType) {
    if (defaultValueExpression == null) {
        return null;
    }
    try {
        switch (cdcType.getTypeRoot()) {
            case CHAR:
            case VARCHAR:
                return Literal.of(defaultValueExpression);
            case BOOLEAN:
                if (&quot;true&quot;.equalsIgnoreCase(defaultValueExpression)) {
                    return Literal.of(true);
                } else if (&quot;false&quot;.equalsIgnoreCase(defaultValueExpression)) {
                    return Literal.of(false);
                } else {
                    LOG.warn(
                            &quot;Invalid boolean default value '{}', skipping default value.&quot;,
                            defaultValueExpression);
                    return null;
                }
            case TINYINT:
            case SMALLINT:
            case INTEGER:
                return Literal.of(Integer.parseInt(defaultValueExpression));
            case BIGINT:
                return Literal.of(Long.parseLong(defaultValueExpression));
            case FLOAT:
                return Literal.of(Float.parseFloat(defaultValueExpression));
            case DOUBLE:
                return Literal.of(Double.parseDouble(defaultValueExpression));
            case DECIMAL:
                int scale = DataTypes.getScale(cdcType).orElse(0);
                return Literal.of(
                        new java.math.BigDecimal(defaultValueExpression)
                                .setScale(scale, java.math.RoundingMode.HALF_UP));
            default:
                LOG.warn(
                        &quot;Unsupported default value type {} for expression '{}', skipping default value.&quot;,
                        cdcType.getTypeRoot(),
                        defaultValueExpression);
                return null;
        }
    } catch (NumberFormatException e) {
        LOG.warn(
                &quot;Failed to parse default value '{}' for type {}, skipping default value.&quot;,
                defaultValueExpression,
                cdcType.getTypeRoot(),
                e);
        return null;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지원하는 타입은 Flink CDC의 파싱 로직에 의존합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 &lt;span style=&quot;background-color: #9feec3;&quot;&gt;uuid(), CURRENT_TIMESTAMP()와&lt;/span&gt; 같은 불명확한 default value 값의 경우 처리하지 않는 것을 원칙으로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;2. 스키마 변경 시 default values 적용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;default values가 적용되는 시나리오는 두 가지입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;#1. CREATE TABLE (테이블 최초 생성 시)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소스 DB 테이블을 Iceberg에 최초 생성할 때, 칼럼에 정의된 기본값을 함께 설정합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1771053702927&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 테이블 생성 후 기본값 적용
Table table = catalog.createTable(tableIdentifier, icebergSchema, partitionSpec,
 tableOptions);
applyDefaultValues(table, cdcSchema);


// applyDefaultValues는 Iceberg의 updateColumnDefault API를 사용합니다
private void applyDefaultValues(Table table, Schema cdcSchema) {
    UpdateSchema updateSchema = null;
    for (Column column : cdcSchema.getColumns()) {
        Literal&amp;lt;?&amp;gt; defaultValue = IcebergTypeUtils.parseDefaultValue(
                column.getDefaultValueExpression(), column.getType());
        if (defaultValue != null) {
            if (updateSchema == null) {
                updateSchema = table.updateSchema();
            }
            updateSchema.updateColumnDefault(column.getName(), defaultValue);
        }
    }
    if (updateSchema != null) {
        updateSchema.commit();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 주석에도 명시했듯이 Iceberg의 updateColumnDefault API를 사용해 칼럼명과 기본값을 넘겨주면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 Iceberg 메타데이터에 &lt;b&gt;write-default&lt;/b&gt;를 설정합니다. 테이블이 방금 생성되어 기존 데이터가 없으므로, 이후 쓰기 시 기본값이 적용됩니다. write-default에 대해서는 밑에서 좀 더 자세히 설명하겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;#2. ADD COLUMN (칼럼을 추가할 때)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운영 중인 테이블에 default values를 가진 새 칼럼이 추가되는 상황입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1771053860002&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Literal&amp;lt;?&amp;gt; defaultValue = IcebergTypeUtils.parseDefaultValue(
        addColumn.getDefaultValueExpression(), addColumn.getType());
if (defaultValue != null) {
    updateSchema.addColumn(columnName, icebergType, columnComment,
defaultValue);
} else {
    updateSchema.addColumn(columnName, icebergType, columnComment);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경우 좀 더 단순하게 Iceberg의 addColumn API에 기본값을 같이 넣어 전달합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 Iceberg 메타데이터에 &lt;b&gt;initial-default, write-default 모두를 설정&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;initial-default, write-default 설명&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;write-default:&amp;nbsp;이후&amp;nbsp;새로운&amp;nbsp;행을&amp;nbsp;쓸&amp;nbsp;때,&amp;nbsp;해당&amp;nbsp;칼럼에&amp;nbsp;값이&amp;nbsp;없으면&amp;nbsp;이&amp;nbsp;기본값&amp;nbsp;사용&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;initial-default: 컬럼 추가 이전에 이미 기록된 데이터 파일을 읽을 때, 해당 칼럼의 기본값으로 사용 (Iceberg format v3부터 사용 가능)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Iceberg가 사용하는 메타데이터 구조의 최적화된 성질로 인해 이런 값을 추가로 넣는 것으로 보입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;굳이 데이터 파일에 default 값을 전부 넣을 필요 없이 조회 시점에 기본 값을 채워 넣는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;주의 사항&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;initial-default는 Iceberg format v3에서 지원하는데요, 이게 쿼리 엔진(Spark, trino..)에서 이 기능을 지원하는지 확인이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이 키 값은 쿼리 엔진의 영향을 크게 받기 때문입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spark의 경우 Iceberg 1.8.0+에서 parquet reader로 initial default 값에 대한 처리를 지원해 주는 것으로 확인됩니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;(&lt;a style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot; href=&quot;https://github.com/apache/iceberg/pull/11803&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/apache/iceberg/pull/11803&lt;/a&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>오픈소스</category>
      <category>flink-cdc</category>
      <category>iceberg</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/214</guid>
      <comments>https://suhwanc.tistory.com/214#entry214comment</comments>
      <pubDate>Sat, 14 Feb 2026 16:31:34 +0900</pubDate>
    </item>
    <item>
      <title>Iceberg - Deletion Vectors 기능 탐구</title>
      <link>https://suhwanc.tistory.com/213</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://iceberg.apache.org/spec/?h=deletion+vec&quot;&gt;https://iceberg.apache.org/spec/?h=deletion+vec&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770450972219&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Spec - Apache Iceberg&amp;trade;&quot; data-og-description=&quot;Iceberg Table Spec This is a specification for the Iceberg table format that is designed to manage a large, slow-changing collection of files in a distributed file system or key-value store as a table. Format Versioning Versions 1, 2 and 3 of the Iceberg s&quot; data-og-host=&quot;iceberg.apache.org&quot; data-og-source-url=&quot;https://iceberg.apache.org/spec/?h=deletion+vec&quot; data-og-url=&quot;https://iceberg.apache.org/spec/?h=deletion+vec&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/uDSsn/dJMb8Z3mQMr/85vb4KfsyzpGjDBaD0xPY1/img.png?width=1248&amp;amp;height=1290&amp;amp;face=0_0_1248_1290&quot;&gt;&lt;a href=&quot;https://iceberg.apache.org/spec/?h=deletion+vec&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://iceberg.apache.org/spec/?h=deletion+vec&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/uDSsn/dJMb8Z3mQMr/85vb4KfsyzpGjDBaD0xPY1/img.png?width=1248&amp;amp;height=1290&amp;amp;face=0_0_1248_1290');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Spec - Apache Iceberg&amp;trade;&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Iceberg Table Spec This is a specification for the Iceberg table format that is designed to manage a large, slow-changing collection of files in a distributed file system or key-value store as a table. Format Versioning Versions 1, 2 and 3 of the Iceberg s&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;iceberg.apache.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Iceberg table format version 3에 소개된 Deletion Vectors 기능에 대해 살펴봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;탄생 배경&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Iceberg는 기존에 다음과 같은 문제점&lt;/b&gt;을 겪고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-size=&quot;size16&quot; data-ke-style=&quot;style1&quot;&gt;쓰기&amp;nbsp;효율(MoR)과&amp;nbsp;읽기&amp;nbsp;성능(CoW)&amp;nbsp;사이의&amp;nbsp;고질적인&amp;nbsp;트레이드오프,&amp;nbsp;그리고&amp;nbsp;삭제&amp;nbsp;파일&amp;nbsp;누적으로&amp;nbsp;인한&amp;nbsp;성능&amp;nbsp;저하&amp;nbsp;문제&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 &lt;a href=&quot;https://iceberg.apache.org/spec/#version-2-row-level-deletes&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Iceberg의 테이블 버전 2&lt;/a&gt;에는 데이터를 업데이트 하거나 삭제하는 Write 과정에서 두 가지 전략을 사용할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Copy on Write (CoW)&lt;/b&gt;: &lt;b&gt;새로운 데이터 파일을 만들고, 다음부터 이 버전의 파일을 사용&lt;/b&gt;하도록 합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Merge on Read (MoR)&lt;/b&gt;: 수정/삭제된 정보만 &lt;b&gt;별도의 삭제 파일에 기록&lt;/b&gt;합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQkKM4/dJMcachHTXA/XkB9cLSvhvyqeefeZwjYC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQkKM4/dJMcachHTXA/XkB9cLSvhvyqeefeZwjYC0/img.png&quot; data-origin-width=&quot;1398&quot; data-origin-height=&quot;818&quot; data-is-animation=&quot;false&quot; width=&quot;464&quot; height=&quot;271&quot; style=&quot;width: 55.4137%; margin-right: 10px;&quot; data-widthpercent=&quot;56.07&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQkKM4/dJMcachHTXA/XkB9cLSvhvyqeefeZwjYC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQkKM4%2FdJMcachHTXA%2FXkB9cLSvhvyqeefeZwjYC0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1398&quot; height=&quot;818&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YwAL1/dJMcafS19Ur/skfz5VTF8QgzRKkmblAgi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YwAL1/dJMcafS19Ur/skfz5VTF8QgzRKkmblAgi1/img.png&quot; data-origin-width=&quot;1358&quot; data-origin-height=&quot;1014&quot; data-is-animation=&quot;false&quot; width=&quot;528&quot; height=&quot;394&quot; style=&quot;width: 43.4235%;&quot; data-widthpercent=&quot;43.93&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YwAL1/dJMcafS19Ur/skfz5VTF8QgzRKkmblAgi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYwAL1%2FdJMcafS19Ur%2Fskfz5VTF8QgzRKkmblAgi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1358&quot; height=&quot;1014&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;출처: https://medium.com/@amananand1701/efficient-data-management-with-apache-iceberg-cow-vs-mor-b7d6fb95f36c&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이 두 전략은 다음과 같은 &lt;b&gt;장단점&lt;/b&gt;이 존재합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CoW&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Write: &lt;b&gt;새로운 파일을 만드는 것에 대한 오버헤드가 존재&lt;/b&gt;합니다. (Bad)&lt;/li&gt;
&lt;li&gt;Read: 그대로 읽기만하면 되므로 매우 빠릅니다. (Good)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MoR&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Write: 변경된 내용만 기록하면 되므로 쓰기 속도가 매우 빠릅니다. (Good)&lt;/li&gt;
&lt;li&gt;Read: &lt;b&gt;데이터를 읽을 때 삭제 파일과 비교 과정을 거치므로 오버헤드가 존재&lt;/b&gt;합니다. (Bad)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 MoR 방식은 삭제 파일을 만든다고 언급했는데요, 이 삭제 파일의 종류는 두 가지가 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Equality delete&lt;/b&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;삭제해야 할 레코드의 PK 값을 저장합니다.&lt;/li&gt;
&lt;li&gt;예를 들어, id = 100인 행을 모두 삭제 (Upsert와 비슷한 동작 방식)&lt;/li&gt;
&lt;li&gt;데이터를 읽을 때 &lt;b&gt;모든 레코드를 삭제 파일에 있는 값들과 일일이 비교(Join) 해야 하므로 &lt;span style=&quot;color: #ee2323;&quot;&gt;읽기 성능&lt;/span&gt;이 떨어질 수 있습니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Position delete&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;삭제해야 할 데이터의 정확한 위치를 기록합니다.&lt;/li&gt;
&lt;li&gt;파일의&amp;nbsp;경로(File&amp;nbsp;Path)와&amp;nbsp;해당&amp;nbsp;파일&amp;nbsp;내의&amp;nbsp;행&amp;nbsp;번호(Row&amp;nbsp;Position)를&amp;nbsp;저장합니다.&lt;/li&gt;
&lt;li&gt;삭제하려는 데이터가 &lt;b&gt;정확히 어느 파일의 몇 번째 줄에 있는지 먼저 찾아내야하므로&lt;/b&gt; &lt;b&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;쓰기 시점&lt;/span&gt;에 약간의 오버헤드가 발생&lt;/b&gt;할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 트레이드오프가 있기 때문에 기존의 사용자들은 자신의 프로젝트 특성에 알맞게 CoW, MoR 중 하나를 고르고는 했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;대표적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;CDC 프로젝트의 경우 실시간으로 파일을 쓰는 연산이 주&lt;/b&gt;를 이루기 때문에,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Iceberg에 데이터를 적재하는 경우 MoR + Equality delete 방식&lt;/b&gt;이 좀 더 적합할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;여기서 Iceberg는 MoR 방식 중 &lt;b&gt;Position delete 과정을 더 효율적으로 최적화&lt;/b&gt;하기 시작합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Partition-scoped Deletes (AS-IS)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 파티션 내 데이터가 삭제되면, &lt;b&gt;해당 파티션에 속한 Position Delete 파일이 생성&lt;/b&gt;된다.&lt;/li&gt;
&lt;li&gt;예를 들어, date=2026-02-07 파티션에 Equality Delete 파일이 생성되면, Iceberg 엔진은 &lt;b&gt;해당 날짜 파티션의 어떤 데이터 파일을 읽더라도 반드시 이 삭제 파일을 불러와서 비교(Join) 해야 합니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;File-scoped Deletes (TO-BE)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Iceberg v1.8.0에 나온 삭제 방식으로, table format 2 설정 시 동작합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;삭제&amp;nbsp;파일이&amp;nbsp;특정&amp;nbsp;데이터&amp;nbsp;파일에만&amp;nbsp;종속되도록&amp;nbsp;범위를&amp;nbsp;좁히는&amp;nbsp;방식&lt;/b&gt;입니다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;쓰기 시점:&lt;/b&gt; &lt;b&gt;삭제 파일이 어떤 데이터 파일들에 적용되는지 명시&lt;/b&gt;&amp;nbsp;(데이터 파일 A, B, C에 영향)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;읽기 시점:&lt;/b&gt; 데이터 파일 A를 읽을 때, A에 링크를 걸어둔 삭제 파일만 확인합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무래도 File-scoped 방식이 쓰기 시점에는 조금 느리더라도&amp;nbsp;&lt;b&gt;삭제 파일을 찾는 과정이 워낙 빠르다 보니&lt;/b&gt; &lt;b&gt;읽기에서 압도적으로 좋습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 과정도 결국엔 &lt;b&gt;삭제 파일을 생성&lt;/b&gt;해야 하며, &lt;b&gt;삭제 파일 조인의 유혹&lt;/b&gt;(?)을 피할 수 없게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deletion Vectors는 이런 문제를 &lt;b&gt;파일이 아닌 벡터, 비트맵 단위로 해결&lt;/b&gt;합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Deletion Vectors&lt;/b&gt;&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제는 삭제 &lt;b&gt;파일&lt;/b&gt;이 아니라, 삭제 &lt;b&gt;벡터&lt;/b&gt;입니다. 본능적으로 일단 더 효율적일 것만 같습니다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1518&quot; data-origin-height=&quot;752&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bu1ITi/dJMcafMhGCA/k2X4zykEHUBd8uYjOkBYyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bu1ITi/dJMcafMhGCA/k2X4zykEHUBd8uYjOkBYyK/img.png&quot; data-alt=&quot;출처: https://www.starburst.io/blog/iceberg-v3/&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bu1ITi/dJMcafMhGCA/k2X4zykEHUBd8uYjOkBYyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbu1ITi%2FdJMcafMhGCA%2Fk2X4zykEHUBd8uYjOkBYyK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1518&quot; height=&quot;752&quot; data-origin-width=&quot;1518&quot; data-origin-height=&quot;752&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처: https://www.starburst.io/blog/iceberg-v3/&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Deletion Vector(DV)&lt;/b&gt;는 기존의 Position Delete 방식을 훨씬 더 가볍고 빠르게 개선한 방식으로, &lt;b&gt;삭제된 위치 정보를 파일 형태가 아니라 비트맵(Bitmap) 형태로 압축해서 관리하는 기술&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Deletion Vector의 특징&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Bitmap을 활용한 초고속 검색&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삭제된 행의 위치(Position P)를 0과 1로 이루어진 비트맵에 표시합니다. 예를 들어, 5번째 행이 삭제되었다면 비트맵의 5번 인덱스를 1(Set)로 바꿉니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Position Delete 파일은 &lt;b&gt;삭제된 번호 목록을 일일이 읽어서 비교&lt;/b&gt;해야 했지만, DV는 메모리 효율이 극도로 높은 &lt;b data-index-in-node=&quot;77&quot; data-path-to-node=&quot;4,1,0&quot;&gt;Roaring Bitmap&lt;/b&gt;을 사용하여 &lt;b&gt;특정 행이 삭제되었는지 즉시(O(1)에 가깝게) 확인&lt;/b&gt;할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Roaring Bitmaps을 사용한 위치 정보 최적화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Roaring Bitmaps은 대규모 데이터 시스템 전반에 사용되는 비트맵 관리 전략입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DV에서는 &lt;b&gt;32비트 Key/Value 분할 방식을 사용&lt;/b&gt;합니다. 앞의 32비트는 Key, 뒤의 32비트는 Sub-position으로 나눕니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Key 값은 어느 구역(Bucket)에 속하는가?&lt;/b&gt; 를 의미하고, &lt;b&gt;Sub-position은 그 구역 안에서 정확히 몇 번째 행인가?&lt;/b&gt;를 결정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 기반으로 매우 큰 숫자를 저장할 때 공간 효율적인 특성을 가집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Roaring Bitmaps은 그 자체로 상당히 복잡한 방식이라 잘 정리된 문서를 보시는 것을 추천드립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://machine-learning-made-simple.medium.com/an-introduction-to-roaring-bitmaps-for-software-engineers-dd98859dd29a&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://machine-learning-made-simple.medium.com/an-introduction-to-roaring-bitmaps-for-software-engineers-dd988 59dd29a&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770454294272&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;An Introduction to Roaring Bitmaps for Software Engineers&quot; data-og-description=&quot;How Roaring Bitmaps improve sets.&quot; data-og-host=&quot;machine-learning-made-simple.medium.com&quot; data-og-source-url=&quot;https://machine-learning-made-simple.medium.com/an-introduction-to-roaring-bitmaps-for-software-engineers-dd98859dd29a&quot; data-og-url=&quot;https://machine-learning-made-simple.medium.com/an-introduction-to-roaring-bitmaps-for-software-engineers-dd98859dd29a&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/oNC9U/dJMb9g46JKY/J5FuE4bKBIiOtDsARGqEy1/img.png?width=534&amp;amp;height=340&amp;amp;face=0_0_534_340&quot;&gt;&lt;a href=&quot;https://machine-learning-made-simple.medium.com/an-introduction-to-roaring-bitmaps-for-software-engineers-dd98859dd29a&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://machine-learning-made-simple.medium.com/an-introduction-to-roaring-bitmaps-for-software-engineers-dd98859dd29a&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/oNC9U/dJMb9g46JKY/J5FuE4bKBIiOtDsARGqEy1/img.png?width=534&amp;amp;height=340&amp;amp;face=0_0_534_340');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;An Introduction to Roaring Bitmaps for Software Engineers&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;How Roaring Bitmaps improve sets.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;machine-learning-made-simple.medium.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. Puffin Files&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;DV는 일반 데이터 파일이 아닌, &lt;b&gt;Iceberg 전용 통계 저장 포맷인 Puffin File 내에 Blob의 형태로 저장&lt;/b&gt;됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;기존 MoR 방식에서는 삭제 파일의 형태는 일반적으로 parquet/ORC로 디스크에 저장되어 처리 시 Deserialization 과정을 거쳤는데요,&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp;&lt;b&gt;Puffin 파일로 저장할 경우 이러한 변환 과정(&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Deserialization)&lt;/span&gt;이 생략되기 때문에 I/O 효율이 매우 좋아집니다.&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;또한 기존 Position Delete 방식에서는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;데이터 파일 하나에 여러 개의 삭제 파일이 연결&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;될 수 있었습니다. 이런 성향으로 인해 수 많은 삭제 파일이 쌓이면서 I/O 성능을 저하시키는 현상이 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만 &lt;b&gt;Puffin File에는 여러 개의 DV를 넣을 수 있기 때문에 삭제 파일이 과하게 생기는 문제를 해결&lt;/b&gt;할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;4. 데이터 파일 당 최대 하나의 DV&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만 DV에서는 특정 행에 삭제가 아무리 많이 일어나도,&lt;b&gt; 하나의 데이터 파일에는 논리적으로 최대 한 개의 DV가 생기는 것을 보장&lt;/b&gt;합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;만약 어떤 데이터 파일에 DV가 쓰였고, 이 데이터 파일에 &lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;변화가 일어나면 Bitwise OR 연산&lt;/b&gt;을 하기 때문에, 하나의 DV(비트맵)로 관리할 수 있는 것이죠.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이는 삭제 파일 수의 감소뿐만아니라, 로직으로도 데이터 파일을 가져올 때 최대 한 개의 DV만 필터링하면 되므로 꽤나 단순해지는 효과가 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;정리하자면 Deletion Vectors 방식은 쓰기 및 저장 방식이 복잡해졌지만 읽기/쓰기의 성능 효율이 매우 좋아졌습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;연산 속도 개선&lt;/b&gt;: Bitmap을 활용해 Read/Write 연산 속도가 아주 빨라졌습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;저장 방식 효율화&lt;/b&gt;: Roaring Bitmap으로 저장 방식이 효율적으로 개선되었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;디스크 I/O 개선&lt;/b&gt;: Puffin 파일 형태로 저장하여 디스크 I/O를 개선할 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;삭제 파일 감소 및 일관성 보장&lt;/b&gt;: &quot;데이터 파일 당 최대 하나의 DV&quot;라는 일관성을 보장할 수 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;사용자 입장에서는 데이터가 어떻게 변화되었든 단 하나의 비트맵 필터만 거치면 되기 때문에, MoR 방식임에도 불구하고 CoW에 근접하는 읽기 성능을 낼 수 있게 된 것입니다.&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여담&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Deletion Vectors의 메인 아이디어는 Iceberg에서 독자적으로 생각한 것이라기 보단, Databricks의 Delta Lake의 아이디어에서 파생된 것이라고 합니다. (출처 - Iceberg 공식 유튜브 소개)&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://iceberg.apache.org/puffin-spec/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://iceberg.apache.org/puffin-spec/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://lestermartin.blog/2025/10/08/understanding-iceberg-deletion-vectors-and-enjoying-some-humble-pie/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://lestermartin.blog/2025/10/08/understanding-iceberg-deletion-vectors-and-enjoying-some-humble-pie/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.youtube.com/watch?v=WqViqjpLsnE&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/watch?v=WqViqjpLsnE&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>deletion vectors</category>
      <category>iceberg</category>
      <category>puffin file</category>
      <category>table format 3</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/213</guid>
      <comments>https://suhwanc.tistory.com/213#entry213comment</comments>
      <pubDate>Sat, 7 Feb 2026 18:24:56 +0900</pubDate>
    </item>
    <item>
      <title>OLAP 시스템 디자인 기초</title>
      <link>https://suhwanc.tistory.com/212</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1528&quot; data-origin-height=&quot;950&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgnSw7/dJMcaf6yGgR/vvjAdl2iSHwJul0nJJeJuk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgnSw7/dJMcaf6yGgR/vvjAdl2iSHwJul0nJJeJuk/img.png&quot; data-alt=&quot;System Design for OLAP Workloads&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgnSw7/dJMcaf6yGgR/vvjAdl2iSHwJul0nJJeJuk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgnSw7%2FdJMcaf6yGgR%2FvvjAdl2iSHwJul0nJJeJuk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1528&quot; height=&quot;950&quot; data-origin-width=&quot;1528&quot; data-origin-height=&quot;950&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;System Design for OLAP Workloads&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OLAP(Online Analytical Processing) 시스템의 가장 기본적인 컴포넌트 구조는 위와 같이 정리할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기초적인 컴포넌트부터 알아봅시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Storage&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OLAP은 기본적으로 방대한 데이터를 저장하고, 분석하는 것을 목표로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 아주 적은 데이터라면 굳이 OLAP을 쓸 필요 없이 OLTP로 트랜잭션, 분석을 동시에 해도 상관 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OLAP에서 사용하는 스토리지는 이런 선택지가 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;로컬 파일 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 파일 시스템 (HDFS)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;객체 스토리지 (S3)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 저장 방식의 유형과 관련해, &lt;b&gt;행 지향(Row-oriented) 데이터베이스&lt;/b&gt;와 &lt;b&gt;열 지향(Columnar) 데이터베이스&lt;/b&gt;를 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근에는 &lt;b&gt;열 지향 데이터베이스가 방대한 양의 데이터를 처리할 때 더 효율적임이 입증되면서 많이 사용&lt;/b&gt;하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 분석 시스템에서는 &lt;b&gt;특정 항목의 통계&lt;/b&gt;를 내는 일이 많은데, 예를 들면 &quot;지난달의 총 매출은?&quot;, &quot;어제 방문자 수는?&quot; 이런 분석을 할 때 &lt;b&gt;&quot;매출&quot; 또는 &quot;방문자 ID&quot;에 해당하는 컬럼의 데이터만 집계하면 되기 때문에 열 지향 데이터베이스가 효율적&lt;/b&gt;인 경우가 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;File format&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일 포맷은 일반적으로 세 가지 범주가 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;정형(CSV)&lt;/li&gt;
&lt;li&gt;반정형(JSON)&lt;/li&gt;
&lt;li&gt;비정형(Text)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 정형과 반정형 범주에서 파일 포맷은 또 행 지향(Row-Oriented), 열 지향(Columnar)으로 나뉩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 &lt;b&gt;행 지향 파일 포맷에는 CSV, Avro, 열 지향 파일 포맷에는 Parquet, ORC&lt;/b&gt;가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특징은 위 데이터베이스에서 설명한 것과 마찬가지인데, &lt;b&gt;그럼 행 지향이 유리한 경우는 없을까?&lt;/b&gt; 물어본다면 다음과 같은 상황이 있을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;매번 적은 행의 데이터만 추출하고자 할 때&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 10억 건의 데이터가 있고, 그 중 100개의 행을 추출하고자 할 때&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;행 지향의 경우 100번의 접근을 하면 됩니다. 같은 행에 있는 데이터는 모두 붙어있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;열 지향의 경우 행 마다 100개의 다른 컬럼 파일에 접근해야 합니다. 같은 컬럼끼리 데이터가 붙어있기 때문입니다. 따라서 1만 번의 읽기 연산이 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Table format&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테이블 포맷은 &lt;b&gt;파일 포맷 상단의 메타데이터 레이어의 역할&lt;/b&gt;을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 데이터 파일들이 스토리지에 어떻게 배치되어야 하는지를 규정하며, 사용자가 수천 개의 데이터 파일을 직접 관리할 필요 없이 마치 하나의 테이블처럼 다룰 수 있도록 도와줍니다. 직접 쿼리를 할 때는 하나의 데이블에 대해서만 작성하므로 테이블 포맷에서 이를 추상화했다고 볼 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 ACID 트랜잭션을 보장해주기 때문에, 데이터 레이크에서도 마치 OLTP처럼 안전한 CRUD 연산이 가능해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Storage engine&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테이블 포맷이 규정한 형태로 데이터를 배치해주는 역할&lt;/b&gt;을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 물리적인 &lt;b&gt;데이터 최적화, 인덱스 관리, 오래된 데이터의 삭제(Iceberg의 maintenance)&lt;/b&gt; 역할을 맡습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Catalog&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 중앙에 위치하여 &lt;b&gt;테이블의 메타데이터를 활용해 데이터를 최대한 빠르게 찾을 수 있도록 도와주는 역할&lt;/b&gt;을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유가 적절할지 모르겠지만, 책의 목차와 비슷하다고 볼 수 있어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 &lt;b&gt;hive 카탈로그&lt;/b&gt;를 많이 쓰는데, 그 이유는 &lt;b&gt;여러 테이블 포맷에서 쓸 수 있도록 개방&lt;/b&gt;해두었기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Compute engine&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방대한 양의 데이터를 효율적으로 처리&lt;/b&gt;하는 역할을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주로 대량 병렬 처리 엔진을 자주 쓰며, 대표적인 예로 &lt;b&gt;Spark&lt;/b&gt;가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;출처&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000208452383&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://product.kyobobook.co.kr/detail/S000208452383&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1770426211378&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Apache Iceberg | Tomer Shiran - 교보문고&quot; data-og-description=&quot;Apache Iceberg | By following the lessons in this book, you'll be able to achieve interactive, batch, machine learning, and streaming analytics with this lakehouse. Authors Tomer Shiran, Jason Hughes, Alex Merced, and Dipankar Mazumdar from Dremio guide yo&quot; data-og-host=&quot;product.kyobobook.co.kr&quot; data-og-source-url=&quot;https://product.kyobobook.co.kr/detail/S000208452383&quot; data-og-url=&quot;https://product.kyobobook.co.kr/detail/S000208452383&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Z4tP9/dJMb82MyBtR/yYnwXMCQipP1sW0t3A3e41/img.jpg?width=458&amp;amp;height=602&amp;amp;face=0_0_458_602,https://scrap.kakaocdn.net/dn/zYpR9/dJMb81GSJY2/tzET1TBIy2GOsgagyUL0P0/img.jpg?width=458&amp;amp;height=602&amp;amp;face=0_0_458_602,https://scrap.kakaocdn.net/dn/t2Gfx/dJMb85WOPrk/xvsDxGkJQmbLzwkg1rtRZk/img.jpg?width=599&amp;amp;height=608&amp;amp;face=0_0_599_608&quot;&gt;&lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000208452383&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://product.kyobobook.co.kr/detail/S000208452383&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Z4tP9/dJMb82MyBtR/yYnwXMCQipP1sW0t3A3e41/img.jpg?width=458&amp;amp;height=602&amp;amp;face=0_0_458_602,https://scrap.kakaocdn.net/dn/zYpR9/dJMb81GSJY2/tzET1TBIy2GOsgagyUL0P0/img.jpg?width=458&amp;amp;height=602&amp;amp;face=0_0_458_602,https://scrap.kakaocdn.net/dn/t2Gfx/dJMb85WOPrk/xvsDxGkJQmbLzwkg1rtRZk/img.jpg?width=599&amp;amp;height=608&amp;amp;face=0_0_599_608');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Apache Iceberg | Tomer Shiran - 교보문고&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Apache Iceberg | By following the lessons in this book, you'll be able to achieve interactive, batch, machine learning, and streaming analytics with this lakehouse. Authors Tomer Shiran, Jason Hughes, Alex Merced, and Dipankar Mazumdar from Dremio guide yo&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;product.kyobobook.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/212</guid>
      <comments>https://suhwanc.tistory.com/212#entry212comment</comments>
      <pubDate>Sat, 7 Feb 2026 10:26:40 +0900</pubDate>
    </item>
    <item>
      <title>[flink-cdc] VARIANT 타입과 PARSE_JSON 함수</title>
      <link>https://suhwanc.tistory.com/211</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4249&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/apache/flink-cdc/pull/4249&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1769821603740&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;[FLINK-38985][docs] Add documentation for VARIANT type and PARSE_JSON functions by suhwan-cheon &amp;middot; Pull Request #4249 &amp;middot; apache/&quot; data-og-description=&quot;Summary Add documentation for VARIANT type support and PARSE_JSON/TRY_PARSE_JSON functions introduced in recent PRs. (in https://issues.apache.org/jira/browse/FLINK-38874 issue - sub tasks) Notes ...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/apache/flink-cdc/pull/4249&quot; data-og-url=&quot;https://github.com/apache/flink-cdc/pull/4249&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/Aj0gR/dJMb84p3EzX/gxeTiYCWheeNB3MMjlOj1k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bhppQh/dJMb8RjWPMl/nwScjSJ1TWGhQ4dZxm5dE0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4249&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/apache/flink-cdc/pull/4249&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/Aj0gR/dJMb84p3EzX/gxeTiYCWheeNB3MMjlOj1k/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bhppQh/dJMb8RjWPMl/nwScjSJ1TWGhQ4dZxm5dE0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[FLINK-38985][docs] Add documentation for VARIANT type and PARSE_JSON functions by suhwan-cheon &amp;middot; Pull Request #4249 &amp;middot; apache/&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Summary Add documentation for VARIANT type support and PARSE_JSON/TRY_PARSE_JSON functions introduced in recent PRs. (in https://issues.apache.org/jira/browse/FLINK-38874 issue - sub tasks) Notes ...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink 2.1.0에서 반정형 데이터를 지원하는 VARIANT 타입과 이를 파싱하기 위한 PARSE_JSON가 도입되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink cdc 라이브러리에서도 YAML 형태의 파이프라인에서 이에 대응하기 위해 기능을 추가했고, 이에 대한 Docs 작업 중 알게된 것들을 정리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;VARIANT 타입&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/types/#variant&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/types/#variant&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1769815413084&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Data Types&quot; data-og-description=&quot;Data Types # Flink SQL has a rich set of native data types available to users. Data Type # A data type describes the logical type of a value in the table ecosystem. It can be used to declare input and/or output types of operations. Flink&amp;rsquo;s data types are&quot; data-og-host=&quot;nightlies.apache.org&quot; data-og-source-url=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/types/#variant&quot; data-og-url=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/types/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/types/#variant&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/types/#variant&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Data Types&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Data Types # Flink SQL has a rich set of native data types available to users. Data Type # A data type describes the logical type of a value in the table ecosystem. It can be used to declare input and/or output types of operations. Flink&amp;rsquo;s data types are&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nightlies.apache.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VARIANT는 반정형 데이터(semi-structured data)를 위한 타입입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반정형 데이터란 &lt;b&gt;정해진 규격이 없는 데이터&lt;/b&gt;로 JSON, MAP과 같이 유연하게 사용할 수 있는 타입을 의미합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 동일한 JSON 타입이래도 어떤 레코드에는 있는 필드가 다른 레코드에는 없을 수도 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1769814933908&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;이름&quot;: &quot;홍길동&quot;,
  &quot;직업&quot;: &quot;개발자&quot;,
  &quot;기술&quot;: [&quot;Python&quot;, &quot;SQL&quot;, &quot;Cloud&quot;]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 타입의 장점은 테이블 스키마의 변경 없이도 새로운 필드를 추가할 수 있다는 점입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 NOSQL에 저장되는 데이터가 그러하며, MySQL과 같은 RDBMS에서도 이런 필드를 지원합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;PARSE_JSON 함수&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/functions/systemfunctions/#variant-functions&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/functions/systemfunctions/#variant-functions&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1769815392399&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;System (Built-in) Functions&quot; data-og-description=&quot;System (Built-in) Functions # Flink Table API &amp;amp; SQL provides users with a set of built-in functions for data transformations. This page gives a brief overview of them. If a function that you need is not supported yet, you can implement a user-defined funct&quot; data-og-host=&quot;nightlies.apache.org&quot; data-og-source-url=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/functions/systemfunctions/#variant-functions&quot; data-og-url=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/functions/systemfunctions/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/functions/systemfunctions/#variant-functions&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://nightlies.apache.org/flink/flink-docs-release-2.2/docs/dev/table/functions/systemfunctions/#variant-functions&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;System (Built-in) Functions&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;System (Built-in) Functions # Flink Table API &amp;amp; SQL provides users with a set of built-in functions for data transformations. This page gives a brief overview of them. If a function that you need is not supported yet, you can implement a user-defined funct&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;nightlies.apache.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON 문자열을 VARIANT 타입으로 파싱하는 기능을 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Flink에서는 두 번째 인자로 allow_duplicate_keys 옵션을 제공하는데요, 이는 중복 키에 대해 어떻게 처리할지 여부를 지정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 allow_duplicate_keys 값이 true라면 키의 중복을 허용하고, 나중에 나온 Value를 최종값으로 정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 아래와 같은 JSON 값이 있을 때&lt;/p&gt;
&lt;pre id=&quot;code_1769815269629&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;suhwan&quot;,
  &quot;age&quot;: 20,
  &quot;age&quot;: 25 &amp;lt;-- 나중에 나온 25가 채택
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 나온 20은 무시되며, 25가 age의 Value로 채택되는 것이죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 이런 옵션은 예측이 불가능하기 때문에 잘 사용하지 않을 것 같고.. 기본값도 false입니다. 중복시 에러를 뱉어내게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 TRY_PARSE_JSON 함수가 있는데요, 이 함수는 PARSE_JSON과 거의 비슷하나 JSON 형식에 맞지 않는 경우 NULL을 반환하도록 합니다. JSON 파싱 실패 시 전체 작업이 실패하는 것을 방지하거나, COALESCE(TRY_PARSE_JSON(col), default_value) 같은 패턴으로 fallback 처리하고 싶은 경우 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Flink CDC에서 이 타입을 지원한 이유&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 Flink CDC가 지원하는 Source, Sink DB에 VARIANT 타입을 지원하는 곳이 없다면, 이 기능은 의미가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 사용하는 MySQL, Iceberg에는 이러한 타입이 없어서 찾아본 결과, &lt;b&gt;Apache Paimon에서 이런 타입을 제공&lt;/b&gt;해주고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 &lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4228&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Paimon의 Sink connector 부분에 관련 PR&lt;/a&gt;이 반영되었고, 이를 지원하기 위해 &lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4217&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;VARIANT 타입 관련 코드가 정말 많이 추가&lt;/a&gt;되었습니다. 대부분 Flink 코어 레포의 변경 사항을 그대로 가져온거긴 하지만 CDC 라이브러리 운영도 참 쉽지 않다는 생각을 했네요..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;TO DO&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 중 Flink SQL은 &lt;b&gt;Calcite -&amp;gt; Janino 형태로 해석 및 처리&lt;/b&gt;되는 것을 알았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Calcite는 &lt;b&gt;사용자가 입력한 SQL 문장을 컴퓨터가 이해할 수 있는 트리 구조로 변환 및 최적화하는 기능&lt;/b&gt;이며&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Janino는 &lt;b&gt;Calcite가 세운 계획을&amp;nbsp;실제&amp;nbsp;실행&amp;nbsp;가능한&amp;nbsp;Java&amp;nbsp;코드로&amp;nbsp;변환한&amp;nbsp;뒤&amp;nbsp;실시간으로&amp;nbsp;컴파일해서&amp;nbsp;실행하는 역할&lt;/b&gt;을 한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 추후 Flink SQL 내부 코드를 보며 공부해보기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>오픈소스</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/211</guid>
      <comments>https://suhwanc.tistory.com/211#entry211comment</comments>
      <pubDate>Sat, 31 Jan 2026 10:21:29 +0900</pubDate>
    </item>
    <item>
      <title>[flink-cdc] MySQL 커넥터의 BIGINT UNSIGNED 무한 Chunk Splitting 버그</title>
      <link>https://suhwanc.tistory.com/210</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;Issue&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://issues.apache.org/jira/browse/FLINK-38247&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://issues.apache.org/jira/browse/FLINK-38247&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1768728419128&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;[FLINK-38247] MySqlChunkSplitter may continuously generate splits when using BIGINT UNSIGNED as primary key  - ASF Jira&quot; data-og-description=&quot;MySqlChunkSplitter may continuously generate splits when using BIGINT UNSIGNED as primary key, The following log illustrates this point: 2025-08-12 18:10:37,885 INFO org.apache.flink.cdc.connectors.mysql.source.assigners.MySqlChunkSplitter [] - Use unevenl&quot; data-og-host=&quot;issues.apache.org&quot; data-og-source-url=&quot;https://issues.apache.org/jira/browse/FLINK-38247&quot; data-og-url=&quot;https://issues.apache.org/jira/browse/FLINK-38247&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://issues.apache.org/jira/browse/FLINK-38247&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://issues.apache.org/jira/browse/FLINK-38247&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[FLINK-38247] MySqlChunkSplitter may continuously generate splits when using BIGINT UNSIGNED as primary key - ASF Jira&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;MySqlChunkSplitter may continuously generate splits when using BIGINT UNSIGNED as primary key, The following log illustrates this point: 2025-08-12 18:10:37,885 INFO org.apache.flink.cdc.connectors.mysql.source.assigners.MySqlChunkSplitter [] - Use unevenl&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;issues.apache.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;증상&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(현재 flink-cdc 3.5.0 이하 버전에서 MySQL 커넥터를 사용해 BIGINT UNSIGNED 타입의 PK를 사용 시 발생하는 이슈입니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySqlChunkSplitter가 테이블을 chunk로 분할할 때 무한 루프에 빠지는 현상이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;발생 로그&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768729149036&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;2025-08-12 18:10:37,885 INFO MySqlChunkSplitter - Use unevenly-sized chunks for table lms_orderservice_0.order_attach_volume_charge_0, the chunk size is 8096 from 9159518964553691904
2025-08-12 18:10:37,892 INFO MySqlChunkSplitter - Use unevenly-sized chunks for table lms_orderservice_0.order_attach_volume_charge_0, the chunk size is 8096 from 9228590553717701376
2025-08-12 18:10:37,899 INFO MySqlChunkSplitter - Use unevenly-sized chunks for table lms_orderservice_0.order_attach_volume_charge_0, the chunk size is 8096 from 68365677240266752
2025-08-12 18:10:37,907 INFO MySqlChunkSplitter - Use unevenly-sized chunks for table lms_orderservice_0.order_attach_volume_charge_0, the chunk size is 8096 from 136590545025291264
2025-08-12 18:10:38,015 INFO MySqlChunkSplitter - ChunkSplitter has split 39800 chunks for table lms_orderservice_0.order_attach_volume_charge_0&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;이상한 점&lt;/b&gt;&lt;br /&gt;로그를 자세히 보면 chunk의 시작 값이 비정상적으로 변합니다&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 91px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style6&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;순서&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;Chunk 시작 값&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;분석&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;9,159,518,964,553,691,904&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;Long.MAX_VALUE&amp;nbsp;근처&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;9,228,590,553,717,701,376&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 19px;&quot;&gt;Long.MAX_VALUE&amp;nbsp;초과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;68,365,677,240,266,752&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;갑자기 작은 값으로 떡락!&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;136,590,545,025,291,264&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #1f1f1f;&quot; data-path-to-node=&quot;11,4,2,0&quot;&gt;다시 여기서부터&lt;/span&gt;&lt;span style=&quot;color: #1f1f1f;&quot; data-path-to-node=&quot;11,4,2,0&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;증가&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; 정상적이라면 값이 계속 증가하다가 테이블의 최댓값에 도달하면 종료되어야 하는데, 이런 식의 무한 루프에 빠지고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;원인&lt;/b&gt;&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 데이터 타입 범위 차이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL의 BIGINT UNSIGNED 타입과 Java의 Long 타입은 서로 다른 범위를 가지고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MySQL BIGINT UNSIGNED
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;범위: 0 ~ 18,446,744,073,709,551,615 (2^64 - 1)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Java long
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;범위: -9,223,372,036,854,775,808 ~ 9,223,372,036,854,775,807 (2^63 - 1)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;-&amp;gt; &lt;b&gt;Java의&amp;nbsp;long은&amp;nbsp;MySQL&amp;nbsp;BIGINT&amp;nbsp;UNSIGNED&amp;nbsp;최대값의&amp;nbsp;절반밖에&amp;nbsp;표현하지&amp;nbsp;못합니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 타입 범위가 문제인 이유는 MySQL 커넥터의 동작 방식이 문제가 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. MySQL&amp;nbsp;Connector/J의&amp;nbsp;setObject()&amp;nbsp;동작&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/AbstractQueryBindings.java#L920를&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/AbstractQueryBindings.java#L920&lt;/a&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1768730238938&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;if (parameterObj instanceof BigInteger) {
    setLong(parameterIndex, ((BigInteger) parameterObj).longValue());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;flink-cdc는 Chunk 과정에서 MySQL에 &quot;&lt;b&gt;여기부터 여기까지 가져갈거야~&lt;/b&gt;&quot; 라는 값을 전달하게 되는데, 이때 BigInteger 값을 전달하면 MySQL 커넥터는 내부적으로 long으로 변환해 버립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;따라서 Long.MAX_VALUE를 초과하는 값은 오버플로우가 발생하게 되어 음수가 되어버릴 수 있는 위험이 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. 무한 Chunk Splitting 발생 메커니즘&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;flink-cdc의 Chunk Splitting 과정은 간략하게 설명하자면 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;테이블 PK의 MIN, MAX 값을 조회합니다. 여기서 PK는 CDC 파이프라인 실행 시, 사용자가 ChunkKeyColumn을 명시한 값을 의미합니다.&lt;/li&gt;
&lt;li&gt;테이블의 대략적인 ROW 분포를 확인 후 Chunk 분할 방식을 결정합니다.
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Evenly-sized chunks: 균등하게 분포되어 있어 사용자가 명시한 SplitSize 만큼씩 가져오게 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Unevenly-sized chunks: 균등하지 않은 skew 상태로, 동적으로 chunk 경계를 찾게 됩니다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 상황은 Unevenly-sized chunks으로, 동적으로 계속해서 chunk 경계를 찾는 과정을 반복합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에 적은 발생 로그가 그 과정의 일부라고 보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(참고: &lt;a href=&quot;https://github.com/apache/flink-cdc/blob/538b8faa1d8826f8d15a902a94a2e77b13e12093/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/assigners/MySqlChunkSplitter.java#L325-L359&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/apache/flink-cdc/blob/538b8faa1d8826f8d15a902a94a2e77b13e12093/flink-cdc-connect/flink-cdc-source-connectors/flink-connector-mysql-cdc/src/main/java/org/apache/flink/cdc/connectors/mysql/source/assigners/MySqlChunkSplitter.java#L325-L359&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 이때, 다음 Chunk 값을 가져오는 과정에서 오버플로우가 발생해 음수가 반환되고 -&amp;gt; 다시 큰 값으로 증가 -&amp;gt; 다시 음수.. 과정을 반복하다 보니 무한 루프에 걸리게 된 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;해결&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;pre id=&quot;code_1768732695366&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public static void setSafeObject(PreparedStatement ps, int parameterIndex, Object value)
        throws SQLException {
    if (value instanceof BigInteger) {
        ps.setBigDecimal(parameterIndex, new BigDecimal((BigInteger) value));
    } else {
        ps.setObject(parameterIndex, value);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;MySQL 커넥터에 chunk 계산에 필요한 값을 넘겨주는 부분에 위와 같은 함수를 거치도록 변경했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BigInteger 타입을 BigDecimal로 세팅하였고, MySQL 커넥터에서도 이 값은 그대로 BigDecimal로 해석하기 때문에 오버플로우의 염려는 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;a href=&quot;https://nightlies.apache.org/flink/flink-cdc-docs-release-3.5/docs/connectors/flink-sources/mysql-cdc/#data-type-mapping&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;flink-cdc 공식 문서&lt;/a&gt;에도 BIGINT UNSIGNED 타입의 경우 Decimal(20,0)으로 변환하기 때문에, 이러한 처리는 문제없을 것으로 예상됩니다. (레코드 값에는 영향 X, 단지 chunkKey 계산 시에만 전달하는 값)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Github PR&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4117&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/apache/flink-cdc/pull/4117&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1768728453931&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;[FLINK-38247] Handle BIGINT UNSIGNED overflow in PreparedStatement by suhwan-cheon &amp;middot; Pull Request #4117 &amp;middot; apache/flink-cdc&quot; data-og-description=&quot;issue: https://issues.apache.org/jira/browse/FLINK-38247 Issue An infinite loop occurred when using the MySqlChunkSplitter to split a table with a MySQL BIGINT UNSIGNED primary key. (This problem h...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/apache/flink-cdc/pull/4117&quot; data-og-url=&quot;https://github.com/apache/flink-cdc/pull/4117&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/dEkS7D/dJMb83kmE5c/86W1HlffHnqsDSeSMXKad1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/pjK4z/dJMb9lk0TrT/XpfLTdO72PFDK4BMEekly1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/apache/flink-cdc/pull/4117&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/apache/flink-cdc/pull/4117&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/dEkS7D/dJMb83kmE5c/86W1HlffHnqsDSeSMXKad1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/pjK4z/dJMb9lk0TrT/XpfLTdO72PFDK4BMEekly1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[FLINK-38247] Handle BIGINT UNSIGNED overflow in PreparedStatement by suhwan-cheon &amp;middot; Pull Request #4117 &amp;middot; apache/flink-cdc&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;issue: https://issues.apache.org/jira/browse/FLINK-38247 Issue An infinite loop occurred when using the MySqlChunkSplitter to split a table with a MySQL BIGINT UNSIGNED primary key. (This problem h...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;참고&lt;/h4&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev.mysql.com/doc/refman/8.0/en/type-conversion.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;- &lt;/a&gt;&lt;a href=&quot;https://nightlies.apache.org/flink/flink-cdc-docs-release-3.5/docs/connectors/flink-sources/mysql-cdc/#data-type-mapping&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://nightlies.apache.org/flink/flink-cdc-docs-release-3.5/docs/connectors/flink-sources/mysql-cdc/#data-type-mapping&lt;/a&gt;&lt;br /&gt;&lt;a href=&quot;https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/AbstractQueryBindings.java#L920&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;- &lt;/a&gt;&lt;a href=&quot;https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/AbstractQueryBindings.java#L920&quot;&gt;https://github.com/mysql/mysql-connector-j/blob/8.0.28/src/main/core-impl/java/com/mysql/cj/AbstractQueryBindings.java#L920&lt;/a&gt;&lt;/p&gt;</description>
      <category>오픈소스</category>
      <author>suhwanc</author>
      <guid isPermaLink="true">https://suhwanc.tistory.com/210</guid>
      <comments>https://suhwanc.tistory.com/210#entry210comment</comments>
      <pubDate>Sun, 18 Jan 2026 19:47:06 +0900</pubDate>
    </item>
  </channel>
</rss>