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

DuckDB와 Iceberg: 차세대 로컬 데이터 분석 환경

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

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

 

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

LUVIT EPL과 유튜브 데이터로 배우는 DuckDB | 복잡한 데이터 분석 흐름을 더 단순하게 만드는 DuckDB 최근 주목받고 있는 DuckDB를 활용해 SQL 기반 데이터 분석과 실전 프로젝트를 학습할 수 있도록 구

product.kyobobook.co.kr

 

DuckDB와 Apache Iceberg를 활용해 현대적인 로컬 분석 스택을 구축하는 방법을 살펴봅니다. 프로덕션 환경에서도 활용 가능한 아키텍처와 Python 연동 방법, 그리고 확장 가능한 레이크하우스 워크플로까지 함께 알아봅니다.

 

한때 데이터 분석 플랫폼을 구축한다는 것은 거대한 인프라를 먼저 준비하는 일과 다르지 않았습니다.

불과 몇 년 전만 해도 분석 플랫폼을 만들려면 긴 준비 목록부터 하나씩 채워야 했습니다.

 

데이터 웨어하우스를 구축해야 했고, 분산 컴퓨팅 엔진도 도입해야 했습니다.

스케줄러도 필요했고, 메타데이터 카탈로그도 갖춰야 했습니다.

클러스터도 구성해야 했고, 객체 스토리지도 준비해야 했습니다.

 

여기에 Terraform 모듈까지 여러 개 관리하다 보면, 결국 누군가 이렇게 묻곤 했습니다.

"SQL 쿼리 하나 실행하는 데 정말 이 모든 게 필요한가요?"

 

아이러니하게도 가장 단순한 분석 작업을 수행하기 위해 가장 복잡한 인프라를 갖춰야 했던 시대였습니다.

하지만 이제는 상황이 달라지고 있습니다.

분산 시스템의 시대가 끝났기 때문은 아닙니다.

로컬 환경에서 수행하는 데이터 분석의 성능과 활용 범위가 비약적으로 발전했기 때문입니다.

 

DuckDB는 임베디드 데이터베이스가 어디까지 가능할 수 있는지를 보여주었고,

Apache Iceberg는 데이터 레이크의 활용 방식을 새롭게 정의했습니다.

그리고 이 두 기술을 함께 사용하면 매우 흥미로운 조합이 만들어집니다.

 

노트북 한 대에서 시작하더라도, 처음부터 복잡한 데이터 플랫폼을 설계할 필요 없이 필요에 따라 점진적으로 확장할 수 있는 현대적인 레이크하우스 아키텍처를 구축할 수 있는 것입니다.

이러한 조합은 오늘날 백엔드 엔지니어와 머신러닝 엔지니어, 그리고 데이터 팀에게 가장 현실적이고 실용적인 분석 스택 가운데 하나로 주목받고 있습니다. 최근 DuckDB는 REST Catalog 연동, MERGE INTO 지원, 다양한 Iceberg 테이블 연산 기능 등을 추가하며 Iceberg 지원을 크게 강화했습니다. 그 결과 로컬 우선(Local-first) 방식은 단순한 개발 환경을 넘어 실제 프로덕션 환경에서도 충분히 활용할 수 있는 수준으로 빠르게 발전하고 있습니다.

데이터 분석에 대해 우리가 믿어온 가장 큰 오해

오랫동안 우리는 데이터 분석에는 반드시 분산 컴퓨팅이 필요하다고 믿어왔습니다.

물론 그런 경우도 있지만, 대부분의 경우는 그렇지 않습니다.

많은 기업이 다루는 데이터는 페타바이트(PB)가 아니라 기가바이트(GB) 수준입니다.

이들의 진짜 병목은 CPU도, 스토리지도, 네트워크 대역폭도 아닙니다.

진짜 문제는 운영 복잡성입니다.

 

쿠버네티스 배포를 하나 더 추가하고, Spark 클러스터를 하나 더 운영하고, 또 하나의 서비스를 유지보수해야 합니다.

이런 경험을 통해 반복하면 다음과 같은 사실을 깨닫게 됩니다 .

대부분의 확장성 문제는 CPU 성능의 한계가 아니라, 운영과 관리의 복잡성이 만들어낸 문제라는 것입니다.

 

