이미지 로딩 중...

대용량 데이터 처리 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 15. · 4 Views

대용량 데이터 처리 완벽 가이드

초급 개발자를 위한 대용량 데이터 처리 전략을 단계별로 알아봅니다. Polars를 활용한 효율적인 데이터 처리부터 메모리 최적화, 병렬 처리까지 실무에서 바로 적용할 수 있는 실전 노하우를 제공합니다.


목차

  1. Polars LazyFrame - 지연 평가로 메모리 절약하기
  2. 청크 단위 처리 - 메모리 한계 극복하기
  3. 병렬 처리 - CPU 코어 활용하기
  4. 컬럼 선택과 프로젝션 - 불필요한 데이터 제거하기
  5. Parquet 포맷 활용 - 저장과 읽기 최적화
  6. 메모리 매핑 - 거대한 파일 효율적으로 다루기
  7. 데이터 타입 최적화 - 메모리 사용량 줄이기
  8. 인덱싱과 필터 최적화 - 불필요한 스캔 줄이기
  9. 집계 연산 최적화 - Group By 성능 끌어올리기
  10. 조인 연산 최적화 - 대용량 테이블 결합하기

1. Polars LazyFrame - 지연 평가로 메모리 절약하기

시작하며

여러분이 수백만 행의 CSV 파일을 불러와서 처리하려는데, 갑자기 "MemoryError"가 발생한 적 있나요? 데이터를 불러오는 순간 컴퓨터가 느려지고, 최악의 경우 프로그램이 멈춰버리는 상황 말이죠.

이런 문제는 대용량 데이터를 다루는 개발자라면 누구나 겪는 고민입니다. 전통적인 Pandas는 모든 데이터를 한 번에 메모리에 올리기 때문에, 데이터가 커질수록 메모리 부족 문제가 발생합니다.

심지어 불필요한 연산까지 모두 수행하기 때문에 시간도 오래 걸리죠. 바로 이럴 때 필요한 것이 Polars의 LazyFrame입니다.

LazyFrame은 데이터를 즉시 불러오지 않고, 여러분이 어떤 작업을 할지 먼저 파악한 다음 가장 효율적인 방법으로 처리합니다.

개요

간단히 말해서, LazyFrame은 "나중에 처리하겠다"는 약속을 기록하는 스마트한 데이터 구조입니다. 실제 데이터를 불러오거나 연산을 수행하는 대신, 여러분이 요청한 작업들을 기억해두었다가 정말 필요한 순간에 한꺼번에 최적화해서 실행합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대용량 데이터 처리에서는 메모리와 속도가 생명입니다. 예를 들어, 10GB짜리 로그 파일에서 특정 조건에 맞는 데이터만 추출하고 집계하는 경우, 전체 파일을 메모리에 올릴 필요 없이 필요한 부분만 처리할 수 있습니다.

기존 Pandas에서는 read_csv()를 호출하는 순간 모든 데이터가 메모리에 올라갔다면, Polars LazyFrame은 실제로 collect()를 호출할 때까지 데이터를 불러오지 않습니다. 그 사이에 쿼리 최적화가 이루어지죠.

LazyFrame의 핵심 특징은 세 가지입니다: 1) 지연 평가(Lazy Evaluation)로 메모리 효율성 극대화, 2) 쿼리 최적화를 통한 불필요한 연산 제거, 3) 병렬 처리를 통한 속도 향상. 이러한 특징들이 대용량 데이터를 다룰 때 Pandas 대비 5배에서 50배까지 성능을 향상시킬 수 있습니다.

코드 예제

import polars as pl

# LazyFrame 생성 - 아직 데이터를 불러오지 않음
df_lazy = pl.scan_csv("large_data.csv")

# 여러 연산을 체이닝 - 여전히 실행되지 않음
result = (
    df_lazy
    .filter(pl.col("age") > 25)  # 나이가 25보다 큰 행만
    .select(["name", "age", "salary"])  # 필요한 컬럼만
    .group_by("age")  # 나이별로 그룹화
    .agg(pl.col("salary").mean())  # 평균 연봉 계산
    .sort("age")  # 나이순 정렬
    .collect()  # 이제 최적화된 쿼리가 실행됨
)

설명

이것이 하는 일: 위 코드는 대용량 CSV 파일에서 25세 이상 직원들의 나이별 평균 연봉을 계산하는데, LazyFrame을 사용하여 메모리 효율적으로 처리합니다. 첫 번째 단계에서 pl.scan_csv()는 파일의 메타데이터만 읽고 실제 데이터는 불러오지 않습니다.

이것이 pl.read_csv()와의 핵심 차이점입니다. 이렇게 하면 거대한 파일도 즉시 처리를 시작할 수 있죠.

두 번째 단계에서 filter, select, group_by, agg, sort 같은 연산들을 체이닝하는데, 이들은 즉시 실행되지 않고 "실행 계획"으로만 저장됩니다. Polars는 내부적으로 이 계획을 분석해서 "아, 나이와 연봉 컬럼만 필요하고, 25세 이하는 필터링되니까 그 부분은 아예 읽지 말자"라고 최적화합니다.

세 번째 단계인 collect()가 호출되면, Polars는 지금까지 모은 모든 연산을 분석하고 가장 효율적인 실행 계획을 수립합니다. 필요한 컬럼만 읽고, 조건에 맞지 않는 행은 건너뛰며, 가능한 연산은 병렬로 처리합니다.

최종적으로 최적화된 결과만 메모리에 올라갑니다. 여러분이 이 코드를 사용하면 같은 작업을 Pandas로 했을 때보다 메모리 사용량은 1/10 수준으로 줄이고, 처리 속도는 5-10배 빠르게 할 수 있습니다.

특히 수백만 행 이상의 데이터에서 효과가 극대화됩니다. 실무에서의 이점은 명확합니다: 더 큰 데이터를 더 적은 리소스로 처리할 수 있고, 개발 과정에서 빠른 피드백을 받을 수 있으며, 프로덕션 환경에서 서버 비용을 절감할 수 있습니다.

실전 팁

💡 describe_optimized_plan()을 사용하면 Polars가 어떻게 쿼리를 최적화했는지 확인할 수 있어, 성능 튜닝에 도움이 됩니다.

💡 LazyFrame은 collect() 전까지 오류를 발견하지 못할 수 있으므로, 작은 데이터로 먼저 테스트한 후 전체 데이터에 적용하세요.

💡 메모리가 부족한 환경이라면 collect(streaming=True)를 사용하여 스트리밍 방식으로 처리할 수 있습니다.

💡 여러 파일을 처리할 때는 scan_csv("data/*.csv")처럼 glob 패턴을 사용하면 모든 파일을 한 번에 LazyFrame으로 불러올 수 있습니다.

💡 디버깅 시에는 .head(100).collect()를 사용해서 일부 데이터만 먼저 확인하면 개발 속도가 빨라집니다.


2. 청크 단위 처리 - 메모리 한계 극복하기

시작하며

여러분의 노트북이 16GB 메모리인데 처리해야 할 데이터는 50GB라면 어떻게 하시겠어요? 포기하거나 더 좋은 장비를 구매하는 것만이 답일까요?

이런 상황은 데이터 분석가나 백엔드 개발자라면 자주 마주치는 현실입니다. 클라우드 서버의 메모리를 늘리면 비용이 기하급수적으로 증가하고, 로컬 환경에서는 물리적 한계가 명확합니다.

전체 데이터를 메모리에 올릴 수 없으면 작업 자체가 불가능해 보이죠. 바로 이럴 때 필요한 것이 청크(Chunk) 단위 처리입니다.

거대한 데이터를 작은 조각으로 나누어 하나씩 처리하면, 어떤 크기의 데이터도 제한된 메모리로 처리할 수 있습니다.

개요

간단히 말해서, 청크 단위 처리는 큰 코끼리를 한 입 크기로 잘라서 먹는 것과 같습니다. 전체 데이터를 한꺼번에 불러오는 대신, 정해진 크기(예: 10만 행)씩 나누어 읽고 처리한 후, 결과만 누적하는 방식입니다.

왜 이 방법이 필요한지 설명하자면, 메모리는 유한한 자원이고 데이터는 계속 커지기 때문입니다. 예를 들어, 1년치 사용자 로그 데이터를 분석해야 하는데 전체 크기가 100GB라면, 청크 단위로 나누어 처리하는 것이 유일한 해결책일 수 있습니다.

서버 비용을 10배 늘리는 것보다 훨씬 경제적이죠. 기존에는 "메모리가 부족하면 작업을 포기하거나 장비를 업그레이드"했다면, 이제는 "청크로 나누어서 순차적으로 처리"할 수 있습니다.

