Published on

Python Polars로 100GB CSV 메모리 오류 해결

Authors

대용량 CSV를 다루다 보면 가장 먼저 마주치는 벽이 메모리입니다. 특히 100GB급 CSV를 한 번에 로드하려고 하면, 파일 크기보다 훨씬 큰 메모리가 필요해져 프로세스가 Killed 되거나 MemoryError 로 종료됩니다. 이유는 간단합니다. CSV는 텍스트 포맷이라 파싱 과정에서 임시 버퍼가 필요하고, 문자열 컬럼은 객체/가변 길이 데이터로 확장되며, 타입 추론과 결측치 처리까지 겹치면 메모리 사용량이 폭증합니다.

Polars는 이 문제를 현실적으로 해결할 수 있는 도구입니다. 핵심은 다음 3가지입니다.

  • Lazy API + 최적화: 필요한 컬럼만 읽고, 필터를 앞당겨(pushdown) 처리
  • Streaming 실행: 가능한 연산을 스트리밍으로 처리해 전체 데이터를 메모리에 올리지 않음
  • 스키마 고정과 문자열 관리: 타입 추론 비용과 문자열 메모리 폭발을 줄임

이 글에서는 100GB CSV에서 메모리 오류를 줄이는 실전 패턴을 코드 중심으로 정리합니다.

왜 Pandas는 100GB CSV에서 쉽게 터질까

Pandas read_csv 는 기본적으로 전체 데이터를 메모리의 DataFrame으로 구성합니다. CSV 파싱은 다음 단계에서 메모리를 크게 씁니다.

  1. 타입 추론: 일부/전체 샘플을 보고 dtype을 결정하며 임시 객체가 늘어남
  2. 문자열 컬럼: Python object 기반으로 저장되면 오버헤드가 큼
  3. 결측치 처리: NaN/None 변환 과정에서 추가 배열 생성
  4. 연산 중간 결과: 필터/조인/그룹바이 중간 테이블이 복제되기 쉬움

반면 Polars는 Arrow 기반 컬럼형 메모리 모델을 사용하고, Lazy 실행으로 연산 계획을 최적화할 수 있어 같은 작업도 훨씬 적은 메모리로 처리할 여지가 큽니다.

Polars로 접근할 때의 기본 전략

100GB CSV를 “읽어서 메모리에 올린 뒤 처리”가 아니라, “읽는 단계부터 처리 계획을 세워서 필요한 만큼만 통과”시키는 방식으로 바꿔야 합니다.

  • Lazy로 스캔: pl.scan_csv 를 사용해 즉시 로드하지 않고 쿼리 계획을 만든다
  • Projection pushdown: 필요한 컬럼만 선택한다
  • Predicate pushdown: 가능한 필터를 읽기 단계로 내린다
  • Streaming collect: collect(streaming=True) 로 스트리밍 실행을 시도한다
  • 스키마/인코딩/널 처리 명시: 추론 비용과 예외를 줄인다

아래부터는 이 전략을 코드로 구체화합니다.

1) read_csv 대신 scan_csv 로 시작하기

가장 흔한 실수는 pl.read_csv 로 바로 읽는 것입니다. 100GB면 이 단계에서 이미 위험합니다. 먼저 Lazy로 스캔하세요.

import polars as pl

path = "./data/events_100gb.csv"

lf = pl.scan_csv(
    path,
    has_header=True,
    ignore_errors=True,
)

# 아직 읽지 않았고, 실행 계획만 만들어진 상태
print(lf.explain())

scan_csv 는 “파일을 스캔할 준비”만 하고, 실제로는 collect 시점에 읽습니다. 이게 중요한 이유는, 이후에 컬럼 선택/필터를 붙이면 Polars가 읽기 단계에서부터 최적화할 수 있기 때문입니다.

2) 필요한 컬럼만 읽기: Projection pushdown

100GB CSV에서 컬럼이 50개인데 5개만 필요하다면, 나머지 45개를 읽는 순간 메모리/CPU/IO가 낭비됩니다.

