토스증권 Iceberg 적용기 #1: CDC 환경은 왜 제대로 동작하지 않을까?
안녕하세요 토스증권 실시간 데이터팀 김용우입니다.
Iceberg는 최근 데이터 레이크 환경에서 가장 주목받는 테이블 포맷 중 하나입니다. 특히 Update와 Delete를 네이티브하게 지원한다는 점이 가장 큰 매력입니다. Hive/Parquet 기반의 테이블 포맷이 사실상 Append-only 구조에 머물렀던 것과 비교하면, Iceberg의 이러한 특성은 데이터 웨어하우스와 레이크의 경계를 허물어주는 강력한 무기입니다. 이러한 강점 덕분에 토스에서도 Iceberg를 적극적으로 활용하며 운영 노하우를 공유하고 있고, 저희 토스증권 또한 다양한 데이터 파이프라인에 Iceberg를 도입해 활용하고 있습니다.
하지만 모든 것이 완벽하지는 않습니다. Iceberg를 실제 CDC(Capture Data Change) 파이프라인에 적용해보면, 기대했던 것 만큼 매끄럽게 동작하지 않는 경우가 많습니다. 단순히 데이터를 Insert하는 것과는 달리, CDC 환경에서는 Key에 대해 빠르게 연속적인 Update/Delete가 발생하고, 이 과정에서 예상치 못한 데이터 정합성 이슈를 마주하게 됩니다.
“그렇다면 Iceberg는 왜 CDC 환경에서 잘 안 굴러가는 걸까?”라는 질문이 자연스럽게 따라옵니다.
토스증권 역시 Iceberg를 적극적으로 도입해 다양한 파이프라인에 활용하고 있지만, 운영 과정에서 마주한 문제들을 직접 경험하고 해결해 나가야 했습니다.
이 글에서는 그중에서도 CDC를 Iceberg에 어떻게 안전하게 적재할 수 있는가에 초점을 맞춰, 우리가 마주했던 문제와 원칙들을 공유하려 합니다. 본격적으로 들어가기 전에, 먼저 Iceberg가 Update를 처리하는 기본 전략부터 짚어보고자 합니다.
Iceberg Update 전략
Iceberg는 테이블에 들어오는 Update를 처리하기 위해 두 가지 대표적인 방식을 채택하고 있습니다. 바로 COW(Copy-on-Write)와 MOR(Merge-on-Read)입니다. 두 방식은 Update라는 동일한 문제를 풀지만, “쓰기 비용을 크게 치르고 읽기를 단순하게 할 것인가”, 혹은 “쓰기를 가볍게 가져가고 읽을 때 병합 비용을 감수할 것인가”라는 서로 다른 선택을 합니다.
COW (Copy-on-Write)
COW 방식은 Update가 들어오면 해당 Row가 들어 있는 데이터 파일 전체를 새로 써버리는 방식입니다. 기존 파일은 더 이상 최신 상태에서 보이지 않고, 새로 만들어진 파일이 최신 스냅샷으로 교체됩니다.
이 접근법의 특징은 읽기의 단순함입니다. 쿼리를 수행할 때 굳이 여러 파일을 병합하거나 삭제 파일을 적용할 필요가 없기 때문에, 분석 쿼리나 집계 쿼리에서 빠르고 직관적인 성능을 낼 수 있습니다.
하지만 그 대가가 있습니다. 작은 Update 하나에도 수십 MB, 수백 MB 짜리 파일을 다시 써야하니, 쓰기 비용이 매우 커지고 스토리지와 I/O에 부담이 생깁니다. 그래서 COW는 주로 읽기 성능이 절대적으로 중요한 데이터 웨어하우스나 분석 테이블에 적합합니다. CDC처럼 자잘한 갱신이 많은 워크로드에는 비효율적일 수 밖에 없습니다.
MOR (Merge-on-Read)
MOR은 정반대의 전략을 취합니다. Update가 들어와도 원본 파일은 건드리지 않고, 대신에 “이 Row는 더 이상 유효하지 않다”라는 정보를 별도의 삭제 파일(Delete File) 로 기록합니다. 새 값은 새로운 데이터 파일에 Append하듯 추가되죠.
이렇게 하면 쓰기 시점에는 거의 Append만 하면 되기 때문에 쓰기 부담이 훨씬 줄어듭니다. CDC 파이프라인이나 이벤트 스트리밍처럼 갱신이 빈번하고 지연(Latency)이 중요한 환경에 유리한 방식입니다.
다만 읽기가 복잡해집니다. 쿼리를 수행하려면 원본 데이터 파일과 삭제 파일을 함께 읽고, 삭제 표시된 Row를 제외한 결과만 보여줘야 하기 때문이죠. 삭제 파일이 누적될수록 쿼리 성능이 점점 떨어지고, 결국엔 주기적인 컴팩션(Compaction)으로 정리해 주어야 합니다.
정리하면, COW는 읽기 단순성을 극대화한 전략이고, MOR은 쓰기 효율성을 극대화한 전략입니다. 읽기와 쓰기 중 무엇을 더 중요시하느냐, 운영 환경이 CDC 스트리밍 중심이냐 분석 쿼리 중심이냐에 따라 선택이 달라집니다.
Iceberg Commit
여기서 한 가지 짚고 넘어가야 할 부분이 있습니다. Iceberg의 Update는 우리가 흔히 상상하는 것처럼 개별 Row를 즉시 교체하는 방식으로 처리되지 않습니다. Iceberg는 매 변경마다 새로운 메타데이터가 생성되기 때문에, Writer는 들어오는 변경 이벤트를 하나하나 반영하지 않고, 여러 Row를 모았다가 파일 단위로 묶어 처리하는 방식을 주로 사용합니다.
즉, 실제로는 Row 단위의 변경이 들어올 때마다 바로 반영되는 것이 아니라, 일정량의 Row를 모아 한 번의 Commit 단위로 적용됩니다. 이 Commit 시점에 Data File과 Delete File이 함께 기록되고, Iceberg는 이를 새로운 스냅샷으로 등록합니다. 이 때마다 Iceberg 내부의 Metadata의 Sequence Number(Data Sequence Number)가 증가합니다. 이 번호는 “데이터가 언제 쓰였는가”를 나타내는 논리적 버전 관리 지표이며, Delete File이 어떤 Data File에 적용될 수 있는지 판단할 때 기준으로 사용됩니다.
이런 특성 때문에 CDC 환경에서는 같은 키(id)에 대해 짧은 시간 동안 연속적으로 발생한 Update/Delete가 동일 Commit 안에 함께 포함되기도 하고, 그 과정에서 어떤 Row가 Position Delete로 지워지고, 또 어떤 Row가 Equality Delete로 처리되는지가 데이터 정합성에 직접적으로 영향을 주게 됩니다.
Position Delete와 Equality Delete
Iceberg에서 Update나 Delete를 기록할 때는 두 가지 방식이 있습니다. 바로 Equality Delete와 Position Delete입니다. 두 방식 모두 MOR(Merge-on-Read) 전략에서 사용되며, 원본 데이터 파일은 그대로 두고 별도의 삭제 파일을 만들어둔 뒤, 읽을 때 병합하여 실제 삭제 효과를 내는 구조입니다.
Equality Delete는 컬럼 값 조건을 기준으로 삭제를 표현합니다. 예를 들어 id=123
이라고 기록하면, Iceberg는 해당 컬럼 값과 일치하는 모든 행을 삭제된 것으로 처리합니다. 이 방식의 특징은 데이터가 어느 파일에 있든, 또 어떤 위치에 있든 상관없이 조건만 맞으면 삭제가 적용된다는 점입니다. 그래서 CDC처럼 빠르게 들어오는 이벤트 데이터를 단순히 “값 기준”으로 무효화할 때 유용합니다.
반면 Position Delete는 훨씬 더 구체적으로, 데이터 파일 경로와 행의 위치(Offset) 를 직접 지정해 삭제를 표현합니다. 즉 “이 파일의 n번째 Row는 삭제됐다”는 식으로 기록되는 것이죠. 이 방식은 Equality Delete보다 훨씬 명확하고, 특정 Row만 정확히 겨냥해 삭제할 수 있다는 장점이 있습니다.
Iceberg v3에서는 Position Delete를 더 효율적으로 관리하기 위해 Deletion Vector라는 방식을 새로 도입했습니다. 이는 삭제할 위치들을 별도의 행 목록으로 관리하지 않고, 데이터 파일에 붙는 비트맵(Bitmap) 으로 표현합니다. 파일의 각 Row가 살아있는지(0) 혹은 삭제되었는지(1)를 표시하기 때문에, 여러 Position Delete를 하나로 묶어 관리할 수 있고, 읽을 때도 빠르게 필터링이 가능합니다. 사실상 Deletion Vector는 Position Delete의 진화형이라고 볼 수 있습니다.
Position Delete vs Equality Delete
Position Delete와 Equality Delete의 차이가 단순하게 “Delete를 기록하는 방식의 차이” 정도로 생각할 수 있지만 좀 더 중요한 차이가 있습니다. Iceberg 공식 문서를 살펴보면 아래와 같은 문구를 확인해 볼 수 있습니다. (참조 링크)
Position Delete는 자신보다 작거나 같은 Data Sequence Number의 Data File에 적용되고, Equality Delete는 자신과 반드시 작은 Data Sequence Number의 Data File에 적용됩니다.
이 차이를 이해하려면 Iceberg가 Update를 어떻게 바라보는지 떠올리면 됩니다. 우리가 UPDATE id = 123
을 수행한다고 할 때 기대하는 모습은 단순합니다. 예전 버전에 있던 id=123은 지워지고, 새로운 버전의 id=123만 남는 것이죠.
Position Delete는 Row가 저장된 정확한 위치(Position) 를 알고 있기 때문에, “여기 있는 Row는 더 이상 유효하지 않다”라고 표시할 수 있습니다. 그래서 이전에 있던 id=123을 Row 단위로 지정해서 무효화하고, 동시에 새로운 id=123을 Data File에 추가로 기록할 수 있습니다.
반면 Equality Delete는 “id=123이라는 값을 가진 행은 모두 지워라”라고 값 조건만을 남깁니다. 이 방식은 기존에 이미 존재하던 행들을 무효화하는 데에는 잘 동작합니다. 단순히 예전 값을 지우고 끝나는 게 아니라, “예전 값은 지우고, 방금 들어온 새로운 값은 남겨야 한다”는 것이 Update의 본질입니다. 만약 이걸 Equality Delete로 지워버리면, 방금 추가된 값까지 같이 사라져 버려서, 결과적으로 Update가 성립하지 않게 됩니다. 즉, 원래 의도했던 옛 값은 지우고 새 값만 남기는 Update가 아니라, 옛 값과 새 값이 둘 다 없어지는 꼴이 되어 버립니다. 따라서 Position Delete는 자신과 함께 들어온 Data File에 대해 적용되지만, Equality Delete는 자신과 함께 들어온 Data File에 적용되지 않는 것이 Iceberg의 기본동작이 되어야만 합니다.
‘Position Delete는 자신보다 작거나 같은 Data Sequence Number의 Data File부터 가능하지만, Equality Delete는 자신보다 반드시 작은 Data Sequence Number의 Data File부터 적용된다.’ 이 차이에서 우리는 하나의 원칙을 도출해낼 수 있습니다.
Iceberg Writer는 동일한 Commit 내에서 같은 id에 대한 삭제나 Update가 발생한다면, 반드시 Position Delete로 이전 Row를 지워야 합니다. 그래야 새로운 Row만 남아 우리가 기대하는 Update의 의미가 보장됩니다. 만약 이 상황에서 Equality Delete로 기록해 버리면 Equality Delete는 Commit 내 새로 들어온 값까지 모두 무효화할 수 없기 때문에, Reader 입장에서는 이전 Row와 새 Row가 동시에 살아 있는 것처럼 보이게 됩니다. 그 결과 쿼리에서 동일한 id가 중복으로 읽히게 되고, 우리가 원하는 CDC는 제대로 동작하지 않게 됩니다.
그렇다면 이 원칙에 어긋나 동일한 Commit 내에 존재하는 id를 Position Delete가 아닌 Equality Delete로 삭제하여 데이터 중복을 일으키는 경우는 어떤게 있을까요?
Iceberg Sink Connector
토스증권에서는 Databricks의 Iceberg Sink Connector를 활용하고 있습니다. 데이터 파이프라인으로 Kafka Connect를 매우 활발하게 사용하고 있고, 관련 운영 경험도 풍부하기 때문에 Iceberg Sink Connector를 선택하게 되었습니다. Iceberg Sink Connector로는 Tabular에서 개발되었다가 Tabular가 Databricks와 합병하며 Databricks의 소속이 된 Iceberg Sink Connector를 사용하고 있습니다. 현재는 추가 개발이 이루어지지 않고, Apache Iceberg로 Donate되어 일부 기능만 제공되고 있습니다. (2025.09 기준 Apache Iceberg 버전에는 Upsert에 대한 지원이 되지 않고 있습니다.) 오늘은 이 Iceberg Sink Connector를 살펴보도록 하겠습니다.