시간은 조금 더 걸릴 수 있지만, 확실하게 완료할 수 있습니다. 청크 처리의 핵심 특징은: 1) 메모리 사용량이 일정하게 유지됨(청크 크기에 비례), 2) 어떤 크기의 데이터도 처리 가능, 3) 진행률을 쉽게 추적할 수 있음.

이러한 특징들이 실무에서 "불가능해 보이는" 작업을 "시간이 걸리지만 가능한" 작업으로 바꿔줍니다.

코드 예제

import polars as pl
from pathlib import Path

chunk_size = 100_000  # 한 번에 처리할 행 수
results = []

# 청크 단위로 CSV 읽기
reader = pl.read_csv_batched(
    "massive_data.csv",
    batch_size=chunk_size
)

# 각 청크를 순회하며 처리
while True:
    chunk = reader.next_batches(1)  # 다음 청크 가져오기
    if chunk is None:
        break  # 더 이상 데이터가 없으면 종료

    # 청크 처리: 필터링 + 집계
    processed = (
        chunk[0]
        .filter(pl.col("status") == "success")
        .group_by("user_id")
        .agg(pl.col("amount").sum())
    )
    results.append(processed)

# 모든 청크 결과 합치기
final_result = pl.concat(results)

설명

이것이 하는 일: 위 코드는 메모리에 한 번에 올릴 수 없는 대용량 CSV 파일을 10만 행씩 나누어 읽고, 각 청크에서 성공한 거래의 사용자별 금액 합계를 계산합니다. 첫 번째 단계에서 read_csv_batched()는 일반적인 read_csv()와 다르게 전체 파일을 읽는 대신 청크 단위로 읽을 수 있는 리더(reader) 객체를 반환합니다.

batch_size 매개변수가 한 번에 읽을 행 수를 결정하는데, 이 값은 여러분의 메모리 크기에 맞춰 조정할 수 있습니다. 메모리가 8GB라면 5만 행, 32GB라면 50만 행으로 설정하는 식이죠.

두 번째 단계의 while 루프에서 next_batches(1)을 호출할 때마다 다음 청크가 메모리에 로드됩니다. 이전 청크는 자동으로 메모리에서 해제되므로 메모리 사용량이 일정하게 유지됩니다.

각 청크에서 필요한 작업(필터링, 집계 등)을 수행하고, 결과만 results 리스트에 저장합니다. 원본 데이터는 버려지므로 메모리 걱정이 없습니다.

세 번째 단계에서 pl.concat()으로 모든 청크의 결과를 하나로 합칩니다. 중요한 점은 전체 원본 데이터를 합치는 게 아니라 "처리된 결과"만 합치는 것입니다.

원본이 100GB여도 집계된 결과는 몇 MB에 불과할 수 있어, 메모리 문제가 발생하지 않습니다. 여러분이 이 패턴을 사용하면 노트북이나 작은 서버에서도 수십 GB의 데이터를 처리할 수 있습니다.

실제로 8GB 메모리 환경에서 50GB 데이터를 처리한 사례도 많습니다. 시간은 전체를 한 번에 처리하는 것보다 20-30% 더 걸릴 수 있지만, "불가능"을 "가능"으로 만든다는 점에서 가치가 큽니다.

실무에서의 이점은: 하드웨어 업그레이드 없이 대용량 데이터 처리 가능, 진행 상황을 실시간으로 모니터링 가능(각 청크마다 진행률 출력 가능), 중간에 오류가 발생해도 이미 처리한 청크는 보존 가능합니다.

실전 팁

💡 청크 크기는 처리 속도와 메모리의 트레이드오프입니다. 너무 작으면 느리고, 너무 크면 메모리 부족이 발생할 수 있으니 여러분의 시스템에 맞게 실험해보세요.

💡 tqdm 라이브러리를 함께 사용하면 진행률 바를 표시할 수 있어 작업 진행 상황을 한눈에 파악할 수 있습니다.

💡 각 청크 처리 중 예외가 발생할 수 있으므로 try-except로 감싸고, 실패한 청크는 별도로 기록해두면 나중에 재처리할 수 있습니다.

💡 결과를 메모리에 누적하는 대신 각 청크 결과를 별도 파일로 저장한 후 마지막에 합치는 방법도 고려해보세요. 더 큰 안정성을 제공합니다.

💡 Polars의 sink_parquet()를 사용하면 청크 처리 결과를 스트리밍 방식으로 파일에 직접 쓸 수 있어 메모리를 더욱 절약할 수 있습니다.


3. 병렬 처리 - CPU 코어 활용하기

시작하며

여러분의 컴퓨터에 8개의 CPU 코어가 있는데, 데이터 처리 작업이 1개의 코어만 100% 사용하고 나머지 7개는 놀고 있다면 답답하지 않으신가요? 마치 8차선 도로에서 1차선만 사용하는 것과 같습니다.

이런 현상은 Python의 기본 동작 방식 때문에 발생합니다. 대부분의 데이터 처리 코드는 순차적으로 실행되어 하나의 코어만 사용합니다.

8코어 CPU를 가지고 있어도 실제로는 12.5%의 성능만 활용하는 셈이죠. 시간이 돈인 실무 환경에서는 큰 낭비입니다.

바로 이럴 때 필요한 것이 병렬 처리(Parallel Processing)입니다. 작업을 여러 개로 나누어 각 CPU 코어에 배분하면, 이론상 코어 수만큼 속도를 높일 수 있습니다.

개요

간단히 말해서, 병렬 처리는 "혼자 하면 8시간 걸릴 일을 8명이 나누어 하면 1시간"이라는 원리를 컴퓨터에 적용하는 것입니다. 하나의 큰 작업을 독립적인 작은 작업들로 분할하고, 각각을 다른 CPU 코어에서 동시에 실행합니다.

왜 이 방법이 중요한지 실무 관점에서 보면, 시간이 곧 비용이고 기회이기 때문입니다. 예를 들어, 매일 밤 실행되는 배치 작업이 8시간 걸린다면 병렬 처리로 2시간으로 줄일 수 있습니다.

이는 더 빠른 인사이트 도출, 더 자주 업데이트되는 대시보드, 더 신속한 의사결정으로 이어집니다. 기존에는 for 루프로 순차적으로 파일들을 하나씩 처리했다면, 이제는 multiprocessing이나 Polars의 내장 병렬 처리 기능으로 동시에 여러 파일을 처리할 수 있습니다.

같은 하드웨어에서 5-8배의 속도 향상을 기대할 수 있죠. 병렬 처리의 핵심 특징은: 1) 여러 CPU 코어를 동시에 활용하여 처리 속도 대폭 향상, 2) 독립적인 작업들에 적용 가능(각 작업이 서로 의존하지 않아야 함), 3) Polars는 자동으로 많은 연산을 병렬화.

이러한 특징들이 여러분의 코드를 몇 줄만 수정해도 극적인 성능 개선을 가능하게 합니다.

코드 예제

import polars as pl
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path

def process_file(file_path):
    """단일 파일 처리 함수"""
    df = pl.read_csv(file_path)
    result = (
        df
        .filter(pl.col("revenue") > 1000)
        .group_by("category")
        .agg([
            pl.col("revenue").sum().alias("total_revenue"),
            pl.col("customer_id").n_unique().alias("unique_customers")
        ])
    )
    return result

# 처리할 파일 목록
files = list(Path("data").glob("sales_*.csv"))

# 병렬 처리 실행 (CPU 코어 수만큼 자동으로 워커 생성)
with ProcessPoolExecutor() as executor:
    results = list(executor.map(process_file, files))

# 모든 결과 합치기
final_df = pl.concat(results)

설명

이것이 하는 일: 위 코드는 여러 개의 CSV 파일을 동시에 읽고 처리하여, 순차 처리 대비 CPU 코어 수에 비례하는 속도 향상을 달성합니다. 첫 번째 단계에서 process_file() 함수는 단일 파일을 처리하는 독립적인 작업 단위입니다.

이 함수는 외부 상태를 변경하지 않고(순수 함수), 오직 입력 파일만 읽어서 결과를 반환합니다. 이런 독립성이 병렬 처리의 핵심 조건입니다.

각 파일 처리가 다른 파일에 영향을 주지 않기 때문에 동시에 실행해도 안전합니다. 두 번째 단계에서 ProcessPoolExecutor()는 여러분의 CPU 코어 수만큼 워커 프로세스를 자동으로 생성합니다.

8코어 CPU라면 8개의 프로세스가 만들어져 각각 독립적으로 작업을 처리합니다. executor.map()은 파일 목록을 자동으로 워커들에게 분배하고, 각 워커는 할당받은 파일을 처리합니다.

