
DuckDB냐 Polars냐…100GB에서 승자가 뒤집힌다

로컬 분석에서 SQL로 여러 테이블을 묶는 일이 중심이라면 DuckDB를, Python의 DataFrame 표현식으로 변환을 이어 간다면 Polars를 먼저 검토할 만하다. 속도만으로 고를 때도 데이터 규모를 빼놓을 수 없다. Polars가 공개한 PDS-H 결과표에서 약 10GB의 CSV 데이터에 해당하는 SF-10의 total_time은 Polars 스트리밍 3.89초, DuckDB 5.87초였지만, 약 100GB에 해당하는 SF-100에서는 DuckDB 19.65초, Polars 스트리밍 23.94초로 순서가 바뀌었다.
이 역전은 모든 100GB 작업에서 DuckDB가 빠르다는 뜻이 아니다. 공개 수치는 특정 버전과 장비에서 여러 쿼리를 실행한 결과다. 자신의 데이터가 메모리에 얼마나 여유 있게 들어가는지, 필터 뒤에 몇 행이 남는지, 조인이 중간 결과를 얼마나 키우는지에 따라 선택은 달라진다. 두 도구의 성능이 비슷하다면 실제로 쿼리를 작성하고 수정할 사람이 편하게 다룰 수 있는 API가 더 유용한 기준이다.
규모가 커질 때 순위가 바뀐 이유
PDS-H에서 비교한 것은 제품 이름만이 아니라 실행 방식이다. Polars의 스트리밍 엔진은 데이터를 배치로 처리했고, 별도로 측정된 인메모리 엔진은 큰 규모에서 상대적으로 더 느려졌다. 당시 스트리밍 엔진이 처리하지 못한 범위 조인은 인메모리 방식으로 넘어갔다. 따라서 결과표의 순위에는 데이터 크기뿐 아니라 쿼리 구성과 엔진의 지원 범위도 반영돼 있다.
측정 장비에는 넉넉한 메모리가 있었으므로, 작은 노트북에서 입력이 RAM을 넘을 때의 완료 가능성까지 이 결과로 판단할 수는 없다. 또한 SF는 원본 CSV 데이터 규모를 대략 나타내는 계수다. 압축된 Parquet 파일이 디스크에서 정확히 같은 용량을 차지했다거나, 각 쿼리가 표의 total_time만큼 실행됐다고 해석해서도 안 된다. 로컬 파일의 실제 크기와 RAM의 비율은 별도로 봐야 한다.
규모가 커지면 읽는 바이트만 늘어나는 것이 아니다. 그룹화할 키의 종류가 많아지면 집계 상태가 커지고, 조인의 양쪽에 중복 키가 많으면 출력 행이 입력보다 크게 늘 수 있다. 반대로 선택적인 필터가 스캔 단계에서 많은 행을 제외한다면 큰 파일을 읽더라도 이후 연산은 작아진다. 파일 크기가 같아도 어떤 연산을 어떤 순서로 수행하는지가 실행 시간과 메모리 사용량을 바꾸는 이유다.
다른 측정에서는 어떤 쿼리가 앞섰나
Coiled의 TPC-H 파생 실험은 2024년 로컬 10GB와 100GB 규모를 메모리 16GB의 M1 MacBook Pro에서 측정했고, 메모리 제약 때문에 Polars 스트리밍을 사용했다. 이 환경에서는 DuckDB가 로컬 계산에서 대체로 앞섰으며, 실험진은 모든 규모와 환경에서 일관되게 이기는 도구는 없다고 정리했다. Coiled는 이후 Polars 스트리밍 엔진이 크게 바뀌었다는 갱신 문구도 덧붙였다.
이 두 실험을 한 표에 넣어 실행 시간의 배율을 계산하는 것은 적절하지 않다. 데이터 배치와 쿼리 구현, CPU, RAM, 라이브러리 버전이 다르다. 메모리가 넉넉한 서버에서 어느 엔진의 집계값이 낮은지와 메모리가 부족한 노트북에서 작업을 끝낼 수 있는지는 서로 다른 질문이다. 앞의 결과는 규모에 따른 순위 역전을 보여주고, 노트북 실험은 RAM 제약이 선택에 얼마나 큰 영향을 주는지 보여준다.
쿼리별 차이는 더 작은 실험에서도 드러난다. Uvik Software의 2026년 측정에서 1,000만 행 그룹화 쿼리 10개의 중앙값 합계는 Polars 2.86초, DuckDB 3.91초였다. 하지만 개별 쿼리에서는 DuckDB가 6개, Polars가 4개에서 빨랐다. 같은 실험의 조인 5개는 Polars 3.16초, DuckDB 10.46초였고, 5.84GiB 메모리 상한에서 5,000만 행 그룹화 작업은 DuckDB가 10개 모두, Polars가 9개를 완료했다.
합계가 빠른 도구와 더 많은 개별 쿼리에서 이긴 도구가 달랐다는 점이 중요하다. 자주 실행하는 작업이 적은 수의 그룹을 만드는 집계인지, 키가 다양해 결과가 커지는 집계인지에 따라 체감 속도는 합계와 다를 수 있다. 조인에서도 작은 참조 테이블을 붙이는 작업과 큰 테이블끼리 많은 행을 연결하는 작업은 부담이 다르다. 공개 순위보다 자신의 반복 작업에 가까운 쿼리를 찾는 편이 선택에 도움이 된다.
SQL과 Lazy API의 차이는 어디서 생기나

