본문 바로가기
  • plotly로 바로쓰는 동적시각화 in R & 파이썬
EPL과 유튜브 데이터로 배우는 DuckDB

CSV 분석의 새로운 시대, 모든 것을 바꾼 DuckDB

by 아참형인간 2026. 7. 20.

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

 

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

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

product.kyobobook.co.kr

 

CSV 기반의 느린 분석 워크플로를 이제 그만 사용하세요. DuckDB가 CSV 처리를 어떻게 프로덕션 환경에서도 사용할 수 있는 수준의 고성능 분석으로 바꾸는지, 그리고 이를 가능하게 하는 최신 데이터 엔지니어링 패턴을 살펴보겠습니다.

 

데이터를 바라보는 방식을 완전히 바꿔 놓은 기술은 많지 않습니다.

Docker가 그랬고, Git도 그랬습니다.

그리고 DuckDB도 조용히 그 목록에 이름을 올렸습니다.

혁신적인 새로운 쿼리 언어를 내놓았기 때문도 아닙니다.

수천 대의 서버에서 동작하기 때문도 아닙니다.

대신 거의 아무도 던지지 않았던 질문을 했기 때문입니다.

왜 우리는 아직도 CSV 파일을 데이터베이스 테이블처럼 다루고 있을까?

오랫동안 저는 거의 똑같은 형태의 ETL 파이프라인을 만들어 왔습니다.

CSV
   │
   ▼
Pandas
   │
   ▼
임시 DataFrame
   │
   ▼
데이터 정제
   │
   ▼
PostgreSQL
   │
   ▼
분석

처음에는 잘 동작했습니다.

하지만 데이터가 커질수록 상황은 달라졌습니다.

데이터셋이 커질수록…

메모리는 점점 부족해졌고,

배포는 점점 느려졌으며,

'임시'로 만든 스크립트는 어느새 프로덕션 코드가 되어 버렸습니다.

결국 모든 팀은 같은 결론에 도달합니다.

느린 것은 CSV가 아닙니다.

CSV를 다루는 우리의 워크플로가 느린 것입니다.

바로 그 깨달음이 제가 분석 서비스를 설계하는 방식을 완전히 바꿔 놓았습니다.

CSV 중심 시스템이 숨기고 있는 비용

모든 백엔드 엔지니어는 한 번쯤 이런 코드를 본 적이 있을 것입니다.

import pandas as pd

orders = pd.read_csv("orders.csv")
customers = pd.read_csv("customers.csv")
products = pd.read_csv("products.csv")

merged = (
    orders
    .merge(customers, on="customer_id")
    .merge(products, on="product_id")
)

report = (
    merged
    .groupby("country")
    .agg(
        revenue=("price", "sum"),
        orders=("order_id", "count")
    )
)

report.to_csv("daily_report.csv")

겉으로 보기에는 아무 문제도 없어 보입니다.

하지만 어느 날 누군가 이런 파일을 보내옵니다.

orders.csv  크기: 47GB

그 순간부터 상황이 달라집니다.

Killed

메모리가 모두 소진됩니다.

스왑 메모리가 과도하게 사용되기 시작합니다.

서버는 사실상 사용할 수 없는 상태가 됩니다.

Kubernetes Pod는 재시작되고,

재시도 큐에는 작업이 끝없이 쌓이기 시작합니다.

더 답답한 사실은,

이 작업의 대부분은 애초에 47GB 전체를 메모리에 올릴 필요조차 없었다는 점입니다.

단지 우리가 사용하던 도구들이 그렇게 동작하도록 만들어져 있었기 때문에,

그 방식을 당연한 것으로 받아들였을 뿐입니다.

CSV는 분석을 위해 만들어진 것이 아니라 데이터 교환을 위해 만들어졌습니다.

CSV가 데이터의 공용 언어가 된 이유는 단순합니다.

거의 모든 시스템이 CSV 파일을 만들 수 있기 때문입니다.

이것이 CSV의 가장 큰 장점입니다.