한 워커가 작업을 끝내면 즉시 다음 파일을 할당받아 처리하므로 효율적입니다. 세 번째 단계에서 list()로 모든 결과를 수집하고 pl.concat()으로 합칩니다.

각 파일의 처리 결과는 카테고리별 집계 데이터인데, 모든 파일의 결과를 하나로 합친 후 필요하다면 추가 집계를 할 수 있습니다. 중요한 점은 병렬 처리로 전체 실행 시간이 크게 단축된다는 것입니다.

여러분이 이 코드를 사용하면 100개의 파일을 순차 처리할 때 100분 걸리던 작업이 8코어 CPU에서는 약 13-15분으로 줄어듭니다(이론상 12.5분이지만 오버헤드가 있음). 파일이 많을수록, 각 파일의 처리 시간이 길수록 효과가 큽니다.

실무에서의 이점: 배치 작업 시간 대폭 단축으로 비즈니스 민첩성 향상, 같은 시간에 더 많은 데이터 처리 가능, 클라우드 환경에서 멀티코어 인스턴스의 비용 효율성 극대화, 사용자는 더 빠른 응답 시간 경험.

실전 팁

💡 작은 파일들을 병렬 처리할 때는 프로세스 생성 오버헤드가 이득보다 클 수 있으니, 각 작업이 최소 1-2초 이상 걸릴 때 사용하세요.

💡 max_workers 매개변수로 워커 수를 조절할 수 있습니다. CPU 집약적 작업은 코어 수만큼, I/O 집약적 작업은 코어 수의 2-4배로 설정하면 좋습니다.

💡 메모리 사용량에 주의하세요. 각 워커가 독립적인 메모리를 사용하므로, 8개 워커가 각각 2GB를 사용하면 총 16GB가 필요합니다.

💡 Polars의 많은 연산(group_by, join 등)은 이미 내부적으로 병렬화되어 있습니다. pl.Config.set_global_threads()로 전역 스레드 수를 설정할 수 있습니다.

💡 디버깅이 어려울 수 있으므로, 먼저 단일 파일로 process_file() 함수를 완벽하게 테스트한 후 병렬 처리를 적용하세요.


4. 컬럼 선택과 프로젝션 - 불필요한 데이터 제거하기

시작하며

여러분이 100개의 컬럼이 있는 테이블에서 실제로는 5개만 사용하는데, 매번 95개의 쓸모없는 컬럼까지 불러오고 있다면 얼마나 비효율적일까요? 마치 필요한 책 한 권을 보려고 도서관 전체를 집으로 옮기는 것과 같습니다.

이런 상황은 레거시 데이터베이스나 외부 API를 다룰 때 흔히 발생합니다. 데이터 소스는 많은 정보를 제공하지만, 우리의 분석이나 애플리케이션에는 그중 일부만 필요합니다.

불필요한 데이터를 불러오면 네트워크 대역폭, 메모리, 처리 시간이 모두 낭비됩니다. 바로 이럴 때 필요한 것이 효율적인 컬럼 선택(Column Selection)과 프로젝션(Projection)입니다.

필요한 컬럼만 정확히 선택하면 데이터 처리의 모든 단계가 빨라집니다.

개요

간단히 말해서, 컬럼 선택은 "뷔페에서 먹을 만큼만 담기"입니다. 데이터 소스가 제공하는 모든 컬럼을 가져오는 대신, 실제로 사용할 컬럼만 명시적으로 지정하여 불러옵니다.

왜 이것이 중요한지 설명하자면, 데이터 처리의 성능은 데이터 크기에 비례하기 때문입니다. 예를 들어, 고객 테이블에 주소, 전화번호, 구매 이력 등 50개 컬럼이 있는데 이름과 이메일만 필요하다면, 2개 컬럼만 선택함으로써 데이터 크기를 95% 이상 줄일 수 있습니다.

기존에는 "일단 전체 데이터를 불러온 후 나중에 필요한 컬럼 선택"했다면, 이제는 "처음부터 필요한 컬럼만 불러오기"가 가능합니다. 특히 Polars의 LazyFrame과 함께 사용하면 파일에서 읽을 때부터 필요한 컬럼만 읽어오므로 I/O도 최소화됩니다.

컬럼 선택의 핵심 특징: 1) 메모리 사용량 대폭 감소(필요한 컬럼만 로드), 2) I/O 시간 단축(특히 Parquet 같은 컬럼 기반 포맷에서), 3) 후속 연산 속도 향상(처리할 데이터 자체가 적으므로). 이러한 특징들이 간단한 코드 변경만으로 2-10배의 성능 개선을 가져올 수 있습니다.

코드 예제

import polars as pl

# 비효율적: 모든 컬럼을 불러온 후 선택
df_bad = pl.read_csv("users.csv")  # 50개 컬럼 모두 읽음
df_bad = df_bad.select(["user_id", "name", "email"])  # 메모리에 올린 후 선택

# 효율적: 필요한 컬럼만 처음부터 지정
df_good = pl.read_csv("users.csv", columns=["user_id", "name", "email"])

# LazyFrame과 함께 사용 (가장 효율적)
df_lazy = (
    pl.scan_csv("users.csv")
    .select([
        "user_id",
        "name",
        "email",
        pl.col("created_at").str.to_date()  # 타입 변환도 함께
    ])
    .filter(pl.col("created_at") > "2024-01-01")
    .collect()
)

설명

이것이 하는 일: 위 코드는 대용량 CSV 파일에서 필요한 3개 컬럼만 선택적으로 읽어 리소스를 절약하는 세 가지 방법을 보여줍니다. 첫 번째 예시(df_bad)는 흔히 하는 실수입니다.

read_csv()가 파일의 모든 컬럼(50개)을 메모리에 올린 후, select()로 3개만 추출합니다. 이미 메모리에 불필요한 47개 컬럼이 로드되었고, 파일 읽기 시간도 전체 컬럼을 읽는 데 소요되었습니다.

이후 select()는 메모리 상에서 컬럼을 제거하지만, 이미 낭비된 리소스는 되돌릴 수 없습니다. 두 번째 예시(df_good)는 개선된 방법입니다.

columns 매개변수로 필요한 컬럼만 명시하면, Polars는 CSV를 파싱할 때 해당 컬럼만 읽어옵니다. 50개 컬럼 중 3개만 읽으므로 I/O 시간이 크게 줄고, 메모리도 필요한 만큼만 사용합니다.

간단한 매개변수 추가만으로 5-10배의 성능 향상을 얻을 수 있습니다. 세 번째 예시(df_lazy)는 가장 효율적인 방법입니다.

LazyFrame과 컬럼 선택을 결합하여 쿼리 최적화의 이점까지 누립니다. scan_csv()는 메타데이터만 읽고, select()는 실행 계획에 추가되며, filter()도 계획에 포함됩니다.

collect()가 호출되면 Polars는 "필요한 3개 컬럼만 읽고, 날짜 변환을 하고, 2024년 이후 데이터만 필터링"하는 최적화된 계획을 실행합니다. 여러분이 이 패턴을 적용하면 100MB 파일이 6MB로 줄어들 수 있고, 읽기 시간은 10초에서 2초로, 메모리 사용량은 1GB에서 60MB로 감소할 수 있습니다.

특히 Parquet 포맷은 컬럼 단위 저장 방식이라 필요한 컬럼만 읽는 효과가 극대화됩니다. 실무에서의 이점: API 호출 시 네트워크 전송량 감소, 데이터베이스 쿼리 성능 향상(SELECT * 대신 명시적 컬럼 지정), 메모리 제약이 있는 환경에서 더 큰 데이터 처리 가능, 코드 가독성 향상(어떤 컬럼을 사용하는지 명확).

실전 팁

💡 프로젝트 초기에 데이터 스키마를 문서화하고, 각 분석/기능에서 실제로 필요한 컬럼만 선택하는 습관을 들이세요.

💡 Polars의 pl.col()을 사용하면 정규표현식으로 여러 컬럼을 선택할 수 있습니다. 예: pl.col("^user_.*$")로 user_로 시작하는 모든 컬럼 선택.

💡 데이터 탐색 단계에서는 .head().describe()로 전체 구조를 파악한 후, 실제 처리에서는 필요한 컬럼만 선택하세요.

💡 Parquet 파일을 사용한다면 컬럼 선택의 효과가 CSV보다 훨씬 큽니다. 가능하면 CSV를 Parquet으로 변환해서 사용하세요.

💡 팀과 협업할 때는 "어떤 컬럼이 필요한지"를 명시적으로 문서화하면, 불필요한 데이터 전송과 처리를 줄일 수 있습니다.