DuckDB는 파일 읽기, 필터, 조인, 집계를 SQL로 표현하기 좋다. 이미 SQL을 중심으로 분석을 검토하고 공유하는 팀이라면 쿼리의 의도를 기존 방식대로 읽을 수 있다. Polars는 열을 선택하고 표현식을 연결하는 Python DataFrame 작업에 잘 맞는다. 변환 단계가 Python 코드 안에서 이어진다면 각 단계의 입력과 결과를 같은 문맥에서 다루기 편하다.
Polars에서는 DataFrame을 쓴다는 사실보다 Lazy API를 쓰는지가 성능에 중요하다. Polars 최적화 문서는 조건을 스캔 단계로 앞당기는 predicate pushdown, 필요한 열만 읽는 projection pushdown, 메모리 부담을 줄이기 위한 조인 순서 선택을 설명한다. 파일을 즉시 모두 읽은 뒤 변환하는 코드와, 스캔에서 시작해 연산 계획을 만든 뒤 실행하는 코드는 같은 결과를 내더라도 읽는 양이 달라질 수 있다.
예를 들어 category와 amount 열만 필요하고 amount가 양수인 행만 집계한다면, Lazy 계획은 필요한 열과 필터 조건을 파일 읽기 가까이에 둘 수 있다. 실제 절감량은 Parquet 파일의 구성과 조건에 맞는 행의 비율에 달려 있다. DuckDB 역시 SQL로 Parquet을 직접 조회할 수 있으므로, 여기서 비교할 대상은 ‘SQL 대 최적화’가 아니라 같은 입력과 결과를 만드는 두 실행 계획이다.
API 선호는 수정 비용에도 영향을 준다. SQL로 결과를 검토해야 하는 팀에서는 Polars 표현식을 별도로 설명하거나 옮기는 일이 생길 수 있다. 반대로 Python 변환이 길게 이어지는 파이프라인을 모두 SQL 문자열로 옮기면 작은 변경의 문맥을 파악하기 어려울 수 있다. 실행 시간 차이가 크지 않다면 팀이 오류를 찾고 결과를 검증하기 쉬운 쪽을 고를 이유가 충분하다.
RAM이 빠듯할 때는 완료 여부부터 본다