cols = ["event_time", "user_id", "event_type", "amount", "country"]

result = (
    lf.select(cols)
      .filter(pl.col("event_type") == "purchase")
      .group_by("country")
      .agg(
          pl.len().alias("cnt"),
          pl.col("amount").sum().alias("revenue"),
      )
      .collect(streaming=True)
)

print(result)

여기서 포인트는 select 를 최대한 앞에 두는 것입니다. Lazy 최적화가 해주긴 하지만, 의도를 명확히 하면 디버깅도 쉬워집니다.

3) 필터를 최대한 빨리: Predicate pushdown

필터가 읽기 단계로 내려가면, 불필요한 행을 파싱하기 전에 버릴 수 있습니다.

from datetime import datetime

start = datetime(2025, 1, 1)
end = datetime(2025, 2, 1)

result = (
    lf.select(["event_time", "user_id", "amount"])
      .with_columns(
          pl.col("event_time").str.strptime(pl.Datetime, strict=False)
      )
      .filter(
          (pl.col("event_time") >= pl.lit(start)) &
          (pl.col("event_time") < pl.lit(end))
      )
      .select([
          pl.col("user_id"),
          pl.col("amount").cast(pl.Float64),
      ])
      .group_by("user_id")
      .agg(pl.col("amount").sum().alias("amount_sum"))
      .collect(streaming=True)
)

주의할 점은 문자열 날짜를 strptime 하는 순간, 필터 푸시다운이 제한될 수 있다는 것입니다. 가능하면 원본 데이터가 이미 ISO-8601 정렬 가능한 형태이거나, 파티션(예: 날짜별 파일)로 나뉘어 있는 구조가 더 유리합니다.

4) 스키마를 고정해 타입 추론 비용과 메모리 폭발 막기

대용량 CSV에서 타입 추론은 느릴 뿐 아니라, 컬럼 일부가 숫자/문자 혼합이면 Utf8 로 승격되어 메모리가 커질 수 있습니다. 스키마를 명시하면 예측 가능성이 올라갑니다.

schema = {
    "event_time": pl.Utf8,
    "user_id": pl.Utf8,
    "event_type": pl.Categorical,
    "amount": pl.Float64,
    "country": pl.Categorical,
}

lf = pl.scan_csv(
    path,
    schema=schema,
    has_header=True,
    ignore_errors=True,
)

out = (
    lf.select(["country", "amount"])
      .filter(pl.col("amount").is_not_null())
      .group_by("country")
      .agg(pl.col("amount").mean().alias("avg_amount"))
      .collect(streaming=True)
)
  • Categorical 은 반복 값이 많은 문자열 컬럼에서 메모리를 크게 절약합니다.
  • 숫자 컬럼은 가능하면 Float64 또는 Int64 로 고정하세요.

5) 청크/배치 처리: 결과를 파일로 흘려보내기

메모리 오류가 나는 또 다른 이유는 “읽기”가 아니라 “결과를 한 번에 만들기”입니다. 예를 들어 전체 데이터를 정렬해서 한 번에 CSV로 저장하려 하면 결과 테이블이 커져 OOM이 납니다.

이럴 때는 중간 결과를 Parquet로 떨어뜨리는 패턴이 안정적입니다.

import polars as pl

lf = pl.scan_csv(path, has_header=True, ignore_errors=True)

# 필요한 전처리만 수행하고 Parquet로 저장
(
    lf.select(["event_time", "user_id", "event_type", "amount", "country"])
      .with_columns([
          pl.col("amount").cast(pl.Float64, strict=False),
          pl.col("country").cast(pl.Categorical),
      ])
      .filter(pl.col("amount").is_not_null())
      .sink_parquet("./out/events_cleaned.parquet")
)

sink_parquet 는 Lazy 파이프라인의 결과를 파일로 바로 흘려보내는 방식이라, “메모리에 다 모아서 write” 하는 것보다 안전합니다.

6) 조인/그룹바이에서 OOM이 나면: 카디널리티와 키 설계를 먼저 의심