5. Parquet 포맷 활용 - 저장과 읽기 최적화

시작하며

여러분이 매일 같은 CSV 파일을 읽는데 매번 10분씩 걸린다면, 한 달이면 5시간을 낭비하는 셈입니다. 게다가 CSV는 타입 정보가 없어서 매번 타입 추론을 하느라 시간이 더 걸리죠.

이런 비효율은 CSV의 구조적 한계에서 비롯됩니다. CSV는 텍스트 기반이라 읽고 쓸 때 파싱이 필요하고, 압축도 비효율적이며, 컬럼 단위 접근이 불가능합니다.

"사람이 읽기 쉬운" 장점이 있지만, 기계가 처리하기에는 최악의 포맷입니다. 바로 이럴 때 필요한 것이 Parquet 포맷입니다.

Parquet은 컬럼 기반 바이너리 포맷으로, 빠른 읽기/쓰기, 효율적인 압축, 타입 정보 보존을 모두 제공합니다.

개요

간단히 말해서, Parquet은 데이터 분석에 최적화된 "슈퍼 엑셀" 같은 포맷입니다. CSV가 행 단위로 데이터를 저장한다면, Parquet은 컬럼 단위로 저장하여 분석 작업에서 필요한 컬럼만 빠르게 읽을 수 있습니다.

왜 Parquet이 필요한지 실무 관점에서 보면, 데이터 파이프라인의 중간 결과나 자주 읽는 데이터는 최적화된 포맷으로 저장해야 전체 워크플로우가 빨라지기 때문입니다. 예를 들어, 원본 로그 데이터를 CSV로 받아도 전처리 후에는 Parquet으로 저장하면, 이후 모든 분석 작업이 5-10배 빨라집니다.

기존 CSV에서는 "100MB 파일을 10초에 읽기"였다면, Parquet은 "같은 데이터를 30MB로 압축하여 2초에 읽기"가 가능합니다. 저장 공간도 절약되고 읽기도 빨라지는 일석이조죠.

Parquet의 핵심 특징: 1) 컬럼 기반 저장으로 필요한 컬럼만 빠르게 읽기, 2) 뛰어난 압축률로 저장 공간 50-80% 절약, 3) 타입 정보와 메타데이터 보존으로 타입 추론 불필요. 이러한 특징들이 Parquet을 대용량 데이터 처리의 사실상 표준으로 만들었습니다.

코드 예제

import polars as pl

# CSV를 Parquet으로 변환 (1회만 수행)
df = pl.read_csv("large_data.csv")
df.write_parquet(
    "large_data.parquet",
    compression="snappy",  # 빠른 압축/해제
    statistics=True,  # 컬럼별 통계 저장 (필터링 최적화)
    row_group_size=100_000  # 청크 크기
)

# Parquet 읽기 (CSV보다 5-10배 빠름)
df_fast = pl.read_parquet("large_data.parquet")

# 특정 컬럼만 읽기 (컬럼 기반 포맷의 장점)
df_columns = pl.read_parquet(
    "large_data.parquet",
    columns=["user_id", "amount", "timestamp"]
)

# LazyFrame으로 필터와 함께 사용 (가장 효율적)
df_optimized = (
    pl.scan_parquet("large_data.parquet")
    .filter(pl.col("amount") > 1000)  # Parquet 통계로 스킵 가능
    .select(["user_id", "amount"])
    .collect()
)

설명

이것이 하는 일: 위 코드는 CSV 파일을 Parquet으로 변환하고, Parquet의 다양한 최적화 기능을 활용하여 빠르고 효율적으로 데이터를 읽는 방법을 보여줍니다. 첫 번째 단계에서 write_parquet()는 여러 최적화 옵션을 제공합니다.

compression="snappy"는 압축률과 속도의 균형이 좋은 옵션입니다(더 높은 압축률을 원하면 "gzip"이나 "zstd" 사용). statistics=True는 각 컬럼의 최솟값, 최댓값, null 개수 등을 메타데이터로 저장하여, 나중에 필터 조건에 맞지 않는 청크를 건너뛸 수 있게 합니다.

row_group_size는 데이터를 몇 행씩 묶을지 결정하는데, 이는 나중에 청크 단위로 읽을 때 중요합니다. 두 번째 단계의 일반적인 Parquet 읽기는 CSV 대비 5-10배 빠릅니다.

바이너리 포맷이라 파싱이 필요 없고, 타입 정보가 저장되어 있어 타입 추론도 불필요하기 때문입니다. 예를 들어 1GB CSV가 10초 걸린다면 같은 데이터의 Parquet은 1-2초에 읽힙니다.

세 번째 단계의 컬럼 선택은 Parquet의 핵심 강점입니다. 컬럼 기반 저장 방식이라 3개 컬럼만 필요하면 정말로 그 3개 컬럼의 데이터만 디스크에서 읽어옵니다.

CSV는 전체 파일을 읽은 후 컬럼을 선택하지만, Parquet은 물리적으로 필요한 부분만 읽습니다. 50개 컬럼 중 3개만 읽으면 I/O가 90% 이상 줄어듭니다.

네 번째 단계의 LazyFrame과 결합한 방법이 가장 강력합니다. scan_parquet()filter()를 함께 사용하면, Parquet의 통계 메타데이터를 활용하여 필터 조건에 맞지 않는 row group을 아예 건너뜁니다.

예를 들어 amount > 1000 조건이면, 최댓값이 1000 이하인 row group은 읽지도 않습니다. 이를 "술어 하향 전달(Predicate Pushdown)"이라고 합니다.

여러분이 Parquet을 사용하면: 1GB CSV가 200-300MB Parquet으로 압축, 읽기 시간 10초 → 1-2초, 필요한 컬럼만 읽을 때는 더 큰 성능 향상. 데이터 팀이라면 반드시 도입해야 할 포맷입니다.

실무에서의 이점: 클라우드 스토리지 비용 절감(작은 파일 크기), 네트워크 전송 시간 단축, 데이터 레이크/웨어하우스에서 표준으로 사용, Spark, Pandas, Polars 등 모든 도구가 지원.

실전 팁

💡 CSV로 데이터를 받더라도, 첫 전처리 후에는 반드시 Parquet으로 저장하여 이후 작업의 효율을 높이세요.

💡 압축 옵션 선택: snappy(빠름, 적당한 압축), gzip(느림, 높은 압축), zstd(균형, 최신 권장). 네트워크 전송이 병목이면 gzip, 로컬 처리 위주면 snappy.

💡 row_group_size는 메모리와 성능의 트레이드오프입니다. 너무 작으면 오버헤드, 너무 크면 메모리 부족. 보통 100,000-1,000,000 사이로 설정합니다.

💡 Parquet 파일은 사람이 읽을 수 없으므로, 데이터를 탐색할 때는 .head()나 전용 도구(DuckDB, Parquet Tools)를 사용하세요.

💡 파티셔닝을 활용하면 더 효율적입니다. write_parquet("data", partition_by="date")로 날짜별 폴더 구조를 만들어 필요한 날짜만 읽을 수 있습니다.


6. 메모리 매핑 - 거대한 파일 효율적으로 다루기

시작하며

여러분이 50GB 파일을 처리해야 하는데 RAM은 16GB뿐이라면, 파일을 열 수조차 없을 것 같지 않나요? 전통적인 방식으로는 파일 전체를 메모리에 올려야 하니 불가능해 보입니다.

이런 상황은 로그 분석, 과학 데이터 처리, 머신러닝 학습 데이터 준비 등에서 자주 발생합니다. 파일은 크지만 실제로는 일부분씩만 접근하는 경우가 많습니다.

전체를 메모리에 올리는 것은 과도한 낭비이자, 물리적으로 불가능한 작업입니다. 바로 이럴 때 필요한 것이 메모리 매핑(Memory Mapping)입니다.

파일을 마치 메모리에 있는 것처럼 다루되, 실제로는 필요한 부분만 자동으로 불러오는 기술입니다.

개요

간단히 말해서, 메모리 매핑은 "거대한 책을 통째로 들고 다니지 않고, 필요한 페이지만 펼쳐보는 것"과 같습니다. 운영체제가 파일의 일부를 필요할 때만 메모리에 로드하고, 사용하지 않는 부분은 자동으로 제거합니다.

왜 이것이 중요한지 실무 관점에서 보면, 파일 크기가 메모리 크기를 초과하는 경우를 우아하게 처리할 수 있기 때문입니다. 예를 들어, 100GB의 머신러닝 학습 데이터를 순회하면서 배치 단위로 읽을 때, 메모리 매핑을 사용하면 마치 전체 데이터가 메모리에 있는 것처럼 코딩할 수 있습니다.

