Published on

Python Parquet 읽다 ArrowInvalid·OOM 해결 가이드

Authors

서버나 노트북에서 Parquet를 읽다가 갑자기 ArrowInvalid 예외가 터지거나, 커널이 죽을 정도로 메모리가 치솟는 OOM(Out Of Memory)을 만나면 보통 “파일이 깨졌나?” 정도로만 생각하고 끝내기 쉽습니다. 하지만 실제 원인은 훨씬 다양합니다. 예를 들어

  • 파일 자체는 정상인데 row group이 너무 커서 한 번에 로드하다가 메모리가 터지거나
  • 스키마가 섞인 데이터셋을 한 번에 합치면서 타입 승격/캐스팅 중 폭발하거나
  • pandas.read_parquet가 내부적으로 pyarrow.Table을 만들고 다시 DataFrame으로 변환하는 과정에서 복사(copy)가 중첩되어 OOM이 나거나
  • 일부 컬럼이 binary인데 사실상 utf8로 쓰였거나, 반대로 utf8인데 깨진 바이트가 섞여 ArrowInvalid가 발생하는 경우가 많습니다.

이 글은 pyarrow를 중심으로 pandas, duckdb, polars까지 포함해, Parquet 읽기에서 자주 나오는 ArrowInvalid와 OOM을 원인별로 분해하고, 메모리를 덜 쓰는 읽기 패턴손상/스키마 문제 진단 루틴을 코드로 정리합니다.

증상부터 분류하기: ArrowInvalid vs OOM

ArrowInvalid에서 흔한 메시지 패턴

ArrowInvalid는 “화살표(Arrow) 형식으로 변환/디코딩할 수 없다”는 범주형 예외라서 메시지에 힌트가 있습니다.

  • Parquet magic bytes not found in footer : Parquet 파일이 아니거나(확장자만 parquet), 업로드/다운로드 중 손상
  • Invalid: UTF8 string contains invalid UTF8 data : 문자열 컬럼에 깨진 바이트
  • Could not convert ... : 타입이 섞였거나, 통합 스키마 추론 중 충돌
  • Invalid column metadata / corrupt : row group 또는 페이지 단위 손상

OOM에서 흔한 패턴

  • Parquet는 컬럼 단위 압축이라 “디스크에서 2GB”가 “메모리에서 30GB”가 될 수 있습니다.
  • 특히 pandas로 변환할 때 object dtype이 많으면 메모리 사용이 급증합니다.
  • dataset.to_table() 같이 “전체 테이블”을 한 번에 materialize 하면 row group이 크거나 파일이 많을 때 바로 터집니다.

1단계: 파일이 진짜 Parquet인지, 최소 진단부터

먼저 “손상인지 / 읽기 방식 문제인지”를 빠르게 가릅니다.

from pathlib import Path
import pyarrow.parquet as pq

path = Path("data/events.parquet")

# 1) 메타데이터만 읽기 (대부분의 경우 메모리 부담이 작음)
pf = pq.ParquetFile(path)
print("num_row_groups:", pf.num_row_groups)
print("schema:")
print(pf.schema)

# 2) row group별 row 수 확인
for i in range(pf.num_row_groups):
    rg = pf.metadata.row_group(i)
    print(i, "rows=", rg.num_rows, "bytes=", rg.total_byte_size)

여기서도 ArrowInvalid가 난다면 파일 헤더/푸터가 깨졌을 가능성이 큽니다. 반대로 메타데이터는 읽히는데 실제 데이터 읽기에서만 터지면, 특정 row group 또는 특정 컬럼이 문제일 확률이 높습니다.

2단계: ArrowInvalid를 “범인 row group/컬럼”까지 좁히기

대부분의 데이터셋은 row group 단위로 독립적입니다. 따라서 row group을 하나씩 읽어보면 어디서 깨지는지 찾을 수 있습니다.

import pyarrow.parquet as pq

