스키마 대통합: Polars와 Iceberg 사이엔 언제나 PyArrow가 있다

Category
Backend
Tags
Apache Arrow
Apache Iceberg
DuckDB
Python
Published
February 9, 2026
Last updated
Last updated March 13, 2026

Polars, Iceberg, DuckDB 스키마를 한 번에 이해하는 방법

데이터 엔지니어링을 하다 보면 한 번쯤 이런 고민을 하게 됩니다.
“Polars로 가공하고 Iceberg에 저장해야 하는데, 스키마는 누구 말을 기준으로 맞춰야 하지?”
처음에는 단순해 보입니다. 컬럼 이름 맞추고 타입만 비슷하게 맞추면 끝날 것 같으니까요. 그런데 라이브러리가 하나씩 늘어날수록 문제가 생깁니다. Polars는 Polars 방식이 있고, PyIceberg는 Iceberg 방식이 있고, 중간에 DuckDB까지 끼면 또 관점이 달라집니다.
이럴 때 기준점으로 삼기 좋은 것이 PyArrow입니다.
정확히 말하면, PyArrow는 Python 데이터 생태계에서 사실상의 스키마 교환 계층에 가깝습니다. Polars는 Arrow와의 입출력을 강하게 지원하고 from_arrow는 대부분 zero-copy로 동작합니다. PyIceberg는 테이블 생성 시 PyArrow 스키마를 받을 수 있고, 읽기/쓰기 역시 Arrow 중심으로 설명합니다. DuckDB도 Arrow 객체를 직접 읽고 다시 Arrow로 내보낼 수 있습니다. 다만 “모든 것이 Arrow 하나로 완전히 통일된다”라고까지 말하면 과장입니다. 기준점에 가깝다는 표현이 더 정확합니다.

1. 왜 굳이 기준점이 필요할까

문제는 “타입 이름이 다르다” 수준에서 끝나지 않습니다.
실제로는 다음 같은 차이가 쌓입니다.
  • 어떤 라이브러리는 int32와 int64를 엄격하게 구분합니다.
  • 어떤 라이브러리는 nullable을 세밀하게 다루고,
  • 어떤 라이브러리는 중첩 구조(struct)를 더 실용적으로 표현합니다.
  • Iceberg는 여기에 더해 필드 ID(field_id) 라는 개념까지 붙습니다.
특히 Iceberg에서 field_id는 장식이 아닙니다. Iceberg는 컬럼을 이름이 아니라 고유 ID로 추적해서 스키마 진화를 안전하게 처리합니다. 그래서 “지금은 이름만 같으면 되겠지”라고 접근하면 나중에 컬럼 추가, 변경, rename 시점에서 생각보다 피곤해집니다.
결국 중요한 건 이겁니다.
분석용 표현과 저장소의 메타데이터 표현은 다를 수 있다. 그 사이를 안정적으로 이어주는 기준이 필요하다.
그리고 그 역할을 가장 무난하게 맡는 것이 PyArrow입니다.

2. 라이브러리별 스키마 정의는 어떻게 다를까

같은 데이터를 표현하더라도, 라이브러리마다 강조점이 다릅니다.

2-1. PyArrow: 가장 중립적이고, 가장 자세한 기준

PyArrow 스키마는 필드 이름, 타입, null 허용 여부를 비교적 명확하게 표현할 수 있고, 필드/스키마 메타데이터도 붙일 수 있습니다. Arrow 문서에서도 Schema와 Field는 이런 저수준 정보까지 담는 구조로 설명합니다.
import pyarrow as pa arrow_schema = pa.schema([ pa.field("id", pa.int64(), nullable=False), pa.field("name", pa.string(), nullable=False), pa.field( "properties", pa.struct([ pa.field("version", pa.int32()), pa.field("source", pa.string()), ]), ), ])
PyArrow가 좋은 이유는 “표현이 예쁘다”가 아닙니다.
다른 라이브러리와 연결될 때 덜 흔들린다는 점이 핵심입니다.