동시에 가장 큰 약점이기도 합니다.

CSV 파일은 다음과 같은 정보를 전혀 알지 못합니다.

  • 컬럼 통계 정보(column statistics)
  • 인덱스(indexes)
  • 압축(compression)
  • 데이터 타입(data types)
  • 파티션(partitions)
  • Predicate Pushdown

그래서 모든 쿼리는 처음부터 다시 시작됩니다.

모든 스캔은 파일의 첫 번째 바이트부터 읽기 시작합니다.

모든 집계 작업은 파일 전체를 다시 읽습니다.

마치 책에서 한 단락만 확인하면 되는데도 매번 처음부터 끝까지 책 전체를 다시 읽는 것과 같습니다.

DuckDB는 접근 방식 자체를 바꿉니다.

DuckDB는 먼저 데이터를 가져오라고 요구하지 않습니다.

별도의 데이터베이스 서버를 실행할 필요도 없습니다.

클러스터를 구성할 필요도 없습니다.

대신 아주 단순한 질문을 던집니다.

"파일을 직접 조회하면 안 되는 이유가 있을까요?"

import duckdb

con = duckdb.connect()

result = con.execute("""
SELECT
    country,
    SUM(price) AS revenue,
    COUNT(*) AS orders
FROM read_csv_auto('orders.csv')
GROUP BY country
ORDER BY revenue DESC
""").fetchdf()

print(result)

이것만으로 충분합니다.

DataFrame을 만들 필요도 없습니다.

ORM도 필요 없습니다.

마이그레이션도 필요 없습니다.

서비스를 먼저 실행할 필요도 없습니다.

단 하나의 질문에 답하기 위해 ETL 파이프라인을 구축할 필요도 없습니다.

DuckDB는 벡터화(Vectorized) 실행 엔진과 고도로 최적화된 CSV 리더를 기반으로 분석 작업을 매우 효율적으로 수행합니다. 또한 단순한 CSV 파서라면 쉽게 오류를 일으키는 다양한 실제 CSV 형식도 안정적으로 처리할 수 있습니다.

실제 운영 환경의 예

전자상거래 플랫폼을 운영하고 있다고 가정해 보겠습니다.

매시간 다른 서비스에서 CSV 파일을 객체 스토리지(Object Storage)에 저장합니다.

incoming/

orders_001.csv
orders_002.csv
orders_003.csv
...
orders_124.csv

기존 방식의 처리 과정은 보통 다음과 같습니다.

CSV
 ↓
Python 반복문
 ↓
Pandas
 ↓
Append
 ↓
파일 저장
 ↓
다시 읽기
 ↓
집계
 ↓
내보내기

이 과정의 모든 단계에서 데이터가 복사됩니다.

모든 단계에서 새로운 메모리가 할당됩니다.

그리고 모든 단계가 또 하나의 장애 지점(Failure Point)이 됩니다.

DuckDB에서는 훨씬 단순합니다.

디렉터리 전체를 하나의 논리적 테이블(Logical Table)처럼 다루면 됩니다.

import duckdb

QUERY = """
SELECT
    customer_id,
    COUNT(*) AS total_orders,
    SUM(total_amount) AS revenue,
    AVG(total_amount) AS avg_order
FROM read_csv_auto(
    'incoming/orders_*.csv'
)
GROUP BY customer_id
ORDER BY revenue DESC
LIMIT 100
"""

top_customers = duckdb.sql(QUERY).df()

여기서 눈여겨볼 점은 없는 것들입니다.

반복문이 없습니다.

파일을 이어 붙이는(concatenate) 작업이 없습니다.

스키마를 직접 맞추는 작업도 없습니다.

DataFrame을 계속 append하는 코드도 없습니다.

그리고 메모리 부족(OOM)으로 프로세스가 종료될 위험도 크게 줄어듭니다.

Repository 계층 전체를 Pandas에 의존할 필요는 없습니다.

