Published on

Python ArrowInvalid - Parquet 스키마 불일치 해결법

Authors

서로 다른 배치(job)나 날짜 파티션에서 생성된 Parquet을 한 번에 읽거나 병합할 때 ArrowInvalid가 터지는 경우가 많습니다. 특히 데이터 레이크에서 dataset 단위로 읽는 순간, 일부 파일의 컬럼 타입이 미묘하게 달라져 전체 로딩이 실패합니다.

이 글에서는 파이썬에서 흔히 마주치는 ArrowInvalid: ... 계열 오류를 스키마 불일치 관점에서 분해하고, 재현 가능한 진단 방법과 **안전한 해결 전략(스키마 고정, 캐스팅, 재작성, 파티션 격리)**을 정리합니다.

또한 운영 환경에서 재시도나 백오프 같은 “장애 대응”이 필요한 경우도 많기 때문에, 관련 사고 대응 관점은 OpenAI 429 rate_limit_exceeded 재시도 설계 글의 패턴과 유사하게 접근할 수 있습니다.

ArrowInvalid가 나는 대표 시나리오

ArrowInvalid는 메시지가 다양하지만, Parquet 스키마 불일치에서는 대체로 아래 패턴으로 나타납니다.

  • 같은 컬럼인데 파일마다 타입이 다름
    • 예: 어떤 파일은 int64, 어떤 파일은 string
    • 예: 어떤 파일은 timestamp[us], 어떤 파일은 timestamp[ns]
  • 어떤 파일에는 컬럼이 없거나, null-only 컬럼이 다른 타입으로 저장됨
  • 리스트/구조체 타입이 일부 파일에서만 다르게 기록됨
    • 예: list<item: int64> vs list<item: string>
  • Pandas에서 저장할 때 object 컬럼이 파일마다 다르게 추론됨

운영에서 특히 자주 보는 케이스는 다음입니다.

  • id 컬럼이 초기에는 숫자였는데, 어느 날부터 "A123" 같은 값이 들어오며 문자열로 저장
  • event_time이 일부 배치에서 timezone 포함/미포함으로 저장
  • amount가 어떤 파일에서는 double, 어떤 파일에서는 decimal128(38, 10)

원인: Parquet은 “파일 단위”로 스키마가 박힌다

Parquet은 각 파일에 스키마가 내장됩니다. 데이터셋 전체에서 “논리적으로는 같은 컬럼”이라도, 파일 생성 시점의 타입 추론 또는 변환 로직에 따라 스키마가 달라질 수 있습니다.

PyArrow의 dataset 로더는 여러 파일을 하나의 테이블처럼 읽으려다, 컬럼 타입이 충돌하면 실패합니다. 즉, 문제는 보통 “읽기”가 아니라 “쓰기 시점의 스키마 드리프트”에서 시작됩니다.

1단계: 어떤 파일이 스키마를 깨는지 찾기

파일별 스키마를 빠르게 출력

import pyarrow.parquet as pq

paths = [
    "data/date=2026-02-20/part-0000.parquet",
    "data/date=2026-02-21/part-0000.parquet",
]

for p in paths:
    pf = pq.ParquetFile(p)
    print(p)
    print(pf.schema_arrow)
    print("-" * 80)

파일이 많을 때는 “문제 컬럼만” 비교하는 게 효율적입니다.

import pyarrow.parquet as pq

col = "event_time"
for p in paths:
    schema = pq.read_schema(p)
    field = schema.field(col) if col in schema.names else None
    print(p, field)

dataset 로딩 시 스키마 충돌 재현

import pyarrow.dataset as ds

dataset = ds.dataset("data", format="parquet", partitioning="hive")
# 아래에서 스키마 충돌이 나면 ArrowInvalid
table = dataset.to_table()

여기서 실패한다면, “데이터셋 내부 파일들의 스키마가 동일하지 않다”는 뜻입니다.

2단계: 해결 전략 선택(상황별)

스키마 불일치는 한 가지 만능 해결이 없습니다. 아래에서 상황에 맞는 전략을 고르세요.

  • A. 읽기 시점에 스키마를 강제(cast)하고 싶다
  • B. 파일을 재작성해서 데이터 레이크를 정리하고 싶다
  • C. 특정 파티션만 격리해서 우회하고 싶다
  • D. 애초에 쓰기 파이프라인에서 스키마를 고정하고 싶다

