https://product.kyobobook.co.kr/detail/S000220221456
LUVIT EPL과 유튜브 데이터로 배우는 DuckDB | 이기준 - 교보문고
LUVIT EPL과 유튜브 데이터로 배우는 DuckDB | 복잡한 데이터 분석 흐름을 더 단순하게 만드는 DuckDB 최근 주목받고 있는 DuckDB를 활용해 SQL 기반 데이터 분석과 실전 프로젝트를 학습할 수 있도록 구
product.kyobobook.co.kr
데이터베이스용 테스트 데이터를 생성하는 것은 실제 운영 데이터를 사용하기 전에 애플리케이션이 현실적인 규모와 다양한 형태의 데이터에서 어떻게 동작하는지 검증하고자 할 때 유용합니다.
안타깝게도 많은 기업들이 애플리케이션 성능 테스트를 위해 운영 데이터를 사용합니다. 이는 특히 유럽에서는 수많은 법규를 위반할 수 있기 때문에, 이러한 경우에는 테스트 데이터를 생성하는 것이 훨씬 더 바람직한 방법입니다.
그렇다면 Pandas, Polars, DuckDB 중 무엇을 사용해야 할까요?
노트북에서 100만 개의 레코드를 생성하는 것은 쉽습니다. 1천만 개도 어떻게든 가능합니다. 하지만 1억 개가 되면 자신의 인생 선택에 대해 다시 생각하게 될지도 모릅니다.
환경
이 모든 테스트는 Intel(R) N150 CPU 4개와 RAM 16GB를 갖춘 미니 PC에서 실행되었습니다. 이 장비는 제 표준 워크스테이션이기도 해서, 백그라운드 프로세스도 여러 개 실행 중입니다. 따라서 대부분의 사람들이 사용하는 현실적인 환경에 가깝습니다. 결과는 여러분의 환경에 따라 달라질 수 있습니다.
여러분의 환경에서도 실행해볼 수 있도록 제가 사용한 모든 스크립트를 포함했습니다. 저는 Python 3.12.3과 가상 환경 관리를 위해 uv를 사용하고 있으며, 모든 스크립트는 그 환경에서 실행됩니다. 아직 uv를 사용하지 않는다면 한번 알아보는 것을 추천합니다. 나중에 고마워하게 될 것입니다.
CSV 100만 줄 생성하기
세 스크립트 모두에서 row_count = 1_000_000이라는 줄을 볼 수 있습니다. 작업 환경의 성능을 제대로 시험해보고 싶다면 이를 1천만, 심지어 1억으로 늘릴 수도 있습니다. 오늘날 AI 생성 데이터의 세계에서는 1억 개의 데이터셋도 낮은 수준일 수 있습니다. 100만 개 데이터셋으로 테스트하면 아마 빠르게 끝날 것입니다. 하지만 1억 또는 10억 개 수준에서는 어디를 튜닝해야 하는지 확인할 수 있습니다.
Generate_data_pandas.py
from pathlib import Path
import threading # 백그라운드 메모리 추적을 위해 추가
import time
import numpy as np
import pandas as pd
import psutil # 반드시 실행하세요: uv pip install psutil
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
row_count = 1_000_000
rng = np.random.default_rng(42)
# - - 백그라운드 메모리 모니터 로직 - -
peak_memory = 0
monitor_active = True
def monitor_memory():
"""프로세스 메모리를 지속적으로 추적하여 최고 사용량 급증을 포착합니다."""
global peak_memory
process = psutil.Process()
while monitor_active:
try:
current_mem = process.memory_info().rss # RSS = 사용 중인 물리 RAM
if current_mem > peak_memory:
peak_memory = current_mem
except Exception:
break
time.sleep(0.01) # 10ms마다 샘플링
# 메모리 추적 스레드 시작
mem_thread = threading.Thread(target=monitor_memory, daemon=True)
mem_thread.start()
# - - 생성 시간 측정 시작 - -
start_gen = time.perf_counter()
df = pd.DataFrame(
{
"event_date": rng.choice(
pd.date_range("2024–01–01", "2026–01–31", freq="D"),
size=row_count,
),
"country": rng.choice(["US", "UK", "DE", "FR", "IN", "JP"], size=row_count),
"channel": rng.choice(["search", "social", "email", "direct"], size=row_count),
"user_id": rng.integers(1, 200_000, size=row_count),
"order_id": rng.integers(1, 900_000, size=row_count),
"revenue": rng.gamma(shape=2.0, scale=30.0, size=row_count).round(2),
}
)
# 15%를 0으로 만드는 단계도 생성 시간에 포함합니다
df.loc[rng.random(row_count) < 0.15, "revenue"] = 0
end_gen = time.perf_counter()
# - - 생성 시간 측정 종료 - -
# - - 쓰기 시간 측정 시작 - -
start_write = time.perf_counter()
df.to_csv(DATA_DIR / "events.csv", index=False)
end_write = time.perf_counter()
# - - 쓰기 시간 측정 종료 - -
# 메모리 추적 스레드를 안전하게 중지합니다
monitor_active = False
mem_thread.join()
# - - 결과 출력 - -
gen_duration = end_gen - start_gen
write_duration = end_write - start_write
total_duration = gen_duration + write_duration
peak_mb = peak_memory / (1024 * 1024) # 바이트를 메가바이트로 변환
print("=" * 40)
print(f"Pandas Performance Metrics ({row_count:,} rows):")
print("=" * 40)
print(f"Data Generation Time: {gen_duration:.4f} seconds")
print(f"CSV Writing Time: {write_duration:.4f} seconds")
print(f"Total Elapsed Time: {total_duration:.4f} seconds")
print(f"Peak Memory Usage: {peak_mb:.2f} MB")
print("=" * 40)
결과
uv run generate_data_pandas.py
========================================
Pandas Performance Metrics (1,000,000 rows):
========================================
Data Generation Time: 0.2268 seconds
CSV Writing Time: 2.5279 seconds
Total Elapsed Time: 2.7548 seconds
Peak Memory Usage: 260.32 MB
========================================
generate_data_polars.py
from pathlib import Path
import threading # 백그라운드 메모리 추적을 위해 추가
import time
import numpy as np
import polars as pl
import psutil # 반드시 실행하세요: uv pip install psutil
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
row_count = 1_000_000
rng = np.random.default_rng(42)
# - - 백그라운드 메모리 모니터 로직 - -
peak_memory = 0
monitor_active = True
def monitor_memory():
"""프로세스 메모리를 지속적으로 추적하여 최고 사용량 급증을 포착합니다."""
global peak_memory
process = psutil.Process()
while monitor_active:
try:
current_mem = process.memory_info().rss # RSS = 사용 중인 물리 RAM
if current_mem > peak_memory:
peak_memory = current_mem
except Exception:
break
time.sleep(0.01) # 10ms마다 샘플링
# 메모리 추적 스레드 시작
mem_thread = threading.Thread(target=monitor_memory, daemon=True)
mem_thread.start()
# - - 생성 시간 측정 시작 - -
start_gen = time.perf_counter()
event_dates = rng.choice(
pl.date_range(
start=pl.datetime(2024, 1, 1),
end=pl.datetime(2026, 1, 31),
interval="1d",
eager=True,
),
size=row_count,
)
countries = rng.choice(["US", "UK", "DE", "FR", "IN", "JP"], size=row_count)
channels = rng.choice(["search", "social", "email", "direct"], size=row_count)
user_ids = rng.integers(1, 200_000, size=row_count)
order_ids = rng.integers(1, 900_000, size=row_count)
revenue = rng.gamma(shape=2.0, scale=30.0, size=row_count).round(2)
revenue[rng.random(row_count) < 0.15] = 0.0
df = pl.DataFrame(
{
"event_date": event_dates,
"country": countries,
"channel": channels,
"user_id": user_ids,
"order_id": order_ids,
"revenue": revenue,
}
)
end_gen = time.perf_counter()
# - - 생성 시간 측정 종료 - -
# - - 쓰기 시간 측정 시작 - -
start_write = time.perf_counter()
df.write_csv(DATA_DIR / "events.csv")
end_write = time.perf_counter()
# - - 쓰기 시간 측정 종료 - -
# 메모리 추적 스레드를 안전하게 중지합니다
monitor_active = False
mem_thread.join()
# - - 결과 출력 - -
gen_duration = end_gen - start_gen
write_duration = end_write - start_write
total_duration = gen_duration + write_duration
peak_mb = peak_memory / (1024 * 1024) # 바이트를 메가바이트로 변환
print("=" * 40)
print(f"Polars Performance Metrics ({row_count:,} rows):")
print("=" * 40)
print(f"Data Generation Time: {gen_duration:.4f} seconds")
print(f"CSV Writing Time: {write_duration:.4f} seconds")
print(f"Total Elapsed Time: {total_duration:.4f} seconds")
print(f"Peak Memory Usage: {peak_mb:.2f} MB")
print("=" * 40)
결과
uv run generate_data_polars.py
========================================
Polars Performance Metrics (1,000,000 rows):
========================================
Data Generation Time: 0.5849 seconds
CSV Writing Time: 0.2005 seconds
Total Elapsed Time: 0.7854 seconds
Peak Memory Usage: 182.00 MB
========================================
generate_data_duckdb.py
from pathlib import Path
import threading # 백그라운드 메모리 추적을 위해 추가
import time
import duckdb
import psutil # 반드시 실행하세요: uv pip install psutil
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
row_count = 1_000_000
csv_path = DATA_DIR / "events.csv"
# 인메모리 DuckDB 데이터베이스에 연결합니다
con = duckdb.connect()
# - - 백그라운드 메모리 모니터 로직 - -
peak_memory = 0
monitor_active = True
def monitor_memory():
"""프로세스 메모리를 지속적으로 추적하여 최고 사용량 급증을 포착합니다."""
global peak_memory
process = psutil.Process()
while monitor_active:
try:
current_mem = process.memory_info().rss # RSS = 사용 중인 물리 RAM
if current_mem > peak_memory:
peak_memory = current_mem
except Exception:
break
time.sleep(0.01) # 10ms마다 샘플링
# 메모리 추적 스레드 시작
mem_thread = threading.Thread(target=monitor_memory, daemon=True)
mem_thread.start()
# - - 시간 측정 시작 - -
start_time = time.perf_counter()
# 단일 SQL 쿼리를 사용해 데이터를 생성하고, 변환하고, 파일로 씁니다
con.execute(
f"""
COPY (
SELECT
-- 1. 2024-01-01부터 2026-01-31 사이의 임의 날짜 생성
'2024-01-01'::DATE + CAST(floor(random() * (DATE '2026-01-31' - DATE '2024-01-01' + 1)) AS INTEGER) AS event_date,
-- 2. 목록에서 국가를 무작위로 선택
(['US', 'UK', 'DE', 'FR', 'IN', 'JP'])[CAST(floor(random() * 6) + 1 AS INTEGER)] AS country,
-- 3. 마케팅 채널을 무작위로 선택
(['search', 'social', 'email', 'direct'])[CAST(floor(random() * 4) + 1 AS INTEGER)] AS channel,
-- 4. 1부터 200,000 사이의 임의 user_id 생성
CAST(floor(random() * 200000) + 1 AS INTEGER) AS user_id,
-- 5. 1부터 900,000 사이의 임의 order_id 생성
CAST(floor(random() * 900000) + 1 AS INTEGER) AS order_id,
-- 6. 15% 확률로 0이 되는 Gamma와 유사한 비대칭 revenue 생성
CASE
WHEN random() < 0.15 THEN 0.0
ELSE round(
-- 균등 난수를 사용해 Gamma shape=2 분포를 흉내 내는 간단한 수학적 방법
(-log(random()) - log(random())) * 30.0, 2
)
END AS revenue
FROM generate_series(1, {row_count})
) TO '{csv_path}' (HEADER, DELIMITER ',');
"""
)
end_time = time.perf_counter()
# - - 시간 측정 종료 - -
# 메모리 추적 스레드를 안전하게 중지합니다
monitor_active = False
mem_thread.join()
# - - 결과 출력 - -
total_duration = end_time - start_time
peak_mb = peak_memory / (1024 * 1024) # 바이트를 메가바이트로 변환
print("=" * 40)
print(f"DuckDB Performance Metrics ({row_count:,} rows):")
print("=" * 40)
print(f"Total Generation + CSV Write Time: {total_duration:.4f} seconds")
print(f"Peak Memory Usage: {peak_mb:.2f} MB")
print("=" * 40)
결과
uv run generate_data_duckdb.py
========================================
DuckDB Performance Metrics (1,000,000 rows):
========================================
Total Generation + CSV Write Time: 0.8155 seconds
Peak Memory Usage: 57.05 MB
========================================
100만 개 데이터셋 비교
100만 개 데이터셋 정도는 제 워크스테이션에 별다른 부담을 주지 않습니다. 실제 사용자의 관점에서도 체감할 만한 차이는 거의 없습니다.
CSV 1억 줄 생성하기
row_count를 1억으로 변경하면 다음과 같은 결과가 나옵니다.
결과
uv run generate_data_duckdb.py
========================================
DuckDB Performance Metrics (100,000,000 rows):
========================================
Total Generation + CSV Write Time: 66.4492 seconds
Peak Memory Usage: 54.23 MB
========================================
uv run generate_data_polars.py
========================================
Polars Performance Metrics (100,000,000 rows):
========================================
Data Generation Time: 70.3434 seconds
CSV Writing Time: 15.3939 seconds
Total Elapsed Time: 85.7373 seconds
Peak Memory Usage: 9569.08 MB
========================================
uv run generate_data_pandas.py
Crashed
1억 개 데이터셋
Pandas: 왜 충돌했을까?
Pandas 스크립트는 OOM(Out of Memory) 오류가 발생했고, Linux 커널이 프로세스를 강제로 종료했습니다.
문제점은 Pandas가 철저한 인메모리(in-memory) 기반의 즉시 실행(eager evaluation) 프레임워크라는 점입니다. 1억 행짜리 DataFrame을 생성하려면 모든 NumPy 배열에 대한 메모리를 동시에 할당해야 하며, 여기에 DataFrame의 내부 구조를 위한 메모리까지 추가로 필요합니다.
계산해보면 6개 컬럼으로 구성된 1억 행 데이터는 CSV로 가공하는 과정에서 대략 20~30GB의 연속적인 RAM을 요구합니다. 특히 국가(country)와 채널(channel) 같은 문자열 데이터는 Pandas에서 Python 객체 포인터로 처리되기 때문에 매우 비효율적입니다. 제 워크스테이션에는 이 정도 메모리가 없었습니다.
Polars: 인메모리 속도의 한계 (~9.6GB RAM)
Polars는 매우 준수한 85.7초 만에 작업을 완료했습니다. 하지만 메모리 사용량을 보면 9,569.08MB, 즉 거의 10GB에 달합니다.
Polars가 살아남은 이유는 Apache Arrow를 사용하기 때문입니다. Arrow는 데이터를 압축된 연속 메모리 구조에 저장하며, 문자열도 무거운 Python 객체 포인터 대신 오프셋(offset) 기반으로 처리합니다. 따라서 Pandas보다 훨씬 효율적입니다.
하지만 이번 스크립트는 Polars의 장점을 제대로 활용하지 못했습니다. 1억 행 규모의 NumPy 배열을 먼저 생성한 뒤 이를 pl.DataFrame으로 감싸는 방식이기 때문에, CSV로 기록하기 전까지 모든 데이터를 RAM에 유지해야 했습니다.
만약 8GB RAM을 가진 일반 노트북에서 동일한 테스트를 수행했다면 Polars 역시 Pandas와 마찬가지로 실패했을 가능성이 높습니다.
DuckDB: 아키텍처의 교과서 (~54MB RAM)
DuckDB는 단순히 승리한 것이 아니라 데이터 처리 효율성에 대한 개념 자체를 바꾸어 놓았습니다. Polars보다 19초 더 빨랐으며, 메모리는 약 176배 적게 사용했습니다.
54MB의 마법은 어떻게 가능할까?
DuckDB는 벡터화 스트리밍 실행(Vectorized Streaming Execution)을 사용합니다. 1억 개의 행을 약 2,048행 단위의 작은 실행 블록(morsel)으로 나누어 처리합니다.
- CPU 레지스터를 사용하여 2,048행 생성
- 생성된 2,048행을 CSV 텍스트로 변환
- 즉시 events.csv 파일로 디스크에 기록
- 해당 메모리 블록을 완전히 해제
- 다음 2,048행 처리
이 과정을 반복하기 때문에 전체 데이터를 메모리에 올릴 필요가 없습니다.
O(1) 메모리 사용량
흥미로운 점은 DuckDB가 데이터 크기와 무관하게 거의 동일한 메모리를 사용했다는 것입니다.
- 100만 행: 약 57MB
- 1천만 행: 약 57MB
- 1억 행: 약 54MB
즉 메모리 사용량이 데이터 크기와 분리(decoupled)되어 있습니다. 10억 행으로 확장하더라도 약 54MB 수준의 메모리만 사용할 수 있습니다.
왜 더 빨랐을까?
DuckDB는 모든 작업을 C++ 엔진 내부에서 직접 수행합니다. Python, NumPy, DataFrame 계층을 오가며 데이터를 복사하거나 변환할 필요가 없습니다.
CPU 명령어에서 시작하여 디스크 기록으로 이어지는 하나의 연속된 파이프라인으로 처리되기 때문에 불필요한 오버헤드가 제거됩니다.
결론
테스트 데이터 생성은 결국 규모의 문제입니다. 100만 건 정도라면 Pandas, Polars, DuckDB 중 무엇을 사용하든 큰 차이가 없습니다. 하지만 데이터 규모가 커질수록 어디에서, 얼마나 많은 메모리를 사용할 것인지가 중요해집니다.
1억 행 테스트는 현대 데이터 엔지니어링이 왜 전통적인 인메모리 도구에서 벗어나고 있는지를 잘 보여줍니다.
- Pandas는 일반적인 하드웨어 환경에서 진정한 엔터프라이즈 규모의 데이터를 처리하기에는 구조적인 한계가 있습니다.
- Polars는 멀티스레드 기반의 매우 빠른 엔진이지만, 기본적으로 인메모리 처리 방식이기 때문에 데이터가 커질수록 RAM도 함께 늘려야 합니다.
- DuckDB는 대규모 데이터 생성, ETL 파이프라인, 디스크 기반 쓰기 작업에 매우 적합합니다. Out-of-Memory 오류 없이 거대한 데이터셋을 처리할 수 있어 제한된 인프라 환경에서도 안정적으로 동작합니다.
'EPL과 유튜브 데이터로 배우는 DuckDB' 카테고리의 다른 글
| DuckDB vs PostgreSQL: 아무도 예상하지 못했던 분석 혁명 (0) | 2026.06.27 |
|---|---|
| DuckDB : 왜 모든 데이터 엔지니어가 갑자기 DuckDB를 이야기하는가? (0) | 2026.06.25 |
| DuckDB: 2026년 데이터 분석가를 위한 가장 과소 평가된 도구 (0) | 2026.06.21 |
| DuckDB + Python: SQL로 CSV 파일 다루기 part 1 (0) | 2026.06.20 |
| DuckDB + Python Part 2: SQL로 Parquet 파일과 다수의 CSV 파일 다루기 (0) | 2026.06.20 |
댓글