100GB CSV에서 가장 위험한 연산은 보통 joingroup_by 입니다.

  • group_by(user_id) 에서 user_id 가 거의 유니크면, 그룹 수가 행 수에 가까워져 메모리 사용량이 커집니다.
  • 대형 테이블 두 개를 조인하면 해시 테이블이 커져 OOM이 날 수 있습니다.

대응책:

  • 조인 전에 필터/컬럼 축소를 최대한 적용
  • 조인 키를 정규화하거나 필요 시 Categorical 로 변환
  • 큰 테이블과 작은 테이블 조인이라면 작은 쪽만 메모리에 올리는 구조로 설계
users = pl.read_parquet("./dim/users.parquet").select(["user_id", "segment"])  # 상대적으로 작다고 가정

lf = pl.scan_csv(path, has_header=True, ignore_errors=True)

out = (
    lf.select(["user_id", "amount"])
      .with_columns(pl.col("amount").cast(pl.Float64, strict=False))
      .filter(pl.col("amount").is_not_null())
      .join(users.lazy(), on="user_id", how="left")
      .group_by("segment")
      .agg(pl.col("amount").sum().alias("revenue"))
      .collect(streaming=True)
)

7) 인코딩/디코딩 문제로 파이프라인이 깨질 때

대용량 CSV는 종종 일부 라인에 잘못된 바이트가 섞여 파싱이 중단됩니다. 이 경우 메모리 오류처럼 보이지만 실제로는 디코딩 예외가 트리거가 되어 재시도/로그 폭증 등으로 리소스가 무너질 수 있습니다.

  • 파일 인코딩을 확인하고
  • 문제가 되는 라인을 격리하거나
  • ETL 단계에서 정규화하는 것이 좋습니다.

관련해서 UTF-8 디코딩 예외를 다룬 글은 여기에서 함께 참고할 수 있습니다.

8) 컨테이너/EKS에서 OOM이 난다면: Polars 이전에 리소스 상한 확인

로컬에서는 버티는데 EKS에서만 OOMKilled 가 반복된다면, 코드 최적화 이전에 파드 메모리 limit이 너무 낮거나, 노드 메모리 압박으로 축출되는 상황일 수 있습니다. 이때는 애플리케이션 레벨에서 streaming=True 를 넣어도 한계가 있습니다.

  • limit/requests 설정
  • 노드 여유 메모리
  • 임시 파일 저장 공간
  • 로그 폭증

같은 운영 요소를 함께 점검해야 합니다.

9) 실전 체크리스트: 100GB CSV를 “안 터지게” 만드는 순서

아래 순서를 그대로 적용하면 대부분의 메모리 오류를 빠르게 줄일 수 있습니다.

  1. pl.read_csv 대신 pl.scan_csv
  2. select 로 컬럼 최소화
  3. filter 를 최대한 앞단에 배치
  4. schema 를 명시해 타입 추론 제거
  5. 반복 문자열은 Categorical 고려
  6. 결과를 한 번에 만들지 말고 sink_parquet 같은 방식으로 흘려보내기
  7. group_by 키의 카디널리티 점검
  8. 조인은 “작은 테이블을 붙인다”는 설계로 재구성
  9. 컨테이너 환경이면 limit/requests부터 확인

마무리

100GB CSV에서 메모리 오류를 해결하는 핵심은 “더 큰 메모리”가 아니라 “처리 모델 변경”입니다. Polars의 Lazy/Streaming을 기본으로 두고, 스키마를 고정하며, 컬럼/행을 읽기 단계에서 최대한 줄이면 대용량 CSV도 안정적으로 처리할 수 있습니다.

마지막으로, CSV는 분석/ETL 관점에서 장기 보관 포맷으로 비효율적입니다. 한 번 안정적으로 읽어 정제한 뒤 Parquet로 변환해 파이프라인의 표준 입력으로 삼으면, 이후 비용(시간/메모리/장애율)이 크게 줄어듭니다.