실무에서는 보통 D로 재발을 막고, 이미 깨진 구간은 B로 청소합니다.

A. 읽기 시점에 스키마를 강제하기

PyArrow datasetschema를 지정해 읽을 수 있습니다. 핵심은 “모든 파일이 해당 스키마로 캐스팅 가능해야 한다”는 점입니다.

예: id를 문자열로 통일

import pyarrow as pa
import pyarrow.dataset as ds

schema = pa.schema([
    pa.field("id", pa.string()),
    pa.field("event_time", pa.timestamp("us")),
    pa.field("amount", pa.float64()),
])

dataset = ds.dataset("data", format="parquet", partitioning="hive", schema=schema)

table = dataset.to_table()

주의할 점:

  • 숫자 id를 문자열로 바꾸는 건 대체로 안전하지만, 반대로 문자열을 숫자로 바꾸는 건 실패 가능성이 큽니다.
  • 타임스탬프 단위 us vs ns는 캐스팅이 가능하지만, timezone 포함 여부는 더 엄격합니다.

Pandas로 읽을 때도 스키마를 의식하기

pandas.read_parquet는 내부적으로 엔진(pyarrow 또는 fastparquet)에 따라 동작이 달라집니다. 스키마 충돌이 잦다면 pyarrow.dataset 경유가 진단과 통제가 더 쉽습니다.

import pandas as pd
import pyarrow.dataset as ds

dataset = ds.dataset("data", format="parquet", partitioning="hive")
df = dataset.to_table().to_pandas()

B. 깨진 파일/파티션을 “정규화 스키마”로 재작성하기

읽을 때마다 억지로 맞추는 것보다, 데이터 레이크를 깨끗하게 만드는 편이 장기적으로 안전합니다.

1) 기준 스키마 결정

기준 스키마는 보통 아래 중 하나로 정합니다.

  • 데이터 계약(스펙 문서)에서 정의한 스키마
  • 가장 최신 배치의 스키마
  • 모든 파일 스키마를 스캔해 “가장 넓은 타입(예: string)”으로 승격한 스키마

승격 규칙 예시는 다음이 현실적입니다.

  • intstring 충돌이면 string
  • floatdecimal 충돌이면 가능한 한 decimal 또는 정책적으로 float
  • timestamp 단위 충돌이면 timestamp[us]로 통일

2) 파티션 단위로 읽고 캐스팅 후 재작성

import pyarrow as pa
import pyarrow.dataset as ds
import pyarrow.parquet as pq

base_schema = pa.schema([
    pa.field("id", pa.string()),
    pa.field("event_time", pa.timestamp("us")),
    pa.field("amount", pa.float64()),
    pa.field("date", pa.string()),
])

src = ds.dataset("data", format="parquet", partitioning="hive")

# 예: 특정 날짜 파티션만 정리
date_value = "2026-02-21"
filtered = src.to_table(filter=ds.field("date") == date_value)

# 스키마에 맞춰 캐스팅
fixed = filtered.cast(base_schema, safe=False)

out_dir = f"data_fixed/date={date_value}"
pq.write_table(fixed, f"{out_dir}/part-0000.parquet")

safe=False는 위험할 수 있습니다. 데이터 손실 가능성이 있는 캐스팅(예: 큰 정수 int64int32로)이라면 safe=True로 두고 실패를 통해 문제 데이터를 찾아내는 게 낫습니다.

3) 원본을 교체하는 운영 팁

  • 원본 경로를 바로 덮어쓰기보다, data_fixed에 재작성 후 검증하고 스왑하세요.
  • 검증은 최소한 다음을 포함하세요.
    • row count 비교
    • null count 비교(핵심 컬럼)
    • 샘플 레코드 비교

이런 “스왑 배포”는 인프라 장애 대응에서 자주 쓰는 패턴이며, 재시작 루프를 진단하고 안전하게 복구하는 접근과 유사합니다: systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘

C. 문제 파티션만 격리해서 우회하기

당장 전체 파이프라인을 살려야 하고, 특정 날짜(또는 특정 배치)만 깨졌다면 우회가 빠릅니다.

import pyarrow.dataset as ds

dataset = ds.dataset("data", format="parquet", partitioning="hive")

