이미지 로딩 중...
AI Generated
2025. 11. 15. · 7 Views
Polars로 시작하는 데이터 분석 성능 최적화 완벽 가이드
Python 데이터 분석의 새로운 강자 Polars를 활용하여 대용량 데이터를 빠르게 처리하는 방법을 배워봅니다. Pandas보다 10배 이상 빠른 성능과 효율적인 메모리 관리로 실무 데이터 분석을 최적화하는 실전 가이드입니다.
목차
- Polars 기본 설정과 DataFrame 생성 - 빠른 시작을 위한 첫걸음
- Lazy Evaluation과 쿼리 최적화 - 성능의 핵심 비밀
- Expressions API로 복잡한 데이터 변환 - 선언적 프로그래밍의 힘
- Group By 집계와 윈도우 함수 - 고급 분석의 핵심
- 대용량 데이터 조인과 최적화 - 효율적인 데이터 결합
- Parquet 파일 형식으로 저장과 로딩 - 최적의 데이터 포맷
- 문자열 처리와 정규표현식 - 텍스트 데이터 마스터하기
- 날짜와 시간 처리 - 시계열 데이터 분석
- Null 값 처리와 데이터 검증 - 데이터 품질 관리
- 성능 프로파일링과 최적화 팁 - 더 빠르게 만들기
1. Polars 기본 설정과 DataFrame 생성 - 빠른 시작을 위한 첫걸음
시작하며
여러분이 수백만 개의 데이터를 처리할 때 Pandas가 너무 느려서 답답했던 경험 있나요? 특히 CSV 파일을 읽거나 groupby 연산을 할 때 커피 한 잔 마실 시간이 생기는 상황 말이죠.
이런 문제는 실제 데이터 분석 현장에서 매일 발생합니다. Pandas는 훌륭한 도구지만, 대용량 데이터를 다룰 때는 싱글 코어만 사용하고 메모리 효율이 떨어진다는 한계가 있습니다.
바로 이럴 때 필요한 것이 Polars입니다. Rust로 작성된 Polars는 멀티 코어를 완벽하게 활용하고, Arrow 메모리 포맷을 사용해서 Pandas보다 훨씬 빠른 성능을 제공합니다.
개요
간단히 말해서, Polars는 차세대 Python 데이터프레임 라이브러리입니다. Pandas와 비슷한 API를 제공하면서도 훨씬 빠르고 효율적인 데이터 처리를 가능하게 합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 요즘 데이터 분석가들은 기가바이트 단위의 데이터를 다루는 일이 흔합니다. 예를 들어, 100만 건의 고객 거래 데이터를 분석하거나 수년간의 로그 데이터를 처리하는 경우에 매우 유용합니다.
전통적인 방법과의 비교를 해보면, 기존에는 Pandas로 데이터를 읽고 처리하면서 느린 속도를 감수했다면, 이제는 Polars로 동일한 작업을 10배 이상 빠르게 할 수 있습니다. Polars의 핵심 특징은 첫째, 병렬 처리를 통한 자동 멀티코어 활용, 둘째, Apache Arrow 기반의 효율적인 메모리 관리, 셋째, Lazy Evaluation으로 쿼리 최적화입니다.
이러한 특징들이 대용량 데이터 처리에서 압도적인 성능 차이를 만들어냅니다.
코드 예제
# Polars 설치 및 기본 DataFrame 생성
import polars as pl
import numpy as np
# CSV 파일 읽기 - 자동으로 멀티코어 활용
df = pl.read_csv("large_dataset.csv")
# 직접 DataFrame 생성
df = pl.DataFrame({
"user_id": range(1, 1000001),
"purchase_amount": np.random.randint(1000, 100000, 1000000),
"category": np.random.choice(["A", "B", "C"], 1000000),
"timestamp": pl.date_range(start="2023-01-01", end="2024-12-31", periods=1000000, eager=True)
})
# 기본 정보 확인
print(df.head()) # 상위 5개 행 출력
print(df.describe()) # 통계 정보 확인
설명
이것이 하는 일: Polars를 설치하고 기본적인 DataFrame을 생성하여 데이터 분석을 시작할 수 있게 합니다. Pandas와 유사한 API를 사용하면서도 훨씬 빠른 성능을 경험할 수 있습니다.
첫 번째로, pl.read_csv()를 사용하여 CSV 파일을 읽는 부분을 살펴보겠습니다. 이 함수는 내부적으로 모든 CPU 코어를 활용하여 파일을 병렬로 읽어들입니다.
Pandas의 read_csv()가 싱글 코어만 사용하는 것과 달리, Polars는 자동으로 멀티코어 처리를 적용하기 때문에 대용량 파일도 빠르게 로드됩니다. 그 다음으로, pl.DataFrame()으로 직접 DataFrame을 생성하는 방법입니다.
딕셔너리 형태로 데이터를 전달하면 Polars가 자동으로 데이터 타입을 추론하고 최적화된 메모리 포맷으로 저장합니다. 예제에서는 100만 개의 가상 거래 데이터를 생성했는데, 이 정도 규모의 데이터도 몇 초 만에 생성됩니다.
마지막으로, head()와 describe() 메서드로 데이터를 확인하는 부분입니다. 이 메서드들도 병렬로 실행되어 빠르게 결과를 반환합니다.
describe()는 각 컬럼의 통계 정보(평균, 표준편차, 최소/최대값 등)를 계산하는데, 백만 건 데이터도 순식간에 처리합니다. 여러분이 이 코드를 사용하면 Pandas 대비 5-10배 빠른 데이터 로딩 속도를 얻을 수 있습니다.
특히 대용량 CSV 파일을 다룰 때 그 차이가 극명하게 드러나며, 메모리 사용량도 훨씬 효율적입니다.
실전 팁
💡 CSV 파일이 아주 크다면 pl.scan_csv()를 사용해서 Lazy Evaluation 모드로 읽어보세요. 필요한 컬럼과 행만 메모리에 로드하여 더욱 효율적입니다.
💡 데이터 타입을 명시적으로 지정하면 메모리를 더 절약할 수 있습니다. 예를 들어 카테고리형 데이터는 pl.Categorical로 지정하세요.
💡 Pandas DataFrame에서 Polars로 변환할 때는 pl.from_pandas(df)를 사용하면 됩니다. 기존 코드를 점진적으로 마이그레이션할 때 유용합니다.
💡 df.schema로 각 컬럼의 데이터 타입을 확인하는 습관을 들이세요. Polars는 타입에 엄격하기 때문에 타입 불일치 오류를 미리 방지할 수 있습니다.
💡 대용량 데이터를 다룰 때는 rechunk=True 옵션을 사용하여 메모리 단편화를 방지하고 성능을 더욱 향상시킬 수 있습니다.
2. Lazy Evaluation과 쿼리 최적화 - 성능의 핵심 비밀
시작하며
여러분이 복잡한 데이터 변환 작업을 할 때, 각 단계마다 중간 결과를 저장하고 있나요? 필터링하고, 그룹화하고, 조인하고...
매번 전체 데이터를 처리하면서 시간을 낭비하는 상황 말입니다. 이런 문제는 전통적인 Eager Evaluation 방식의 한계입니다.
Pandas는 각 연산을 즉시 실행하기 때문에, 나중에 필요 없는 중간 결과까지 모두 계산하게 됩니다. 이는 불필요한 메모리 사용과 느린 처리 속도로 이어집니다.
바로 이럴 때 필요한 것이 Polars의 Lazy Evaluation입니다. 모든 연산을 계획만 세워두고, 실제로 결과가 필요할 때 한 번에 최적화된 순서로 실행하는 방식입니다.
개요
간단히 말해서, Lazy Evaluation은 연산을 즉시 실행하지 않고 "계획"만 세워두었다가, 마지막에 collect()를 호출할 때 한 번에 최적화하여 실행하는 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터 파이프라인에서는 여러 단계의 변환과 필터링이 연속적으로 일어납니다.
예를 들어, 전체 데이터에서 특정 조건에 맞는 행만 추출하고, 몇 개 컬럼만 선택하고, 집계하는 경우에 Lazy Evaluation이 빛을 발합니다. 전통적인 방법과의 비교를 해보면, 기존에는 각 단계마다 전체 데이터를 처리했다면, 이제는 Polars가 자동으로 연산 순서를 재배치하고 불필요한 컬럼을 미리 제거하여 최소한의 데이터만 처리합니다.
Lazy Evaluation의 핵심 특징은 첫째, 쿼리 최적화를 통한 성능 향상, 둘째, Projection Pushdown으로 필요한 컬럼만 읽기, 셋째, Predicate Pushdown으로 필터링을 최대한 앞당기기입니다. 이러한 최적화 기법들이 자동으로 적용되어 수배에서 수십 배의 성능 향상을 가져옵니다.
코드 예제
# Lazy Evaluation을 사용한 쿼리 최적화
import polars as pl
# LazyFrame 생성 - 아직 실행되지 않음
lazy_df = pl.scan_csv("sales_data.csv")
# 연산 체인 구성 - 모두 계획만 세워짐
result = (
lazy_df
.filter(pl.col("amount") > 1000) # 1000원 이상만
.filter(pl.col("region") == "Seoul") # 서울 지역만
.select(["user_id", "amount", "product"]) # 필요한 컬럼만
.group_by("user_id") # 사용자별 그룹화
.agg(pl.col("amount").sum().alias("total_amount")) # 합계 계산
)
# 쿼리 실행 계획 확인
print(result.explain())
# 실제 실행 - 최적화된 순서로 한 번에 실행
final_result = result.collect()
설명
이것이 하는 일: 데이터 처리 파이프라인의 모든 단계를 계획만 세워두고, 마지막에 최적화된 순서로 실행하여 성능을 극대화합니다. Polars의 쿼리 옵티마이저가 자동으로 최적의 실행 계획을 만들어줍니다.
첫 번째로, pl.scan_csv()로 LazyFrame을 생성하는 부분입니다. 이 시점에서는 파일을 실제로 읽지 않고, 파일의 구조(스키마)만 파악합니다.
이렇게 하면 나중에 필요한 컬럼만 선택적으로 읽을 수 있어 I/O 비용을 크게 줄일 수 있습니다. 그 다음으로, 여러 연산을 체인으로 연결하는 부분입니다.
filter(), select(), group_by(), agg() 등의 연산이 순차적으로 나열되어 있지만, 실제로는 아무것도 실행되지 않습니다. Polars는 이 모든 연산을 Logical Plan이라는 추상적인 계획으로만 저장합니다.
이때 Polars 옵티마이저는 Predicate Pushdown(필터를 최대한 앞으로), Projection Pushdown(필요한 컬럼만 선택)을 자동으로 적용합니다. explain() 메서드는 실제 실행 계획을 보여줍니다.
여기서 여러분은 Polars가 어떻게 쿼리를 최적화했는지 확인할 수 있습니다. 예를 들어, CSV 파일을 읽을 때 select()로 지정한 세 개 컬럼만 읽고, filter() 조건은 파일을 읽는 동안 동시에 적용되는 것을 볼 수 있습니다.
마지막으로, collect()를 호출하는 순간 실제 실행이 시작됩니다. Polars는 최적화된 계획에 따라 필요한 컬럼만 읽고, 필터 조건을 미리 적용하고, 병렬로 처리하여 최종 결과를 반환합니다.
만약 Eager 모드로 동일한 작업을 했다면, 전체 파일을 읽고, 모든 컬럼을 메모리에 올리고, 각 단계마다 중간 결과를 저장했을 것입니다. 여러분이 이 코드를 사용하면 대용량 데이터에서 필요한 부분만 처리하여 메모리 사용량을 크게 줄이고, 실행 시간을 5-10배 단축할 수 있습니다.
특히 복잡한 다단계 파이프라인에서 그 효과가 극대화됩니다.
실전 팁
💡 개발 중에는 collect()를 호출하기 전에 항상 explain()으로 실행 계획을 확인하세요. 쿼리 최적화가 제대로 적용되었는지 검증할 수 있습니다.
💡 Lazy 모드에서 일부 결과만 미리 보고 싶다면 collect().head(10) 대신 fetch(10)를 사용하세요. 전체를 계산하지 않고 10개 행만 처리합니다.
💡 여러 필터 조건이 있다면 선택도가 낮은(걸러지는 행이 많은) 조건을 앞에 배치하세요. Polars가 더 효율적으로 최적화할 수 있습니다.
💡 LazyFrame을 중간에 저장해두고 여러 번 재사용할 수 있습니다. collect()를 호출하기 전까지는 메모리를 거의 사용하지 않습니다.
💡 sink_parquet() 또는 sink_csv()를 사용하면 결과를 메모리에 올리지 않고 바로 파일로 저장할 수 있어, 메모리 부족 문제를 피할 수 있습니다.
3. Expressions API로 복잡한 데이터 변환 - 선언적 프로그래밍의 힘
시작하며
여러분이 데이터 변환 작업을 할 때 반복문을 돌리거나 복잡한 함수를 작성하느라 시간을 낭비한 적 있나요? "이 컬럼에서 조건에 따라 값을 바꾸고, 다른 컬럼과 계산하고..." 이런 작업을 위해 apply() 함수를 사용하면 너무 느려지는 경험 말입니다.
이런 문제는 row-by-row 처리 방식의 근본적인 한계입니다. Pandas의 apply()나 반복문은 Python 레벨에서 각 행을 하나씩 처리하기 때문에, 벡터화된 연산의 이점을 전혀 살리지 못합니다.
이는 대용량 데이터에서 치명적인 성능 저하를 일으킵니다. 바로 이럴 때 필요한 것이 Polars의 Expressions API입니다.
복잡한 데이터 변환을 선언적으로 표현하면, Polars가 자동으로 병렬화하고 최적화하여 실행합니다.
개요
간단히 말해서, Expressions API는 데이터 변환 로직을 "무엇을 할지"로 표현하는 선언적 방식입니다. "어떻게 할지"는 Polars가 알아서 최적화하여 처리합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 데이터 분석에서는 조건부 변환, 문자열 처리, 날짜 연산, 윈도우 함수 등 복잡한 작업이 빈번합니다. 예를 들어, 고객 등급을 구매 금액과 구매 횟수로 계산하거나, 시계열 데이터에서 이동 평균을 구하는 경우에 매우 유용합니다.
전통적인 방법과의 비교를 해보면, 기존에는 apply() 함수로 각 행을 순회하면서 처리했다면, 이제는 Expression을 사용해서 전체 컬럼에 대해 벡터화된 연산을 수행할 수 있습니다. Expressions API의 핵심 특징은 첫째, 자동 병렬 처리로 모든 CPU 코어 활용, 둘째, 체이닝 가능한 메서드로 복잡한 로직을 간결하게 표현, 셋째, 타입 안정성으로 런타임 에러 방지입니다.
이러한 특징들이 코드의 가독성과 성능을 동시에 향상시킵니다.
코드 예제
# Expressions API를 사용한 복잡한 데이터 변환
import polars as pl
df = pl.DataFrame({
"user_id": [1, 2, 3, 4, 5],
"purchase_count": [3, 15, 7, 25, 10],
"total_amount": [50000, 300000, 120000, 800000, 200000],
"join_date": ["2023-01-15", "2022-06-20", "2023-08-10", "2021-03-05", "2022-11-30"]
})
# 복잡한 변환을 Expression으로 간결하게 표현
result = df.select([
pl.col("user_id"),
# 조건부 로직: 구매 횟수와 금액으로 등급 계산
pl.when(pl.col("total_amount") > 500000)
.then(pl.lit("VIP"))
.when(pl.col("purchase_count") > 10)
.then(pl.lit("Gold"))
.otherwise(pl.lit("Silver"))
.alias("grade"),
# 날짜 파싱 및 계산: 가입 기간(일)
(pl.lit("2024-01-01").str.strptime(pl.Date) -
pl.col("join_date").str.strptime(pl.Date)).dt.total_days().alias("membership_days"),
# 복합 계산: 1회당 평균 구매액
(pl.col("total_amount") / pl.col("purchase_count")).round(2).alias("avg_per_purchase")
])
print(result)
설명
이것이 하는 일: 복잡한 비즈니스 로직을 Expression으로 표현하면, Polars가 자동으로 벡터화하고 병렬 처리하여 빠르게 실행합니다. Python 반복문이나 apply() 함수 없이도 고급 데이터 변환이 가능합니다.
첫 번째로, pl.when().then().otherwise() 구문을 사용한 조건부 로직입니다. 이는 SQL의 CASE WHEN과 유사하며, 여러 조건을 체이닝할 수 있습니다.
예제에서는 총 구매액이 50만 원을 초과하면 VIP, 구매 횟수가 10회를 초과하면 Gold, 그 외는 Silver 등급을 부여합니다. 이 모든 계산이 벡터화되어 한 번에 처리되므로, Pandas의 apply(lambda x: ...) 보다 100배 이상 빠릅니다.
그 다음으로, 날짜 문자열을 파싱하고 계산하는 Expression입니다. str.strptime(pl.Date)로 문자열을 날짜 타입으로 변환하고, 두 날짜의 차이를 dt.total_days()로 일수로 계산합니다.
이러한 날짜 연산도 모두 병렬로 처리되며, Polars의 타입 시스템이 컴파일 타임에 오류를 잡아줍니다. 세 번째로, 여러 컬럼을 조합한 복합 계산입니다.
total_amount / purchase_count로 평균 구매액을 계산하고 round(2)로 소수점 둘째 자리까지 반올림합니다. Expression은 체이닝이 가능하여 여러 변환을 연속적으로 적용할 수 있으며, 각 단계는 최적화되어 실행됩니다.
마지막으로, select() 안에서 여러 Expression을 리스트로 전달하는 부분입니다. 이 모든 Expression들은 병렬로 계산되므로, 순차적으로 처리하는 것보다 훨씬 빠릅니다.
또한 각 Expression에 alias()로 명확한 이름을 부여하여 결과 컬럼의 의미를 명확히 할 수 있습니다. 여러분이 이 코드를 사용하면 Pandas의 apply() 함수 대비 10-100배 빠른 성능을 얻을 수 있습니다.
특히 수십만 건 이상의 데이터에서 조건부 로직이나 복잡한 계산을 할 때, 그 차이가 극명하게 드러납니다. 또한 코드가 더 간결하고 읽기 쉬워져 유지보수도 편해집니다.
실전 팁
💡 복잡한 조건이 많다면 pl.when()을 여러 번 체이닝하세요. 각 조건은 순서대로 평가되며, 첫 번째로 만족하는 조건의 값이 반환됩니다.
💡 문자열 처리는 str 네임스페이스를, 날짜는 dt 네임스페이스를 사용하세요. 각 타입에 특화된 메서드들이 제공됩니다.
💡 여러 컬럼에 동일한 연산을 적용하려면 pl.col(["col1", "col2", "col3"])처럼 컬럼 리스트를 전달할 수 있습니다. 반복 코드를 줄일 수 있습니다.
💡 Expression은 재사용 가능합니다. 복잡한 Expression을 변수에 저장해두고 여러 곳에서 사용하면 코드 중복을 줄일 수 있습니다.
💡 null 값 처리는 fill_null(), fill_nan(), drop_nulls() 등의 메서드를 체이닝하세요. Python의 if pd.isna() 체크보다 훨씬 빠르고 안전합니다.
4. Group By 집계와 윈도우 함수 - 고급 분석의 핵심
시작하며
여러분이 그룹별 통계를 계산하거나 이동 평균을 구할 때, Pandas의 groupby()가 느려서 답답했던 경험 있나요? 특히 여러 컬럼으로 그룹화하고 복잡한 집계 함수를 적용할 때, 몇 분씩 기다려야 하는 상황 말입니다.
이런 문제는 Pandas의 groupby가 싱글 코어로 동작하고, 중간 결과를 여러 번 복사하기 때문입니다. 또한 윈도우 함수를 사용하려면 복잡한 rolling() 또는 transform() 코드를 작성해야 하며, 성능도 좋지 않습니다.
바로 이럴 때 필요한 것이 Polars의 Group By와 Window Functions입니다. SQL 스타일의 직관적인 문법으로 복잡한 집계를 표현하고, 자동으로 병렬 처리되어 빠르게 결과를 얻을 수 있습니다.
개요
간단히 말해서, Polars의 Group By는 하나 이상의 컬럼으로 그룹화하고, 각 그룹에 대해 다양한 집계 함수를 병렬로 실행하는 기능입니다. Window Functions는 그룹 내에서 순위, 누적합, 이동 평균 등을 계산합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터 분석의 핵심은 그룹별 통계와 시계열 분석입니다. 예를 들어, 지역별 매출 합계, 제품 카테고리별 평균 가격, 월별 누적 매출, 7일 이동 평균 등을 계산하는 경우에 필수적입니다.
전통적인 방법과의 비교를 해보면, 기존에는 Pandas로 groupby().agg()를 사용하거나 apply()로 커스텀 함수를 작성했다면, 이제는 Polars의 Expression 기반 집계로 훨씬 간결하고 빠르게 처리할 수 있습니다. Group By의 핵심 특징은 첫째, 자동 병렬 집계로 멀티코어 완전 활용, 둘째, 여러 집계 함수를 동시에 적용 가능, 셋째, Window Functions로 고급 분석 지원입니다.
이러한 특징들이 복잡한 분석 작업을 빠르고 쉽게 만들어줍니다.
코드 예제
# Group By 집계와 Window Functions
import polars as pl
df = pl.DataFrame({
"region": ["Seoul", "Seoul", "Busan", "Busan", "Seoul", "Busan"],
"category": ["A", "B", "A", "B", "A", "B"],
"sales": [100, 150, 80, 120, 130, 90],
"date": ["2024-01-01", "2024-01-01", "2024-01-01", "2024-01-02", "2024-01-02", "2024-01-02"]
})
# 복잡한 그룹 집계 - 여러 집계 함수 동시 적용
grouped = df.group_by(["region", "category"]).agg([
pl.col("sales").sum().alias("total_sales"),
pl.col("sales").mean().alias("avg_sales"),
pl.col("sales").count().alias("transaction_count"),
pl.col("sales").max().alias("max_sales")
])
# Window Function - 그룹 내 순위와 누적합
windowed = df.select([
pl.col("*"), # 기존 컬럼 모두 포함
# 지역별 매출 순위
pl.col("sales").rank(method="dense").over("region").alias("rank_in_region"),
# 카테고리별 누적 매출
pl.col("sales").cum_sum().over("category").alias("cumulative_sales"),
# 전체 대비 비율
(pl.col("sales") / pl.col("sales").sum() * 100).round(2).alias("sales_pct")
])
print(grouped)
print(windowed)
설명
이것이 하는 일: 데이터를 그룹별로 나누고 각 그룹에 대해 통계를 계산하거나, 그룹 내에서 행 간의 관계를 분석합니다. SQL의 GROUP BY와 OVER 절과 유사하지만, 훨씬 빠르고 유연합니다.
첫 번째로, group_by(["region", "category"])로 여러 컬럼으로 동시에 그룹화하는 부분입니다. 이렇게 하면 서울-A, 서울-B, 부산-A, 부산-B 네 개의 그룹이 생성됩니다.
Polars는 내부적으로 해시 테이블을 사용하여 매우 빠르게 그룹을 만들며, 이 과정이 병렬로 처리됩니다. 그 다음으로, agg() 메서드에 여러 집계 Expression을 리스트로 전달하는 부분입니다.
합계, 평균, 개수, 최댓값을 동시에 계산하는데, 이 모든 집계가 한 번의 데이터 순회로 완료됩니다. Pandas에서 여러 집계를 적용하려면 agg({'sales': ['sum', 'mean', 'count', 'max']})처럼 복잡하게 작성해야 하지만, Polars는 Expression으로 명확하게 표현할 수 있습니다.
세 번째로, Window Functions를 사용한 고급 분석입니다. over("region")을 사용하면 지역별로 윈도우를 나누고, 각 윈도우 내에서 순위를 계산합니다.
rank(method="dense")는 동점인 경우에도 다음 순위를 건너뛰지 않는 조밀한 순위를 부여합니다. 이러한 윈도우 연산은 원본 데이터의 행 수를 유지하면서 그룹별 통계를 추가하는 방식입니다.
네 번째로, cum_sum().over("category")는 카테고리별 누적 매출을 계산합니다. 각 카테고리 내에서 위에서부터 매출을 누적하여 더해가는 방식으로, 시계열 분석에서 매우 유용합니다.
또한 전체 합계 대비 비율을 계산하는 Expression도 포함되어 있어, 한 번의 select()로 여러 파생 컬럼을 생성할 수 있습니다. 여러분이 이 코드를 사용하면 Pandas 대비 5-20배 빠른 그룹 집계 성능을 얻을 수 있습니다.
특히 수백만 건의 데이터를 여러 컬럼으로 그룹화하고 복잡한 집계를 수행할 때, 그 차이가 극명합니다. Window Functions도 병렬로 처리되어 대용량 시계열 데이터 분석이 빠르게 완료됩니다.
실전 팁
💡 여러 집계를 적용할 때는 리스트로 한 번에 전달하세요. Polars가 자동으로 최적화하여 한 번의 순회로 모든 집계를 계산합니다.
💡 Window Function의 over() 절에 여러 컬럼을 전달하면 복합 그룹 윈도우를 만들 수 있습니다. 예: over(["region", "category"])
💡 이동 평균은 rolling_mean(window_size=7)을 사용하세요. 시계열 데이터의 트렌드를 파악할 때 유용하며, 병렬로 처리됩니다.
💡 group_by() 후에 maintain_order=True 옵션을 주면 원본 데이터의 순서를 유지합니다. 정렬이 중요한 경우 사용하세요.
💡 quantile(), median(), std() 등의 통계 함수도 Expression으로 사용 가능합니다. 복잡한 통계 분석도 간결하게 표현할 수 있습니다.
5. 대용량 데이터 조인과 최적화 - 효율적인 데이터 결합
시작하며
여러분이 두 개 이상의 테이블을 조인할 때, 메모리가 부족하거나 너무 오래 걸려서 포기한 경험 있나요? 특히 수백만 건의 주문 데이터와 고객 데이터를 조인하려고 하면, Pandas가 메모리를 너무 많이 사용해서 서버가 다운되는 상황 말입니다.
이런 문제는 Pandas의 조인이 메모리 비효율적이고, 해시 테이블을 중복으로 생성하기 때문입니다. 또한 여러 테이블을 연속으로 조인하면 중간 결과가 계속 복사되어 메모리와 시간이 기하급수적으로 증가합니다.
바로 이럴 때 필요한 것이 Polars의 효율적인 조인 기능입니다. 메모리 효율적인 해시 조인과 다양한 조인 전략으로 대용량 데이터도 빠르게 결합할 수 있습니다.
개요
간단히 말해서, Polars의 조인은 Apache Arrow 기반의 메모리 효율적인 방식으로 두 개 이상의 DataFrame을 결합하는 기능입니다. Inner, Left, Right, Outer, Cross, Semi, Anti 등 다양한 조인 타입을 지원합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 데이터 분석에서는 여러 소스의 데이터를 결합하는 작업이 필수입니다. 예를 들어, 주문 데이터에 고객 정보를 붙이고, 거기에 제품 정보를 추가로 조인하는 경우에 매우 유용합니다.
전통적인 방법과의 비교를 해보면, 기존에는 Pandas의 merge()를 사용하면서 메모리 부족과 느린 속도를 감수했다면, 이제는 Polars로 동일한 작업을 훨씬 적은 메모리로, 훨씬 빠르게 할 수 있습니다. Polars 조인의 핵심 특징은 첫째, 메모리 효율적인 해시 조인 알고리즘, 둘째, 병렬 처리로 멀티코어 완전 활용, 셋째, Lazy 모드에서 조인 순서 자동 최적화입니다.
이러한 특징들이 복잡한 다중 조인 쿼리도 빠르고 안정적으로 처리하게 합니다.
코드 예제
# 효율적인 데이터 조인
import polars as pl
# 주문 데이터
orders = pl.DataFrame({
"order_id": [1, 2, 3, 4, 5],
"user_id": [101, 102, 101, 103, 102],
"product_id": [201, 202, 203, 201, 204],
"amount": [15000, 25000, 30000, 15000, 40000]
})
# 고객 데이터
users = pl.DataFrame({
"user_id": [101, 102, 103],
"name": ["김철수", "이영희", "박민수"],
"grade": ["Gold", "VIP", "Silver"]
})
# 제품 데이터
products = pl.DataFrame({
"product_id": [201, 202, 203, 204],
"product_name": ["노트북", "마우스", "키보드", "모니터"],
"category": ["전자기기", "액세서리", "액세서리", "전자기기"]
})
# 다중 조인 - Lazy 모드로 최적화
result = (
orders.lazy()
.join(users.lazy(), on="user_id", how="left") # 고객 정보 추가
.join(products.lazy(), on="product_id", how="left") # 제품 정보 추가
.select([
"order_id", "name", "product_name", "category", "amount", "grade"
])
.collect()
)
print(result)
설명
이것이 하는 일: 여러 테이블의 데이터를 공통 키를 기준으로 결합하여, 연관된 정보를 하나의 테이블로 만듭니다. SQL의 JOIN과 동일한 개념이지만, 훨씬 빠르고 메모리 효율적입니다.
첫 번째로, 세 개의 서로 다른 DataFrame을 정의하는 부분입니다. 주문 데이터는 user_id와 product_id라는 외래 키를 가지고 있어, 고객 정보와 제품 정보를 조인할 수 있습니다.
실무에서는 이런 식으로 여러 테이블이 관계형 데이터베이스처럼 연결되어 있습니다. 그 다음으로, .lazy()를 호출하여 LazyFrame으로 변환하는 부분입니다.
이렇게 하면 Polars의 쿼리 옵티마이저가 조인 순서를 분석하고, 가장 효율적인 실행 계획을 수립합니다. 예를 들어, 작은 테이블을 먼저 조인하거나, 필요한 컬럼만 미리 선택하는 등의 최적화가 자동으로 적용됩니다.
세 번째로, 첫 번째 .join()에서 주문 데이터와 고객 데이터를 user_id 기준으로 결합합니다. how="left"는 왼쪽 테이블(orders)의 모든 행을 유지하고, 오른쪽 테이블(users)에서 매칭되는 정보를 추가하는 Left Join입니다.
이렇게 하면 고객 정보가 없는 주문도 결과에 포함됩니다. 네 번째로, 두 번째 .join()에서 앞의 결과에 제품 데이터를 추가로 조인합니다.
이런 식으로 조인을 체이닝하면 여러 테이블을 연속으로 결합할 수 있습니다. Polars는 내부적으로 해시 테이블을 효율적으로 관리하여, 중간 결과를 불필요하게 복사하지 않습니다.
마지막으로, select()로 필요한 컬럼만 선택하고 collect()로 실제 실행합니다. Lazy 모드에서는 select()가 조인보다 먼저 최적화되어, 실제로는 조인에 필요한 컬럼만 먼저 읽고 조인을 수행합니다.
이를 Projection Pushdown이라고 하며, 메모리와 시간을 크게 절약합니다. 여러분이 이 코드를 사용하면 Pandas 대비 3-10배 빠른 조인 성능과, 50% 이상 적은 메모리 사용량을 경험할 수 있습니다.
특히 수백만 건의 데이터를 조인할 때, Pandas는 메모리 부족으로 실패하는 경우가 많지만 Polars는 안정적으로 처리합니다.
실전 팁
💡 조인하기 전에 rechunk=True 옵션을 사용하면 메모리 단편화를 방지하고 조인 성능을 향상시킬 수 있습니다.
💡 여러 컬럼을 조인 키로 사용하려면 on=["col1", "col2"]처럼 리스트로 전달하세요. 복합 키 조인이 가능합니다.
💡 왼쪽과 오른쪽 테이블의 컬럼 이름이 다르면 left_on="user_id", right_on="customer_id"처럼 명시할 수 있습니다.
💡 Semi Join(how="semi")은 오른쪽 테이블에 매칭되는 왼쪽 행만 반환하며, Anti Join(how="anti")은 매칭되지 않는 행만 반환합니다. 데이터 검증에 유용합니다.
💡 대용량 조인은 항상 Lazy 모드를 사용하세요. 쿼리 옵티마이저가 조인 순서와 실행 계획을 최적화하여 성능을 크게 향상시킵니다.
6. Parquet 파일 형식으로 저장과 로딩 - 최적의 데이터 포맷
시작하며
여러분이 처리한 데이터를 저장하고 다시 읽을 때, CSV 파일이 너무 크고 느려서 불편했던 경험 있나요? 특히 수백만 건의 데이터를 CSV로 저장하면 파일 크기가 기가바이트 단위로 커지고, 다시 읽는 데도 몇 분씩 걸리는 상황 말입니다.
이런 문제는 CSV가 텍스트 기반 포맷이라서 압축 효율이 낮고, 데이터 타입 정보를 저장하지 않아서 매번 파싱해야 하기 때문입니다. 또한 컬럼 단위 읽기가 불가능하여, 전체 파일을 읽어야 합니다.
바로 이럘 때 필요한 것이 Parquet 파일 형식입니다. 컬럼 기반 바이너리 포맷으로 압축률이 높고, 필요한 컬럼만 선택적으로 읽을 수 있어 대용량 데이터 저장에 최적입니다.
개요
간단히 말해서, Parquet은 Apache에서 개발한 컬럼 지향 파일 포맷으로, 데이터 분석에 최적화되어 있습니다. 압축률이 높고, 데이터 타입을 포함하며, 컬럼 단위로 읽을 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터 파이프라인에서는 중간 결과를 저장하고 나중에 다시 로드하는 작업이 빈번합니다. 예를 들어, 전처리한 데이터를 저장해두고 여러 분석 작업에서 재사용하거나, 데이터 레이크에 저장하는 경우에 매우 유용합니다.
전통적인 방법과의 비교를 해보면, 기존에는 CSV로 저장하면서 큰 파일 크기와 느린 I/O를 감수했다면, 이제는 Parquet으로 파일 크기를 5-10배 줄이고, 읽기 속도를 10-100배 향상시킬 수 있습니다. Parquet의 핵심 특징은 첫째, 컬럼 단위 압축으로 높은 압축률, 둘째, 스키마 정보 포함으로 타입 안정성, 셋째, Predicate Pushdown과 Projection Pushdown으로 부분 읽기 지원입니다.
이러한 특징들이 대용량 데이터 처리를 효율적으로 만들어줍니다.
코드 예제
# Parquet 파일로 저장하고 효율적으로 로드
import polars as pl
import numpy as np
# 대용량 데이터 생성
large_df = pl.DataFrame({
"user_id": range(1, 10000001),
"transaction_date": pl.date_range(start="2023-01-01", end="2024-12-31", periods=10000000, eager=True),
"amount": np.random.randint(1000, 100000, 10000000),
"category": np.random.choice(["A", "B", "C", "D", "E"], 10000000),
"region": np.random.choice(["Seoul", "Busan", "Incheon"], 10000000)
})
# Parquet으로 저장 - 자동 압축
large_df.write_parquet("transactions.parquet", compression="snappy")
# 전체 파일 로드 - 매우 빠름
df_loaded = pl.read_parquet("transactions.parquet")
# 필요한 컬럼만 로드 - Projection Pushdown
df_partial = pl.scan_parquet("transactions.parquet").select(["user_id", "amount"]).collect()
# 조건 필터링과 함께 로드 - Predicate Pushdown
df_filtered = (
pl.scan_parquet("transactions.parquet")
.filter(pl.col("region") == "Seoul")
.filter(pl.col("amount") > 50000)
.collect()
)
설명
이것이 하는 일: 데이터를 컬럼 단위로 압축하여 저장하고, 나중에 필요한 부분만 빠르게 읽어옵니다. 데이터 타입 정보도 함께 저장되어 타입 변환 없이 바로 사용할 수 있습니다.
첫 번째로, 1000만 건의 가상 거래 데이터를 생성하는 부분입니다. 이 정도 규모의 데이터는 실무에서 흔히 다루는 크기이며, CSV로 저장하면 수 기가바이트가 됩니다.
Polars는 이런 대용량 데이터도 메모리 효율적으로 생성합니다. 그 다음으로, write_parquet()로 Parquet 파일로 저장하는 부분입니다.
compression="snappy" 옵션은 빠른 압축/해제 속도를 제공하는 Snappy 압축 알고리즘을 사용합니다. 다른 옵션으로는 gzip(높은 압축률, 느린 속도), lz4(매우 빠름, 중간 압축률), zstd(높은 압축률, 빠른 속도) 등이 있습니다.
Parquet은 각 컬럼을 별도로 압축하여, 같은 데이터 타입끼리 모아서 압축하므로 압축률이 매우 높습니다. 세 번째로, read_parquet()로 전체 파일을 로드하는 부분입니다.
Parquet 파일은 메타데이터에 스키마 정보가 포함되어 있어, 각 컬럼의 데이터 타입을 자동으로 인식합니다. CSV처럼 타입 추론이나 파싱이 필요 없기 때문에 훨씬 빠릅니다.
또한 병렬로 읽기 때문에 멀티코어를 완전히 활용합니다. 네 번째로, scan_parquet()로 Lazy 모드로 읽고 select()로 필요한 컬럼만 선택하는 부분입니다.
Parquet은 컬럼 기반 포맷이라서, 전체 파일을 읽지 않고 user_id와 amount 컬럼만 디스크에서 읽어올 수 있습니다. 이를 Projection Pushdown이라고 하며, I/O 양을 크게 줄입니다.
마지막으로, filter() 조건과 함께 로드하는 부분입니다. Parquet 파일은 각 컬럼의 최소/최대값을 메타데이터에 저장하므로, 파일을 읽기 전에 필터 조건에 맞지 않는 블록을 건너뛸 수 있습니다.
이를 Predicate Pushdown이라고 하며, 서울 지역에 5만 원 이상 거래만 필요한 경우 해당 데이터만 읽어옵니다. 여러분이 이 코드를 사용하면 CSV 대비 파일 크기를 5-10배 줄이고, 읽기 속도를 10-100배 향상시킬 수 있습니다.
특히 필요한 컬럼만 읽거나 필터 조건이 있는 경우, 그 차이가 극명합니다. 또한 데이터 타입이 보존되어 타입 관련 버그를 방지할 수 있습니다.
실전 팁
💡 압축 알고리즘 선택이 중요합니다. 저장 공간이 중요하면 zstd, 속도가 중요하면 snappy를 사용하세요.
💡 파티셔닝 기능을 사용하면 날짜나 카테고리별로 파일을 나눠 저장할 수 있습니다. write_parquet(..., partition_by=["region"])
💡 대용량 데이터는 항상 scan_parquet()로 Lazy 모드로 읽으세요. 필요한 부분만 로드하여 메모리를 절약할 수 있습니다.
💡 Parquet 파일의 Row Group 크기를 조정하려면 row_group_size 파라미터를 사용하세요. 기본값은 128MB이지만, 쿼리 패턴에 따라 조정할 수 있습니다.
💡 클라우드 스토리지(S3, GCS 등)에서도 Parquet을 직접 읽을 수 있습니다. pl.scan_parquet("s3://bucket/file.parquet")처럼 URL을 전달하면 됩니다.
7. 문자열 처리와 정규표현식 - 텍스트 데이터 마스터하기
시작하며
여러분이 텍스트 데이터를 정제하거나 변환할 때, Pandas의 str 메서드가 너무 느려서 답답했던 경험 있나요? 특히 수백만 개의 이메일 주소에서 도메인을 추출하거나, 전화번호를 표준 형식으로 변환할 때 몇 분씩 기다려야 하는 상황 말입니다.
이런 문제는 Pandas의 문자열 처리가 Python의 기본 문자열 메서드를 반복문으로 호출하기 때문입니다. 각 행마다 Python 함수를 호출하므로 오버헤드가 크고, 벡터화의 이점을 살리지 못합니다.
바로 이럴 때 필요한 것이 Polars의 고성능 문자열 처리입니다. Rust로 구현된 벡터화된 문자열 연산과 정규표현식 엔진으로 Pandas보다 10-100배 빠른 텍스트 처리가 가능합니다.
개요
간단히 말해서, Polars의 str 네임스페이스는 문자열 컬럼에 대한 다양한 처리 메서드를 제공하며, 모두 병렬로 실행됩니다. 정규표현식, 분리, 추출, 치환, 대소문자 변환 등 모든 텍스트 작업을 지원합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 실제 데이터는 텍스트 형태로 들어오는 경우가 많습니다. 예를 들어, 로그 파싱, 이메일 주소 정제, URL 파싱, 제품명 표준화, 주소 분리 등의 작업이 데이터 전처리의 대부분을 차지합니다.
전통적인 방법과의 비교를 해보면, 기존에는 Pandas의 str.extract(), str.replace() 등을 사용하면서 느린 속도를 감수했다면, 이제는 Polars로 동일한 작업을 훨씬 빠르게 처리할 수 있습니다. Polars 문자열 처리의 핵심 특징은 첫째, Rust 기반의 벡터화된 문자열 연산, 둘째, 병렬 정규표현식 매칭, 셋째, null 값 안전 처리입니다.
이러한 특징들이 대량의 텍스트 데이터 처리를 빠르고 안정적으로 만들어줍니다.
코드 예제
# 문자열 처리와 정규표현식
import polars as pl
df = pl.DataFrame({
"email": ["user1@gmail.com", "admin@company.co.kr", "test@yahoo.com", None, "invalid-email"],
"phone": ["010-1234-5678", "01012345678", "02-123-4567", "031-987-6543", None],
"address": ["서울시 강남구 123", "부산시 해운대구 456", "인천시 남동구 789", None, "대전시 유성구"],
"product": ["MacBook Pro 2024", "iPad Air (5th)", "AirPods Pro 2", "iPhone 15 Pro Max", None]
})
result = df.select([
# 이메일 도메인 추출 - 정규표현식
pl.col("email").str.extract(r"@(.+)$", 1).alias("domain"),
# 전화번호 표준화 - 하이픈 제거 후 다시 추가
pl.col("phone")
.str.replace_all("-", "")
.str.slice(0, 3).str.concat("-")
.str.concat(pl.col("phone").str.replace_all("-", "").str.slice(3, 4))
.str.concat("-")
.str.concat(pl.col("phone").str.replace_all("-", "").str.slice(7))
.alias("formatted_phone"),
# 주소에서 시 추출 - 공백으로 분리
pl.col("address").str.split(" ").list.first().alias("city"),
# 제품명 소문자 변환 및 길이
pl.col("product").str.to_lowercase().alias("product_lower"),
pl.col("product").str.len_chars().alias("product_length")
])
print(result)
설명
이것이 하는 일: 텍스트 컬럼에 대한 다양한 변환과 추출 작업을 벡터화된 방식으로 수행합니다. null 값을 안전하게 처리하며, 모든 연산이 병렬로 실행됩니다.
첫 번째로, str.extract()를 사용한 정규표현식 매칭입니다. r"@(.+)$" 패턴은 @기호 이후의 모든 문자를 캡처하여 도메인을 추출합니다.
두 번째 인자 1은 첫 번째 캡처 그룹을 의미합니다. Polars는 내부적으로 최적화된 정규표현식 엔진을 사용하며, 모든 행을 병렬로 처리하여 Pandas의 str.extract() 보다 수십 배 빠릅니다.
invalid-email처럼 패턴이 매칭되지 않으면 자동으로 null을 반환합니다. 그 다음으로, 전화번호를 표준 형식으로 변환하는 복잡한 문자열 체이닝입니다.
먼저 str.replace_all("-", "")로 모든 하이픈을 제거하고, str.slice(0, 3)으로 앞 3자리를 추출합니다. str.concat("-")으로 하이픈을 연결하고, 다시 중간 4자리와 마지막 4자리를 추가합니다.
이렇게 여러 단계의 문자열 연산을 체이닝할 수 있으며, Polars가 자동으로 최적화하여 실행합니다. 세 번째로, str.split(" ")으로 주소를 공백 기준으로 분리하는 부분입니다.
이 연산은 리스트 컬럼을 반환하며, list.first()로 첫 번째 요소(시 이름)를 추출합니다. Polars는 리스트 컬럼도 네이티브하게 지원하여, 복잡한 중첩 데이터 구조도 효율적으로 처리할 수 있습니다.
네 번째로, str.to_lowercase()로 소문자 변환과 str.len_chars()로 문자 개수 계산입니다. 이러한 간단한 연산도 모두 벡터화되어 병렬로 실행됩니다.
len_chars()는 유니코드 문자 개수를 정확히 계산하므로, 한글이나 이모지도 올바르게 처리됩니다. 여러분이 이 코드를 사용하면 대량의 텍스트 데이터를 빠르게 정제하고 변환할 수 있습니다.
Pandas의 str 메서드 대비 10-100배 빠른 성능으로, 수백만 건의 로그나 텍스트를 몇 초 만에 처리할 수 있습니다. 또한 null 값을 안전하게 처리하여 예외 처리 코드를 별도로 작성할 필요가 없습니다.
실전 팁
💡 복잡한 정규표현식은 미리 컴파일하지 않아도 됩니다. Polars가 자동으로 최적화하여 재사용합니다.
💡 여러 패턴을 찾으려면 str.extract_all()을 사용하세요. 모든 매칭 결과를 리스트로 반환합니다.
💡 대소문자 구분 없이 매칭하려면 정규표현식에 (?i) 플래그를 추가하세요. 예: (?i)apple
💡 문자열 컬럼의 null 값을 처리하려면 fill_null() 또는 replace()를 문자열 연산 전에 적용하세요.
💡 JSON 문자열을 파싱하려면 str.json_extract() 메서드를 사용하세요. JSON 컬럼에서 특정 필드를 추출할 수 있습니다.
8. 날짜와 시간 처리 - 시계열 데이터 분석
시작하며
여러분이 시계열 데이터를 다룰 때, 날짜 파싱과 변환에 시간이 너무 오래 걸렸던 경험 있나요? 특히 수백만 건의 로그 데이터에서 타임스탬프를 파싱하고, 주말/평일을 구분하고, 월별로 집계할 때 Pandas가 느려서 답답한 상황 말입니다.
이런 문제는 Pandas의 datetime 처리가 Python의 datetime 객체를 사용하여 오버헤드가 크기 때문입니다. 또한 날짜 연산이 벡터화되지 않아 반복문처럼 느리게 동작합니다.
바로 이럴 때 필요한 것이 Polars의 고성능 날짜/시간 처리입니다. 네이티브 타입으로 날짜를 저장하고, 벡터화된 연산으로 빠르게 처리하며, 다양한 시계열 분석 기능을 제공합니다.
개요
간단히 말해서, Polars는 날짜, 시간, datetime, duration 타입을 네이티브하게 지원하며, dt 네임스페이스를 통해 다양한 시계열 연산을 제공합니다. 타임존 처리, 날짜 산술, 주기 추출 등이 모두 병렬로 실행됩니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 로그 분석, 금융 데이터, IoT 센서 데이터 등 대부분의 실무 데이터는 시간 정보를 포함합니다. 예를 들어, 일별 매출 추이, 시간대별 트래픽 분석, 사용자의 활동 패턴 파악 등에 날짜/시간 처리가 필수적입니다.
전통적인 방법과의 비교를 해보면, 기존에는 Pandas의 pd.to_datetime(), dt.year, dt.dayofweek 등을 사용하면서 느린 속도를 감수했다면, 이제는 Polars로 10배 이상 빠르게 처리할 수 있습니다. Polars 날짜/시간 처리의 핵심 특징은 첫째, Arrow 기반의 효율적인 날짜 타입, 둘째, 벡터화된 날짜 연산, 셋째, 타임존 aware 처리 지원입니다.
이러한 특징들이 대용량 시계열 데이터 분석을 빠르고 정확하게 만들어줍니다.
코드 예제
# 날짜와 시간 처리
import polars as pl
from datetime import datetime
df = pl.DataFrame({
"timestamp": [
"2024-01-15 09:30:00",
"2024-02-20 14:45:30",
"2024-03-10 18:20:15",
"2024-06-25 23:59:59",
"2024-12-31 00:00:01"
],
"sales": [150000, 230000, 180000, 320000, 450000]
})
result = df.select([
# 문자열을 datetime으로 파싱
pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").alias("parsed_dt"),
# 연, 월, 일, 요일 추출
pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").dt.year().alias("year"),
pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").dt.month().alias("month"),
pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").dt.day().alias("day"),
pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").dt.weekday().alias("weekday"),
# 주말/평일 구분
pl.when(pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").dt.weekday().is_in([5, 6]))
.then(pl.lit("주말"))
.otherwise(pl.lit("평일"))
.alias("day_type"),
# 현재 시간과의 차이 (일 단위)
(pl.lit(datetime.now()) - pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S")).dt.total_days().alias("days_ago")
])
# 월별 집계
monthly = df.group_by(
pl.col("timestamp").str.strptime(pl.Datetime, "%Y-%m-%d %H:%M:%S").dt.month().alias("month")
).agg(pl.col("sales").sum().alias("monthly_sales"))
print(result)
print(monthly)
설명
이것이 하는 일: 문자열 형태의 날짜를 파싱하여 datetime 타입으로 변환하고, 연/월/일/요일 등의 주기를 추출하며, 날짜 간 산술 연산을 수행합니다. 모든 연산이 벡터화되어 병렬로 실행됩니다.
첫 번째로, str.strptime()을 사용한 날짜 파싱입니다. 문자열 컬럼을 pl.Datetime 타입으로 변환하며, 포맷 문자열 "%Y-%m-%d %H:%M:%S"을 지정하여 파싱 방식을 정의합니다.
Polars는 내부적으로 최적화된 파서를 사용하여 Pandas의 pd.to_datetime() 보다 5-10배 빠릅니다. 또한 Arrow 포맷으로 저장되어 메모리 효율도 좋습니다.
그 다음으로, dt 네임스페이스의 다양한 추출 메서드들입니다. dt.year(), dt.month(), dt.day(), dt.weekday() 등으로 날짜의 각 구성 요소를 추출할 수 있습니다.
weekday()는 월요일부터 0으로 시작하는 요일 번호를 반환하며, 0-4는 평일, 5-6은 주말(토/일)입니다. 이러한 추출 연산도 모두 병렬로 처리되어 수백만 건의 날짜도 순식간에 변환됩니다.
세 번째로, is_in([5, 6])과 when-then-otherwise를 조합한 조건부 로직입니다. 요일이 5(토요일) 또는 6(일요일)이면 "주말", 그렇지 않으면 "평일"로 분류합니다.
이렇게 날짜 연산과 조건 로직을 체이닝하여 복잡한 비즈니스 로직을 간결하게 표현할 수 있습니다. 네 번째로, 날짜 간 산술 연산입니다.
pl.lit(datetime.now())로 현재 시간을 생성하고, 파싱된 타임스탬프와의 차이를 계산합니다. 결과는 Duration 타입이며, dt.total_days()로 일 단위로 변환합니다.
이런 식으로 날짜 더하기/빼기, 기간 계산 등이 모두 벡터화되어 처리됩니다. 마지막으로, 월별 집계 예제입니다.
group_by()에서 직접 dt.month()를 사용하여 월 단위로 그룹화하고, 각 월의 매출 합계를 계산합니다. 이렇게 날짜 주기로 그룹화하는 패턴은 시계열 분석에서 매우 흔하며, Polars는 이를 효율적으로 처리합니다.
여러분이 이 코드를 사용하면 대량의 시계열 데이터를 빠르게 파싱하고 분석할 수 있습니다. Pandas 대비 5-20배 빠른 날짜 처리로, 수백만 건의 로그도 몇 초 만에 집계할 수 있습니다.
또한 타입 안정성 덕분에 날짜 관련 버그를 미리 방지할 수 있습니다.
실전 팁
💡 타임존이 있는 데이터는 strptime() 대신 str.to_datetime()을 사용하고 tz 파라미터로 타임존을 지정하세요.
💡 날짜 범위를 생성하려면 pl.date_range(start, end, interval="1d")를 사용하세요. 일별, 주별, 월별 범위 생성이 가능합니다.
💡 분기(quarter) 추출은 dt.quarter()를, ISO 주차는 dt.week()을 사용하세요. 비즈니스 분석에 유용합니다.
💡 날짜를 특정 형식의 문자열로 변환하려면 dt.strftime("%Y년 %m월 %d일")을 사용하세요.
💡 시간대별 분석을 위해 dt.hour()로 시간을 추출하고 그룹화하면, 트래픽 패턴이나 활동 시간대를 파악할 수 있습니다.
9. Null 값 처리와 데이터 검증 - 데이터 품질 관리
시작하며
여러분이 실제 데이터를 받았을 때, null 값과 이상치 때문에 분석이 막혔던 경험 있나요? 특히 외부 시스템에서 받은 데이터에 빈 값, NaN, None이 섞여 있고, 각각 다르게 처리해야 해서 복잡한 상황 말입니다.
이런 문제는 실제 데이터의 현실입니다. 센서 오류, 입력 누락, 시스템 장애 등으로 인해 데이터에는 항상 결측치가 존재합니다.
이를 제대로 처리하지 않으면 분석 결과가 왜곡되거나 에러가 발생합니다. 바로 이럴 때 필요한 것이 Polars의 강력한 Null 처리 기능입니다.
null 검증, 채우기, 제거, 대체 등 다양한 방법으로 결측치를 안전하게 처리할 수 있습니다.
개요
간단히 말해서, Polars는 null 값을 타입 안전하게 처리하는 다양한 메서드를 제공합니다. is_null(), fill_null(), drop_nulls(), forward_fill(), backward_fill() 등으로 결측치를 다룰 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터 분석의 첫 단계는 데이터 품질 검증입니다. 예를 들어, 고객 데이터에서 이메일이 누락된 행을 제거하거나, 매출 데이터에서 null을 0으로 채우거나, 센서 데이터에서 결측치를 보간하는 작업이 필수입니다.
전통적인 방법과의 비교를 해보면, 기존에는 Pandas의 isna(), fillna(), dropna() 등을 사용했지만 느리고 메모리 비효율적이었다면, 이제는 Polars로 훨씬 빠르고 안전하게 처리할 수 있습니다. Polars Null 처리의 핵심 특징은 첫째, null과 NaN을 명확히 구분, 둘째, 타입 안전한 null 처리, 셋째, 벡터화된 연산으로 빠른 처리입니다.
이러한 특징들이 데이터 품질 관리를 쉽고 안전하게 만들어줍니다.
코드 예제
# Null 값 처리와 데이터 검증
import polars as pl
import numpy as np
df = pl.DataFrame({
"user_id": [1, 2, 3, 4, 5, 6],
"name": ["Alice", None, "Charlie", "David", None, "Frank"],
"age": [25, 30, None, 40, 35, None],
"email": ["alice@email.com", "bob@email.com", None, "david@email.com", "eve@email.com", None],
"score": [85.5, 90.0, 78.5, None, 88.0, 92.5]
})
# Null 검증 및 통계
null_stats = df.select([
pl.col("name").is_null().sum().alias("name_nulls"),
pl.col("age").is_null().sum().alias("age_nulls"),
pl.col("email").is_null().sum().alias("email_nulls"),
pl.col("score").is_null().sum().alias("score_nulls")
])
# 다양한 Null 처리 방법
cleaned = df.select([
pl.col("user_id"),
# Null을 기본값으로 채우기
pl.col("name").fill_null("Unknown").alias("name"),
# Null을 평균값으로 채우기
pl.col("age").fill_null(pl.col("age").mean()).alias("age"),
# Forward Fill - 이전 값으로 채우기
pl.col("score").forward_fill().alias("score_ffill"),
# Null이 아닌 경우만 선택적 처리
pl.when(pl.col("email").is_not_null())
.then(pl.col("email").str.to_lowercase())
.otherwise(pl.lit("no-email@unknown.com"))
.alias("email_clean")
])
# Null이 있는 행 완전히 제거
dropped = df.drop_nulls()
# 특정 컬럼의 Null만 제거
partial_dropped = df.drop_nulls(subset=["email"])
print(null_stats)
print(cleaned)
설명
이것이 하는 일: 데이터의 null 값을 검출하고, 비즈니스 로직에 맞게 채우거나 제거합니다. 타입 안전성을 유지하면서 벡터화된 연산으로 빠르게 처리합니다.
첫 번째로, is_null().sum()을 사용한 null 통계 계산입니다. 각 컬럼별로 null 값의 개수를 세어 데이터 품질을 파악합니다.
이는 데이터 전처리의 첫 단계로, 어떤 컬럼에 결측치가 얼마나 있는지 파악해야 적절한 처리 방법을 선택할 수 있습니다. Polars는 이 연산을 병렬로 처리하여 수백만 건의 데이터도 순식간에 검증합니다.
그 다음으로, fill_null()을 사용한 다양한 null 채우기 방법입니다. name 컬럼은 고정값 "Unknown"으로 채우고, age 컬럼은 평균값으로 채웁니다.
pl.col("age").mean()은 Expression이므로, Polars가 자동으로 평균을 계산하여 null 위치에 삽입합니다. 이렇게 컬럼별로 다른 전략을 적용할 수 있습니다.
세 번째로, forward_fill()을 사용한 시계열 데이터 보간입니다. 이 메서드는 null 값을 바로 위(이전) 행의 값으로 채웁니다.
반대로 backward_fill()은 바로 아래(다음) 행의 값으로 채웁니다. 센서 데이터나 주가 데이터처럼 시간 순서가 중요한 경우에 매우 유용하며, Last Observation Carried Forward (LOCF) 방식으로 불립니다.
네 번째로, when-then-otherwise와 is_not_null()을 조합한 조건부 처리입니다. 이메일이 null이 아닌 경우에만 소문자로 변환하고, null인 경우에는 기본 이메일 주소로 대체합니다.
이렇게 null 여부에 따라 다른 로직을 적용할 수 있으며, null 안전성이 보장됩니다. 마지막으로, drop_nulls()를 사용한 null 행 제거입니다.
인자 없이 호출하면 하나라도 null이 있는 모든 행을 제거하고, subset 파라미터로 특정 컬럼만 지정하면 해당 컬럼의 null만 체크합니다. 예제에서 subset=["email"]은 이메일이 null인 행만 제거하여, 다른 컬럼의 null은 유지합니다.
여러분이 이 코드를 사용하면 데이터의 결측치를 체계적으로 관리할 수 있습니다. Pandas의 null 처리 대비 5-10배 빠른 성능으로, 대용량 데이터의 품질 검증과 정제를 빠르게 완료할 수 있습니다.
또한 타입 안전성 덕분에 null로 인한 런타임 에러를 미리 방지할 수 있습니다.
실전 팁
💡 null 비율이 50% 이상인 컬럼은 제거를 고려하세요. df.select([pl.col(c) for c in df.columns if df[c].is_null().mean() < 0.5])
💡 fill_null() 대신 coalesce()를 사용하면 여러 컬럼 중 첫 번째 non-null 값을 선택할 수 있습니다. 백업 값 로직에 유용합니다.
💡 숫자 컬럼의 null을 보간하려면 interpolate() 메서드를 사용하세요. 선형 보간으로 앞뒤 값 사이를 채웁니다.
💡 is_nan()과 is_null()은 다릅니다. float 컬럼에서 NaN을 null로 변환하려면 fill_nan(None)을 사용하세요.
💡 데이터 검증 시 assert 대신 Polars Expression을 사용하세요. df.filter(pl.col("age") < 0).height로 이상치 개수를 확인할 수 있습니다.
10. 성능 프로파일링과 최적화 팁 - 더 빠르게 만들기
시작하며
여러분이 Polars 코드를 작성했는데, 예상보다 느리거나 메모리를 너무 많이 사용해서 당황했던 경험 있나요? 특히 복잡한 쿼리를 실행했을 때 어느 부분이 병목인지 알 수 없어서 최적화를 못 하는 상황 말입니다.
이런 문제는 데이터 처리 파이프라인이 복잡해질수록 흔히 발생합니다. 여러 조인, 필터, 집계가 연쇄되면서 어디서 시간이 오래 걸리는지 파악하기 어렵습니다.
또한 Lazy Evaluation의 이점을 제대로 활용하지 못하면 성능이 크게 저하됩니다. 바로 이럴 때 필요한 것이 성능 프로파일링과 최적화 기법입니다.
Polars가 제공하는 도구와 베스트 프랙티스로 병목을 찾고, 성능을 극대화할 수 있습니다.
개요
간단히 말해서, 성능 최적화는 쿼리의 실행 계획을 분석하고, 병목 지점을 찾아 개선하는 과정입니다. Polars는 explain(), show_graph(), profile() 등의 도구를 제공하여 성능을 가시화합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 프로덕션 환경에서는 데이터 크기가 계속 증가하므로 초기에는 빠르던 쿼리도 점점 느려집니다. 예를 들어, 일일 배치 작업이 1시간에서 5시간으로 늘어나면, 최적화를 통해 다시 1시간으로 줄여야 합니다.
전통적인 방법과의 비교를 해보면, 기존에는 코드 여러 곳에 시간 측정 코드를 삽입하면서 수동으로 프로파일링했다면, 이제는 Polars의 내장 도구로 자동으로 분석할 수 있습니다. 성능 최적화의 핵심 특징은 첫째, 쿼리 실행 계획 분석으로 병목 발견, 둘째, Lazy Evaluation 활용으로 불필요한 연산 제거, 셋째, 데이터 타입과 메모리 최적화입니다.
이러한 기법들이 동일한 작업을 2-10배 빠르게 만들어줍니다.
코드 예제
# 성능 프로파일링과 최적화
import polars as pl
import time
# 비효율적인 Eager 방식 (피해야 함)
def slow_version():
df = pl.read_csv("large_data.csv")
df = df.filter(pl.col("amount") > 1000) # 즉시 실행
df = df.select(["user_id", "amount", "date"]) # 또 실행
df = df.group_by("user_id").agg(pl.col("amount").sum()) # 또 실행
return df
# 최적화된 Lazy 방식 (권장)
def fast_version():
result = (
pl.scan_csv("large_data.csv") # Lazy로 시작
.filter(pl.col("amount") > 1000)
.select(["user_id", "amount", "date"])
.group_by("user_id")
.agg(pl.col("amount").sum())
)
# 실행 계획 확인
print("=== Query Plan ===")
print(result.explain())
# 실제 실행
return result.collect()
# 프로파일링 - 각 단계의 실행 시간 측정
lazy_query = (
pl.scan_csv("large_data.csv")
.filter(pl.col("amount") > 1000)
.select(["user_id", "amount"])
.group_by("user_id")
.agg(pl.col("amount").sum())
)
# Profile로 병목 지점 찾기
result, profile_info = lazy_query.profile()
print("\n=== Profiling Results ===")
print(profile_info)
# 메모리 최적화 - 적절한 데이터 타입 사용
optimized = pl.DataFrame({
"id": pl.Series([1, 2, 3], dtype=pl.UInt32), # Int64 대신 UInt32
"category": pl.Series(["A", "B", "A"], dtype=pl.Categorical), # Utf8 대신 Categorical
"score": pl.Series([85.5, 90.0, 78.5], dtype=pl.Float32) # Float64 대신 Float32
})
설명
이것이 하는 일: 쿼리의 실행 계획을 시각화하고, 각 단계의 실행 시간을 측정하여 병목을 찾습니다. 그리고 Lazy Evaluation과 적절한 데이터 타입으로 성능을 극대화합니다.
첫 번째로, Eager 방식과 Lazy 방식의 차이입니다. slow_version()은 각 단계마다 df =로 결과를 저장하여 즉시 실행합니다.
이렇게 하면 CSV 전체를 읽고, 필터링하고, 그 결과를 또 복사하고... 불필요한 중간 결과가 계속 생성됩니다.
반면 fast_version()은 scan_csv()로 Lazy 모드로 시작하고, 모든 연산을 체이닝합니다. 이렇게 하면 Polars가 쿼리를 최적화하여, 필요한 컬럼만 읽고, 필터를 먼저 적용하여 처리량을 줄입니다.
그 다음으로, explain() 메서드로 쿼리 실행 계획을 출력하는 부분입니다. 이 출력을 보면 Polars가 어떤 순서로 연산을 실행할지, 어떤 최적화를 적용했는지 알 수 있습니다.
예를 들어, "FILTER pushed down to CSV SCAN"이라는 메시지가 나오면, 파일을 읽는 동안 필터를 동시에 적용하여 I/O를 줄인다는 의미입니다. "PROJECT 2/10"은 10개 컬럼 중 2개만 읽는다는 뜻입니다.
세 번째로, profile() 메서드로 실제 실행 시간을 측정하는 부분입니다. 이 메서드는 쿼리를 실행하면서 각 단계의 소요 시간을 기록하고, (result, profile_info) 튜플로 반환합니다.
profile_info를 출력하면 어느 단계에서 시간이 오래 걸렸는지 확인할 수 있습니다. 예를 들어, JOIN이 80%의 시간을 차지한다면, 조인 최적화가 필요합니다.
네 번째로, 데이터 타입 최적화입니다. dtype 파라미터로 명시적으로 타입을 지정하여 메모리를 절약합니다.
ID가 40억 이하라면 Int64 대신 UInt32를 사용하여 메모리를 50% 절약할 수 있습니다. 카테고리형 데이터는 Categorical로 지정하면 문자열을 정수로 인코딩하여 메모리와 비교 속도를 크게 개선합니다.
Float도 정밀도가 필요 없다면 Float32를 사용하세요. 여러분이 이 기법들을 사용하면 동일한 작업을 2-10배 빠르게 처리할 수 있습니다.
특히 Lazy Evaluation을 제대로 활용하면 대용량 데이터 파이프라인의 실행 시간을 획기적으로 단축할 수 있습니다. 또한 메모리 사용량도 크게 줄어, 더 큰 데이터를 처리할 수 있게 됩니다.
실전 팁
💡 항상 scan_csv(), scan_parquet()로 시작하여 Lazy 모드를 활용하세요. read_*() 대신 scan_*()을 기본으로 사용하는 습관을 들이세요.
💡 collect()를 호출하기 전에 explain()으로 실행 계획을 확인하는 습관을 들이세요. 최적화가 제대로 적용되었는지 검증할 수 있습니다.
💡 반복 작업에서는 sink_parquet()로 중간 결과를 디스크에 저장하고 재사용하세요. 메모리에 올리지 않고 바로 저장하여 OOM을 방지합니다.
💡 병렬 처리 수준을 조정하려면 POLARS_MAX_THREADS 환경 변수를 설정하세요. 기본은 CPU 코어 수입니다.
💡 with_columns() 대신 select()를 사용하면 불필요한 컬럼을 조기에 제거하여 메모리를 절약할 수 있습니다. 필요한 컬럼만 명시적으로 선택하세요.