pf = pq.ParquetFile("data/events.parquet")

bad = []
for i in range(pf.num_row_groups):
    try:
        # row group 하나만 읽기
        t = pf.read_row_group(i)
    except Exception as e:
        bad.append((i, repr(e)))

print("bad row groups:")
for i, err in bad:
    print(i, err)

row group이 특정되면, 그 row group에서 컬럼 단위로 더 좁힐 수 있습니다.

import pyarrow.parquet as pq

pf = pq.ParquetFile("data/events.parquet")
rg_index = 3

cols = pf.schema.names
for c in cols:
    try:
        _ = pf.read_row_group(rg_index, columns=[c])
    except Exception as e:
        print("bad column:", c, repr(e))

이렇게 하면 “어떤 컬럼의 어떤 row group이 문제인지”가 나오고, 이후 선택지는 크게 3가지입니다.

  1. 문제 row group을 제외하고 읽는다
  2. 문제 컬럼만 제외하고 읽는다
  3. 손상된 파일을 재생성(가능하면 원본 소스에서 다시 export)

3단계: UTF8 깨짐(문자열)로 인한 ArrowInvalid 대응

메시지에 invalid UTF8가 보이면, Parquet에는 문자열이 utf8로 저장되어야 하는데 실제 바이트가 깨졌거나, binary를 문자열로 강제 변환하는 과정에서 문제가 생긴 경우가 많습니다.

우회 1: 문제 컬럼을 binary로 읽고 나중에 정제

import pyarrow as pa
import pyarrow.parquet as pq

table = pq.read_table(
    "data/events.parquet",
    columns=["user_id", "payload"],
)

# payload가 깨진 utf8라면, 일단 bytes로 다루고
payload = table["payload"].cast(pa.binary())

# 안전 디코딩(깨진 바이트는 대체)
import pandas as pd
s = pd.Series(payload.to_pylist()).map(lambda b: b.decode("utf-8", errors="replace") if b is not None else None)
print(s.head())

우회 2: 아예 pandas 변환을 피하고 Arrow에서 처리

문자열 정제, 필터링, 집계 같은 것을 Arrow compute로 처리하면 pandas 변환(그리고 object dtype)에서 오는 메모리 폭발도 줄일 수 있습니다.

import pyarrow.parquet as pq
import pyarrow.compute as pc

t = pq.read_table("data/events.parquet", columns=["event_time", "country"])

# null 제거 + 조건 필터
mask = pc.and_(pc.is_valid(t["country"]), pc.equal(t["country"], "KR"))
filtered = t.filter(mask)
print(filtered.num_rows)

4단계: 스키마 충돌로 인한 ArrowInvalid (특히 dataset 읽기)

여러 Parquet 파일을 한 번에 읽을 때(파티션 디렉터리) 파일별로 스키마가 조금씩 다르면 문제가 생깁니다.

예:

  • 어떤 파일은 user_idint64, 다른 파일은 string
  • 어떤 파일은 컬럼이 없거나, null-only 컬럼이 다른 타입으로 저장

해결 1: pyarrow.dataset로 읽고, 스키마를 명시

import pyarrow as pa
import pyarrow.dataset as ds

schema = pa.schema([
    ("user_id", pa.string()),
    ("event_time", pa.timestamp("ms")),
    ("country", pa.string()),
])

dataset = ds.dataset("data/events_dataset", format="parquet", schema=schema)

# 필요한 컬럼만 + 필터 먼저
table = dataset.to_table(
    columns=["user_id", "event_time", "country"],
    filter=ds.field("country") == "KR",
)
print(table.num_rows)

스키마를 강제하면 “어떤 파일은 int, 어떤 파일은 string” 같은 혼합을 읽는 과정에서 발생하는 예외를 줄일 수 있습니다. 다만 강제 캐스팅이 불가능한 값이 있으면 여전히 실패할 수 있으므로, 실제 데이터 정합성이 중요합니다.