아직도 자주 보이는 안티패턴 중 하나는 Pandas를 애플리케이션의 데이터 접근 계층(Data Access Layer)처럼 사용하는 것입니다.

Service
   ↓
Pandas
   ↓
CSV

더 나은 방법은 DuckDB를 Repository 계층 뒤에 숨기는 것입니다.

app/
├── repositories/
│   ├── analytics_repository.py
│
├── services/
│   ├── analytics_service.py
│
├── api/
│   ├── reports.py
│
└── database/
    └── duckdb.py

데이터베이스 연결은 다음과 같이 구성할 수 있습니다.

# database/duckdb.py

import duckdb

class DuckDBConnection:

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

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

Repository 계층은 SQL만 담당합니다.

# repositories/analytics_repository.py

class AnalyticsRepository:

    def __init__(self, db):
        self.db = db

    def revenue_by_country(self):

        sql = """
        SELECT
            country,
            SUM(total_amount) AS revenue
        FROM read_csv_auto(
            'data/orders/*.csv'
        )
        GROUP BY country
        ORDER BY revenue DESC
        """

        return self.db.execute(sql).fetchall()

Service 계층은 Repository만 호출하면 됩니다.

class AnalyticsService:

    def __init__(self, repository):
        self.repository = repository

    def dashboard(self):
        return self.repository.revenue_by_country()

이렇게 하면 애플리케이션의 다른 부분은 데이터가 어디에서 오는지 신경 쓸 필요가 없습니다.

CSV에서 읽든,

Parquet에서 읽든,

DuckDB의 네이티브 테이블에서 읽든,

애플리케이션의 나머지 코드는 그대로 유지됩니다.

이처럼 계층을 분리해 두면 그 효과는 6분 뒤가 아니라 6개월 뒤에 더욱 크게 나타납니다.

가장 빠른 CSV는 한 번만 읽는 CSV입니다.

팀들이 가장 많이 저지르는 실수 중 하나는 같은 CSV 파일을 몇 달 동안 매일 반복해서 조회하는 것입니다.

CSV는 데이터를 교환하기 위한 형식입니다.

분석을 위한 저장 형식은 아닙니다.

DuckDB에서는 Parquet로의 전환도 놀랄 만큼 간단합니다.

import duckdb

duckdb.sql("""
COPY (
    SELECT *
    FROM read_csv_auto('orders.csv')
)
TO 'orders.parquet'
(FORMAT PARQUET)
""")

단 한 줄의 명령만으로도 저장 공간을 크게 줄일 수 있으며, 이후의 쿼리에서는 컬럼 프루닝(Column Pruning)Predicate Pushdown을 활용하여 훨씬 빠른 분석이 가능합니다.

DuckDB는 CSV와 Parquet를 모두 자연스럽게 지원하도록 설계되어 있으며, 동일한 데이터를 반복해서 분석해야 하는 환경에서는 열 지향(Columnar) 포맷인 Parquet를 적극 활용할 것을 권장하고 있습니다.

쿼리는 적게, 변환은 한 번만

제가 가장 선호하는 운영 환경 패턴 중 하나는 의외로 매우 단순합니다.

원본 CSV 파일을 수신합니다.

유효성을 검증합니다.

단 한 번 Parquet로 변환합니다.

다시 처리해야 하는 특별한 경우가 아니라면 원본 CSV는 더 이상 읽지 않습니다.

CSV를 운송 컨테이너라고 생각해 보세요.

Parquet는 창고라고 생각하면 됩니다.

컨테이너는 데이터를 우리에게 전달하는 역할을 합니다.

창고는 데이터를 효율적으로 보관하고 활용할 수 있게 해 줍니다.

실제 운영 환경의 데이터 적재(Ingestion) 서비스는 다음과 같이 단순할 수 있습니다.

from pathlib import Path
import duckdb

RAW_DIR = Path("incoming")
PARQUET_DIR = Path("warehouse")

PARQUET_DIR.mkdir(exist_ok=True)