기존에는 "파일을 작은 청크로 명시적으로 나누어 읽는 복잡한 코드"를 작성해야 했다면, 이제는 "운영체제가 자동으로 관리하는 메모리 매핑"을 사용할 수 있습니다. 코드는 단순해지고 성능은 향상됩니다.

메모리 매핑의 핵심 특징: 1) 파일 크기가 메모리 크기를 초과해도 처리 가능, 2) 운영체제가 자동으로 캐싱 관리(자주 접근하는 부분은 메모리에 유지), 3) 여러 프로세스가 같은 파일을 효율적으로 공유 가능. 이러한 특징들이 시스템 프로그래밍이나 대용량 데이터 처리에서 필수 기술입니다.

코드 예제

import polars as pl
import numpy as np
from pathlib import Path

# Polars에서 메모리 매핑 사용 (Parquet에 효과적)
df = pl.read_parquet(
    "huge_file.parquet",
    memory_map=True  # 파일을 메모리 맵으로 열기
)

# 또는 scan_parquet와 함께 사용
df_lazy = pl.scan_parquet("huge_file.parquet")  # 기본적으로 메모리 매핑 사용
result = (
    df_lazy
    .filter(pl.col("amount") > 1000)
    .select(["user_id", "amount"])
    .collect()  # 필요한 부분만 실제로 읽음
)

# NumPy 배열을 메모리 맵으로 저장/로드
# 거대한 배열 생성
arr = np.random.rand(10_000_000, 100)  # 8GB 배열
# 메모리 맵 파일로 저장
np.save("large_array.npy", arr)
# 메모리 맵으로 로드 (전체를 메모리에 올리지 않음)
arr_mmap = np.load("large_array.npy", mmap_mode="r")  # 읽기 전용
# 필요한 부분만 접근
batch = arr_mmap[1000:2000, :]  # 이 순간에만 해당 부분이 로드됨

설명

이것이 하는 일: 위 코드는 Polars와 NumPy에서 메모리 매핑을 활용하여 물리적 메모리보다 큰 파일을 효율적으로 처리하는 방법을 보여줍니다. 첫 번째 Polars 예시에서 memory_map=True 옵션은 파일을 전통적인 방식(파일 → 버퍼 → 메모리)으로 읽는 대신, 파일을 가상 메모리 공간에 직접 매핑합니다.

이렇게 하면 파일 시스템 캐시를 더 효율적으로 활용할 수 있고, 데이터를 중복으로 복사하지 않아 메모리 사용량이 줄어듭니다. 특히 Parquet 같은 바이너리 포맷에서 효과적입니다.

두 번째 LazyFrame 예시는 메모리 매핑과 지연 평가의 시너지를 보여줍니다. scan_parquet()는 기본적으로 메모리 매핑을 사용하고, 파일의 구조만 파악합니다.

filterselect 조건이 추가되면, collect() 시점에 정말 필요한 부분만 메모리에 로드됩니다. 100GB 파일이어도 필터 후 결과가 1GB라면 실제로는 1GB 정도만 메모리를 사용합니다.

세 번째 NumPy 예시는 대용량 배열 작업에서 메모리 매핑을 사용하는 전형적인 패턴입니다. 10백만 행 × 100 컬럼의 float64 배열은 약 8GB인데, mmap_mode="r"로 열면 전체를 메모리에 올리지 않습니다.

arr_mmap[1000:2000, :]처럼 슬라이싱할 때, 운영체제는 해당 페이지(보통 4KB 단위)만 디스크에서 읽어와 메모리에 로드합니다. 다른 부분은 여전히 디스크에 있죠.

여러분이 메모리 매핑을 사용하면 50GB 파일을 16GB RAM 환경에서 처리할 수 있습니다. 물론 전체를 순회하면 시간이 걸리지만, "불가능"이 "가능하지만 느림"으로 바뀝니다.

또한 같은 파일을 여러 번 읽을 때는 운영체제 캐시 덕분에 두 번째부터는 훨씬 빠릅니다. 실무에서의 이점: 머신러닝 학습 시 거대한 데이터셋을 배치 단위로 로드할 때 유용, 여러 프로세스/스레드가 같은 대용량 참조 데이터를 공유할 때 메모리 절약, 서버 애플리케이션에서 대용량 정적 데이터를 효율적으로 서빙.

실전 팁

💡 메모리 매핑은 순차 접근보다 랜덤 접근에서 성능 차이가 큽니다. 랜덤 접근이 많으면 캐시 미스가 빈번하여 느려질 수 있습니다.

💡 SSD에서는 메모리 매핑의 효과가 극대화되지만, HDD에서는 랜덤 액세스가 느려 성능이 떨어질 수 있습니다.

💡 NumPy의 mmap_mode에는 "r"(읽기), "r+"(읽기/쓰기), "w+"(쓰기, 기존 데이터 덮어씀), "c"(copy-on-write) 옵션이 있습니다. 용도에 맞게 선택하세요.

💡 여러 프로세스가 같은 메모리 맵 파일을 공유하면 물리적 메모리는 한 번만 로드되고, 각 프로세스의 가상 메모리에 매핑되어 효율적입니다.

💡 디버깅 시에는 작은 파일로 먼저 테스트하세요. 메모리 매핑은 에러 메시지가 일반 파일 I/O와 다를 수 있습니다.


7. 데이터 타입 최적화 - 메모리 사용량 줄이기

시작하며

여러분의 데이터프레임이 10GB 메모리를 차지하는데, 알고 보니 실제 데이터는 2GB면 충분했다면 어떨까요? 잘못된 데이터 타입 선택이 5배의 메모리를 낭비하고 있는 것입니다.

이런 비효율은 대부분의 데이터 분석에서 무심코 발생합니다. Polars나 Pandas가 자동으로 타입을 추론하지만, 보수적으로 큰 타입을 선택하는 경향이 있습니다.

예를 들어 1-100 범위의 숫자를 Int64로 저장하면, Int8이면 충분한데 8배의 메모리를 사용하게 됩니다. 바로 이럴 때 필요한 것이 데이터 타입 최적화입니다.

각 컬럼의 실제 데이터 범위와 특성에 맞는 최소 타입을 선택하면, 메모리 사용량을 대폭 줄일 수 있습니다.

개요

간단히 말해서, 데이터 타입 최적화는 "짐을 보내는데 필요한 최소 크기의 상자 선택하기"입니다. 작은 물건을 거대한 상자에 담으면 공간 낭비인 것처럼, 작은 숫자를 큰 타입에 담으면 메모리 낭비입니다.

왜 이것이 중요한지 실무 관점에서 보면, 메모리는 모든 데이터 처리의 병목이기 때문입니다. 예를 들어, 1억 행의 데이터에서 나이 컬럼을 Int64(8바이트) 대신 Int8(1바이트)로 저장하면, 그것만으로 700MB를 절약합니다.

여러 컬럼을 최적화하면 전체 메모리를 50% 이상 줄일 수 있죠. 기존에는 "자동 추론된 타입을 그대로 사용"했다면, 이제는 "데이터의 실제 범위를 파악하고 적절한 타입 명시"가 필요합니다.

한 번의 타입 지정으로 지속적인 성능 향상을 얻을 수 있습니다. 데이터 타입 최적화의 핵심 특징: 1) 메모리 사용량 30-70% 감소 가능, 2) 메모리가 줄면 캐시 효율 향상으로 처리 속도도 개선, 3) 저장 공간도 함께 절약.

이러한 특징들이 대용량 데이터 처리에서 타입 선택을 중요한 최적화 포인트로 만듭니다.

코드 예제

import polars as pl

# 자동 추론 (비효율적)
df_auto = pl.read_csv("data.csv")
print(df_auto.dtypes)  # [Int64, Int64, Float64, Utf8, ...]

# 수동 타입 지정 (효율적)
df_optimized = pl.read_csv(
    "data.csv",
    dtypes={
        "age": pl.Int8,  # 0-127, 1바이트 (원래 Int64는 8바이트)
        "user_id": pl.UInt32,  # 0-4billion, 4바이트 (음수 불필요)
        "amount": pl.Float32,  # 4바이트 (Float64는 8바이트)
        "status": pl.Categorical,  # 범주형으로 저장 (문자열보다 효율적)
        "date": pl.Date,  # 날짜 전용 타입 (Utf8보다 효율적)
    }
)

# 기존 데이터프레임 타입 변환
df_converted = df_auto.with_columns([
    pl.col("age").cast(pl.Int8),
    pl.col("user_id").cast(pl.UInt32),
    pl.col("status").cast(pl.Categorical),
])

