Apache Hudi 핵심: copy-on-write, merge-on-read, incremental pull
Hudi는 왜 “upsert 중심”으로 보아야 하나
Apache Hudi는 Iceberg나 Delta Lake처럼 데이터 레이크 위에 테이블 단위의 트랜잭션과 메타데이터 계층을 얹는 open table format이다. 다만 Hudi를 이해할 때 출발점은 조금 다르다. Iceberg가 대규모 분석 테이블의 snapshot, manifest, partition evolution을 강하게 밀고, Delta Lake가 transaction log 기반의 Spark 친화적 lakehouse 경험을 강조한다면, Hudi는 이름 그대로 Hadoop Upserts Deletes and Incrementals에 가까운 문제에서 출발했다.
운영 관점에서 Hudi가 자주 등장하는 상황은 다음과 같다.
- CDC나 이벤트 스트림에서 같은 business key가 반복해서 갱신된다.
- 데이터 레이크에 append-only 파일만 쌓으면 중복, 정정, 삭제 처리가 복잡해진다.
- 매번 전체 테이블을 다시 읽는 대신, 마지막 처리 시점 이후 바뀐 레코드만 downstream으로 넘기고 싶다.
- 쿼리 성능과 수집 지연 사이에서 테이블별로 다른 선택을 해야 한다.
그래서 Hudi의 핵심은 “파일 레이크에 ACID를 붙였다” 정도로 끝나지 않는다. 실제 운영에서는 record key를 기준으로 변경 레코드를 어느 file group에 반영할지 찾고, 그 결과를 timeline에 commit으로 남기며, downstream은 그 timeline을 기준으로 incremental pull을 수행한다. 이 장에서는 그중 가장 중요한 선택지인 Copy-on-Write(CoW), Merge-on-Read(MoR), incremental query를 연결해서 본다.
1. Hudi 테이블을 움직이는 기본 단위
Hudi 테이블에는 데이터 파일만 있는 것이 아니다. 테이블 루트 아래의 .hoodie/ 디렉터리에 timeline, commit metadata, table service metadata가 쌓인다. Hudi reader와 writer는 이 메타데이터를 기준으로 어떤 파일이 현재 유효한지 판단한다.
insert / upsert / delete
record key → file group
CoW rewrite or MoR log append
commit / delta commit / compaction
현재 테이블 상태
주로 base file
특정 instant 이후 변경분
몇 가지 용어를 먼저 잡아두면 뒤 내용이 훨씬 쉽다.
- Record key: 한 레코드를 식별하는 키다. CDC의 primary key처럼 생각하면 된다.
- Precombine field: 같은 record key에 여러 변경이 들어왔을 때 어떤 레코드를 최신으로 볼지 정하는 필드다. 보통 이벤트 시간이나 update timestamp를 둔다.
- File group: 같은 file id를 공유하는 파일 묶음이다. Hudi index는 record key가 어느 file group에 속하는지 찾는 데 중요하다.
- File slice: 특정 file group의 한 시점 상태다. base Parquet 파일과, MoR의 경우 delta log 파일들이 한 slice를 이룬다.
- Instant: timeline상의 시각 식별자다. write, compaction, clean, rollback 같은 action이 instant를 기준으로 기록된다.
중요한 점은 Hudi가 “어느 파일을 다시 써야 하는가”를 매번 전체 스캔으로 결정하지 않으려 한다는 것이다. Hudi 공식 문서는 indexing을 설계의 핵심으로 설명한다. CoW에서는 upsert/delete 시 어떤 base file을 rewrite해야 하는지 빨리 찾기 위해, MoR에서는 어떤 base file에 어떤 delta를 붙일지 제한하기 위해 index가 필요하다.
2. Copy-on-Write: 읽기 지연을 낮추기 위해 쓰기 때 비용을 낸다
Copy-on-Write(CoW) 테이블은 base file, 보통 Parquet 파일만으로 최신 snapshot을 구성한다. 어떤 레코드를 update하거나 delete해야 하면 Hudi는 대상 레코드가 들어 있는 파일을 찾아 기존 파일을 직접 수정하지 않고 새 파일 버전을 만든다. commit이 완료되면 reader는 timeline을 보고 각 file group의 최신 file slice만 읽는다.
예를 들어 orders 테이블에서 1KB짜리 주문 상태 변경 하나가 들어왔다고 하자. 그 주문이 256MB Parquet 파일 안에 있다면 CoW는 해당 파일의 관련 레코드를 병합해 새 Parquet 파일을 쓴다. 변경량은 작아도 파일 단위 rewrite가 발생한다. 이 때문에 CoW는 update가 잦은 테이블에서는 write amplification이 커질 수 있다.
대신 읽기는 단순하다.
- reader가 completed commit만 본다.
- 각 file group의 최신 base file을 고른다.
- 일반 Parquet scan처럼 읽는다.
BI 대시보드, 리포팅, ad-hoc 분석처럼 읽기 지연이 예측 가능해야 하는 테이블에는 이 단순함이 큰 장점이다. 별도의 delta log merge가 없으므로 query engine 입장에서도 이해하기 쉽고, compaction 지연 때문에 최신 snapshot 쿼리가 갑자기 무거워지는 위험도 MoR보다 작다.
다만 운영자는 CoW를 선택할 때 다음 질문을 해야 한다.
- update/delete 비율이 낮은가?
- 파일 크기를 너무 크게 잡아 작은 변경에도 과도한 rewrite가 발생하지 않는가?
- ingest latency가 수 초에서 수 분 수준으로 늘어도 괜찮은가?
- 쓰기 비용보다 dashboard/query latency의 예측 가능성이 더 중요한가?
CoW는 “느린 포맷”이 아니다. 정확히는 변경 병합 비용을 쓰기 경로에서 미리 지불하는 포맷이다. 그래서 읽기 많은 analytics serving table에는 합리적일 수 있지만, 초당 계속 들어오는 CDC 원본 테이블에는 부담이 될 수 있다.
3. Merge-on-Read: 쓰기 지연을 낮추고 나중에 병합한다
Merge-on-Read(MoR) 테이블은 base file과 delta log를 함께 사용한다. update/delete가 들어오면 매번 base Parquet 파일을 새로 쓰는 대신, 변경분을 log file에 append한다. 이후 reader가 snapshot query를 수행할 때 base file과 delta log를 합쳐 최신 상태를 만들거나, compaction table service가 주기적으로 log를 base file에 병합한다.
MoR의 장점은 쓰기 경로에서 바로 드러난다.
- 작은 update가 큰 Parquet 파일 rewrite로 번지지 않는다.
- CDC, streaming ingestion, near-real-time 파이프라인에 적합하다.
- log append 중심이므로 ingest latency를 낮추기 쉽다.
하지만 비용이 사라지는 것은 아니다. 단지 비용의 위치가 바뀐다. compaction이 늦어져 delta log가 많이 쌓이면 snapshot query는 base file과 여러 log file을 계속 merge해야 한다. read optimized query는 base file만 읽기 때문에 빠를 수 있지만, 아직 compact되지 않은 최신 변경분은 보지 못할 수 있다. 즉 MoR에서는 “어떤 query type을 서비스에 허용할 것인가”가 운영 설계의 일부가 된다.
MoR 운영에서 특히 봐야 할 지표는 다음과 같다.
- file group별 delta log 개수와 크기
- compaction backlog와 compaction 소요 시간
- ingestion job과 compaction job의 리소스 경쟁
- snapshot query latency와 read optimized query latency의 차이
- cleaner가 오래된 file slice를 지우기 전 필요한 rollback/time travel 보존 기간
MoR은 write-heavy 테이블에 강하지만, 방치하면 “쓰기만 빠르고 읽기는 점점 느려지는 테이블”이 된다. 따라서 MoR을 고른다는 것은 compaction, clustering, cleaner 같은 table service를 운영 책임에 포함한다는 뜻이다.
4. CoW와 MoR 선택 기준
두 테이블 타입을 단순히 “CoW는 느리고 MoR은 빠르다”로 기억하면 위험하다. 더 정확한 질문은 어느 시점에 병합 비용을 낼 것인가다.
| 기준 | Copy-on-Write | Merge-on-Read |
|---|---|---|
| 업데이트 처리 | 관련 base file을 새 버전으로 rewrite | delta log에 변경분 append 후 나중에 merge |
| 쓰기 지연 | 상대적으로 높음 | 상대적으로 낮음 |
| 읽기 지연 | 예측 가능하고 낮은 편 | query type과 compaction 상태에 의존 |
| 운영 복잡도 | 비교적 단순 | compaction/backlog 관리가 중요 |
| 잘 맞는 workload | read-heavy BI, 집계 serving, 정기 batch 갱신 | CDC 원본, streaming upsert, near-real-time ingestion |
| 주요 위험 | write amplification, 작은 변경이 큰 rewrite로 확대 | delta log 누적, compaction 리소스 경쟁, read amplification |
실무에서는 한 플랫폼 안에서도 둘을 섞는다. 예를 들어 raw CDC bronze 계층은 MoR로 받아 쓰기 지연을 낮추고, 정제된 gold mart나 dashboard serving table은 CoW로 만들어 읽기 성능을 안정화할 수 있다. 이때 중요한 것은 “포맷 하나로 모든 테이블을 통일한다”가 아니라 테이블의 SLA를 기준으로 선택하는 것이다.
선택을 위한 간단한 판단 흐름은 다음과 같다.
- 이 테이블은 변경 이벤트가 매우 자주 들어오는가?
- 그렇다면 MoR 후보가 된다.
- 이 테이블은 사용자-facing dashboard나 반복 리포트가 많고 query latency 예측 가능성이 중요한가?
- 그렇다면 CoW 후보가 된다.
- 최신성이 몇 분 늦어도 되는가?
- 그렇다면 MoR의 read optimized view나 주기적 compaction 후 query가 가능하다.
- compaction을 운영할 리소스와 모니터링이 있는가?
- 없다면 MoR의 장점보다 운영 부채가 커질 수 있다.
5. Timeline과 instant: incremental pull의 기준선
Hudi의 차별점 중 하나는 incremental query다. downstream job이 “마지막으로 처리한 instant 이후의 변경분만 달라”고 요청할 수 있다. 공식 문서의 Spark 예시도 hoodie.datasource.query.type=incremental과 hoodie.datasource.read.begin.instanttime 옵션을 사용해 특정 begin instant 이후 변경 레코드를 읽는 형태다.
이 구조에서 .hoodie timeline은 단순 로그가 아니라 source of truth다. write action은 REQUESTED, INFLIGHT, COMPLETED 같은 상태를 거치며, reader는 completed instant를 기준으로 일관된 snapshot이나 incremental range를 본다.
incremental pull을 운영할 때 핵심은 checkpoint다.
20260701090000
begin instant 이후 completed commit
insert / update / delete
idempotent write 권장
20260701100000
이 패턴은 전체 테이블 scan을 줄여준다. 예를 들어 10TB 테이블에서 하루 2%만 바뀐다면 downstream 집계가 매번 10TB를 읽는 대신 변경 파일과 변경 레코드 중심으로 움직일 수 있다. 그러나 checkpoint를 잃어버리거나 잘못 앞으로 밀면 데이터 손실이 발생할 수 있다. 반대로 너무 뒤로 되돌리면 중복 처리가 발생한다. 그래서 incremental pull consumer는 다음 원칙을 지켜야 한다.
- checkpoint는 job local disk가 아니라 durable metadata store에 저장한다.
- downstream write는 같은 record key와 instant를 재처리해도 깨지지 않도록 idempotent하게 만든다.
- begin instant와 end instant를 로그에 남겨 장애 후 재현 가능하게 한다.
- cleaner 보존 정책이 consumer 지연보다 짧지 않도록 한다.
- 대규모 backfill과 실시간 incremental job의 checkpoint를 분리한다.
incremental query는 “자동으로 정확한 파이프라인을 만들어주는 기능”이 아니다. Hudi가 변경분을 읽을 수 있는 primitive를 제공하고, 운영자는 checkpoint, 재처리, 중복 제거, 지연 허용치를 설계해야 한다.
6. 운영자가 자주 만나는 실패 패턴
Compaction을 나중 일로 미루는 MoR 테이블
MoR 도입 초기에는 ingest latency가 낮아져 좋아 보인다. 하지만 compaction backlog를 보지 않으면 delta log가 누적되고 snapshot query가 느려진다. 특히 동일 file group에 update가 집중되는 hot key 패턴에서는 merge 비용이 특정 파티션에 몰린다.
운영 기준은 단순해야 한다. file group당 log file 개수, log size, compaction pending instant 수, compaction duration을 대시보드화하고, 임계값을 넘으면 ingestion과 compaction 리소스를 분리하거나 compaction 주기를 조정해야 한다.
CoW 테이블의 파일 크기를 무조건 크게 잡는 경우
Parquet scan만 생각하면 큰 파일이 좋아 보일 수 있다. 하지만 CoW update는 대상 file group rewrite를 일으킨다. update가 잦은 테이블에서 base file을 너무 크게 잡으면 작은 변경이 큰 rewrite 비용으로 번진다. 반대로 파일이 너무 작으면 small files 문제가 생긴다. Hudi의 automatic file sizing이나 clustering 전략을 쓰더라도, workload의 update 분포를 보고 목표 파일 크기를 정해야 한다.
Incremental consumer의 checkpoint와 cleaner 정책 불일치
consumer가 며칠 동안 멈췄는데 cleaner가 이미 필요한 오래된 file slice나 metadata를 정리했다면, consumer는 마지막 checkpoint 이후 변경분을 온전히 읽지 못할 수 있다. 이런 경우에는 checkpoint를 억지로 최신으로 미는 것이 아니라, full resync나 backfill 경로를 명시해야 한다.
Query type을 명확히 구분하지 않는 경우
MoR 테이블을 읽는 쪽에서 snapshot, read optimized, incremental query의 의미를 혼동하면 “왜 최신 데이터가 안 보이지?” 또는 “왜 쿼리가 갑자기 느려졌지?” 같은 문제가 생긴다. 서비스별로 어떤 query type을 쓰는지 문서화하고, 최신성 SLA와 지연 SLA를 함께 써야 한다.
7. 실무 설계 예시
CDC 기반 주문 플랫폼을 예로 들어보자.
orders_raw_hudi: 원본 CDC를 거의 실시간으로 받는다. update/delete가 잦고 write latency가 중요하므로 MoR을 고려한다.orders_clean_hudi: 정제와 deduplication을 거친 silver 테이블이다. 변경 빈도와 query 요구에 따라 MoR 또는 CoW를 고른다.orders_daily_mart: dashboard와 리포트가 반복 조회한다. 읽기 안정성이 중요하므로 CoW가 더 자연스럽다.- downstream aggregation job:
orders_raw_hudi또는orders_clean_hudi에서 incremental query로 마지막 instant 이후 변경분만 읽는다.
이 구조에서 운영 체크리스트는 다음과 같다.
- raw MoR 테이블의 compaction backlog가 dashboard SLA에 영향을 주지 않도록 serving table을 분리한다.
- incremental consumer checkpoint를 테이블별, job별로 분리한다.
- schema evolution과 delete 처리 방식을 downstream까지 테스트한다.
- cleaner 보존 기간을 가장 느린 consumer의 최대 지연보다 길게 둔다.
- 장애 시에는 “어느 instant까지 성공했는지”를 기준으로 재처리한다.
이렇게 보면 Hudi는 단일 테이블 포맷이라기보다 변경 데이터 운영 모델에 가깝다. CoW/MoR 선택, timeline, indexing, compaction, incremental pull이 하나로 묶여야 안정적인 lakehouse 파이프라인이 된다.
정리
Apache Hudi를 운영 관점에서 이해하려면 다음 네 문장을 기억하면 된다.
- Hudi는 record key 기반 upsert/delete와 incremental processing에 강점을 둔 table format이다.
- Copy-on-Write는 쓰기 때 base file을 다시 써서 읽기 경로를 단순하고 예측 가능하게 만든다.
- Merge-on-Read는 변경분을 log에 append해 쓰기 지연을 낮추지만, compaction과 query type 관리가 필수다.
- Incremental pull은 timeline instant와 consumer checkpoint가 맞물릴 때 강력하며, checkpoint를 잃으면 정확성 문제가 된다.
따라서 Hudi를 선택할 때는 “Iceberg/Delta보다 좋은가?”보다 먼저 “우리 테이블은 변경이 얼마나 잦고, 최신성과 쿼리 지연 중 무엇을 더 중시하며, compaction과 checkpoint를 운영할 준비가 되어 있는가?”를 물어야 한다.
References
- Apache Hudi Docs, “Table & Query Types”, https://hudi.apache.org/docs/table_types/
- Apache Hudi Docs, “Timeline”, https://hudi.apache.org/docs/timeline/
- Apache Hudi Docs, “Indexes”, https://hudi.apache.org/docs/indexes/
- Apache Hudi Docs, “Compaction”, https://hudi.apache.org/docs/compaction/
- Apache Hudi Docs, “Querying Data”, https://hudi.apache.org/docs/querying_data
- Onehouse Blog, “Getting Started: Incrementally process data with Apache Hudi”, https://www.onehouse.ai/blog/getting-started-incrementally-process-data-with-apache-hudi