가장 빠른 쿼리는 네트워크를 거쳐 실행되는 쿼리가 아니라, 내 컴퓨터 안에서 바로 실행되는 쿼리인 경우가 많습니다.

DuckDB는 바로 이러한 철학을 바탕으로 만들어졌습니다.

SQL을 네트워크 너머의 데이터베이스로 보내는 대신, 데이터베이스가 애플리케이션 안으로 들어오는 방식을 선택한 것입니다.

DuckDB는 PostgreSQL을 대체하는 데이터베이스가 아닙니다

DuckDB에 대해 가장 흔히 하는 오해 중 하나는 이를 또 하나의 트랜잭션 데이터베이스로 생각하는 것입니다.

하지만 DuckDB의 역할은 전혀 다릅니다.

아키텍처를 다음과 같이 생각해 보면 이해하기 쉽습니다.

              애플리케이션
                    │
        PostgreSQL (OLTP)
                    │
         CDC / 배치 데이터 추출
                    │
            Parquet 파일
                    │
        Apache Iceberg 테이블
                    │
        DuckDB 분석 엔진
                    │
     대시보드 · 머신러닝 · 보고서

 

각 구성 요소는 서로 다른 역할을 담당합니다.

여기서 눈여겨볼 점은 지금까지의 플랫폼과 비교해서 무엇이 없는가입니다.

Spark 클러스터도 없습니다.

데이터 웨어하우스도 없습니다.

JVM도 없습니다.

코디네이터도 없습니다.

수많은 실행 노드(Executor)도 없습니다.

그저 파일이 있을 뿐입니다.

바로 이 단순함이 많은 개발자와 데이터 팀이 이 아키텍처에 주목하는 가장 큰 이유입니다.

Iceberg가 데이터 레이크를 바꾸는 이유

Parquet 파일은 훌륭한 저장 형식이지만, 모든 문제를 해결해 주지는 않습니다.

예를 들어 데이터 레이크가 다음과 같이 구성되어 있다고 가정해 보겠습니다.

orders/

    part-0001.parquet

    part-0002.parquet

    part-0003.parquet

    part-0004.parquet

 

겉으로 보기에는 매우 단순합니다.

그런데 누군가 이런 요청을 합니다.

"고객 번호 481의 데이터를 삭제해 주세요."

또는

"어제 적재한 데이터를 이전 상태로 되돌려 주세요."

혹은

"지난주 화요일에는 이 데이터셋이 어떤 모습이었나요?"

순수한 Parquet 파일만으로는 이러한 요청에 답하기 어렵습니다.

하지만 Iceberg는 가능합니다.

그 이유는 Iceberg가 파일 자체를 데이터베이스로 취급하지 않기 때문입니다.

대신 메타데이터를 데이터베이스처럼 관리합니다.

데이터가 변경될 때마다 새로운 스냅샷(Snapshot) 이 생성되고,

각 스냅샷은 변경되지 않는(Immutable) Parquet 파일들을 참조합니다.

쿼리를 실행할 때는 필요한 스냅샷만 선택해서 읽으면 됩니다.

덕분에 다음과 같은 기능을 자연스럽게 사용할 수 있습니다.

  • 특정 시점의 데이터를 조회하는 Time Travel
  • 데이터가 한 번에 반영되는 원자적 커밋(Atomic Commit)
  • 기존 데이터를 유지한 채 구조를 변경하는 스키마 진화(Schema Evolution)
  • 읽는 동안 데이터 일관성을 보장하는 스냅샷 격리(Snapshot Isolation)
  • 이전 상태로 손쉽게 되돌리는 롤백(Rollback)
  • 필요한 메타데이터만 읽어 성능을 높이는 메타데이터 프루닝(Metadata Pruning)
  • 여러 사용자가 동시에 안전하게 조회할 수 있는 동시 읽기(Concurrent Readers)

무엇보다 중요한 점은, 이러한 기능을 사용하기 위해 모든 Parquet 파일을 다시 작성할 필요가 없다는 것입니다. 바로 이것이 Iceberg가 단순한 파일 저장소를 현대적인 레이크하우스로 바꾸는 핵심 이유입니다.

프로덕션 환경을 고려한 프로젝트 구조