# 예: 문제가 있는 날짜를 제외
bad_date = "2026-02-21"
filter_expr = ds.field("date") != bad_date

table = dataset.to_table(filter=filter_expr)

이 방식은 “장애 격리”에는 좋지만, 데이터 누락을 만들기 때문에 반드시 별도의 백필(backfill) 작업으로 메워야 합니다.

D. 쓰기 파이프라인에서 스키마 드리프트를 원천 봉쇄하기

재발 방지의 핵심은 쓰기 시점에 스키마를 고정하는 것입니다.

Pandas to_parquet 전에 명시적 dtype 정규화

Pandas의 object는 특히 위험합니다. 배치마다 값의 형태가 달라지면 Parquet 타입도 달라질 수 있습니다.

import pandas as pd

# 예시: id는 무조건 문자열
# event_time은 timezone 정책을 통일(예: UTC로 변환 후 tz 제거 또는 유지)

df["id"] = df["id"].astype("string")
df["event_time"] = pd.to_datetime(df["event_time"], utc=True).dt.tz_convert("UTC")

# tz 포함을 싫어하면 다음처럼 tz 제거(정책에 따라 선택)
# df["event_time"] = df["event_time"].dt.tz_localize(None)

df.to_parquet("out.parquet", engine="pyarrow", index=False)

PyArrow Table로 변환하면서 스키마를 강제

import pyarrow as pa
import pyarrow.parquet as pq

schema = pa.schema([
    pa.field("id", pa.string()),
    pa.field("event_time", pa.timestamp("us")),
    pa.field("amount", pa.float64()),
])

table = pa.Table.from_pandas(df, schema=schema, preserve_index=False)
pq.write_table(table, "out.parquet")

이렇게 하면 “이번 배치에서는 우연히 숫자만 들어왔으니 정수로 저장” 같은 일이 줄어듭니다.

스키마 체크를 CI/배치에 넣기

데이터 생성 배치가 끝날 때마다, 산출물 Parquet의 스키마가 기대 스키마와 일치하는지 검사하는 단계를 넣어두면 사고를 크게 줄일 수 있습니다.

import pyarrow.parquet as pq

expected = {
    "id": "string",
    "event_time": "timestamp[us]",
    "amount": "double",
}

schema = pq.read_schema("out.parquet")
for name, typ in expected.items():
    if name not in schema.names:
        raise RuntimeError(f"missing column: {name}")
    actual = str(schema.field(name).type)
    if actual != typ:
        raise RuntimeError(f"type mismatch: {name} expected={typ} actual={actual}")

자주 터지는 타입 충돌과 실전 처방

1) int64 vs string

  • 처방: 대부분 string으로 승격
  • 이유: 식별자 컬럼은 숫자처럼 보이지만 실제로는 문자열인 경우가 많고, 선행 0 문제도 피할 수 있음

2) timestamp[ns] vs timestamp[us]

  • 처방: 단위를 통일(보통 us가 호환성 측면에서 무난)
  • 주의: 단위 변환 시 정밀도 손실 가능

3) decimal vs double

  • 처방: 금액/정산이면 decimal 유지가 일반적
  • 현실: 분석 편의 때문에 double로 통일하는 팀도 많지만, 합계/반올림에서 오차가 누적될 수 있음

4) null-only 컬럼

어떤 파티션에서는 컬럼이 전부 null이라 타입이 애매하게 기록되기도 합니다.

  • 처방: 스키마를 명시하고 쓰기
  • 또는: 재작성 시 해당 컬럼을 명시 타입으로 캐스팅

운영 관점 체크리스트

  • “읽기 실패”가 아니라 “쓰기 스키마 드리프트”가 근본 원인인지 확인
  • 문제 파일을 찾기 위해 파일별 schema_arrow를 덤프
  • 단기 복구는 파티션 격리(C) 또는 읽기 스키마 강제(A)
  • 장기 해결은 재작성(B) + 쓰기 스키마 고정(D)
  • 스키마 검증을 배치 파이프라인에 자동화

대규모 파이프라인에서는 스키마 불일치가 한 번 나면 연쇄적으로 장애가 커집니다. 재시도만으로 해결되지 않는 “데이터 계약 위반”이기 때문입니다. 재시도/백오프 같은 안정화 패턴은 필요하지만, 궁극적으로는 스키마를 제품처럼 관리해야 합니다.