본문 바로가기
  • plotly로 바로쓰는 동적시각화 in R & 파이썬
폴라스로 시작하는 데이터 분석

Polars와 DuckDB가 데이터 엔지니어링의 새로운 최강 조합인 이유

by 아참형인간 2026. 9. 11.

https://product.kyobobook.co.kr/detail/S000220221456

 

LUVIT EPL과 유튜브 데이터로 배우는 DuckDB - 교보문고

복잡한 데이터 분석 흐름을 더 단순하게 만드는 DuckDB 최근 주목받고 있는 DuckDB를 활용해 SQL 기반 데이터 분석과 실전 프로젝트를 학습할 수 있도록 구성한 입문서다. 기본 사용법부터 SQL 기초와

product.kyobobook.co.kr

https://product.kyobobook.co.kr/detail/S000218938819

 

LUVIT 폴라스로 시작하는 데이터 분석 - 교보문고

복잡한 분석을 더 빠르게, 더 간결하게 Pandas를 뛰어넘는 분석 도구 Polars

product.kyobobook.co.kr

 

 

Polars와 DuckDB가 초고속 분석, 제로 카피 실행, 프로덕션 환경에 적합한 Python 워크플로를 통해 현대 데이터 엔지니어링을 어떻게 변화시키고 있는지 살펴본다.

몇 년에 한 번씩 데이터 엔지니어링 생태계에는 조용한 변화가 찾아온다.

누군가 완전히 새로운 아이디어를 발명해서가 아니다.

불필요한 복잡성을 충분히 걷어내고 나면, 그동안 당연하게 사용해 온 기존 방식이 갑자기 불편하게 느껴지기 때문이다.

지금 PolarsDuckDB에서 바로 그런 일이 일어나고 있다.

오랫동안 대부분의 Python 데이터 파이프라인은 거의 비슷한 모습이었다.

CSV
 ↓
Pandas
 ↓
변환
 ↓
Pandas
 ↓
SQL 데이터베이스
 ↓
내보내기

잘 작동했다.

그러다 어느 순간 더 이상 잘 작동하지 않기 시작했다.

몇 GB였던 데이터가 수백 GB로 늘어났다.

메모리 사용량이 폭증했다.

쿼리는 점점 느려졌다.

개발자들은 더 나은 질문을 던지는 대신 RAM을 더 추가했다.

대부분의 성능 문제는 코드가 느려서 발생하는 것이 아니다. 데이터를 너무 많이 이동하기 때문에 발생한다.

오늘날 많은 엔지니어링 팀이 Pandas 중심 파이프라인의 상당 부분을 교체하고 있다. Pandas가 나빠서가 아니라 처리해야 하는 워크로드가 달라졌기 때문이다.

현대의 분석 환경에서는 컬럼SQL을 모두 이해하는 엔진이 필요하다.

바로 이 지점에서 Polars와 DuckDB는 서로를 보완한다.

하나는 표현력이 뛰어난 DataFrame 변환에 강하다.

다른 하나는 놀라울 정도로 빠르게 분석 SQL을 실행한다.

둘을 함께 사용하면 엄청난 양의 불필요한 작업을 제거할 수 있다.

이 두 도구가 자연스럽게 어울리는 이유

일반적인 분석 파이프라인에서 수행해야 하는 작업들을 생각해 보자.

어떤 작업은 Python으로 표현하는 것이 더 쉽다.

반면 어떤 작업은 SQL로 작성하는 것이 훨씬 간단하다.

하나의 도구에 모든 작업을 억지로 맡기는 대신, 현대적인 파이프라인에서는 각 구성 요소가 자신이 가장 잘하는 작업에 집중하도록 한다.

                 원본 파일
                     │
         ┌───────────┴───────────┐
         │                       │
     CSV / JSON              Parquet
         │                       │
         └───────────┬───────────┘
                     │
                DuckDB 엔진
                     │
          SQL 필터링 및 조인
                     │
                Arrow 메모리
                     │
                  Polars
                     │
             비즈니스 변환
                     │
              Parquet 출력
                     │
          BI / 머신러닝

여기서 흥미로운 점이 하나 있다.

불필요한 직렬화가 거의 없다.

데이터 복사도 거의 없다.

중간 저장 공간도 거의 필요하지 않다.

모든 것이 Apache Arrow 메모리를 중심으로 동작한다.

이것만으로도 대규모 데이터 처리의 비용 구조가 달라진다.