분석가가 자주하는 실수 중 하나는 분석용 스크립트를 아무 폴더에나 넣어두는 것입니다.

처음에는 별문제 없어 보입니다.

하지만 6개월이 지나면 어떤 SQL이 어떤 데이터셋을 만들었는지 아무도 알 수 없게 됩니다.

그래서 분석 작업도 소프트웨어처럼 관리해야 합니다.

analytics-stack/
│
├── app/
│   ├── config.py
│   ├── catalog.py
│   ├── repository.py
│   ├── analytics.py
│   └── services.py
│
├── sql/
│   ├── revenue.sql
│   ├── customers.sql
│   └── retention.sql
│
├── warehouse/
│   ├── bronze/
│   ├── silver/
│   └── gold/
│
├── iceberg/
│   └── catalog/
│
├── docker/
│
├── tests/
│
└── pyproject.toml

 

지금은 혼자 개발하고 있더라도, 미래의 나는 또 다른 개발자와 같습니다.

그 개발자가 이해하고 유지보수하기 쉽도록 잘 정리해 두어야 합니다.

스택 설치하기

생각보다 작은 환경만으로도 충분합니다.

python -m venv .venv

source .venv/bin/activate

pip install duckdb

pip install pyarrow

pip install pandas

pip install polars

 

컨테이너 환경에서는 다음과 같이 구성할 수 있습니다.

FROM python:3.13-slim

WORKDIR /app

COPY pyproject.toml .

RUN pip install --no-cache-dir duckdb \
    pyarrow \
    pandas \
    polars

COPY . .

CMD ["python", "main.py"]

 

여기서 눈에 띄는 것은 무엇이 없는가입니다.

Spark가 없습니다.

Hadoop도 없습니다.

Java 런타임도 없습니다.

클러스터 매니저도 없습니다.

DuckDB를 올바르게 초기화하기

여기저기서 매번 연결을 새로 만들기보다는, 설정을 한곳에서 관리하는 것이 좋습니다.

# app/config.py

from pathlib import Path

DATABASE = Path("analytics.duckdb")

WAREHOUSE = Path("warehouse")

THREADS = 8
# app/catalog.py

import duckdb

from app.config import DATABASE

def connection():

    conn = duckdb.connect(str(DATABASE))

    conn.execute("INSTALL iceberg;")
    conn.execute("LOAD iceberg;")

    conn.execute("INSTALL httpfs;")
    conn.execute("LOAD httpfs;")

    return conn

 

시작 단계에서 확장 기능을 한 번 로드해 두는 편이 노트북이나 스크립트 곳곳에 설치 코드를 흩어놓는 것보다 훨씬 깔끔합니다. DuckDB의 Iceberg 확장은 REST Catalog와 연동할 수 있고, httpfs 같은 확장을 통해 객체 스토리지와도 연결되므로 Iceberg 테이블을 직접 다룰 수 있습니다.

Iceberg 테이블 읽기

확장 기능만 로드하면 Iceberg 테이블을 조회하는 일은 놀랄 만큼 간단합니다.

from app.catalog import connection

db = connection()

query = """
SELECT
    customer_id,
    SUM(total_amount) AS revenue
FROM iceberg_scan(
    'warehouse/sales/metadata/v1.metadata.json'
)
GROUP BY customer_id
ORDER BY revenue DESC
LIMIT 20
"""

result = db.execute(query).fetch_df()

print(result)

 

처음 보면 iceberg_scan()은 또 하나의 파일 읽기 함수처럼 보일 수 있지만 실제로는 그렇지 않습니다.

여기서 조회하는 대상은 단순한 Parquet 파일들의 집합이 아니라, 버전 관리되는 Iceberg 테이블입니다.

쿼리를 실행하면 DuckDB는 내부적으로 메타데이터를 분석하여 어떤 스냅샷을 읽어야 하는지 결정하고, 매니페스트(Manifest)를 해석하며, 필요한 데이터 파일만 선별하는 파일 프루닝(File Pruning)까지 자동으로 수행한 뒤 쿼리를 실행합니다.

즉, 개발자는 일반 SQL만 작성하면 되고, Iceberg 테이블을 구성하는 메타데이터와 스냅샷 관리, 파일 선택 과정은 DuckDB가 모두 처리해 줍니다.