# 메모리 사용량 비교
print(f"자동: {df_auto.estimated_size() / 1024**2:.2f} MB")
print(f"최적화: {df_optimized.estimated_size() / 1024**2:.2f} MB")

설명

이것이 하는 일: 위 코드는 자동 타입 추론의 비효율을 보여주고, 데이터 특성에 맞는 적절한 타입을 지정하여 메모리를 대폭 절약하는 방법을 시연합니다. 첫 번째 예시는 일반적으로 많이 하는 방식입니다.

read_csv()가 자동으로 타입을 추론하는데, 보수적으로 판단하여 정수는 Int64, 실수는 Float64, 문자열은 Utf8로 지정합니다. 안전하지만 비효율적입니다.

예를 들어 age 컬럼의 값이 0-100 범위여도 Int64(8바이트)를 사용하여 -9경부터 9경까지 표현할 수 있는 낭비가 발생합니다. 두 번째 예시는 최적화된 방식입니다.

dtypes 매개변수로 각 컬럼의 타입을 명시합니다. age는 Int8(1바이트)로 충분하고, user_id는 음수가 없으므로 UInt32(4바이트)를 사용합니다.

amount는 소수점 6자리까지의 정밀도만 필요하면 Float32로 충분합니다. status처럼 반복되는 문자열은 Categorical로 저장하면 각 고유 값을 한 번만 저장하고 정수 인덱스로 참조하므로 훨씬 효율적입니다.

세 번째 예시는 이미 로드된 데이터프레임의 타입을 변환하는 방법입니다. with_columns()cast()를 사용하여 기존 컬럼을 새 타입으로 변환합니다.

단, 데이터 손실에 주의해야 합니다. Int64를 Int8로 변환할 때 값이 127을 초과하면 오버플로우가 발생할 수 있으므로, 사전에 데이터 범위를 확인해야 합니다.

네 번째 단계의 메모리 비교에서 극적인 차이를 볼 수 있습니다. 1억 행 데이터에서 age(8→1바이트), user_id(8→4바이트), amount(8→4바이트), status(평균 10바이트 → 1바이트 인덱스) 최적화만으로도 메모리가 10GB에서 3GB로 줄어듭니다.

여러분이 타입 최적화를 적용하면 더 큰 데이터를 같은 메모리에서 처리할 수 있고, 클라우드 환경에서는 더 작은 인스턴스를 사용하여 비용을 절감할 수 있습니다. 또한 메모리가 줄면 CPU 캐시 히트율이 높아져 연산 속도도 10-20% 향상됩니다.

실무에서의 이점: 제한된 메모리 환경에서 더 큰 데이터 처리 가능, Parquet 파일 크기 감소로 저장 및 전송 비용 절감, 머신러닝 학습 시 배치 크기 증가로 학습 속도 향상, 데이터베이스 INSERT 시 네트워크 전송량 감소.

실전 팁

💡 타입을 결정하기 전에 df.describe()df.select(pl.col("column").min(), pl.col("column").max())로 실제 데이터 범위를 확인하세요.

💡 정수 타입 선택 가이드: Int8(-128127), Int16(-32K32K), Int32(-2B2B), Int64(-9E9E). 음수가 없으면 UInt 시리즈로 범위 2배 확장.

💡 Categorical 타입은 고유 값이 전체 행의 50% 미만일 때 효율적입니다. 고유 값이 너무 많으면 오히려 오버헤드가 발생할 수 있습니다.

💡 Float32 vs Float64: 과학 계산이나 정밀도가 중요하면 Float64, 일반적인 비즈니스 데이터는 Float32로 충분합니다.

💡 타입 변환 후에는 반드시 assert 문으로 데이터 손실이 없는지 검증하세요. 예: assert df.select(pl.col("age").max() <= 127).item().


8. 인덱싱과 필터 최적화 - 불필요한 스캔 줄이기

시작하며

여러분이 1억 행의 데이터에서 특정 사용자의 기록 100개를 찾는데 전체 데이터를 다 뒤진다면, 999,900행을 헛되이 읽는 셈입니다. 마치 전화번호부에서 이름을 찾는데 처음부터 끝까지 한 줄씩 읽는 것과 같죠.

이런 비효율은 데이터베이스 없이 파일 기반으로 대용량 데이터를 처리할 때 흔히 발생합니다. 필터 조건이 있어도 전체 데이터를 스캔하면 시간과 리소스가 엄청나게 낭비됩니다.

특히 반복적으로 같은 패턴의 쿼리를 수행할 때는 더욱 심각합니다. 바로 이럴 때 필요한 것이 스마트한 인덱싱과 필터 최적화입니다.

데이터를 미리 정렬하거나 파티셔닝하고, 필터 조건을 최대한 활용하면 필요한 부분만 빠르게 찾을 수 있습니다.

개요

간단히 말해서, 인덱싱과 필터 최적화는 "도서관에서 책을 찾을 때 분류 체계와 색인을 활용하기"입니다. 무작위로 쌓인 책더미보다, 분류되고 정리된 서가에서 훨씬 빠르게 원하는 책을 찾을 수 있죠.

왜 이것이 중요한지 실무에서 보면, 대부분의 데이터 처리는 전체가 아닌 일부 조건에 맞는 데이터만 필요하기 때문입니다. 예를 들어, 최근 7일간의 로그만 분석하거나, 특정 지역의 거래만 추출하거나, VIP 고객의 행동만 추적하는 경우가 대부분입니다.

조건에 맞는 데이터만 빠르게 찾는 능력이 전체 성능을 결정합니다. 기존에는 "파일을 처음부터 끝까지 읽으면서 조건을 확인"했다면, 이제는 "데이터를 미리 정렬/파티셔닝하여 필요한 부분만 읽기"가 가능합니다.

1억 행 스캔이 10만 행 스캔으로 줄면 100배 빠릅니다. 필터 최적화의 핵심 특징: 1) 정렬된 데이터에서 범위 필터는 극적으로 빠름, 2) 파티셔닝으로 불필요한 파일/청크 스킵, 3) Parquet의 통계 메타데이터 활용으로 자동 최적화.

이러한 특징들이 "데이터 준비 단계"의 중요성을 강조합니다.

코드 예제

import polars as pl
from datetime import datetime, timedelta

# 비효율적: 정렬되지 않은 데이터에서 필터링
df = pl.read_parquet("transactions.parquet")
result_slow = df.filter(
    (pl.col("date") >= "2024-01-01") &
    (pl.col("date") <= "2024-01-31")
)  # 전체 데이터 스캔

# 효율적 1: 날짜로 정렬 후 저장 (1회만 수행)
df_sorted = df.sort("date")
df_sorted.write_parquet(
    "transactions_sorted.parquet",
    statistics=True  # 통계 메타데이터 활성화
)

# 효율적 2: 파티셔닝 (1회만 수행)
df.write_parquet(
    "transactions_partitioned",
    partition_by="date",  # 날짜별 폴더 구조 생성
    partition_by_format="%Y-%m"  # YYYY-MM 형식으로
)

# LazyFrame으로 최적화된 읽기
result_fast = (
    pl.scan_parquet("transactions_sorted.parquet")
    .filter(pl.col("date").is_between("2024-01-01", "2024-01-31"))
    .collect()
)  # Parquet 통계로 불필요한 row group 스킵

# 파티셔닝된 데이터 읽기 (해당 월 파일만 읽음)
result_partitioned = pl.read_parquet("transactions_partitioned/2024-01/*.parquet")

설명

이것이 하는 일: 위 코드는 대용량 데이터에서 특정 날짜 범위를 필터링할 때, 데이터 구조 최적화로 전체 스캔을 피하고 필요한 부분만 읽는 방법들을 보여줍니다. 첫 번째 비효율적인 예시는 일반적인 실수입니다.

Parquet 파일을 읽고 필터를 적용하지만, 데이터가 날짜순으로 정렬되어 있지 않으면 전체 파일을 스캔해야 합니다. 1월 데이터가 파일 곳곳에 흩어져 있다면, 모든 row group을 읽어야 하죠.

1억 행이면 수십 초에서 수 분이 걸립니다. 두 번째 정렬 방식은 간단하지만 강력합니다.

sort("date")로 날짜순 정렬 후 Parquet으로 저장하면, 각 row group의 min/max 날짜가 메타데이터에 기록됩니다. 이후 날짜 범위 필터를 사용할 때, Polars는 메타데이터를 확인하여 "이 row group의 최댓값이 2023-12-31이네?

2024-01 데이터는 없으니 스킵"하고 건너뜁니다. 이를 술어 하향 전달(Predicate Pushdown)이라고 합니다.

