Published on

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

Authors

서버 로그나 배치 파이프라인에서 ArrowInvalid가 보이면 대개 Parquet 파일들의 스키마가 서로 다르거나, 동일 컬럼이더라도 물리 타입/논리 타입이 미묘하게 달라 병합에 실패한 경우가 많습니다. 특히 S3나 HDFS 같은 데이터 레이크에 날짜별로 Parquet를 쌓는 구조에서, 일부 날짜에만 컬럼 타입이 바뀌거나 컬럼이 추가·삭제되면 갑자기 읽기 단계에서 폭발합니다.

이 글은 pyarrow 기준으로 ArrowInvalid: Parquet 스키마 불일치재현하고, 원인을 분류한 뒤, 운영에서 재발을 막는 스키마 고정/정규화 전략을 코드로 정리합니다.

참고: 데이터 파이프라인 장애는 “원인 파악”보다 “재발 방지”가 더 어렵습니다. 비슷한 맥락의 실전 트러블슈팅 글로는 Pandas SettingWithCopyWarning 완전 해결 가이드, PostgreSQL VACUUM 안 먹힐 때 - bloat·autovacuum 튜닝도 같이 보면 좋습니다.

ArrowInvalid가 나는 전형적인 상황

1) 같은 컬럼인데 파일마다 타입이 다름

예를 들어 user_id가 어떤 날은 int64, 어떤 날은 문자열로 들어옵니다.

  • 2025-01-01: user_id int64
  • 2025-01-02: user_id string

단일 파일은 읽히지만, 디렉터리 전체를 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: 읽을 때 스키마를 강제로 고정하기

가장 깔끔한 방법은 “우리 테이블의 정답 스키마”를 정의하고, 읽는 순간 그 스키마로 맞추는 것입니다.

핵심은 두 가지입니다.

  1. dataset을 만들 때 schema=를 지정해 병합 기준을 통제
  2. 읽은 뒤 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는 변환 실패 시 예외 대신 강제 변환을 시도합니다. 하지만 데이터 의미가 깨질 수 있으므로, 운영에서는 변환 실패 레코드를 따로 격리하거나 품질 지표로 남기는 편이 안전합니다.
  • 파티션 컬럼 dtpartitioning="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)

운영 관점 체크리스트

  1. 정답 스키마를 문서화하고 코드에 상수로 박아두기
  2. 쓰기 단계에서 Table.cast로 스키마 고정하기
  3. 날짜별 파티션에는 “파일 내부에 파티션 컬럼을 쓰지 않는다” 같은 규칙 만들기
  4. CI에서 스키마 스냅샷 테스트로 계약 위반 차단
  5. 이미 깨졌다면 리라이트를 자동화하고, 리라이트 전후 row count 및 null 비율 같은 품질 지표 비교

데이터 파이프라인 장애는 한 번의 핫픽스로 끝나기보다, 같은 유형이 재발하면서 비용이 커지는 패턴이 많습니다. 애플리케이션 장애에서 커넥션 풀을 고갈시키지 않도록 설계하는 것처럼(예: Spring Boot HikariCP 커넥션 고갈 원인과 해결 가이드), Parquet도 “읽기에서 운 좋게 되면 OK”가 아니라 “쓰기에서 계약을 강제”하는 구조로 바꿔야 합니다.

마무리

ArrowInvalid는 증상이고, 원인은 대부분 스키마 계약 부재입니다. 가장 효과적인 해결책은 다음 한 줄로 요약됩니다.

  • “읽을 때 억지로 맞춘다”가 아니라, “쓸 때부터 절대 흔들리지 않게 만든다”

먼저 스키마 충돌 컬럼을 찾아내고, pyarrow 스키마를 기준으로 캐스팅하여 저장하도록 파이프라인을 정리하면, Parquet 기반 데이터 레이크의 안정성이 눈에 띄게 올라갑니다.