프로덕션 쿼리에는 프로덕션다운 구조가 필요합니다

테이블을 읽는 것은 쉽지만 유지보수하기 좋은 분석 계층을 만드는 일은 훨씬 어렵습니다.

SQL 파일 하나는 금방 스무 개가 되고, 스무 개는 어느새 수백 개가 됩니다.

시간이 조금만 지나도, 결국 많은 회사가 후회하는 “정체를 알 수 없는 데이터 웨어하우스”를 다시 만들게 됩니다.

그래서 API를 만들 때처럼 SQL도 Repository 뒤에 분리해 두는 것이 좋습니다.

# app/repository.py

from pathlib import Path

from app.catalog import connection


class AnalyticsRepository:

    def __init__(self):

        self.db = connection()

    def execute_sql_file(self, filename: str):

        sql = Path("sql") / filename

        query = sql.read_text()

        return self.db.execute(query).fetch_df()

 

이제 각각의 분석 리포트는 독립적인 SQL 모듈로 관리할 수 있습니다.

-- sql/revenue.sql

SELECT

    order_date,

    SUM(total_amount) AS revenue,

    COUNT(*) AS orders

FROM iceberg_scan(
    'warehouse/sales/metadata/v1.metadata.json'
)

GROUP BY order_date

ORDER BY order_date;

 

사용 방법도 깔끔합니다.

repo = AnalyticsRepository()

daily_revenue = repo.execute_sql_file("revenue.sql")

print(daily_revenue.head())

 

Repository 패턴을 쓰는 이유는 단순히 추상화를 위한 추상화를 하려는 것이 아닙니다.

분석 코드를 테스트 가능하고, 관리 가능한 구조로 만들기 위한 것입니다.

DataFrame을 계속 복사하지 마세요

거의 모든 분석 코드베이스에서 반복적으로 보이는 안티패턴이 하나 있습니다.

df = db.execute(sql).fetch_df()
df = transform(df)
df = transform_again(df)
df = another_transform(df)
df = export(df)

 

이 방식은 단계마다 메모리를 복사합니다.

단계가 늘어날수록 임시 객체도 계속 만들어집니다.

대신 DuckDB가 잘하는 일을 DuckDB에 맡기는 편이 좋습니다.

WITH customer_revenue AS (
    SELECT
    customer_id,
    SUM(total_amount) revenue
    FROM iceberg_scan('warehouse/sales/metadata/v1.metadata.json')
    GROUP BY customer_id
    ),
top_customers AS (
    SELECT *
    FROM customer_revenue
    WHERE revenue > 10000
    )
SELECT *
FROM top_customers
ORDER BY revenue DESC;

 

계산을 SQL 안에서 처리하면 메모리 사용량을 예측하기 쉬워지고, DuckDB의 옵티마이저가 필터 푸시다운, 매니페스트 프루닝, 병렬 실행 같은 최적화를 적용할 수 있습니다.

분석을 위한 서비스 계층 만들기

프로젝트가 커지면 대시보드와 API가 동일한 데이터를 여러 곳에서 요청하기 시작합니다.

이때 SQL을 여기저기 복사해서 사용하는 것은 좋은 방법이 아닙니다.

대신 서비스 계층(Service Layer) 을 만들어 분석 로직을 한곳에서 관리하는 것이 좋습니다.

# app/services.py

from app.repository import AnalyticsRepository
class RevenueService:
    def __init__(self):
        self.repo = AnalyticsRepository()
    def top_customers(self):
        return self.repo.execute_sql_file(
            "top_customers.sql"
        )
    def monthly_growth(self):
        return self.repo.execute_sql_file(
            "growth.sql"
        )

이렇게 구성하면 FastAPI 엔드포인트는 놀랄 만큼 단순해집니다.

그리고 바로 그것이 우리가 원하는 모습입니다.

from fastapi import FastAPI
from app.services import RevenueService

app = FastAPI()
service = RevenueService()

@app.get("/analytics/top-customers")
def customers():
    return service.top_customers().to_dict(
        orient="records"
    )

 

좋은 분석 API는 특별해 보일 필요가 없습니다.