전통적인 파이프라인의 문제

사용자 이벤트를 수집하는 애플리케이션을 생각해 보자.

event_id
user_id
country
device
browser
session_time
purchase_amount
timestamp

수백만 행 정도라면?

문제없다.

수억 행이라면?

상황이 완전히 달라진다.

전통적인 접근 방식은 대개 다음과 같다.

import pandas as pd

df = pd.read_csv("events.csv")

df = df[df["country"] == "US"]

df["purchase_amount"] *= 1.18

result = (
    df.groupby("device")
      .agg({"purchase_amount": "sum"})
)

result.to_csv("summary.csv")

기술적으로 잘못된 코드는 아니다.

하지만 이 과정에서는 여러 비용이 큰 작업이 발생한다.

  • 전체 데이터셋을 메모리에 로드한다.
  • 여러 개의 DataFrame 복사본이 생성된다.
  • 많은 연산이 단일 스레드로 실행된다.
  • 대용량 임시 메모리 할당이 발생한다.
  • 캐시 지역성이 떨어진다.

결국 누군가는 이렇게 말한다.

“더 큰 서버를 사자.”

효과는 있다.

다음 분기까지는 말이다.

DuckDB의 등장

DuckDB는 이 문제에 다른 방식으로 접근한다.

모든 데이터를 먼저 메모리에 로드하는 대신, 파일을 대상으로 직접 분석 SQL을 실행한다.

import duckdb

con = duckdb.connect()

result = con.execute("""
SELECT
    device,
    SUM(purchase_amount * 1.18) AS revenue
FROM 'events.parquet'
WHERE country='US'
GROUP BY device
ORDER BY revenue DESC
""").fetch_df()

print(result)

여기서 무엇이 발생하지 않았는지 살펴보자.

ORM이 없다.

PostgreSQL 서버도 없다.

다른 데이터베이스로 데이터를 옮기는 ETL도 없다.

별도의 데이터 가져오기 과정도 없다.

DuckDB는 그저 Parquet 파일을 직접 조회한다.

이 단순함은 생각보다 훨씬 강력하다.

여기에 Polars를 더한다

DuckDB는 분석 SQL에 매우 뛰어나다.

하지만 엔지니어들은 비즈니스 로직을 구현할 때 DataFrame을 선호하는 경우가 많다.

Polars는 SQL을 대체하는 것이 아니라 SQL을 보완한다.

import duckdb
import polars as pl

con = duckdb.connect()

arrow_table = con.execute("""
SELECT *
FROM 'events.parquet'
WHERE timestamp >= CURRENT_DATE - INTERVAL 30 DAY
""").arrow()

df = pl.from_arrow(arrow_table)

result = (
    df
    .with_columns(
        (
            pl.col("purchase_amount") * 1.18
        ).alias("taxed_amount")
    )
    .group_by("country")
    .agg(
        pl.sum("taxed_amount").alias("revenue"),
        pl.count().alias("orders")
    )
    .sort("revenue", descending=True)
)

print(result)

자세히 살펴보자.

DuckDB가 필터링을 수행했다.

Polars가 데이터 변환을 처리했다.

두 도구 모두 자신에게 적합하지 않은 작업을 억지로 수행하지 않는다.

이것은 중요한 엔지니어링 원칙이다.

좋은 아키텍처는 강력한 도구만으로 만들어지는 것이 아니다. 각자의 역할에 특화된 도구들을 명확한 경계로 연결할 때 만들어진다.

프로덕션 프로젝트 구조

많은 팀이 저지르는 실수 중 하나는 모든 분석 코드를 하나의 거대한 노트북에 집어넣는 것이다.

프로덕션 시스템에는 프로덕션에 적합한 아키텍처가 필요하다.

analytics-platform/

├── app/
│   ├── api/
│   ├── services/
│   ├── repositories/
│   ├── analytics/
│   │      ├── duckdb_client.py
│   │      ├── polars_pipeline.py
│   │      ├── reports.py
│   │      └── exports.py
│   ├── models/
│   └── config.py
│
├── data/
│      ├── bronze/
│      ├── silver/
│      └── gold/
│
├── tests/
├── Dockerfile
└── pyproject.toml

각 영역이 분리되어 있다는 점에 주목하자.

DuckDB 코드가 애플리케이션 곳곳에 흩어져 있지 않다.

Polars 변환 로직도 API 엔드포인트와 뒤섞여 있지 않다.

데이터 처리는 분석 계층이 담당한다.