세 번째 파티셔닝 방식은 더 강력합니다. partition_by="date"partition_by_format="%Y-%m"을 사용하면, 데이터가 날짜별 폴더 구조(예: 2024-01/, 2024-02/)로 저장됩니다.

1월 데이터만 필요하면 아예 2024-01/ 폴더의 파일들만 열면 되므로, 다른 월의 데이터는 읽지조차 않습니다. 12개월 데이터 중 1개월만 필요하면 12배 빠릅니다.

네 번째와 다섯 번째 예시는 최적화된 데이터를 읽는 방법입니다. LazyFrame의 scan_parquet()filter()를 함께 쓰면 쿼리 최적화 엔진이 자동으로 불필요한 부분을 스킵합니다.

파티셔닝된 데이터는 glob 패턴으로 특정 폴더만 지정하면 됩니다. 두 방법 모두 전체 스캔 대비 10-100배 빠릅니다.

여러분이 이 기법들을 적용하면 정렬/파티셔닝에 한 번의 초기 비용(시간)이 들지만, 이후 모든 쿼리가 극적으로 빨라집니다. 매일 반복되는 쿼리라면 첫날의 준비 시간을 둘째 날부터 계속 회수하는 셈입니다.

실무에서의 이점: 대시보드나 리포트 생성 시간 대폭 단축, 탐색적 데이터 분석 시 빠른 피드백, 데이터 레이크에서 특정 조건의 데이터만 빠르게 추출, 시계열 데이터 분석에서 최근 데이터만 효율적으로 처리.

실전 팁

💡 가장 자주 필터링하는 컬럼으로 정렬하거나 파티셔닝하세요. 날짜, 지역, 카테고리 등이 일반적입니다.

💡 파티셔닝할 때는 각 파티션의 크기가 적당해야 합니다(100MB-1GB). 너무 작으면 파일이 많아져 오버헤드, 너무 크면 필터 효과 감소.

💡 여러 컬럼으로 자주 필터링한다면 복합 파티셔닝을 고려하세요. 예: partition_by=["region", "date"]로 지역별 → 날짜별 중첩 구조.

💡 is_between()>=<=를 개별로 쓰는 것보다 최적화가 잘 됩니다. Polars가 범위 쿼리임을 명확히 인식하기 때문입니다.

💡 정렬이나 파티셔닝을 변경했다면, 기존 쿼리가 여전히 잘 작동하는지 테스트하세요. 데이터 구조 변경은 예상치 못한 버그를 유발할 수 있습니다.


9. 집계 연산 최적화 - Group By 성능 끌어올리기

시작하며

여러분이 수백만 개의 거래 데이터를 고객별로 그룹화하여 집계하는데 10분이 걸린다면, 실시간 분석은 꿈도 꿀 수 없겠죠? 집계 연산은 데이터 분석에서 가장 흔하면서도 가장 무거운 작업입니다.

이런 성능 문제는 group by 연산의 본질적인 복잡도 때문에 발생합니다. 데이터를 그룹으로 나누고, 각 그룹에 함수를 적용하고, 결과를 합치는 과정이 모두 시간을 소모합니다.

특히 그룹 수가 많거나 집계 함수가 복잡할 때는 더욱 느려집니다. 바로 이럴 때 필요한 것이 Polars의 최적화된 집계 연산입니다.

Polars는 group by를 병렬로 처리하고, 메모리 효율적인 해시 테이블을 사용하며, 여러 집계를 한 번에 수행하는 최적화를 제공합니다.

개요

간단히 말해서, 집계 연산 최적화는 "공장에서 제품을 종류별로 분류하고 개수를 세는 작업을 여러 작업자가 동시에 하기"입니다. 한 사람이 순차적으로 하는 것보다 팀이 병렬로 하면 훨씬 빠르죠.

왜 이것이 중요한지 실무에서 보면, 비즈니스 인텔리전스의 핵심이 바로 집계이기 때문입니다. 예를 들어, "지역별 매출 합계", "고객별 평균 구매액", "상품별 판매 건수" 같은 인사이트는 모두 group by + 집계 연산입니다.

이것이 느리면 의사결정이 지연됩니다. 기존 Pandas에서는 "단일 스레드로 순차 처리"했다면, Polars는 "멀티 스레드 병렬 처리 + 메모리 효율적 알고리즘"으로 5-20배 빠릅니다.

같은 하드웨어에서 극적인 성능 차이를 경험할 수 있습니다. 집계 최적화의 핵심 특징: 1) 병렬 해시 그룹화로 멀티코어 활용, 2) 여러 집계를 한 번의 스캔으로 처리(효율적인 쿼리 플랜), 3) 메모리 효율적인 알고리즘으로 대용량 그룹 처리 가능.

이러한 특징들이 Polars를 대용량 집계 작업의 최적 선택으로 만듭니다.

코드 예제

import polars as pl

# 대용량 거래 데이터
df = pl.scan_csv("transactions.csv")

# 비효율적: 여러 번의 group by (각각 전체 스캔)
result1 = df.group_by("customer_id").agg(pl.col("amount").sum()).collect()
result2 = df.group_by("customer_id").agg(pl.col("amount").mean()).collect()
result3 = df.group_by("customer_id").agg(pl.col("transaction_id").count()).collect()

# 효율적: 한 번의 group by로 여러 집계
result_optimized = (
    df.group_by("customer_id")
    .agg([
        pl.col("amount").sum().alias("total_amount"),
        pl.col("amount").mean().alias("avg_amount"),
        pl.col("amount").max().alias("max_amount"),
        pl.col("transaction_id").count().alias("transaction_count"),
        pl.col("product_id").n_unique().alias("unique_products"),
    ])
    .collect()
)

# 복잡한 조건부 집계
result_advanced = (
    df.group_by(["customer_id", "region"])
    .agg([
        # 조건부 합계
        pl.col("amount").filter(pl.col("status") == "completed").sum().alias("completed_amount"),
        # 백분위수
        pl.col("amount").quantile(0.95).alias("amount_95th"),
        # 첫 번째/마지막 값
        pl.col("date").min().alias("first_purchase"),
        pl.col("date").max().alias("last_purchase"),
    ])
    .collect()
)

설명

이것이 하는 일: 위 코드는 대용량 거래 데이터를 고객별로 그룹화하여 여러 통계를 계산할 때, 비효율적인 방법과 최적화된 방법을 비교합니다. 첫 번째 비효율적인 예시는 초보자들이 흔히 하는 실수입니다.

합계, 평균, 건수를 각각 별도의 group by로 계산합니다. 각 연산이 전체 데이터를 처음부터 끝까지 스캔하므로, 데이터를 3번 읽습니다.

LazyFrame이더라도 각 collect()마다 별도의 실행이 발생하여 최적화가 제한적입니다. 1억 행 데이터라면 3억 행을 처리하는 셈입니다.

두 번째 최적화된 예시는 모든 집계를 단일 agg() 안에 리스트로 모았습니다. 이렇게 하면 Polars는 "아, 같은 그룹에 대해 여러 집계를 하는구나"라고 인식하고, 한 번의 데이터 스캔으로 모든 집계를 동시에 수행합니다.

데이터를 그룹으로 나누는 비용(가장 큰 비용)을 한 번만 지불하고, 이후 각 그룹에 대해 5개 함수를 빠르게 적용합니다. 1억 행 데이터를 1번만 처리하므로 3배 빠릅니다.

또한 Polars는 내부적으로 멀티스레드 해시 그룹화를 사용합니다. 데이터를 여러 청크로 나누어 각 스레드가 병렬로 그룹화하고, 마지막에 결과를 병합합니다.

8코어 CPU라면 이론상 8배 빠르지만, 실제로는 오버헤드로 4-6배 정도 빨라집니다. 세 번째 고급 예시는 실무에서 자주 쓰이는 복잡한 패턴들을 보여줍니다.

filter()를 집계 안에 사용하여 조건부 집계(예: 완료된 거래의 합계만), quantile()로 이상치 영향을 덜 받는 통계, min()/max()로 첫 구매/마지막 구매 날짜 추출. 이 모든 것이 한 번의 스캔으로 처리됩니다.

여러분이 이 패턴을 사용하면 Pandas로 10분 걸리던 집계가 Polars로 1-2분으로 줄어들고, 여러 집계를 통합하면 추가로 2-3배 더 빨라집니다. 전체적으로 20-30배 성능 향상을 기대할 수 있습니다.

실무에서의 이점: 대시보드 리프레시 시간 대폭 단축, 애드혹 분석 시 빠른 피드백으로 생산성 향상, 더 복잡한 집계와 세그먼트 분석 가능, 실시간에 가까운 비즈니스 인텔리전스 구현.

실전 팁

