대용량 데이터 처리와 성능
앞선 챕터에서 mo.sql()로 CSV와 Parquet 파일을 쿼리하고, 결과를 Python 데이터프레임으로 받아 처리했습니다. 데이터가 수백 행이면 어떤 방식이든 크게 상관없습니다. 하지만 파일 크기가 수백 MB에서 수 GB로 올라가면 이야기가 달라집니다.
이 챕터에서는 대용량 데이터를 다룰 때 marimo와 DuckDB의 조합이 어떻게 동작하는지, 그리고 메모리 사용을 줄이는 접근 방법을 다룹니다.
DuckDB의 처리 방식
DuckDB는 컬럼나(columnar) 처리 방식을 사용합니다. 일반적인 행 기반 데이터베이스는 한 행 전체를 함께 읽지만, DuckDB는 쿼리에 필요한 컬럼만 선택적으로 읽습니다.
예를 들어 100개의 컬럼이 있는 CSV 파일에서 SELECT name, score FROM data처럼 2개 컬럼만 쿼리하면, DuckDB는 나머지 98개 컬럼은 읽지 않습니다. 전체 파일을 먼저 메모리에 올리지도 않습니다.
또한 DuckDB는 지연 평가(lazy evaluation)를 지원합니다. 쿼리를 정의해도 실제로 데이터를 읽는 시점은 결과가 필요할 때입니다. native 출력 타입이 이 특성을 활용합니다.
import marimo as mo# native 타입 사용 (DuckDB lazy relation — 아직 데이터를 읽지 않음)lazy_result = mo.sql(f""" SELECT * FROM 'data/large_dataset.parquet' WHERE year = 2024""")# 이 시점에서 lazy_result는 DuckDB가 내부적으로 관리하는 lazy 객체lazy_result
native 타입으로 받으면 결과가 화면에 표시될 때까지 전체 데이터를 메모리에 올리지 않습니다. UI에서 첫 10행만 렌더링하면 10행만 실제로 가져옵니다.
native vs polars vs pandas — 어떤 타입을 선택할까
출력 타입마다 특성이 다릅니다.
| 타입 | 특성 | 적합한 상황 |
|---|---|---|
native |
DuckDB lazy relation. 지연 평가. 추가 SQL 연산 연결에 유리 | 파일이 크고 전체를 Python으로 가져올 필요가 없을 때 |
polars |
즉시 Polars DataFrame으로 변환 | Python에서 Polars API를 이어서 써야 할 때 |
pandas |
즉시 Pandas DataFrame으로 변환 | Python에서 Pandas API를 이어서 써야 할 때 |
lazy-polars |
Polars LazyFrame. Polars 체인 연산 가능 | Polars 생태계 안에서 연산을 이어 붙일 때 |
대용량 파일을 다룰 때 기본 전략은 이렇습니다. DuckDB 수준에서 가능한 한 많이 집계하고, 필요한 결과만 Python으로 가져옵니다.
import marimo as mo# 전체 파일을 Python으로 가져오는 대신,# DuckDB 안에서 집계를 마친 결과만 가져옴summary = mo.sql(f""" SELECT year, month, SUM(sales) AS total_sales, AVG(sales) AS avg_sales, COUNT(*) AS transaction_count FROM 'data/transactions.parquet' GROUP BY year, month ORDER BY year, month""")# 수백만 행 파일이라도 summary는 연도-월 조합 수만큼만 행이 있음summary
Parquet가 CSV보다 유리한 이유
대용량 데이터 작업에서 Parquet 파일은 CSV보다 여러 측면에서 유리합니다.
컬럼 단위 저장: Parquet는 같은 컬럼의 데이터를 연속으로 저장합니다. SELECT name, score처럼 특정 컬럼만 읽을 때 불필요한 디스크 I/O를 줄입니다.
압축: Parquet는 같은 타입 데이터가 모여 있어 압축 효율이 높습니다. 동일한 데이터라도 CSV보다 파일 크기가 훨씬 작은 경우가 많습니다.
타입 정보 내장: CSV는 모든 것이 문자열입니다. Parquet는 컬럼의 데이터 타입을 파일 안에 저장합니다. DuckDB가 파일을 읽을 때 타입 추론 비용이 없습니다.
기존 CSV를 Parquet로 한 번 변환해두면 반복적인 쿼리 작업이 빨라집니다.
import marimo as moimport pandas as pd# CSV를 읽어 Parquet로 저장 (한 번만 실행)df = pd.read_csv("data/large_data.csv")df.to_parquet("data/large_data.parquet", index=False)mo.md("Parquet 변환 완료.")
이 셀은 처음 한 번만 실행하면 됩니다. 이후 쿼리는 Parquet 파일을 대상으로 합니다. 반복 실행이 부담스럽다면 Ch 05에서 다루는 캐싱 패턴을 적용할 수 있습니다.
두 가지 "지연"을 구분하기
이 챕터에서 언급한 "지연 평가"와 PART 02 Ch 04에서 다룬 "lazy 모드"는 다른 개념입니다.
- DuckDB
native타입의 지연 평가: 쿼리 결과가 필요한 시점까지 데이터를 실제로 읽지 않는 데이터베이스 수준의 동작입니다. - marimo의 lazy 실행 모드: 위젯을 조작해도 의존 셀을 즉시 재실행하지 않고 "stale" 상태로 표시만 하는 셀 실행 방식입니다.
두 개념은 계층이 다릅니다. marimo lazy 모드를 켜더라도 DuckDB의 지연 평가 특성은 별개로 동작합니다.
메모리 사용에 대한 현실적인 접근
대용량 데이터를 다룰 때 완벽한 해법은 없습니다. 몇 가지 실용적인 접근을 정리합니다.
가능하면 DuckDB 안에서 집계를 마친다. 수백만 행 파일을 전부 Pandas로 가져와 Python에서 필터링하는 대신, SQL WHERE와 GROUP BY로 필요한 행과 컬럼만 추려서 가져옵니다.
LIMIT으로 먼저 탐색합니다. 데이터 구조를 파악할 때는 작은 샘플로 시작합니다.
import marimo as mo# 먼저 작은 샘플로 구조 파악sample = mo.sql(f"SELECT * FROM 'data/big_file.parquet' LIMIT 1000")sample
native 타입을 활용합니다. 전체를 Python으로 가져올 이유가 없다면 native 타입으로 두고 DuckDB 안에서 후속 집계를 이어 붙입니다.
구체적인 성능 수치는 데이터 크기, 파일 구조, 쿼리 형태, 실행 환경에 따라 달라집니다. "DuckDB가 항상 N배 빠르다"는 식의 단정은 피하고, 실제 데이터로 비교해서 판단하는 것이 정확합니다.
정리
- DuckDB는 컬럼나 처리 방식으로 필요한 컬럼만 선택적으로 읽습니다.
native출력 타입은 DuckDB lazy relation으로, 결과가 필요한 시점까지 데이터를 가져오지 않습니다.- 대용량 파일은 DuckDB 안에서 집계를 마친 다음 결과만 Python으로 가져오는 방식이 효율적입니다.
- Parquet는 컬럼 단위 저장과 내장 타입 정보 덕분에 CSV보다 대용량 쿼리에 유리합니다.
- DuckDB의 "지연 평가"와 marimo의 "lazy 실행 모드"는 계층이 다른 별개의 개념입니다.