해결 2: 파일 단위로 읽어서 정규화 후 concat

스키마가 많이 깨져 있으면 “한 번에 dataset 읽기”보다 파일 단위로 읽고 정규화하는 편이 디버깅이 쉽습니다.

from pathlib import Path
import pyarrow.parquet as pq
import pyarrow as pa

base = Path("data/events_dataset")
files = sorted(base.rglob("*.parquet"))

out = []
for f in files:
    t = pq.read_table(f)

    # 예: user_id를 무조건 string으로
    if "user_id" in t.column_names:
        t = t.set_column(t.schema.get_field_index("user_id"), "user_id", t["user_id"].cast(pa.string()))

    out.append(t.select([c for c in ["user_id", "event_time", "country"] if c in t.column_names]))

merged = pa.concat_tables(out, promote=True)
print(merged.schema)

5단계: OOM을 막는 핵심 원칙 6가지

OOM은 “한 번에 너무 많이 materialize”가 대부분입니다. 아래 원칙을 조합하면 체감적으로 안정성이 크게 올라갑니다.

원칙 1: 필요한 컬럼만 읽기 (projection)

import pyarrow.parquet as pq

t = pq.read_table("data/events.parquet", columns=["event_time", "country"])

컬럼 수를 줄이면 디코딩/압축해제/버퍼 할당이 줄어듭니다.

원칙 2: 필터를 먼저 적용하기 (predicate pushdown)

pyarrow.dataset는 필터를 파일/row group 수준에서 밀어넣을 수 있어 전체 로드를 줄입니다.

import pyarrow.dataset as ds

dataset = ds.dataset("data/events_dataset", format="parquet")

table = dataset.to_table(
    columns=["event_time", "country"],
    filter=(ds.field("event_time") >= "2026-01-01") & (ds.field("country") == "KR"),
)

원칙 3: to_table() 대신 배치/스트리밍으로 처리

전체 테이블을 만들지 말고 to_batches()로 레코드 배치 단위 처리로 바꿉니다.

import pyarrow.dataset as ds

dataset = ds.dataset("data/events_dataset", format="parquet")
scanner = dataset.scanner(columns=["country"], batch_size=65536)

count = 0
for batch in scanner.to_batches():
    # batch는 pyarrow.RecordBatch
    count += batch.num_rows

print("rows:", count)

원칙 4: pandas 변환을 늦추거나 피하기

pandas.read_parquet는 편하지만, DataFrame 변환 과정에서 메모리 사용이 커질 수 있습니다. 가능하면 Arrow에서 필터/집계를 한 뒤 결과만 pandas로 가져오세요.

import pyarrow.dataset as ds
import pyarrow.compute as pc

dataset = ds.dataset("data/events_dataset", format="parquet")
scanner = dataset.scanner(columns=["country"])

kr = 0
for batch in scanner.to_batches():
    kr += pc.sum(pc.equal(batch.column(0), "KR")).as_py()

print("KR rows:", kr)

원칙 5: row group을 작게 생성하기 (생성 단계 개선)

읽기에서 OOM이 자주 난다면, 사실 근본 해결은 “쓰기 단계에서 row group을 적절히 쪼개기”입니다.

import pyarrow as pa
import pyarrow.parquet as pq

# 예시: 큰 테이블을 row_group_size로 나눠 저장
pq.write_table(
    table,  # pyarrow.Table
    "data/out.parquet",
    compression="zstd",
    row_group_size=128_000,
)

row group이 지나치게 크면 특정 row group 하나를 읽는 순간 메모리가 튑니다.

원칙 6: 엔진을 바꿔서 “쿼리로 읽기” (DuckDB/Polars)

대용량 Parquet를 “파일”이 아니라 “쿼리 가능한 스토리지”로 취급하면, 메모리 효율이 좋아지는 경우가 많습니다.

DuckDB 예시:

import duckdb

