한눈에 보기
- 문제: NAS의 Parquet 수백만 행을 조회할 엔진이 필요했다. 그리고 DuckDB 파일에 데이터를 넣었다가 동시 접근 락에 걸렸다.
- 원인: DuckDB 파일은 단일 writer 전제라, 노트북이 파일을 잡고 있으면 다른 프로세스가
database is locked로 죽는다. - 선택: “정본은 MinIO Parquet, DuckDB는 읽기 전용 VIEW”라는 역할 원칙을 세웠다. Spark는 규모 미달로 기각했다.
- 결과: 백테스팅·노트북·파이프라인이 서로를 막지 않게 됐다. 조회 UI는 duckdb-ui를 버리고 Jupyter로 정착했다.
- 핵심 교훈: 도구의 역할을 한 문장으로 정의하고 지키는 것이 성능 튜닝보다 먼저다.
Spark부터 검토했지만
“주식 전 종목 데이터”라고 하면 반사적으로 Spark를 떠올리기 쉽다. 실제로 이 프로젝트의 전신이 되는 코드에는 Spark 기반 지표 계산기가 있었다. 하지만 규모를 실제로 재보면 얘기가 다르다.
당시 bronze는 약 930만 행이었다. 이 규모에서 Spark는 얻는 것보다 내는 비용이 크다. 클러스터든 로컬이든 JVM 기동과 분산 조율 오버헤드가 있고, 단일 머신에서는 그 오버헤드(2~3GB 메모리)를 내고도 내부적으로는 결국 pandas와 같은 연산을 한다. s3a 커넥터 설정 뭉치도 따라온다. DuckDB는 프로세스 안에서 바로 뜨고, MinIO의 Parquet을 httpfs로 직접 읽고, 이 규모의 집계를 수 초 안에 끝냈다.
Spark가 정당해지는 건 수십억 행부터라고 판단했고, “클러스터를 보유하게 되거나 데이터가 10배로 늘면 재검토”라는 조건을 문서에 남기고 기각했다. 이 판단은 이후에도 두 번 더 시험대에 오르는데(분봉 도입 검토 때), 그 이야기는 실시간은 관심종목만 - Spark 없는 람다 아키텍처에 있다.
락 사고, 그리고 역할 원칙
DuckDB를 쓰면서 한 가지를 잘못 짚었다. 초기에 일부 데이터를 DuckDB 파일(.duckdb)에 직접 넣어 운영했는데, 어느 날 백테스팅 스크립트가 이렇게 죽었다.
TransactionException: database is locked
원인은 단순했다. Jupyter 노트북 세션이 그 파일을 잡고 있었다. DuckDB 파일은 임베디드 DB라 동시 다중 프로세스 접근을 전제하지 않는다. 노트북을 열어둔 채 스크립트를 돌리는, 개인 플랫폼에서 가장 흔한 사용 패턴이 정확히 락 충돌 시나리오였다.
사고 후에 원칙을 한 문장으로 박았다. “DuckDB는 조회 엔진이다. 데이터 저장소가 아니다.”
- 모든 데이터의 정본은 MinIO의 Parquet. 쓰기 주체는 Dagster 하나뿐이다.
- DuckDB 파일에는 데이터가 아니라 VIEW 정의만 넣는다. VIEW는 MinIO 경로의 별칭일 뿐이다.
- 백테스팅은 파일을 열지 않고
duckdb.connect()in-memory 인스턴스에 같은 VIEW 정의를 적용해 쓴다. 파일 락이 없으니 몇 개를 동시에 돌려도 서로를 막지 않는다.
hive_partitioning=true로 경로의 exchange=/symbol=이 자동으로 컬럼이 되고, union_by_name=true가 거래소별 스키마 차이(KRX는 원화 정수 BIGINT, NASDAQ은 yfinance의 DOUBLE)를 흡수한다. 새 데이터가 쌓여도 glob 패턴이 자동으로 인식하므로 VIEW를 다시 만들 필요도 없다.
duckdb-ui를 버리고 Jupyter로
조회 UI로는 처음에 duckdb-ui를 썼는데, 예상 밖의 제약으로 제거했다. duckdb-ui는 인증 구조(auth0의 secure-origin 요구) 때문에 localhost에서만 열린다. 데스크톱 앞에 앉아 있을 때만 쓸 수 있는 조회 도구는 반쪽짜리다.
대체는 Jupyter 상시 컨테이너였다. LAN 어디서든 브라우저로 접속하고, 컨테이너가 뜰 때 Vault에서 MinIO 자격증명을 받아 전용 DuckDB 파일(VIEW+시크릿만 담긴)을 초기화한다. 파이프라인의 DuckDB와 파일을 공유하지 않으므로 락 문제도 없다. “SQL 한 줄 쳐보고 싶을 때”의 마찰이 사라진 것이 체감상 가장 큰 개선이었다.
이 구조의 한계가 다음 단계를 불렀다
DuckDB 중심 조회 구조는 두 지점에서 한계를 드러냈다.
glob 스캔은 파일 수에 비례해 느려진다. 심볼 수천 개 × 연도별 파일 구조에서 파일이 8만 개까지 불어나자, VIEW 초기화와 대량 조회가 NAS의 LIST 한계에 부딪히기 시작했다. 이건 DuckDB의 잘못이 아니라 “매 쿼리마다 파일 목록을 나열하는” 방식 자체의 한계였다.
dbt의 산출물이 살아남지 못했다. dbt-duckdb를 :memory:로 돌리면 dbt run이 끝나는 순간 테이블이 사라진다. 파일 모드로 돌리면 위의 락 문제로 돌아간다. durable한 SQL 변환 산출물을 만들려면 다른 실행 엔진이 필요했다.
이 두 한계가 Trino + Iceberg 레이크하우스 도입으로 이어진다 — 사라지는 산출물 - dbt와 Trino Iceberg 레이크하우스. 다만 지금도 DuckDB는 Jupyter 조회 환경에서 현역이다. 역할 원칙(“조회 엔진”)을 지키는 한, 이만큼 마찰 없는 도구가 없다.
정리
| 결정 | 근거 | 감수한 것 |
|---|---|---|
| Spark 기각 | 수백만 행 규모에서 JVM/분산 오버헤드가 이득을 초과 | 데이터 10배 성장 시 재검토 부채 |
| DuckDB = 읽기 전용 | 파일 락 사고로 단일 writer 전제를 실측 | 쓰기 가능한 로컬 DB의 편의성 포기 |
| duckdb-ui → Jupyter | localhost 전용 제약으로 원격 조회 불가 | UI 전용 도구 대비 초기 설정 비용 |
기술 선택보다 오래 남은 것은 원칙이다. “이 도구의 역할은 무엇인가”를 한 문장으로 답하지 못하면, 그 도구는 언젠가 다른 역할까지 떠맡다가 사고를 낸다.