다른 백엔드 서비스와 마찬가지로 구조가 명확하고, 유지보수가 쉬우며, 필요한 기능만 간결하게 제공하면 충분합니다.

증분 데이터 처리하기

대부분의 데이터셋은 한 번에 모두 들어오지 않습니다.

몇 분마다 들어오기도 하고,

한 시간 단위로 쌓이기도 합니다.

따라서 처음부터 증분 적재를 고려해 설계하는 것이 좋습니다.

from pathlib import Path
import duckdb

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

for parquet in Path("incoming").glob("*.parquet"):
    db.execute("""
    INSERT INTO bronze_orders
    SELECT *
    FROM read_parquet(?)
    """, [str(parquet)])

데이터가 bronze 계층에 적재되면, 원본 파일을 직접 수정하지 말고 변환 과정을 거쳐 다음 계층으로 승격시키는 방식이 좋습니다.

CREATE TABLE silver_orders AS
SELECT
order_id,
customer_id,
CAST(order_time AS DATE) order_date,
total_amount
FROM bronze_orders
WHERE status='completed';

계층을 나누어 생각해야 합니다.

Raw.

Validated.

Business-ready.

이렇게 구조화해 두면 몇 년이 지난 뒤에도 SQL의 흐름을 쉽게 이해할 수 있습니다.

관측 가능성은 화려한 SQL보다 중요합니다

분석 파이프라인은 조용히 실패하는 경우가 많습니다.

그래서 더 위험합니다.

프로덕션에서 실행되는 모든 작업은 구조화된 로그를 남겨야 합니다.

import logging

logger = logging.getLogger("analytics")

logger.info(
    "running_revenue_job",
    extra={
        "dataset": "orders",
        "snapshot": snapshot_id,
        "rows": row_count
    }
)

여기에 상태 점검용 엔드포인트까지 제공하면 더 좋습니다.

@app.get("/health")
def health():
    return {
        "status": "healthy",
        "database": "duckdb",
        "warehouse": "available"
    }

이처럼 작은 장치들이 실제 장애 상황에서는 몇 시간의 디버깅 시간을 줄여 줍니다.

배치 작업이 실시간 처리보다 더 적합한 경우가 생각보다 많습니다

모든 것을 실시간으로 처리하고 싶은 유혹에 빠지기 쉽습니다.

Kafka를 도입하고,

Consumer를 만들고,

Materialized View를 구성하고,

데이터를 계속 새로 고칩니다.

물론 이런 구성이 꼭 필요한 경우도 있습니다.

하지만 대부분의 비즈니스 보고서는 매초마다 열어보는 것이 아닙니다.

오히려 한 시간에 한 번만 갱신해도 충분한 경우가 훨씬 많습니다.

from apscheduler.schedulers.blocking import BlockingScheduler
from app.services import RevenueService

scheduler = BlockingScheduler()
service = RevenueService()

@scheduler.scheduled_job("cron", minute=0)
def refresh():
    service.monthly_growth()

scheduler.start()

항상 최신인 시스템보다 충분히 최신이면서 단순한 시스템이 더 좋은 선택인 경우가 많습니다.

실제로 성능을 높여 주는 최적화 방법

많은 엔지니어는 성능 문제가 생기면 가장 먼저 인덱스를 떠올립니다.

하지만 DuckDB는 PostgreSQL이 아닙니다.

DuckDB의 성능은 인덱스보다 데이터 저장 방식(Layout) 에서 결정됩니다.

다음과 같은 원칙을 지키는 것이 훨씬 효과적입니다.

  • 가능하면 Parquet 형식을 사용합니다.
  • 파티셔닝은 효과가 충분히 있는 경우에만 적용합니다.
  • 너무 작은 파일을 많이 만들지 않습니다.
  • 필터 조건은 SQL 안에서 처리합니다.
  • 필요한 컬럼만 조회합니다.

좋지 않은 예는 다음과 같습니다.

SELECT *
FROM iceberg_scan(...);

다음과 같이 필요한 데이터만 조회하는 것이 훨씬 효율적입니다.

SELECT
customer_id, total_amount
FROM iceberg_scan(...)
WHERE order_date >= DATE '2026-01-01';