con = duckdb.connect()

# Parquet를 직접 스캔하면서 필터/프로젝션
df = con.execute(
    """
    SELECT country, count(*) AS cnt
    FROM read_parquet('data/events_dataset/**/*.parquet')
    WHERE country = 'KR'
    GROUP BY country
    """
).fetch_df()

print(df)

Polars 예시(지연 실행 + 스트리밍 옵션 활용):

import polars as pl

lf = pl.scan_parquet("data/events_dataset/**/*.parquet")

result = (
    lf
    .filter(pl.col("country") == "KR")
    .select([pl.col("event_time"), pl.col("country")])
    .collect(streaming=True)
)

print(result.shape)

6단계: 운영 환경에서 재발 방지 체크리스트

(1) “읽기” 실패를 관측 가능하게 만들기

배치 작업에서 Parquet 읽다가 죽으면 원인 추적이 어렵습니다. 최소한 아래를 로그로 남기면 재현이 쉬워집니다.

  • 파일 경로, 파일 크기
  • pyarrow 버전, pandas 버전
  • 실패한 row group index
  • 실패한 컬럼명
  • row group의 num_rows, total_byte_size

(2) 컨테이너/파드에서 OOMKill이면 메모리 제한부터 확인

Kubernetes에서는 프로세스가 MemoryError를 내기 전에 OOMKill로 죽을 수 있습니다. 이 경우 앱 로그가 깔끔하게 남지 않습니다. 작업 파드의 memory limit과 실제 peak를 맞추거나, 위에서 설명한 배치/스캐너 방식으로 peak 자체를 낮추는 게 우선입니다.

이런 “운영에서 갑자기 죽는” 문제는 결국 재시도/백오프와도 연결됩니다. 대규모 파이프라인이라면 실패 지점을 잘게 쪼개고 재시도 전략을 함께 두는 편이 안전합니다. 관련해서는 OpenAI 429/RateLimitError 재시도·백오프 패턴의 패턴을 데이터 파이프라인에도 그대로 응용할 수 있습니다.

(3) 파일 생성 파이프라인을 안정화

Parquet 손상은 보통 “쓰기 도중 중단” 또는 “업로드 중 잘림”이 원인입니다. 생성 단계에서 원자적 커밋(임시 파일에 쓰고 rename), 체크섬, 파일 사이즈 검증을 넣으면 magic bytes 계열 오류가 크게 줄어듭니다.

배치가 systemd 서비스로 돌아가고 있다면, 비정상 종료 시 자동 복구(재시작)와 함께 “부분 생성 파일 정리” 루틴을 넣는 게 좋습니다. 운영 안정화 관점에서는 systemd 서비스 자동 재시작 - 죽었다 깨도 복구도 같이 참고할 만합니다.

결론: 가장 효과 좋은 해결 순서

ArrowInvalid와 OOM은 증상이 다르지만, 해결 접근은 의외로 비슷합니다. “전체를 한 번에 읽지 말고, 원인을 쪼개서 확인하고, 필요한 만큼만 읽는다”가 핵심입니다.

  1. pq.ParquetFile로 메타데이터부터 읽어 파일/스키마/row group 상태 확인
  2. row group 단위로 읽어 ArrowInvalid 범인 구간을 특정
  3. 컬럼 단위로 좁혀 UTF8/스키마/손상 중 무엇인지 분류
  4. OOM은 columns 최소화, dataset 필터 푸시다운, to_batches() 스트리밍으로 peak를 낮춤
  5. 가능하면 쓰기 단계에서 row group 크기와 스키마를 정리
  6. 대규모 분석/ETL이면 DuckDB/Polars로 “쿼리 기반 읽기”로 전환

위 루틴대로만 해도, “Parquet 읽다가 가끔 죽는다” 수준의 문제는 대부분 재현 가능해지고, 재현 가능해지면 해결은 생각보다 빠르게 끝납니다.