API 계층은 단순히 리포트를 요청할 뿐이다.

이렇게 분리하면 테스트와 유지보수가 훨씬 쉬워진다.

재사용 가능한 DuckDB 클라이언트 만들기

여기저기에서 연결을 생성하는 대신 접근 방식을 중앙에서 관리한다.

import duckdb


class DuckDBClient:

    def __init__(self, database="analytics.db"):
        self.connection = duckdb.connect(database)

    def execute(self, query, params=None):
        return self.connection.execute(
            query,
            params or {}
        )

    def arrow(self, query, params=None):
        return (
            self.execute(query, params)
            .arrow()
        )

사용 방법도 깔끔해진다.

db = DuckDBClient()

orders = db.arrow("""
SELECT *
FROM read_parquet('data/silver/orders/*.parquet')
WHERE order_date >= CURRENT_DATE - INTERVAL 90 DAY
""")

서비스 계층은 연결이 어떻게 생성되는지 알 필요가 없다.

그저 분석 결과를 받아 사용하면 된다.

이러한 경계가 바로 대규모 코드베이스를 유지보수하기 쉽게 만드는 요소다.

Polars로 데이터 변환하기

데이터가 Arrow 테이블로 전달되면 Polars가 작업을 이어받는다.

import polars as pl

def revenue_report(table):

    df = pl.from_arrow(table)

    return (
        df
        .with_columns([
            (
                pl.col("price") *
                pl.col("quantity")
            ).alias("total")
        ])
        .group_by([
            "country",
            "category"
        ])
        .agg([
            pl.sum("total").alias("revenue"),
            pl.mean("total").alias("avg_order"),
            pl.count().alias("orders")
        ])
        .sort(
            "revenue",
            descending=True
        )
    )

코드는 표현력이 뛰어나고 읽기 쉬우며, 동시에 고도로 최적화되어 있다.

명시적인 반복문이 없다.

직접 배치 처리를 구현할 필요도 없다.

행 단위로 데이터를 처리하지도 않는다.

벡터화는 엔진이 자동으로 처리한다.

그리고 바로 이 지점에서 많은 팀이 놀라운 사실을 발견한다.

가장 빠른 최적화 방법은 Python 코드를 다시 작성하는 것이 아니다.

Python 코드를 덜 작성하는 것이다.

여기까지 현대적인 분석 파이프라인의 핵심 구조를 완성했다.

  • DuckDB는 별도의 데이터베이스 서버 없이도 대규모 Parquet 데이터셋을 효율적으로 스캔한다.
  • Apache Arrow는 공유 인메모리 형식으로 동작하여 비용이 큰 데이터 복사를 피한다.
  • Polars는 고성능 벡터화 방식으로 비즈니스 데이터 변환을 수행한다.
  • 깔끔한 프로젝트 구조를 통해 분석 계층과 애플리케이션 계층을 분리한다.

FastAPI를 통해 분석 기능 제공하기

결국 데이터팀 외부의 누군가도 이러한 리포트를 사용하고 싶어 한다.

이때 가장 좋지 않은 방법은 데이터베이스에 직접 접근할 수 있도록 하는 것이다.

대신 작은 서비스를 통해 분석 기능을 제공한다.

from fastapi import FastAPI
from app.analytics.duckdb_client import DuckDBClient
from app.analytics.polars_pipeline import revenue_report

app = FastAPI()

db = DuckDBClient()

@app.get("/reports/revenue")
def revenue():

    table = db.arrow("""
    SELECT *
    FROM read_parquet('data/gold/orders/*.parquet')
    """)

    report = revenue_report(table)

    return report.to_dicts()

여기서 중요한 점이 있다.

API는 SQL을 알지 못한다.

API는 Polars도 알지 못한다.

단지 서비스들을 연결하고 조율할 뿐이다.

API 코드의 역할은 정확히 여기까지여야 한다.

대규모 리포트가 요청을 막게 해서는 안 된다

HTTP 요청을 처리하면서 5초짜리 리포트를 생성하는 것이 개발 환경에서는 괜찮아 보일 수 있다.

하지만 프로덕션 환경에서는 안정성을 떨어뜨리는 문제가 된다.

대신 비용이 큰 분석 작업을 백그라운드 워커로 넘긴다.

클라이언트
   │
   ▼
FastAPI
   │
작업 생성
   │
Redis Queue
   │
   ▼
Worker
   │
DuckDB
   │