for csv_file in RAW_DIR.glob("*.csv"):

    output = PARQUET_DIR / (
        csv_file.stem + ".parquet"
    )

    duckdb.sql(f"""
        COPY (
            SELECT *
            FROM read_csv_auto('{csv_file}')
        )
        TO '{output}'
        (FORMAT PARQUET)
    """)

이것이 전체 데이터 적재 파이프라인입니다.

Spark 클러스터도 필요 없습니다.

Airflow DAG도 필요 없습니다.

분산 스케줄러도 필요 없습니다.

오직 SQL만 있으면 됩니다.

DuckDB는 COPY 명령으로 Parquet 파일을 매우 간단하게 생성할 수 있으며, Parquet는 높은 압축률과 컬럼 프루닝(Column Pruning), 그리고 반복적인 분석 쿼리의 뛰어난 성능을 제공합니다.

FastAPI로 분석 서비스를 제공하기

결국 누군가는 분석 결과를 API로 제공해 달라고 요청하게 됩니다.

좋습니다.

DuckDB는 백엔드 서비스와도 매우 자연스럽게 통합됩니다.

from fastapi import FastAPI
import duckdb

app = FastAPI()

db = duckdb.connect("analytics.duckdb")

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

    query = """
    SELECT
        country,
        SUM(total_amount) AS revenue
    FROM read_parquet(
        'warehouse/*.parquet'
    )
    GROUP BY country
    ORDER BY revenue DESC
    """

    rows = db.execute(query).fetchall()

    return [
        {
            "country": row[0],
            "revenue": row[1]
        }
        for row in rows
    ]

여기서 주목할 점은 없는 것들입니다.

ORM이 없습니다.

임시 테이블이 없습니다.

데이터 동기화 작업도 없습니다.

API는 분석용 Parquet 파일을 직접 조회합니다.

대부분의 내부 대시보드나 리포팅 서비스에서는 이것만으로도 충분히 빠른 성능을 얻을 수 있습니다.

비용이 큰 리포트는 캐시하세요

일부 리포트는 실행 비용이 큽니다.

그렇다고 요청이 들어올 때마다 매번 다시 계산할 필요는 없습니다.

Client
   ↓
FastAPI
   ↓
DuckDB
   ↓
10GB 스캔

대신 결과를 캐시에 저장하면 됩니다.

import json
import redis

cache = redis.Redis()

KEY = "dashboard:revenue"

cached = cache.get(KEY)

if cached:
    return json.loads(cached)

rows = db.execute(query).fetchall()

payload = [
    {
        "country": r[0],
        "revenue": r[1]
    }
    for r in rows
]

cache.setex(
    KEY,
    300,
    json.dumps(payload)
)

return payload

5분만 캐시를 유지해도 동일한 분석 쿼리가 수천 번 반복 실행되는 것을 막을 수 있습니다.

요청을 기다리기보다 백그라운드에서 미리 갱신하세요

분석 데이터는 대부분 밀리초 단위의 실시간성을 요구하지 않습니다.

사용자의 요청이 들어올 때마다 리포트를 계산하는 대신,

백그라운드에서 지속적으로 계산해 두는 편이 훨씬 효율적입니다.

CSV 수신
   ↓
백그라운드 작업(Worker)
   ↓
DuckDB
   ↓
집계 리포트(Materialized Report)
   ↓
FastAPI
   ↓
사용자

간단한 백그라운드 작업은 다음과 같습니다.

from celery import shared_task
import duckdb

@shared_task
def refresh_dashboard():

    db = duckdb.connect(
        "analytics.duckdb"
    )

    db.execute("""

    CREATE OR REPLACE TABLE dashboard AS

    SELECT
        country,
        SUM(total_amount) AS revenue,
        COUNT(*) AS orders

    FROM read_parquet(
        'warehouse/*.parquet'
    )

    GROUP BY country

    """)

그러면 API는 복잡한 집계를 다시 수행할 필요 없이,

다음과 같은 단순한 쿼리만 실행하면 됩니다.