2-2. Polars: 분석에 맞춘 실용적인 표현

Polars도 스키마를 정의할 수 있지만, 결이 조금 다릅니다.
PyArrow가 교환 포맷에 가깝다면, Polars는 연산과 사용성에 더 가까운 표현입니다.
import polars as pl polars_schema = pl.Schema({ "id": pl.Int64, "name": pl.String, "properties": pl.Struct({ "version": pl.Int32, "source": pl.String, }), })
이 방식의 장점은 명확합니다. 읽기 쉽고, 작성이 빠릅니다. 실제로 데이터 가공 코드를 짤 때는 이 정도 추상화가 훨씬 편합니다.
다만 Polars 스키마를 “전체 파이프라인의 기준 스키마”로 삼기 시작하면, 저장 계층이나 외부 시스템과 맞물릴 때 다시 Arrow나 Iceberg 관점으로 번역해야 하는 순간이 옵니다. 그래서 가공은 Polars교환 기준은 Arrow로 나누어 생각하는 편이 덜 복잡합니다. Polars 공식 문서도 Arrow와의 zero-copy 교환, from_arrow, PyCapsule 인터페이스를 핵심 상호운용 포인트로 설명합니다.

2-3. PyIceberg: 저장과 진화를 위한 스키마

PyIceberg는 관점이 확실합니다.
이건 “분석용 타입 선언”이 아니라 테이블 스키마입니다.
from pyiceberg.schema import Schema from pyiceberg.types import ( NestedField, LongType, IntegerType, StringType, StructType, ) iceberg_schema = Schema( NestedField(field_id=1, name="id", field_type=LongType(), required=True), NestedField(field_id=2, name="name", field_type=StringType(), required=True), NestedField( field_id=3, name="properties", field_type=StructType( NestedField(field_id=4, name="version", field_type=IntegerType(), required=False), NestedField(field_id=5, name="source", field_type=StringType(), required=False), ), required=False, ), )
여기서 눈에 띄는 건 역시 field_id입니다. Iceberg는 각 필드를 고유 ID로 추적하고, 이 덕분에 rename이나 add/drop 같은 스키마 진화가 더 안전해집니다.
흥미로운 점은, 새 테이블을 만들 때는 굳이 직접 Schema(...)를 손으로 다 쓰지 않아도 된다는 것입니다. PyIceberg는 catalog.create_table(..., schema=df.schema)처럼 PyArrow 스키마를 바로 받아서 테이블을 만들 수 있습니다. 공식 문서도 이 경로를 직접 보여줍니다.
즉,
  • 테이블을 처음 만들 때: PyArrow 스키마로 시작해도 충분하다.
  • 기존 Iceberg 테이블을 진화시킬 때: field ID를 의식해야 한다.
이 차이를 이해하면 PyIceberg가 왜 다른지 훨씬 빨리 감이 옵니다.

3. 상호 운용은 결국 Arrow를 중심으로 풀린다

이제 중요한 부분입니다.
라이브러리마다 스키마 표현이 다르다고 해서, 파이프라인이 조각나는 건 아닙니다. 오히려 잘 짜면 흐름은 꽤 단순합니다.

Case 1. Iceberg 데이터를 Polars로 바로 가져와 분석하기

PyIceberg는 스캔 결과를 Arrow로 내보낼 수 있고, Polars는 Arrow를 바로 받아들일 수 있습니다. pl.from_arrow()는 대부분 zero-copy로 동작합니다.
import polars as pl arrow_table = iceberg_table.scan().to_arrow() df = pl.from_arrow(arrow_table)
이 패턴이 좋은 이유는 분명합니다.
저장소는 Iceberg가 맡고, 분석은 Polars가 맡되, 둘 사이에 이상한 중간 변환 계층을 만들 필요가 없습니다.