Polars
   │
Parquet
   │
Object Storage
   │
다운로드 링크

요청은 거의 즉시 종료된다.

무거운 작업은 다른 곳에서 수행된다.

간단한 Celery 워커는 다음과 같이 작성할 수 있다.

from celery import Celery
from app.analytics.reports import build_report

celery = Celery(
    broker="redis://localhost:6379/0"
)

@celery.task
def generate_report(start, end):

    build_report(start, end)

사용자는 연산이 끝날 때까지 기다리는 대신 작업 ID를 받는다.

서버는 계속 빠르게 응답할 수 있다.

수백 개의 파일을 효율적으로 읽기

DuckDB에서 과소평가되는 기능 중 하나는 폴더 전체를 대상으로 쿼리할 수 있다는 점이다.

query = """
SELECT
    customer_id,
    SUM(total)
FROM read_parquet(
    'data/gold/orders/**/*.parquet'
)
GROUP BY customer_id
"""

반복문이 없다.

직접 파일을 찾을 필요도 없다.

파일들을 연결할 필요도 없다.

DuckDB는 수천 개의 Parquet 파일을 하나의 논리적 테이블처럼 다룬다.

이 기능은 데이터 레이크 아키텍처를 크게 단순화한다.

필요해지기 전에 데이터를 파티셔닝하라

많은 팀이 쿼리를 최적화한다.

하지만 스토리지를 최적화하는 팀은 상대적으로 적다.

디렉터리 파티셔닝은 SQL 튜닝보다 더 큰 성능 향상을 가져오는 경우가 많다.

orders/

year=2026/
    month=01/
    month=02/
    month=03/

year=2025/
    month=12/

이렇게 구성하면 쿼리가 자연스럽게 필요한 데이터만 선택할 수 있다.

SELECT *
FROM read_parquet(
'orders/year=2026/month=06/*.parquet'
)

모든 파일을 스캔하는 것보다 적은 파일을 스캔하는 것이 거의 항상 빠르다.

데이터를 저장하는 방식도 중요하다.

지연 실행이 모든 것을 바꾼다

Polars를 특히 매력적으로 만드는 기능 중 하나가 지연 실행(Lazy Execution)이다.

각각의 변환을 즉시 실행하는 대신 Polars는 최적화된 실행 계획을 만든다.

좋지 않은 방식:

df = (
    df.filter(...)
      .group_by(...)
      .sort(...)
)

더 나은 방식:

result = (

    pl.scan_parquet(
        "orders/*.parquet"
    )

    .filter(
        pl.col("country") == "US"
    )

    .group_by("category")

    .agg(
        pl.sum("sales")
    )

    .sort("sales")

    .collect()
)

collect()를 호출하기 전까지는 실제 연산이 실행되지 않는다.

덕분에 옵티마이저는 다음과 같은 최적화를 수행할 수 있다.

  • 필터를 더 앞 단계로 이동한다.
  • 불필요한 컬럼을 제거한다.
  • 여러 연산을 결합한다.
  • 메모리 할당을 줄인다.

SQL 옵티마이저가 동작하는 방식과 비슷하다.

비용이 큰 리포트 캐싱하기

어떤 대시보드는 하루에도 수천 번씩 조회된다.

요청이 들어올 때마다 다시 계산하면 CPU를 낭비하게 된다.

간단한 Redis 캐시로 이 문제를 해결할 수 있다.

import redis
import json

cache = redis.Redis()

def get_dashboard():

    cached = cache.get("dashboard")

    if cached:
        return json.loads(cached)

    report = build_dashboard()

    cache.setex(
        "dashboard",
        300,
        json.dumps(report)
    )

    return report

단 5분 동안 캐싱하는 것만으로도 수천 번의 동일한 분석 쿼리를 제거할 수 있다.

최적화가 항상 더 빠른 알고리즘을 의미하는 것은 아니다.

때로는 같은 작업을 반복하지 않는 것만으로 충분하다.

print 문보다 구조화된 로깅이 낫다

분석 작업은 시작한 지 몇 시간이 지난 뒤 실패하기도 한다.

좋은 로그가 있어야 디버깅할 수 있다.

import logging

logger = logging.getLogger(__name__)

logger.info(

    "Revenue report started",

    extra={
        "year":2026,
        "dataset":"orders"
    }
)