SELECT * FROM dashboard;

매 요청마다 수백만 건의 데이터를 다시 계산하는 것보다 훨씬 효율적입니다.


지저분한 CSV 파일도 고려해야 합니다

현실의 CSV 파일은 거의 항상 완벽하지 않습니다.

예를 들어 다음과 같은 데이터가 있을 수 있습니다.

price

100
200
N/A
350
UNKNOWN

DuckDB의 자동 스키마 추론 기능은 매우 뛰어납니다.

하지만 운영 환경에서는 모든 파일을 자동 추론에만 맡기는 것은 바람직하지 않습니다.

DuckDB는 스키마를 직접 지정할 수 있으며, sniff_csv() 같은 기능으로 CSV의 형식과 추론된 데이터 타입을 미리 확인할 수도 있습니다.

예를 들어 다음과 같이 명시적으로 스키마를 지정할 수 있습니다.

query = """

SELECT *

FROM read_csv(

    'orders.csv',

    auto_detect=False,

    columns={

        'order_id':'BIGINT',

        'price':'DOUBLE',

        'country':'VARCHAR'

    }

)

"""

명시적인 스키마를 사용하면 예기치 않은 오류를 크게 줄일 수 있습니다.

운영 시스템은 무엇보다 예측 가능성(Predictability) 을 좋아합니다.


구조화된 로그는 필수입니다

데이터 적재가 실패했을 때 필요한 것은

추측이 아니라 원인입니다.

import logging

logger = logging.getLogger(__name__)

try:

    db.execute(query)

except Exception:

    logger.exception(

        "duckdb import failed",

        extra={

            "file": filename,

            "pipeline": "orders"

        }

    )

    raise

어떤 파일에서,

어떤 파이프라인에서,

왜 오류가 발생했는지 함께 기록해 두면,

6개월 뒤의 자신이 분명히 고마워할 것입니다.