이처럼 조건을 SQL에 포함하면 DuckDB는 Iceberg의 메타데이터를 활용해 필요한 데이터만 읽습니다. 내부적으로 메타데이터와 매니페스트(Manifest)를 분석해 불필요한 파일을 건너뛰고(Manifest Pruning), 필요한 컬럼만 읽는 컬럼 기반(Columnar) 스캔을 수행합니다. 그 결과 읽어야 하는 파일과 컬럼의 수가 크게 줄어들어 쿼리 성능을 효과적으로 높일 수 있습니다.

작은 캐시 하나가 큰 차이를 만듭니다

반복해서 실행되는 분석 쿼리는 동일한 스냅샷을 여러 번 조회하는 경우가 많습니다.

이럴 때마다 메타데이터를 다시 내려받는 것은 비효율적입니다.

필요한 결과만 적절하게 캐시하면 성능을 크게 향상시킬 수 있습니다.

from functools import lru_cache

@lru_cache(maxsize=32)
def revenue():
    repo = AnalyticsRepository()
    return repo.execute_sql_file(
        "revenue.sql"
    )

더 큰 규모의 환경에서는 Redis를 사용해 직렬화된 결과를 캐시하는 것도 좋은 방법입니다.

DuckDB는 분석 쿼리를 수행하는 데 집중하고,

Redis는 반복되는 요청에 빠르게 응답하는 역할을 맡습니다.

각 도구가 자신이 가장 잘하는 일을 담당하도록 역할을 분리하는 것이 중요합니다.

자주 발생하는 실수

1. DuckDB를 PostgreSQL처럼 사용하는 것

DuckDB는 분석 엔진입니다.

트랜잭션을 처리하는 운영 데이터베이스가 아닙니다.

2. Parquet 파일을 직접 수정하는 것

Iceberg는 바로 이러한 문제를 해결하기 위해 만들어졌습니다.

원본 Parquet 파일을 직접 수정하는 대신 메타데이터와 스냅샷을 관리해야 합니다.

3. 너무 많은 작은 파일을 만드는 것

수천 개의 작은 파일은 스캔 성능을 크게 떨어뜨립니다.

주기적으로 파일을 병합(Compaction)하여 적절한 크기를 유지하는 것이 좋습니다.

4. 모든 데이터를 먼저 Pandas로 가져오는 것

SQL로 처리할 수 있는 작업이라면 굳이 Pandas로 가져올 필요가 없습니다.

가능한 한 DuckDB에서 SQL로 처리하고, 필요한 결과만 가져오는 것이 훨씬 효율적입니다.

5. 스냅샷을 활용하지 않는 것

Time Travel은 단순히 흥미로운 기능이 아닙니다.

Iceberg가 제공하는 가장 강력한 운영상의 장점 가운데 하나입니다.

DuckDB는 Iceberg와 연동하여 스냅샷을 기준으로 데이터를 조회할 수 있으며, 트랜잭션 일관성을 유지한 상태에서 데이터를 읽을 수 있습니다. 이러한 기능은 분석 결과를 재현하거나, 장애를 분석하고 원인을 추적하는 과정에서 특히 큰 가치를 발휘합니다.

이 스택이 적합하지 않은 경우

DuckDB와 Iceberg의 조합은 매우 강력합니다.

하지만 모든 문제를 해결하는 만능 해법은 아닙니다.

다음과 같은 환경이라면 다른 기술을 선택하는 것이 더 적합합니다.

  • 수백만 건의 동시 OLTP 트랜잭션을 처리해야 하는 경우
  • 매우 낮은 지연 시간이 요구되는 운영 데이터베이스가 필요한 경우
  • 초당 수많은 이벤트를 실시간으로 처리해야 하는 경우
  • 지속적으로 스트림 데이터를 조인해야 하는 경우
  • 수백 대의 컴퓨팅 노드에서 동시에 데이터를 기록해야 하는 경우

이러한 요구 사항은 트랜잭션 데이터베이스나 분산 데이터 처리 엔진이 훨씬 더 잘 해결할 수 있습니다.

좋은 아키텍처란 가장 많은 기술을 사용하는 아키텍처가 아닙니다.

현재의 문제를 해결하면서도 미래의 확장까지 고려할 수 있는, 가장 단순한 아키텍처가 가장 좋은 아키텍처입니다.

 

댓글