💡 가능한 한 모든 집계를 단일 agg() 호출로 통합하세요. 코드 가독성도 좋아지고 성능도 향상됩니다.

💡 pl.col("*")를 사용하면 모든 숫자 컬럼에 집계를 적용할 수 있습니다. 예: pl.col(pl.Float64).mean()으로 모든 Float64 컬럼의 평균.

💡 복잡한 비즈니스 로직은 pl.when().then().otherwise()로 표현 가능하며, 이것도 집계 안에서 병렬로 처리됩니다.

💡 그룹 수가 매우 많을 때(수백만 개)는 메모리 사용량에 주의하세요. 필요하다면 maintain_order=False로 정렬 오버헤드를 제거할 수 있습니다.

💡 결과를 다시 원본 데이터와 조인할 때는 join() 대신 with_columns()over()를 사용하면 더 효율적입니다(윈도우 함수).


10. 조인 연산 최적화 - 대용량 테이블 결합하기

시작하며

여러분이 1억 행의 거래 데이터와 1천만 행의 고객 데이터를 조인해야 하는데, 메모리가 부족하거나 시간이 너무 오래 걸린다면 어떻게 하시겠어요? 조인은 관계형 데이터의 핵심이지만, 대용량에서는 가장 까다로운 연산입니다.

이런 어려움은 조인 연산의 복잡도가 데이터 크기의 곱에 비례하기 때문입니다. 특히 잘못된 조인 타입이나 키 선택은 불필요한 카테시안 곱을 만들어 시스템을 마비시킬 수 있습니다.

"데이터가 너무 커서 조인할 수 없다"는 말을 들어본 적이 있을 것입니다. 바로 이럴 때 필요한 것이 Polars의 최적화된 조인 알고리즘과 전략입니다.

적절한 조인 타입 선택, 조인 순서 최적화, 그리고 Polars의 병렬 해시 조인을 활용하면 불가능해 보이는 작업도 가능해집니다.

개요

간단히 말해서, 조인 최적화는 "두 개의 거대한 전화번호부를 합칠 때 효율적인 전략 사용하기"입니다. 모든 항목을 일일이 비교하는 대신, 해시 테이블로 빠르게 매칭하거나, 한쪽이 작으면 메모리에 올려두고 다른 쪽을 순회하는 식입니다.

왜 이것이 중요한지 실무에서 보면, 실제 데이터는 여러 테이블에 정규화되어 있고, 분석을 위해서는 조인이 필수이기 때문입니다. 예를 들어, 거래 데이터에 고객 정보를 붙이고, 상품 정보를 추가하고, 지역 정보를 결합하는 식입니다.

조인이 느리면 전체 파이프라인이 느려집니다. 기존 Pandas에서는 "단일 스레드 조인으로 대용량에서 느리거나 메모리 부족"이었다면, Polars는 "병렬 해시 조인 + 메모리 효율적 알고리즘"으로 훨씬 큰 데이터를 빠르게 조인할 수 있습니다.

수백만 행 조인도 몇 초 내에 완료됩니다. 조인 최적화의 핵심 특징: 1) 병렬 해시 조인으로 멀티코어 활용, 2) 작은 테이블을 해시 테이블로 구축하여 효율성 극대화, 3) LazyFrame에서 조인 순서 자동 최적화.

이러한 특징들이 대용량 조인을 실용적으로 만듭니다.

코드 예제

import polars as pl

# 대용량 거래 데이터 (1억 행)
transactions = pl.scan_csv("transactions.csv")

# 고객 마스터 (1천만 행)
customers = pl.scan_csv("customers.csv")

# 상품 마스터 (10만 행)
products = pl.scan_csv("products.csv")

# 비효율적: 큰 테이블끼리 먼저 조인
bad_result = (
    transactions
    .join(customers, on="customer_id", how="left")
    .join(products, on="product_id", how="left")
    .collect()
)

# 효율적: 작은 테이블부터 조인 + 필요한 컬럼만 선택
good_result = (
    transactions
    .join(
        products.select(["product_id", "product_name", "category"]),
        on="product_id",
        how="left"
    )
    .join(
        customers.select(["customer_id", "customer_name", "region"]),
        on="customer_id",
        how="left"
    )
    .filter(pl.col("amount") > 1000)  # 필터를 조인 후 적용
    .select([
        "transaction_id",
        "customer_name",
        "product_name",
        "amount",
        "region"
    ])
    .collect()
)

# 더 효율적: 필터를 조인 전에 적용
best_result = (
    transactions
    .filter(pl.col("amount") > 1000)  # 조인 전 필터로 크기 감소
    .join(
        products.select(["product_id", "product_name", "category"]),
        on="product_id",
        how="left"
    )
    .join(
        customers.select(["customer_id", "customer_name", "region"]),
        on="customer_id",
        how="left"
    )
    .select([
        "transaction_id",
        "customer_name",
        "product_name",
        "amount",
        "region"
    ])
    .collect()
)

설명

이것이 하는 일: 위 코드는 대용량 거래 데이터를 고객 및 상품 마스터와 조인할 때, 잘못된 방법과 최적화된 방법을 비교하여 10배 이상의 성능 차이를 보여줍니다. 첫 번째 비효율적인 예시는 흔한 실수입니다.

transactions(1억 행)과 customers(1천만 행)을 먼저 조인하면, 결과가 최대 1억 행이 되고 이것이 메모리를 많이 차지합니다. 이 큰 중간 결과를 다시 products와 조인하면 메모리 압박이 심해지고 속도도 느립니다.

또한 customers와 products의 모든 컬럼을 가져오므로 불필요한 데이터가 많습니다. 두 번째 개선된 예시는 여러 최적화를 적용합니다.

첫째, products(10만 행, 가장 작음)부터 조인합니다. 작은 테이블을 해시 테이블로 만드는 비용이 적고, 메모리 효율도 좋습니다.

둘째, select()로 필요한 컬럼만 가져옵니다. customers에 50개 컬럼이 있어도 3개만 필요하면 3개만 조인하여 메모리와 시간을 절약합니다.

셋째, 조인 후 필터링하지만 아직 최적은 아닙니다. 세 번째 최고 효율 예시는 필터를 조인 전에 적용합니다.

amount > 1000 조건으로 transactions가 1억 행에서 2천만 행으로 줄었다면, 조인할 데이터가 1/5로 감소하여 조인 시간도 1/5로 줄어듭니다. 이것이 "술어 하향 전달(Predicate Pushdown)"의 핵심입니다.

LazyFrame의 쿼리 옵티마이저가 자동으로 이런 최적화를 하기도 하지만, 명시적으로 순서를 정하면 더 확실합니다. Polars의 조인 알고리즘은 내부적으로 병렬 해시 조인을 사용합니다.

작은 테이블(예: products)을 해시 테이블로 구축하고(빌드 단계), 큰 테이블(transactions)을 여러 스레드로 나누어 각 행의 키를 해시 테이블에서 찾습니다(프로브 단계). 8코어에서는 프로브 단계가 거의 8배 빨라집니다.

여러분이 이러한 최적화를 적용하면 Pandas로 30분 걸리던 조인이 Polars로 3분으로 줄고, 추가 최적화로 30초까지 단축될 수 있습니다. 60배의 성능 향상입니다.

실무에서의 이점: ETL 파이프라인에서 조인 단계 병목 해소, 복잡한 다단계 조인도 실용적 시간 내 완료, 메모리 제약이 있는 환경에서도 대용량 조인 가능, 대시보드나 리포트 생성 속도 대폭 향상.

실전 팁

💡 조인 순서의 황금 규칙: 가장 작은 테이블부터, 필터링으로 크기를 최대한 줄인 후, 필요한 컬럼만 선택. 이 순서를 지키면 대부분의 조인이 빨라집니다.

💡 조인 타입 선택이 중요합니다. inner는 양쪽 모두 매칭, left는 왼쪽 모두 유지, outer는 양쪽 모두 유지. 필요한 것만 선택하여 불필요한 데이터 생성 방지.

💡 조인 키에 중복이 많으면 결과가 폭발적으로 증가할 수 있습니다(one-to-many 조인). 미리 n_unique()로 카디널리티를 확인하세요.

💡 여러 컬럼으로 조인할 때는 on=["col1", "col2"] 형태로 리스트로 전달합니다. 복합 키 조인도 Polars가 효율적으로 처리합니다.

💡 조인 결과의 크기를 예측하려면 df1.height * df2.height / df2.select(pl.col("key").n_unique())로 대략적인 행 수를 추정할 수 있습니다.


#Polars#LazyFrame#ChunkedProcessing#ParallelProcessing#MemoryOptimization#데이터분석,Python,Polars

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.