입력이 메모리를 넘는다면 DuckDB의 디스크 임시 공간 사용을 먼저 고려할 만하다. DuckDB 환경 가이드는 그룹화, 조인, 정렬, 윈도 연산의 중간 데이터를 디스크로 넘겨 메모리보다 큰 작업을 처리할 수 있다고 설명한다. 이때 빠른 로컬 저장장치와 충분한 임시 공간이 필요하며, 디스크에 쓰고 다시 읽는 비용도 실행 시간에 포함된다.
Polars 스트리밍도 입력을 배치로 처리해 메모리 압박을 줄인다. 다만 배치로 읽는다는 사실만으로 모든 중간 결과가 작게 유지되지는 않는다. 그룹 수가 매우 많거나 조인 결과가 커지면 메모리 요구량도 커질 수 있다. 따라서 ‘파일을 읽을 수 있는가’와 ‘결과를 끝까지 만들 수 있는가’를 구분해야 한다.
로컬 분석에서 먼저 시험할 후보를 정리하면 다음과 같다. 이는 공개 벤치마크의 우승자를 그대로 옮긴 표가 아니라, 작업 방식과 병목에 따른 출발점이다.
- 약 10GB, RAM에 여유가 있고 Python 변환이 중심: Polars Lazy를 먼저 살펴본다. 필터와 열 선택을 스캔에서 처리할 수 있는지, 이어지는 작업도 DataFrame 표현식으로 자연스럽게 작성되는지가 관건이다.
- 약 100GB, RAM이 빠듯하고 여러 테이블의 SQL 조인이 중심: DuckDB를 먼저 시험한다. 실행 시간과 함께 완료 여부, 임시 디스크 공간 사용량을 확인해야 한다.
- 입력은 RAM보다 크지만 집계 결과는 작음: DuckDB의 디스크 사용과 Polars 스트리밍을 모두 후보에 둔다. 필터와 집계가 같은 결과를 내도록 맞춰야 비교할 수 있다.
- 최종 결과까지 RAM보다 큼: 결과를 Python 객체로 모두 가져오는 시간 대신, 실제 작업에 필요한 파일 출력까지 포함해 측정한다. 출력 방식이 다르면 비교하는 작업 자체가 달라진다.
내 데이터로 해보는 최소 비교
같은 Parquet 파일 묶음에 category와 숫자형 amount 열이 있다고 가정하자. 두 도구로 amount가 양수인 행을 category별로 합산하면 파일 읽기, 필터, 집계를 한 번에 비교할 수 있다. 이 예시는 조건부 실험이므로 실제 파일의 열 이름과 자료형에 맞게 바꿔야 한다. 각 실행은 별도 프로세스에서 시작하고, 결과 행 수와 합계가 일치하는지 먼저 확인한다.
- 패키지를 설치하고 버전을 기록한다: python -m pip install duckdb polars. 입력 파일의 크기와 개수, 사용 가능한 RAM, 저장장치도 함께 적는다.
- DuckDB에서 같은 Parquet 묶음을 SQL로 읽는다: time python -c "import duckdb; c=duckdb.connect(); c.execute('SET threads=2'); r=c.execute(\"SELECT category, SUM(amount) AS total FROM read_parquet('data/*.parquet') WHERE amount > 0 GROUP BY category\").fetchall(); print(len(r),sum(v for _,v in r))"
- Polars에서 Lazy 스트리밍으로 집계한다: time env POLARS_MAX_THREADS=2 python -c "import polars as pl; r=pl.scan_parquet('data/*.parquet').filter(pl.col('amount') > 0).group_by('category').agg(pl.col('amount').sum().alias('total')).collect(engine='streaming'); print(r.height,r['total'].sum())"
이 명령은 결과 행 수와 전체 합계를 보여주지만, 그것만으로 각 category의 값이 모두 같은지 증명하지는 않는다. 실제 선택에 쓰기 전에는 결과를 category별로 맞춰 비교하는 편이 정확하다. 또한 DuckDB는 Python 튜플 목록을, Polars는 DataFrame을 만들기 때문에 결과 변환 비용이 완전히 같지 않다. 결과가 큰 작업이라면 양쪽 모두 같은 형식으로 파일에 쓰는 실험을 별도로 해야 한다.
첫 실행과 반복 실행의 시간도 나눠 적는다. 반복할 때는 파일이 운영체제 캐시에 남을 수 있고, 다른 작업이 RAM이나 디스크를 쓰면 차이가 생긴다. 실패한 쿼리는 빠른 시간으로 취급할 수 없으므로 완료 여부와 메모리 부족 여부를 함께 기록한다. 최종 선택은 자주 실행하는 쿼리가 정확히 끝나는지, 그때 걸리는 시간과 필요한 메모리·디스크가 자신의 장비에서 감당 가능한지에 달려 있다.
함께 읽기:
관련 기사
뉴스레터 구독
최신 Web3, AI, 암호화폐 뉴스를 이메일로 받아보세요.




