- Published on
Python ArrowInvalid - Parquet 스키마 불일치 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그나 배치 파이프라인에서 ArrowInvalid가 보이면 대개 Parquet 파일들의 스키마가 서로 다르거나, 동일 컬럼이더라도 물리 타입/논리 타입이 미묘하게 달라 병합에 실패한 경우가 많습니다. 특히 S3나 HDFS 같은 데이터 레이크에 날짜별로 Parquet를 쌓는 구조에서, 일부 날짜에만 컬럼 타입이 바뀌거나 컬럼이 추가·삭제되면 갑자기 읽기 단계에서 폭발합니다.
이 글은 pyarrow 기준으로 ArrowInvalid: Parquet 스키마 불일치를 재현하고, 원인을 분류한 뒤, 운영에서 재발을 막는 스키마 고정/정규화 전략을 코드로 정리합니다.
참고: 데이터 파이프라인 장애는 “원인 파악”보다 “재발 방지”가 더 어렵습니다. 비슷한 맥락의 실전 트러블슈팅 글로는 Pandas SettingWithCopyWarning 완전 해결 가이드, PostgreSQL VACUUM 안 먹힐 때 - bloat·autovacuum 튜닝도 같이 보면 좋습니다.
ArrowInvalid가 나는 전형적인 상황
1) 같은 컬럼인데 파일마다 타입이 다름
예를 들어 user_id가 어떤 날은 int64, 어떤 날은 문자열로 들어옵니다.
- 2025-01-01:
user_idint64 - 2025-01-02:
user_idstring
단일 파일은 읽히지만, 디렉터리 전체를 dataset으로 읽으려는 순간 pyarrow가 스키마 병합에 실패하며 ArrowInvalid가 발생합니다.
2) timestamp 단위(ms/us/ns) 또는 timezone 차이
timestamp[ms]와 timestamp[us] 혼재, 혹은 timestamp에 timezone이 붙었다가 빠지는 경우도 흔합니다.
timestamp[ms]timestamp[us]timestamp[us, tz=UTC]
3) decimal precision/scale 불일치
decimal128(10,2)와 decimal128(12,2)처럼 precision이 달라지면 자동 승격이 항상 안전하지 않아 오류로 이어질 수 있습니다.
4) list/struct/nested 타입의 필드 차이
JSON을 Parquet로 저장하는 과정에서 nested 필드가 어떤 날은 null만 들어와 null 타입으로 굳거나, struct 내부 필드가 추가/삭제되며 충돌합니다.
5) 파티션 컬럼과 실제 컬럼이 충돌
/dt=2026-02-24/처럼 파티션 디렉터리에서 dt가 자동으로 컬럼이 되는데, 파일 내부에도 dt 컬럼이 있고 타입이 다르면 충돌합니다.
빠른 진단: 어떤 파일이 스키마를 망가뜨렸는지 찾기
운영에서 가장 먼저 필요한 건 “어느 파일이 문제인지”를 좁히는 것입니다. 아래 코드는 디렉터리 아래 Parquet 파일들의 스키마를 수집해 컬럼별로 타입이 다른 파일을 찾아줍니다.
from __future__ import annotations
from collections import defaultdict
from pathlib import Path
import pyarrow.parquet as pq
def collect_schema_signatures(parquet_dir: str) -> dict[str, set[str]]:
types_by_col: dict[str, set[str]] = defaultdict(set)
for p in Path(parquet_dir).rglob("*.parquet"):
pf = pq.ParquetFile(p)
schema = pf.schema_arrow
for f in schema:
# 문자열로 남기면 비교가 쉽습니다
types_by_col[f.name].add(str(f.type))
return dict(types_by_col)
def find_conflicts(types_by_col: dict[str, set[str]]) -> dict[str, set[str]]:
return {c: ts for c, ts in types_by_col.items() if len(ts) > 1}
parquet_dir = "./data/events"
conflicts = find_conflicts(collect_schema_signatures(parquet_dir))
for col, types in conflicts.items():
print(col, types)
여기서 user_id {'int64', 'string'} 같은 결과가 나오면, 해당 컬럼이 원인일 가능성이 큽니다.
다음은 “어떤 파일이 어떤 타입을 가졌는지”까지 찍어 문제 파일을 특정합니다.
from pathlib import Path
import pyarrow.parquet as pq
def file_type_map(parquet_dir: str, target_col: str) -> dict[str, str]:
out = {}
for p in Path(parquet_dir).rglob("*.parquet"):
schema = pq.ParquetFile(p).schema_arrow
field = schema.field(target_col) if target_col in schema.names else None
out[str(p)] = str(field.type) if field else "<MISSING>"
return out
m = file_type_map("./data/events", "user_id")
for path, t in sorted(m.items()):
print(path, t)
<MISSING>가 섞여 있으면 “컬럼 존재 여부”도 스키마 병합 이슈가 될 수 있으니 함께 정리해야 합니다.
해결 전략 1: 읽을 때 스키마를 강제로 고정하기
가장 깔끔한 방법은 “우리 테이블의 정답 스키마”를 정의하고, 읽는 순간 그 스키마로 맞추는 것입니다.
핵심은 두 가지입니다.
dataset을 만들 때schema=를 지정해 병합 기준을 통제- 읽은 뒤
cast또는compute로 타입을 정규화
import pyarrow as pa
import pyarrow.dataset as ds
import pyarrow.compute as pc
# 정답 스키마(예시)
TARGET_SCHEMA = pa.schema(
[
pa.field("user_id", pa.string()),
pa.field("event_time", pa.timestamp("ms", tz="UTC")),
pa.field("amount", pa.float64()),
pa.field("dt", pa.date32()),
]
)
# dataset 로드 시 스키마를 고정
dataset = ds.dataset(
"./data/events",
format="parquet",
schema=TARGET_SCHEMA,
partitioning="hive", # dt=... 형태
)
table = dataset.to_table()
# event_time이 timezone 없이 들어온 파일이 있다면 보정
if table.schema.field("event_time").type.tz is None:
# timezone 부여는 상황에 맞게 처리 필요
table = table.set_column(
table.schema.get_field_index("event_time"),
"event_time",
pc.assume_timezone(table["event_time"], timezone="UTC"),
)
# user_id는 어떤 날 int로 들어와도 string으로 통일
table = table.set_column(
table.schema.get_field_index("user_id"),
"user_id",
pc.cast(table["user_id"], pa.string(), safe=False),
)
주의할 점:
safe=False는 변환 실패 시 예외 대신 강제 변환을 시도합니다. 하지만 데이터 의미가 깨질 수 있으므로, 운영에서는 변환 실패 레코드를 따로 격리하거나 품질 지표로 남기는 편이 안전합니다.- 파티션 컬럼
dt는partitioning="hive"로 읽으면 자동 생성됩니다. 파일 내부에도dt가 있으면 충돌할 수 있으니 “파일에는dt를 쓰지 않는다” 같은 규칙을 정하는 게 좋습니다.
해결 전략 2: 쓰기 단계에서 스키마를 고정해 재발 방지
ArrowInvalid는 읽기에서 터지지만, 원인은 거의 항상 “쓰기 단계에서 스키마가 흔들린 것”입니다. 따라서 근본 처방은 쓰기 파이프라인에서 스키마를 강제하는 것입니다.
Pandas에서 Parquet로 쓸 때
Pandas가 dtype을 추론하는 과정에서 날짜별로 타입이 달라지기 쉽습니다. 예를 들어 어떤 날은 모두 숫자라 int64, 어떤 날은 결측치가 섞여 float64가 되는 식입니다.
아래처럼 쓰기 전에 pyarrow.Table로 만들고 cast 후 저장하면 스키마를 고정할 수 있습니다.
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
TARGET_SCHEMA = pa.schema(
[
pa.field("user_id", pa.string()),
pa.field("event_time", pa.timestamp("ms", tz="UTC")),
pa.field("amount", pa.float64()),
]
)
def write_parquet_with_schema(df: pd.DataFrame, path: str) -> None:
# pandas -> arrow
t = pa.Table.from_pandas(df, preserve_index=False)
# 스키마 정규화
t = t.cast(TARGET_SCHEMA, safe=False)
pq.write_table(t, path, compression="zstd")
df = pd.DataFrame(
{
"user_id": [1, 2, 3],
"event_time": pd.to_datetime(["2026-02-24", "2026-02-24", "2026-02-24"], utc=True),
"amount": [10, 20, 30],
}
)
write_parquet_with_schema(df, "./data/events/dt=2026-02-24/part-000.parquet")
이렇게 하면 “한 번이라도 잘못된 타입으로 쓰여서 이후 읽기가 깨지는” 상황을 크게 줄일 수 있습니다.
배치 작업에서 스키마 계약을 테스트로 고정
데이터 팀에서 자주 하는 실수는, 코드 변경으로 컬럼 타입이 바뀌었는데 리뷰에서 놓치는 것입니다. 간단히는 “오늘 생성된 Parquet의 arrow schema를 스냅샷으로 비교”하는 테스트를 CI에 넣을 수 있습니다.
import json
from pathlib import Path
import pyarrow.parquet as pq
def schema_to_dict(schema) -> dict:
return {f.name: str(f.type) for f in schema}
def assert_schema_equals(parquet_file: str, expected_json: str) -> None:
actual = schema_to_dict(pq.ParquetFile(parquet_file).schema_arrow)
expected = json.loads(Path(expected_json).read_text(encoding="utf-8"))
assert actual == expected, (actual, expected)
운영 규모가 커질수록 “장애가 나면 고친다”보다 “계약 위반을 빌드에서 막는다”가 훨씬 싸게 먹힙니다.
해결 전략 3: 이미 깨진 데이터 레이크를 수리하기(리라이트)
이미 여러 날짜에 걸쳐 스키마가 뒤섞였다면, 가장 확실한 방법은 정답 스키마로 전체를 재작성하는 것입니다.
아래는 디렉터리 전체를 읽어 TARGET_SCHEMA로 캐스팅한 뒤, 새로운 위치에 다시 쓰는 예시입니다.
from pathlib import Path
import pyarrow as pa
import pyarrow.dataset as ds
import pyarrow.parquet as pq
TARGET_SCHEMA = pa.schema(
[
pa.field("user_id", pa.string()),
pa.field("event_time", pa.timestamp("ms", tz="UTC")),
pa.field("amount", pa.float64()),
pa.field("dt", pa.string()), # 파티션 컬럼을 문자열로 둘 수도 있음
]
)
src = ds.dataset("./data/events", format="parquet", partitioning="hive")
out_root = Path("./data/events_rewritten")
out_root.mkdir(parents=True, exist_ok=True)
# 배치 단위로 읽어서 메모리 폭주를 피합니다
for batch in src.to_batches(batch_size=200_000):
t = pa.Table.from_batches([batch])
# 없는 컬럼은 null로 채우고, 있는 컬럼은 캐스팅
# cast는 컬럼이 없으면 실패하므로, 먼저 스키마를 맞추는 과정이 필요할 수 있음
cols = {}
for f in TARGET_SCHEMA:
if f.name in t.column_names:
cols[f.name] = t[f.name]
else:
cols[f.name] = pa.nulls(t.num_rows, type=f.type)
normalized = pa.table(cols).cast(TARGET_SCHEMA, safe=False)
# 여기서는 단순히 단일 파일로 append 예시(실전에서는 dt별로 분리 권장)
pq.write_table(
normalized,
out_root / "part-rewrite.parquet",
compression="zstd",
use_dictionary=True,
)
실전 팁:
- 리라이트는 I/O가 크므로, 스토리지 사용량/처리 시간/비용을 산정하고 진행해야 합니다.
- 가능하면
dt같은 파티션 키로 분리해서 쓰고, 기존 경로를 원자적으로 스위치하는 방식(예: 새 경로 작성 후 rename 또는 메타스토어 갱신)을 추천합니다.
자주 터지는 케이스별 처방전
int 컬럼에 null이 섞여 float로 바뀌는 문제
Pandas에서는 정수 컬럼에 결측치가 생기면 float64로 승격되는 일이 많습니다. 해결은 두 가지입니다.
- Pandas의 nullable dtype을 사용:
Int64(대문자) - Arrow 스키마로 캐스팅 후 저장
import pandas as pd
df["age"] = df["age"].astype("Int64")
timestamp 단위가 섞이는 문제
수집 시스템이 초 단위, 밀리초 단위가 혼재하면 저장 시점에 통일해야 합니다.
import pyarrow.compute as pc
import pyarrow as pa
# us -> ms로 내림 변환(정확도 손실 가능)
col_ms = pc.cast(col_us, pa.timestamp("ms", tz="UTC"), safe=False)
정확도를 유지해야 한다면 timestamp[us]로 표준을 잡고 모두 그쪽으로 올리는 편이 낫습니다.
dictionary encoding 차이로 인한 혼란
dictionary<values=string, indices=int32> 같은 타입이 파일마다 달라 보일 수 있습니다. 보통은 cast(string)으로 평탄화하면 해결됩니다.
import pyarrow.compute as pc
import pyarrow as pa
col = pc.cast(col, pa.string(), safe=False)
운영 관점 체크리스트
- 정답 스키마를 문서화하고 코드에 상수로 박아두기
- 쓰기 단계에서
Table.cast로 스키마 고정하기 - 날짜별 파티션에는 “파일 내부에 파티션 컬럼을 쓰지 않는다” 같은 규칙 만들기
- CI에서 스키마 스냅샷 테스트로 계약 위반 차단
- 이미 깨졌다면 리라이트를 자동화하고, 리라이트 전후 row count 및 null 비율 같은 품질 지표 비교
데이터 파이프라인 장애는 한 번의 핫픽스로 끝나기보다, 같은 유형이 재발하면서 비용이 커지는 패턴이 많습니다. 애플리케이션 장애에서 커넥션 풀을 고갈시키지 않도록 설계하는 것처럼(예: Spring Boot HikariCP 커넥션 고갈 원인과 해결 가이드), Parquet도 “읽기에서 운 좋게 되면 OK”가 아니라 “쓰기에서 계약을 강제”하는 구조로 바꿔야 합니다.
마무리
ArrowInvalid는 증상이고, 원인은 대부분 스키마 계약 부재입니다. 가장 효과적인 해결책은 다음 한 줄로 요약됩니다.
- “읽을 때 억지로 맞춘다”가 아니라, “쓸 때부터 절대 흔들리지 않게 만든다”
먼저 스키마 충돌 컬럼을 찾아내고, pyarrow 스키마를 기준으로 캐스팅하여 저장하도록 파이프라인을 정리하면, Parquet 기반 데이터 레이크의 안정성이 눈에 띄게 올라갑니다.