Case 2. Polars에서 가공한 데이터를 Iceberg에 저장하기

반대로 쓰는 방향도 비슷합니다.
Polars에서 가공한 뒤 Arrow로 바꾸고, 그 Arrow 스키마를 기준으로 Iceberg 테이블을 만들거나 데이터를 append하면 됩니다. PyIceberg 공식 문서도 Arrow 스키마로 테이블을 만들고, Arrow Table을 append/overwrite 하는 흐름을 기본 경로로 설명합니다.
arrow_table = df.to_arrow() table = catalog.create_table( "default.users", schema=arrow_table.schema, ) table.append(arrow_table)
여기서 포인트는 “Polars 스키마를 Iceberg 스키마로 억지로 번역한다”가 아닙니다.
Arrow를 한 번 거쳐서 저장 계층으로 넘긴다가 더 자연스러운 사고방식입니다.

Case 3. 중간 검증은 DuckDB로 SQL 확인하기

실무에서는 가끔 이런 순간이 있습니다.
  • 가공은 Polars로 했고
  • 저장은 Iceberg에 할 건데
  • 그 전에 SQL 한 번으로 결과를 빠르게 검증하고 싶다
이럴 때 DuckDB가 편합니다. DuckDB는 Arrow 객체를 직접 읽을 수 있고, 결과도 다시 Arrow로 꺼낼 수 있습니다. 다만 DuckDB가 내부적으로 Arrow 포맷을 그대로 쓰는 것은 아닙니다. Arrow와의 상호운용이 매우 좋다는 쪽이 정확한 표현입니다.
import duckdb arrow_table = df.to_arrow() rel = duckdb.arrow(arrow_table) result = rel.filter("id > 100").arrow()
이 구조가 좋은 이유는, Arrow를 공통 분모로 두면 분석 라이브러리와 SQL 엔진을 자연스럽게 오갈 수 있기 때문입니다.

4. 그래서 실무에서는 무엇을 기준으로 잡아야 할까

정리하면 판단 기준은 생각보다 단순합니다.

1) 교환 기준은 PyArrow로 본다

라이브러리 사이를 오갈 때 가장 덜 마찰이 나는 기준입니다. Polars, PyIceberg, DuckDB 모두 Arrow와의 연결점이 분명합니다.

2) 데이터 가공은 Polars답게 쓴다

가공 코드에서까지 Iceberg의 field_id를 붙들고 있을 필요는 없습니다. 가공 단계에서는 Polars 스키마가 훨씬 간결합니다.

3) 저장소 스키마는 Iceberg의 규칙을 존중한다

특히 기존 테이블을 진화시키는 순간부터는 이름보다 field_id가 중요합니다. 이건 저장 포맷의 규칙입니다. 무시하면 나중에 더 귀찮아집니다.

4) DuckDB는 검증용 SQL 레이어로 두면 좋다

Arrow를 바로 읽고 다시 Arrow로 내보낼 수 있어서, 파이프라인 중간 확인 용도로 깔끔합니다.

결론

“Polars, PyIceberg, DuckDB 중에서 누구 스키마를 기준으로 삼아야 하지?”라는 질문에 대한 제 답은 이겁니다.
분석은 Polars답게 하고, 저장은 Iceberg답게 하되, 그 사이의 기준점은 PyArrow로 잡는 것이 가장 덜 흔들립니다.
PyArrow는 모든 걸 지배하는 절대 군주라기보다, 이질적인 도구들을 서로 말이 통하게 만들어주는 공용어에 가깝습니다.
그래서 파이프라인을 설계할 때도 이렇게 생각하면 편합니다.
  • 스키마의 기준점은 Arrow
  • 데이터 가공은 Polars
  • 테이블 관리와 진화는 Iceberg
  • 중간 검증은 DuckDB
이렇게 역할을 분리해서 보면, 라이브러리가 많아져도 머릿속 구조는 오히려 단순해집니다.