원활한 설명을 위해 Databricks의 Iceberg Sink Connector의 동작 방식을 간략하게 설명 드리겠습니다. Iceberg Sink Connector는 여러 Worker가 병렬로 파일을 쓰고, Coordinator가 최종적으로 Commit을 수행하는 구조를 가지고 있습니다. 이 구조 덕분에 Iceberg Sink Connector는 Exactly-once를 보장하면서도, 동시에 여러 프로세스가 Commit을 시도하면서 생길 수 있는 Iceberg 메타데이터 충돌이나 동시성 이슈를 피할 수 있습니다.
Worker들은 각자 Data File과 Delete File(Position, Equality)을 계속 써 내려가지만, 최종적으로 Iceberg 테이블에 Snapshot을 추가하는 Commit은 Coordinator가 단일 지점에서 한 번에 수행합니다. Worker와 Coordinator와 소통하기 위해 Iceberg Control Topic을 두고, Worker와 Coordinator가 이 Control Topic을 통해 CommitStart, DataWritten 같은 내부 메시지를 주고받습니다. 이런 동기화 과정을 거쳐, 여러 Worker가 병렬로 Write하더라도 Commit 시점에는 모든 결과물이 Coordinator에 모이고, 최종적으로 원자적(Atomic)으로 Iceberg에 반영됩니다.
흐름을 간단하게 단계별로 정리하면 다음과 같습니다.
Worker 내부에서는 BaseEqualityDeltaWriter
가 Update를 관리하기 위해 insertedRowMap
을 들고 있습니다. 이 Map은 이번 Commit 안에서 새로 쓴 Row의 위치 정보를 저장해둡니다.
- 만약 같은 키(Row)가 이미
insertedRowMap
에 있다면 → 그 위치(path, offset)를 찾아서 Position Delete로 지웁니다. insertedRowMap
에 없으면 → 파일 위치를 알 수 없으니 Equality Delete로 값 기준 삭제를 기록합니다.
즉, 같은 커밋 내에서 방금 쓴 Row는 Position Delete로, 그 외는 Equality Delete로 처리하는 구조입니다.
public void delete(T Row) thRows IOException {
if (!this.internalPosDelete(this.structProjection.wrap(this.asStructLike(Row)))) {
this.eqDeleteWriter.write(Row); // insertedRowMap에 없으면 값 기반(eq) 삭제
}
}
private boolean internalPosDelete(StructLike key) {
PathOffset previous = (PathOffset)this.insertedRowMap.remove(key);
if (previous != null) {
// 같은 Commit 안에서 방금 쓴 행을 정확한 위치로 지움 (pos delete)
this.posDeleteWriter.delete(previous.path, previous.RowOffset, (Object)null);
return true;
} else {
return false;
}
}
Iceberg Sink Connector 내에서 발생할 수 있는 문제들
Iceberg Sink Connector에서는 특정 조건에서 동일 Commit 내 id가 Position Delete 대신 Equality Delete로 기록되는 문제가 발생할 수 있습니다. 이렇게 되면 Update/Delete 이벤트가 정상적으로 반영되지 않고, 결과적으로 데이터 중복으로 이어지게 됩니다. 저희는 Connector의 동작 방식을 소스코드 기준으로 분석하여 이러한 문제가 발생하는 지점을 확인했고, 대표적인 원인을 아래 세 가지로 정리할 수 있었습니다.
먼저 살펴볼 수 있는 문제는 동일한 id 이벤트가 같은 Kafka Topic Partition으로 들어오지 않는 경우입니다.
Kafka에서 메시지의 순서를 보장받으려면, 같은 키(key)를 가진 이벤트들이 반드시 동일한 Partition으로 들어가야 합니다. 예를 들어 id
라는 컬럼을 레코드의 key로 지정했다면, 같은 id=123
에 대한 모든 변경 이벤트는 항상 같은 Partition에 기록되므로 순서가 보장됩니다.
그런데 만약 id
를 key로 지정하지 않고 그냥 value 안에만 담아 보낸다면, 같은 id의 이벤트들이 서로 다른 Partition으로 흩어질 수 있습니다. 이렇게 되면 Consumer 입장에서는 같은 id의 이벤트 순서를 보장할 수 없게 됩니다.
Iceberg를 CDC 데이터의 Sink로 사용할 때는 이 문제가 심각해집니다. Iceberg Sink Connector의 Worker들은 Partition 단위로 메시지를 받아 처리하는데, 동일한 id의 이벤트가 서로 다른 Partition으로 들어오면 같은 Worker가 아닌 서로 다른 Worker에서 처리하게 됩니다. 이 경우 Worker마다 관리하는 insertedRowMap
이 다르기 때문에, 같은 Commit 안에서 발생한 Update/Delete 이벤트가 Position Delete로 처리되지 못하고 Equality Delete로 기록될 수 있습니다. 그 결과 동일 id의 중복 Row가 남아버려, 쿼리 시 데이터가 중복으로 읽히게 됩니다.
따라서 Iceberg Sink Connector를 CDC 파이프라인에 사용할 때는 반드시 Iceberg Table의 id 컬럼을 Kafka Topic의 key로 사용해서 Produce해야 합니다. 이렇게 해야 같은 id의 모든 변경 이벤트가 같은 Partition → 같은 Worker → 같은 insertedRowMap
에 모이게 되고, 동일 Commit 내에서도 올바르게 Position Delete가 적용됩니다.