나중에는 중앙화된 로깅 시스템을 통해 다음과 같은 질문에 답할 수 있다.

  • 어떤 리포트가 실패했는가?
  • 어떤 데이터셋에서 문제가 발생했는가?
  • 실행에 얼마나 걸렸는가?
  • 어떤 고객의 요청으로 실행되었는가?

구조화된 로그가 없다면 이러한 질문에 답하는 과정은 마치 유적을 발굴하는 작업처럼 어려워진다.

추측하지 말고 성능을 측정하라

DuckDB는 쿼리 프로파일링 기능을 제공한다.

db.execute("""
PRAGMA enable_profiling;
""")

db.execute("""
SELECT *
FROM read_parquet(
'orders/*.parquet'
)
""")

어디에서 시간이 소요되는지 추측하지 말고 직접 측정한다.

직관에만 의존한 엔지니어링 의사결정은 결국 큰 비용으로 돌아온다.

Polars와 DuckDB를 기본 선택으로 사용하면 안 되는 경우

모든 기술에는 자신에게 적합한 영역이 있다.

이 조합은 분석 워크로드에서는 매우 뛰어나다.

하지만 모든 문제에 대한 최선의 답은 아니다.

고빈도 트랜잭션 시스템

PostgreSQL이나 다른 OLTP 데이터베이스를 사용한다.

수백만 건의 작은 업데이트 처리는 DuckDB의 강점이 아니다.

실시간 이벤트 스트림

Kafka와 Flink 또는 Spark Streaming을 결합하는 방식이 더 적합하다.

DuckDB는 데이터가 저장된 이후의 분석에서 강점을 발휘한다.

여러 사용자가 동시에 쓰기를 수행하는 데이터베이스

DuckDB는 기업용 관계형 데이터베이스를 대체하기 위한 도구가 아니다.

기존 데이터베이스를 보완하는 도구다.

분석 시스템을 생각해야 한다.

은행 시스템이 아니다.

현대적인 데이터 파이프라인을 구축하며 얻은 교훈

성공적인 분석 플랫폼에서는 몇 가지 패턴이 반복해서 나타난다.

데이터는 Parquet으로 저장한다.

압축된 컬럼 기반 저장 방식은 디스크 사용량과 스캔 시간을 모두 크게 줄인다.

필터를 가능한 한 스토리지 가까이에서 적용한다.

나중에 코드를 최적화하는 것보다 처음부터 적은 데이터를 읽는 것이 낫다.

데이터 조회는 SQL에 맡긴다.

비즈니스 로직은 DataFrame에 맡긴다.

이렇게 역할을 분리하면 확장성이 매우 좋아진다.

데이터 형식을 반복해서 변환하지 않는다.

Arrow가 존재하는 데에는 이유가 있다.

적극적으로 활용한다.

최적화 옵션보다 아키텍처를 먼저 최적화한다.

서버를 하나 더 구입한다고 해서 잘못된 데이터 이동 구조가 해결되는 경우는 거의 없다.

더 큰 변화

지금 일어나고 있는 변화는 단지 두 개의 뛰어난 라이브러리가 인기를 얻고 있다는 것만을 의미하지 않는다.

철학 자체가 바뀌고 있다.

오랫동안 분석 파이프라인에서는 스토리지 엔진, Python 객체, 임시 파일, 데이터베이스 사이에서 데이터를 계속 이동시켜 왔다.

데이터를 이동할 때마다 지연 시간이 늘어났다.

데이터를 복사할 때마다 메모리가 소모되었다.

형식을 변환할 때마다 복잡성이 증가했다.

Polars와 DuckDB는 다른 접근 방식을 권장한다.

데이터를 컬럼 형태로 유지한다.

변환 작업을 벡터화한다.

데이터 이동을 최소화한다.

각 작업에 특화된 엔진이 자신이 가장 잘하는 일을 하도록 한다.

이러한 철학은 하나의 특정 최적화 기법보다 훨씬 뛰어난 확장성을 제공한다.

빠른 시스템은 CPU를 더 열심히 일하게 만들어서 구축하는 것이 아니다. CPU가 불필요한 작업을 덜 하도록 만들어서 구축한다.

바로 이것이 이 두 도구가 매력적인 이유다.

유행하는 도구이기 때문이 아니다.

엔지니어들이 오랫동안 당연하게 받아들여 온 불편함을 제거하기 때문이다.

그리고 일단 이러한 아이디어를 중심으로 파이프라인을 구축하고 나면, 예전 방식으로 돌아가기는 놀라울 정도로 어렵다.

댓글