간단한 운영 아키텍처

              공급 시스템
                   │
              CSV 업로드
                   │
                   ▼
            데이터 검증 서비스
                   │
                   ▼
         DuckDB 적재(Ingestion) 계층
                   │
         COPY → Parquet 변환
                   │
        warehouse/*.parquet
                   │
          ┌────────┴────────┐
          │                 │
          ▼                 ▼
       FastAPI        백그라운드 작업
          │                 │
          └────────┬────────┘
                   │
              Redis 캐시
                   │
             Dashboard API

이 아키텍처에는 특별하거나 복잡한 기술이 없습니다.

바로 그것이 핵심입니다.

좋은 아키텍처는 대개 화려하지 않습니다.

단순하고 이해하기 쉬우며, 운영하기 쉽습니다.

성능은 더 빠르게 계산하는 것이 아니라, 덜 읽는 것에서 시작됩니다

개발자는 알고리즘을 최적화하는 것을 좋아합니다.

반면 데이터베이스 엔진은 애초에 일을 하지 않는 것을 좋아합니다.

이 둘은 완전히 다른 철학입니다.

DuckDB가 뛰어난 성능을 내는 이유도 여기에 있습니다.

필요한 컬럼만 읽고,

벡터화된 배치(Vectorized Batch) 단위로 데이터를 처리하며,

가능한 경우 작업을 병렬로 수행합니다.

특히 Parquet처럼 잘 구성된 열 기반(Columnar) 파일에서는 이러한 장점이 더욱 크게 나타납니다.

제가 가장 좋아하는 데이터 엔지니어링 문구 중 하나는 다음과 같습니다.

가장 빠른 쿼리는 더 빨리 실행되는 쿼리가 아닙니다. 처음부터 불필요한 데이터를 읽지 않는 쿼리입니다.

얼핏 비슷해 보이지만,

이 차이가 바로 분석 데이터베이스가 존재하는 이유입니다.

자주 하는 실수

요청이 들어올 때마다 같은 CSV를 다시 읽기

@app.get("/report")
def report():

    return duckdb.sql("""
        SELECT *
        FROM read_csv_auto(
            'orders.csv'
        )
    """)

API 요청이 들어올 때마다 CSV를 다시 파싱하게 됩니다.

그 대신 한 번만 Parquet로 변환해 두는 것이 훨씬 효율적입니다.

Pandas를 데이터 전달 계층으로 사용하는 것

CSV
 ↓
Pandas
 ↓
DuckDB
 ↓
Pandas
 ↓
JSON

이 과정에서는 데이터가 계속 복사됩니다.

대부분의 경우 이러한 복사는 불필요합니다.

CSV를 영구 저장소로 사용하는 것

CSV는 영구 저장 형식이 아닙니다.

데이터를 전달하기 위한 형식일 뿐입니다.

검증이 끝났다면 Parquet와 같은 분석용 포맷으로 변환하세요.

원본 CSV는 감사(Audit)나 재처리(Replay)가 필요한 경우를 위해 보관하면 충분합니다.

스키마 변경을 고려하지 않는 것

데이터를 제공하는 시스템은 시간이 지나면서 바뀝니다.

누군가는 price 컬럼 이름을 변경할 수도 있고,

다른 팀은 discount 컬럼을 추가할 수도 있습니다.

이러한 변화는 API에서 처리하는 것이 아니라,

데이터 적재(Ingestion) 단계에서 검증하도록 설계해야 합니다.

DuckDB가 적합하지 않은 경우

DuckDB는 분석(Analytics) 작업에서는 매우 뛰어난 성능을 제공합니다.

하지만 모든 용도에 적합한 만능 데이터베이스는 아닙니다.

다음과 같은 환경이라면 저는 DuckDB를 선택하지 않을 것입니다.

  • 높은 동시성을 요구하는 OLTP 시스템
  • 사용자 인증 데이터베이스
  • 실시간 주문 처리 시스템
  • 다수의 사용자가 동시에 데이터를 수정하는 애플리케이션
  • 항상 네트워크를 통해 서비스되어야 하는 데이터베이스

이러한 환경에서는 PostgreSQL과 같은 데이터베이스가 여전히 훨씬 적합한 선택입니다.

여기서 중요한 메시지는

"PostgreSQL을 DuckDB로 교체하라."가 아닙니다.

오히려 다음과 같습니다.

DuckDB가 더 자연스럽게 해결할 수 있는 문제까지 PostgreSQL로 해결하려고 하지 마세요.

마무리

오랫동안 우리는 아주 이상한 방식으로 데이터를 처리해 왔습니다.

데이터를 내보내고,

다른 곳으로 옮긴 뒤,

변환하고,

다시 내보내고,

다음 날 또 같은 과정을 반복했습니다.

DuckDB는 이러한 사고방식 자체에 질문을 던집니다.

"파일 자체를 바로 조회할 수 있다면 어떨까?"

한 번이라도 수십 GB의 CSV 파일을 직접 조회하고,

이를 효율적인 Parquet 데이터셋으로 변환한 뒤,

가벼운 임베디드 데이터베이스 하나만으로 운영 환경의 분석 서비스를 제공하는 경험을 해 보면,

예전 방식으로 돌아가기는 쉽지 않습니다.

DuckDB의 진정한 혁신은 단순히 빠르다는 데 있지 않습니다.

불필요한 단계를 없애 준다는 데 있습니다.

그리고 백엔드 엔지니어링에서는

최적화보다 불필요한 작업을 제거하는 것이 거의 항상 더 큰 효과를 가져옵니다.

단계 하나를 없앤다는 것은

유지보수해야 할 것도 하나 줄고,

디버깅해야 할 것도 하나 줄며,

확장해야 할 것도 하나 줄고,

새벽 2시에 장애 알림을 받고 잠에서 깨어날 일도 하나 줄어든다는 뜻입니다.

<출처: https://medium.com/@komalbaparmar007/stop-using-csvs-like-its-2015-duckdb-changed-everything-4b4f7c5de96a>

댓글