Wrong case를 보면, id=123
Update가 다른 partition → 다른 worker로 흩어져 처리됩니다.value=1
은 worker0가 data001/100
에 쓰고, 이어진 value=2
는 다른 worker가 data002/100
에 씁니다. 이 때 worker 1의 insertedRowMap
에는 value=1
의 (path, offset)
이 없어서, 우리가 기대한 “value=2가 value=1을 position delete로 지운다”가 작동하지 않습니다. 이후에 value=3
이 들어와, value=1
을 position delete로 지워주지만 value=2
를 지워주진 못합니다. 이대로 Commit 된다면 동일 Commit 내에 id=123에 대한 value=1 , 2, 3 세 개의 데이터가 있지만 position delete는 value=1의 position만 delete해줍니다. 따라서 value 2,3은 그대로 읽어지게 되고, id=123에 대해 value 2,3이 나타나는 데이터 중복이 발생하게 됩니다.
따라서 반드시 Iceberg Table의 id 컬럼을 Kafka 메시지의 key로 설정해, 동일 id 이벤트가 항상 같은 Partition으로 들어가도록 보장해야 합니다. 그래야 동일 Worker가 처리하면서 같은 insertedRowMap
을 공유할 수 있고, Position Delete가 올바르게 적용되어 중복이 발생하지 않습니다.
두 번째는 Commit Timeout이 발생하는 경우입니다. 위에서 설명드렸듯, Commit 시점이 오면 Worker들은 지금까지 적은 Data File/ Delete File 정보를 Coordinator로 보내고, Coordinator는 모든 worker의 보고가 모이면 한 번에 Commit합니다. 그런데 Commit Timeout이 발생하면 Coordinator가 이미 받은 파일 정보를 품은 채 Commit을 하지 못하고 다음 라운드로 넘어가고, 그 다음 Commit 때 지연돼 들어온 파일 정보까지 합쳐 한꺼번에 Commit합니다.
문제는 그 사이 CommitStart 메시지를 받은 Worker들은 Writer를 리셋해 버리고, 따라서 insertedRowMap
도 초기화 된다는 점입니다. 이후 들어온 Update/Delete가 이미 들어왔던 id에 대한 것이라면 Position Delete로 적용되어야하지만, insertedRowMap
이 비었으니 Equality Delete로 다시 기록됩니다. 하지만 이 Equality Delete는 이전 Commit의 Data File, Delete File들과 합쳐 적용됩니다. 앞 Commit의 Data File/Delete File에 쓰인 id가 뒤 Commit의 Data File/Delete File에서 Update/Delete 되었지만, Equality Delete로 기록된다면 “동일 Commit 내 Update/Delete는 반드시 Position Delete” 원칙을 정면으로 어기는 것이고, 결과적으로 중복이 발생합니다.


토스증권에서는 이 경우를 해결하기 위해, Coordinator에서 Commit 시작 시에 Data File/Delete File가 남아있다면 이를 먼저 처리하고 Commit 절차를 수행하도록 별도의 preCommit 단계를 두어, 이전과 이후의 Commit이 뒤섞이지 않도록 수정하여 사용하게 되었습니다.
마지막은 Schema Evolution이 일어나는 경우입니다. Iceberg Sink Connector는 감사하게도 Schema Evolution 기능을 제공합니다. SinkRecord에 현재 Iceberg Table의 schema와 다른 새로운 schema가 나타난다면 자동으로 해당 Schema로 Update 해줍니다. (이 글에서 자세하게 언급하지는 않지만, add columns, update Types, make optional 세 가지를 지원하고 있습니다.) Iceberg Sink Connector의 구현에서는 Schema Evolution이 일어난다면 여태까지 Write했던 Data File과 Delete File들을 Flush하고 지금까지 쓴 파일들의 메타데이터만 메모리에 유지하게 됩니다. 그리곤 새로운 Writer를 생성합니다. 이 때, BaseEqualityDeltaWriter
가 새로 생성되며 현재까지의 Position을 기록해두었던 insertedRowMap
또한 사라지게 됩니다.
private Record convertToRow(SinkRecord record) {
if (!config.evolveSchemaEnabled()) {
return recordConverter.convert(record.value());
}
SchemaUpdate.Consumer updates = new Consumer();
Record Row = recordConverter.convert(record.value(), updates);
if (!updates.empty()) {
// complete the current file
flush();
// apply the schema updates, this will refresh the table
SchemaUtils.applySchemaUpdates(table, updates);
LOG.info("Table schema evolution on table {} caused by record at topic: {}, partition: {}, offset: {}", table.name(), record.topic(), record.kafkaPartition(), record.kafkaOffset());
// initialize a new writer with the new schema
initNewWriter();
// convert the Row again, this time using the new table schema
Row = recordConverter.convert(record.value(), null);
}
return Row;
}
한 Commit 내 동일 id가 들어왔을 때, 이를 Posistion Delete로 잘 처리해주기 위한 insertedRowMap
이 초기화되었으므로 이어서 동일한 id가 들어온다면 Position Delete로 처리하지 못하고 Equality Delete로 처리하게 됩니다. “동일 Commit 내 Update/Delete는 반드시 Position Delete” 원칙을 어기게 되었고, 데이터 중복이 발생합니다.
토스증권에서는 이를 해결하기 위해 insertedRowMap
을 BaseEqualityDeltaWriter
내부에서 생성·초기화하는 구조가 아니라, 별도의 Registry에서 관리하다가 Commit 시점에만 정리되도록 재개발하였습니다. Schema Evolution으로 Writer가 교체되어도 동일 Commit 내에서 이어지는 Update를 Position Delete로 처리할 수 있도록 말이죠.
Iceberg CDC의 활용
토스증권은 위 세 가지 문제를 해결하면서 데이터 중복과 정합성 이슈 없이, Iceberg CDC를 준실시간 범위에서 안정적으로 활용할 수 있게 되었습니다. 기존 Hive처럼 Hadoop 스케일의 대용량 데이터 적재가 가능하면서도, Kudu와 같은 실시간 시스템만큼은 아니더라도 충분히 빠른 반영 속도를 제공할 수 있게 되었습니다.

단순히 최신 상태만 관리하는 데서 그치지 않고, 필요에 따라 과거 변경 내역을 모두 보존하는 History 테이블, 최신값만 유지하는 Upsert 모드, 삭제 신호만 반영하는 Delete-only 모드 등 다양한 형태로 유연하게 활용하고 있습니다. 또한 Batch Job 규모가 너무 커서 매일 전체 Dump를 수행하기 부담스러웠던 작업들도 이제는 CDC + Iceberg의 조합으로 효율적으로 적재할 수 있게 되었습니다.
Iceberg를 단순한 저장소를 넘어, 중복 제거(Storage-level Deduplication) 기능을 제공하는 저장소로도 쓸 수 있게 되었습니다. 이전까지는 Flink 같은 스트리밍 엔진의 State Store를 이용해 짧은 Retention 내에서만 제한적으로 중복 제거를 할 수 있었지만, 이제는 Iceberg가 자체적으로 Update를 지원하면서 저장소 레벨에서 id 단위의 중복 이벤트를 정확히 제거할 수 있습니다. 그 결과 스트리밍 레이어에 불필요한 부담을 주지 않고도 데이터 정합성을 안정적으로 보장하는 저장소로 활용할 수 있게 되었습니다.
마치며
이번 글에서는 Iceberg에 데이터를 적재하는 Writer의 관점에서, Iceberg CDC를 활용했을 때 발생하는 데이터 정합성 이슈에 대해서 살펴보았습니다. Iceberg CDC가 올바르게 수행되기 위해서는 어떤 조건들이 지켜져야 하는지, 그리고 현재 사용 중인 writer 구조 안에서 이러한 조건들이 잘 충족되고 있는지 확인해 보았습니다. 이를 통해 예상치 못한 동작들을 짚어내고 문제를 해결할 수 있었던 방안을 간략히 공유했으며, 더 나아가 저희 토스증권에서 Iceberg CDC를 실제로 어떻게 활용하고 있는지도 소개해 드렸습니다.
다음 글에서는 Infra 수준에서 이렇게 적재한 Iceberg 테이블을 어떻게 관리하고 운영하는지, 그리고 성능을 어떻게 향상시킬 수 있는지에 대해 이야기해보려 합니다. Iceberg를 실제로 안정적으로 운영하기 위한 테이블 관리 전략과 최적화 기법들을 공유할 예정이니 많은 기대 부탁드립니다.