본문으로 건너뛰기
피드

덕디비는 왜 빠를까: 서버 없는 분석 엔진의 내부 구조 뜯어보기

backend 약 19분
vote
0
댓글
북마크

DuckDB가 단일 바이너리, 인프로세스 실행, 컬럼형 저장, 최적화 패스, Parquet 푸시다운으로 빠른 분석 쿼리를 처리하는 방식을 깊게 설명한 글이다. 6GB Parquet 파일을 노트북에서 바로 SQL로 읽는 경험 뒤에 어떤 설계가 깔려 있는지 따라간다.

  • 1

    DuckDB는 서버가 아니라 애플리케이션 안에 로드되는 인프로세스 분석 SQL 엔진이라 네트워크 직렬화 비용을 피한다.

  • 2

    SQL은 파싱, 바인딩, 최적화, 물리 계획 변환을 거치며 DuckDB에는 확인 가능한 최적화 패스가 33개 있다.

  • 3

    Parquet와 DuckDB 네이티브 포맷은 컬럼형 저장, row group, 통계 기반 pruning 덕분에 필요한 바이트만 읽을 수 있다.

  • 4

    CSV는 스키마와 통계가 없기 때문에 DuckDB가 dialect, 타입, 헤더를 추론하는 CSV sniffer를 먼저 돌린다.

DuckDB가 뜬 이유부터 꽤 현실적임

  • DuckDB는 2019년 암스테르담 CWI의 연구 프로젝트에서 출발해, 지난 10년 사이 가장 널리 쓰이는 데이터베이스 중 하나가 됐음

    • 노트북, ETL 파이프라인, 대시보드, CI 테스트 러너, SaaS 제품 안의 임베디드 분석 엔진까지 들어감
    • 심지어 아이폰에서 TPC-H scale factor 100을 돌리는 사례도 언급됨. 이쯤 되면 '장난감 DB'라고 부르기 민망함
  • 실제 제품도 DuckDB 위에 꽤 많이 올라가 있음

    • MotherDuck은 DuckDB를 클라우드 데이터 웨어하우스로 감싸고 있음
    • Hex, Omni, Evidence는 앱 안의 실행 엔진이나 캐시로 DuckDB를 사용함
    • Fivetran의 Managed Data Lake Service는 데이터 레이크 writer에서 병합과 compaction에 DuckDB를 씀
    • Rill은 DuckDB 기반 오픈소스 BI 도구를 만들고, 글쓴이 회사 Greybeam도 BI/분석 워크로드에서 수백만 쿼리를 DuckDB로 처리한다고 함
  • DuckDB의 정체성은 '인프로세스 분석 SQL 데이터베이스'임

    • 분석용이라는 건 단일 row를 primary key로 찾는 OLTP가 아니라, 수백만 row를 훑고 필터링하고 집계하고 조인하는 쿼리에 맞춰져 있다는 뜻임
    • 인프로세스라는 건 서버가 없다는 뜻임. 데몬도 포트도 클러스터도 없고, NumPy나 Polars처럼 라이브러리로 로드해서 씀
    • 바이너리는 20MB 미만이고 외부 의존성이 없어서 pip install duckdb, brew install duckdb, C++의 libduckdb 링크 정도로 끝남

중요

> 글의 질문은 이거임. 노트북에서 6GB Parquet 파일에 SELECT * FROM 'orders.parquet'를 날렸는데 1초 안에 결과가 돌아오는 이유가 대체 뭐냐는 것.

서버가 아니라 라이브러리라서 피하는 비용

  • 대부분의 분석 데이터베이스는 서버임

    • Snowflake, Postgres, BigQuery, Redshift는 클라이언트가 TCP로 SQL을 보내고 결과를 다시 받는 구조임
    • 결과는 wire protocol로 직렬화되고, 네트워크를 타고, 클라이언트에서 다시 역직렬화됨
    • 큰 결과셋에서는 이 인코딩/디코딩과 전송이 실제 쿼리 계산보다 오래 걸릴 수 있음
  • 2017년 논문 Don't Hold My Data Hostage가 이 병목을 꽤 노골적으로 보여줬음

    • ODBC, JDBC 같은 row-by-row API가 전체 쿼리 경로에서 가장 느린 단일 단계가 되는 경우가 있었다고 함
    • 기가비트 이더넷은 대략 125MB/s가 상한이라, 결과가 크면 계산보다 전송이 더 느려짐
    • 1억 row 결과를 row와 field 단위로 꺼내면 함수 호출이 수억 번 생기고, 각 호출마다 복사, 타입 체크, 문자열 할당 같은 자잘한 비용이 붙음
  • DuckDB는 같은 프로세스 안에 살기 때문에 이 비용을 정면으로 우회함

    • Python 스크립트에서 pandas 데이터프레임에 SQL을 날릴 때, DuckDB는 replacement scan으로 데이터프레임 참조를 실행 시점의 읽기 함수로 바꿀 수 있음
    • 최선의 경우 NumPy가 가진 int64 버퍼 같은 동일한 메모리 버퍼를 DuckDB가 직접 읽음. 이게 zero-copy 경로임
    • 다만 물리 레이아웃, 컬럼 타입, null 표현, 문자열 저장 방식이 맞아야 진짜 zero-copy가 되고, 안 맞으면 일부 컬럼은 변환 버퍼를 만들 수 있음
  • Arrow는 이 이야기의 가장 깔끔한 버전임

    • Arrow는 애초에 시스템 사이에서 공유하기 좋은 컬럼형 typed memory format임
    • DuckDB 결과를 Arrow로 받거나 Arrow-backed 데이터를 쿼리하면 전통적인 row-by-row 변환 비용을 많이 피할 수 있음

SQL이 들어오면 바로 실행되는 게 아님

  • DuckDB도 SQL을 받으면 파싱, 바인딩, 계획, 최적화 단계를 밟음

    • 파싱은 SQL 문자열을 추상 구문 트리(AST)로 바꾸는 단계임
    • DuckDB는 Postgres parser의 fork를 사용해서 문법이 친숙하게 느껴지는 면이 있음
    • AST가 있어야 binder가 컬럼을 해석하고, optimizer가 WHERE 조건을 scan 쪽으로 밀어넣고, planner가 실행 가능한 operator를 고를 수 있음
  • 바인딩은 이름과 타입을 현실 세계의 객체에 연결하는 단계임

    • lineitem은 특정 스키마를 가진 테이블이 되고, l_quantity는 특정 타입의 컬럼이 됨
    • sum은 해당 입력 타입에 맞는 aggregate function으로 해석됨
    • l_shipdate > '2024-01-01' 같은 비교에서는 문자열 literal이 날짜로 강제 변환될 수 있는지 타입 체크도 여기서 일어남
  • DuckDB optimizer는 작은 변환 패스들의 묶음이고, 직접 확인하거나 끌 수 있음

    • SELECT * FROM duckdb_optimizers();를 실행하면 33개 optimizer 이름이 나온다고 함
    • filter_pullup, filter_pushdown, join_order, row_group_pruner, top_n, late_materialization, cte_inlining 같은 패스들이 포함됨
    • SET disabled_optimizers = 'filter_pullup, join_order'처럼 특정 패스를 꺼서 차이도 볼 수 있음
  • 필터 푸시다운은 고전적이지만 여전히 강력함

    • WHERE 조건을 scan에 최대한 가깝게 옮겨서 가능한 한 빨리 데이터를 버림
    • 먼저 필터를 위로 끌어올려 합치고 재구성한 뒤, 다시 가능한 곳까지 아래로 밀어넣는 식임
    • 읽기 전에 버릴 수 있는 row group을 버리면 뒤의 조인과 집계 비용이 같이 줄어듦
  • correlated subquery도 그냥 row마다 실행하지 않도록 바꿈

    • 전통적으로 correlated subquery는 outer row마다 inner query를 돌릴 수 있어서 느림
    • DuckDB는 Unnesting Arbitrary Queries 논문 계열 기법으로 이런 쿼리를 join 형태로 재작성함
    • 같은 결과를 더 데이터베이스다운 방식으로 계산하게 만드는 셈임
  • 조인에서는 runtime filter도 씀

    • hash join의 build side를 먼저 읽은 뒤, 실제 join key의 min/max를 계산함
    • 그 범위를 probe side scan에 다시 밀어 넣어서 zone map으로 row group을 건너뛸 수 있음
    • build side의 distinct join key가 50개 미만이면 min/max 대신 IN list를 만들어 더 정밀하게 거름
  • 조인 순서 선택은 optimizer에서 가장 큰 결정 중 하나임

    • 6개 테이블을 조인하는 쿼리는 가능한 tree shape이 30,240개라고 함
    • 최선과 최악의 조인 순서는 중간 결과 크기 때문에 실행 시간이 자릿수 단위로 갈릴 수 있음
    • DuckDB는 테이블을 node, join predicate를 edge로 보는 graph를 만들고 DPhyp나 DPccp 같은 동적 계획법으로 좋은 순서를 찾음

논리 계획에서 물리 실행으로

  • optimizer가 만든 logical plan은 '무엇을 계산할지'만 말함

    • 예를 들면 events를 읽고, 날짜 필터를 걸고, customer_id별로 amount를 합산하고, total 내림차순으로 정렬한 뒤 top 10을 반환하라는 식임
    • 하지만 join을 hash join으로 할지, index join으로 할지, piecewise merge join으로 할지는 아직 정해지지 않음
    • DuckDB는 입력 모양과 predicate를 보고 각 node에 물리 operator를 고름
  • DuckDB 실행은 거대한 트리를 한 번에 걷는 방식이 아니라 pipeline으로 나뉨

    • pipeline은 조립 라인처럼 데이터를 한쪽에서 받아 여러 station을 통과시키는 구조임
    • 각 station이 row 또는 chunk를 보고 바로 다음으로 넘길 수 있으면 streaming으로 계속 흘러감
    • 여러 CPU core가 각자 입력 조각을 맡아 같은 pipeline을 병렬로 돌릴 수 있음
  • 모든 operator가 streaming으로 되는 건 아님

    • ORDER BY는 모든 row를 보기 전까지 첫 row를 확정할 수 없음
    • GROUP BY도 각 그룹의 최종 합계를 내려면 입력 전체를 봐야 함
    • 이런 operator를 pipeline breaker 또는 sink라고 부르고, 한 pipeline의 끝과 다음 pipeline의 시작을 만듦
  • sink는 sink, combine, finalize 세 단계로 동작함

    • 각 thread는 DuckDB의 2,048 row chunk를 받아 자기 local state에 씀
    • HASH_GROUP_BY면 thread-local hash table, ORDER_BY면 thread-local sorted run, HASH_JOIN build side면 thread-local hash table 같은 식임
    • 한 shared hash table에 모든 thread가 쓰면 lock 싸움이 나기 때문에 local state로 먼저 달리는 게 핵심임
    • 이후 partial result를 global state로 합치는데, DuckDB는 이 combine 단계도 모든 core를 쓰도록 설계함

저장 포맷도 속도의 절반임

  • DuckDB 네이티브 DB는 SQLite처럼 단일 파일임

    • 보통 .duckdb.db 파일 하나로 이동, 백업, 공유가 쉬움
    • 내부 데이터는 기본 256KB fixed-size block으로 쪼개지고, 설정에 따라 16KB까지 줄일 수 있음
    • 각 block에는 checksum이 있어 읽을 때 다시 계산해 비교하고, 값이 다르면 corruption error를 냄
  • 분석 워크로드라서 컬럼형 저장이 중요함

    • row store는 한 record의 모든 컬럼이 붙어 있어 SELECT * FROM users WHERE id = 42 같은 조회에 유리함
    • column store는 같은 컬럼 값들이 붙어 있어, 300개 컬럼 중 4개만 읽는 분석 쿼리에서 훨씬 효율적임
    • Snowflake, BigQuery, ClickHouse 같은 분석 엔진들이 컬럼형 저장을 쓰는 이유도 이쪽임
  • DuckDB는 column을 row group과 column segment로 나눔

    • 각 column은 최대 122,880 row의 row group으로 나뉨
    • row group 안에서는 column segment가 보통 하나의 256KB block에 매핑됨
    • row group은 병렬화 단위이기도 해서, 8 thread로 쿼리를 돌리려면 scope 안에 최소 8개 row group은 있어야 core를 놀리지 않음
  • zone map은 읽지 않아도 되는 row group을 버리는 장치임

    • 각 row group은 min, max, null count를 들고 있음
    • WHERE event_date > '2026-01-01' 조건이 있으면, row group의 max event_date가 그 날짜 이하인 경우 통째로 스킵함
    • Snowflake의 micro-partition pruning, BigQuery의 block pruning, ClickHouse의 minmax data skipping index와 비슷한 계열임
    • 정렬되거나 삽입 시간처럼 자연스럽게 증가하는 컬럼은 min/max 범위가 좁아 zone map 효율이 좋고, 랜덤하게 흩어진 컬럼은 효과가 떨어짐

Parquet는 DuckDB와 궁합이 좋고, CSV는 손이 많이 감

  • 실무에서는 DuckDB 테이블보다 Parquet 파일을 직접 읽는 경우가 많음

    • read_parquet('/my/nice/files/*.parquet', union_by_name=TRUE) 같은 식으로 여러 파일을 바로 SQL로 조회함
    • 또는 CREATE TABLE events AS SELECT * FROM read_parquet(...)로 DuckDB 테이블에 적재할 수도 있음
    • Parquet를 직접 조회해도 일부 쿼리가 DuckDB 네이티브 테이블만큼 빠른 이유는 설계 원리가 비슷해서임
  • DuckDB는 Parquet footer를 먼저 읽고 필요한 바이트만 가져옴

    • footer에서 schema와 row group statistics를 확인함
    • predicate를 만족할 수 없는 row group은 제외함
    • 살아남은 row group에서도 쿼리에 필요한 column chunk만 읽고 decompress해서 pipeline으로 넘김
    • 원격 파일이면 전체 파일을 다운로드하지 않고, footer와 필요한 byte range만 HTTP 요청으로 가져올 수 있음

💡

> S3 같은 원격 저장소 위에서 DuckDB와 Parquet를 같이 쓸 때 WHERE 조건이 row group pruning을 잘 타면, 네트워크로 가져오는 바이트 자체가 줄어듦. 단순 CPU 최적화가 아니라 비용과 지연시간을 같이 줄이는 포인트임.

  • CSV는 Parquet의 정반대임

    • schema, 통계, 압축된 column chunk가 없고 그냥 텍스트임
    • DuckDB는 delimiter, quote character, escape character, newline style 같은 dialect를 먼저 추론해야 함
    • 예를 들어 도시명에 쉼표가 들어간 파일이라면 comma가 아니라 |가 delimiter라는 걸 일관된 column 수로 판단할 수 있음
  • CSV sniffer는 타입과 헤더도 샘플 기반으로 추론함

    • 기본 샘플 크기는 20,480 row임
    • 타입 후보는 NULL, BOOLEAN, TIME, DATE, TIMESTAMP, TIMESTAMPTZ, BIGINT, DOUBLE, VARCHAR 순서로 좁혀감
    • 모든 값은 결국 VARCHAR로 표현 가능하므로 fallback은 VARCHAR
    • 첫 row가 아래 row들과 다르게 보이면 column name으로 쓰고, 아니면 column0, column1 같은 기본 이름을 붙임
  • 결론적으로 DuckDB의 빠름은 '실행 엔진 하나가 빠르다'보다 훨씬 넓은 이야기임

    • SQL이 AST와 typed tree로 바뀌고, 30개 넘는 optimizer pass를 거치고, 물리 operator와 pipeline으로 쪼개짐
    • 저장 계층은 block, checksum, column store, row group, zone map으로 읽을 데이터를 줄임
    • Parquet에서는 footer와 statistics를 이용해 원격에서도 필요한 byte range만 읽음
    • 이 모든 준비가 끝난 뒤에야 실제 쿼리 실행이 시작됨

기술 맥락

  • DuckDB의 가장 큰 선택은 데이터베이스를 서버가 아니라 라이브러리로 만든 거예요. 왜냐면 분석 쿼리에서는 계산보다 결과를 밖으로 꺼내는 비용이 더 커질 수 있거든요. 같은 프로세스 안에서 pandas, Arrow, Parquet와 붙으면 네트워크 전송과 row 단위 직렬화를 많이 피할 수 있어요.

  • Parquet와 궁합이 좋은 이유도 단순히 파일을 잘 읽어서가 아니에요. Parquet가 schema, row group 통계, 컬럼 chunk를 갖고 있기 때문에 DuckDB가 '읽기 전에 버릴 데이터'를 판단할 수 있어요. 원격 파일에서도 footer만 먼저 읽고 필요한 byte range만 가져오는 식이라, 저장소와 네트워크 비용까지 같이 줄어들어요.

  • optimizer 쪽에서는 조인 순서와 필터 푸시다운이 특히 중요해요. 6개 테이블 조인에서 가능한 tree shape이 30,240개라는 숫자가 말해주듯이, 잘못 고르면 중간 결과가 폭발해요. DuckDB가 graph와 동적 계획법을 쓰는 건 이 탐색 공간을 현실적인 시간 안에 줄이기 위해서예요.

  • pipeline과 sink 구조는 CPU를 놀리지 않기 위한 선택이에요. streaming 가능한 operator는 chunk 단위로 계속 흘리고, GROUP BYORDER BY처럼 전체 입력이 필요한 지점에서는 thread-local state에 먼저 쌓아요. 그래야 여러 thread가 같은 hash table lock을 두고 싸우지 않고 각자 빠르게 처리할 수 있어요.

  • CSV sniffer가 길게 설명되는 것도 실무적으로 의미가 있어요. CSV는 schema도 통계도 없는 텍스트라서, DuckDB가 빠르게 쿼리하기 전에 파일을 어떻게 해석할지부터 맞춰야 해요. Parquet가 분석 엔진에게 친절한 포맷이라면, CSV는 엔진이 먼저 추측과 검증을 해줘야 하는 포맷인 셈이에요.

DuckDB의 속도는 마법 같은 C++ 최적화 하나가 아니라, 데이터 이동을 줄이고 읽을 바이트를 줄이고 CPU가 병렬로 먹기 좋은 단위로 쪼개는 설계들의 합이다. 로컬 분석, 임베디드 BI, 데이터 파이프라인 비용 절감에 관심 있는 개발자라면 꽤 실전적인 글임.

댓글

댓글

댓글을 불러오는 중...

backend

피지독, 포스트그레스를 수평 확장시키겠다고 550만 달러 투자 유치

피지독은 포스트그레스 앞단에 프록시를 두고 샤딩과 라우팅을 처리해 수평 확장을 가능하게 하겠다는 오픈소스 프로젝트다. 이미 프로덕션에서 초당 200만 건이 넘는 쿼리를 처리하고, 확인된 규모만 20테라바이트 이상을 샤딩했다고 밝히며 550만 달러 투자를 공개했다.

backend

펜타시스템, EDB 포스트그레SQL로 국내 엔터프라이즈 DB 전환 시장 공략

펜타시스템테크놀러지가 EDB와 파트너 계약을 맺고 국내에 EDB 포스트그레SQL 기반 데이터 플랫폼을 공급한다. 기존 상용 DBMS 정책 변화로 비용 부담이 커진 기업들을 겨냥해, 오픈소스 기반 엔터프라이즈 데이터 플랫폼 전환 수요를 잡겠다는 전략이다. 금융, 공공, 제조, 유통, 클라우드, AI 데이터 분석 환경까지 적용 범위를 넓히려는 움직임이다.

backend

펜타시스템·EDB, 국내 오픈소스 DBMS 전환 시장 공략

펜타시스템이 EDB와 파트너십을 맺고 엔터프라이즈 오픈소스 데이터 플랫폼 시장에 본격적으로 들어간다. 핵심은 PostgreSQL 기반 대안 DBMS로 공공·금융·제조 영역의 비용 절감, 고가용성, 백업·복구 수요를 잡겠다는 전략이다.

backend

펜타시스템, EDB와 손잡고 엔터프라이즈 오픈소스 데이터 플랫폼 시장 공략

펜타시스템테크놀러지가 EDB와 국내 파트너 계약을 맺고 엔터프라이즈 오픈소스 데이터 플랫폼 사업을 확대한다. 양사는 PostgreSQL 기반 대안 데이터베이스 시장, 클라우드와 인공지능 데이터 분석 환경, 고가용성 및 백업 복구 영역을 함께 공략할 계획이다.

backend

몽고DB 실적 반등, AI 워크로드와 Atlas 성장에 투자 서사가 바뀌는 중

몽고DB가 2026년 1분기에 매출 6억 8762만 달러와 순이익 443만 달러를 발표하고, 2027 회계연도 가이던스를 올렸다. 글은 Atlas 클라우드, AI 관련 워크로드, ClarityDB 인수, 지배구조 개편이 몽고DB 투자 스토리를 어떻게 바꾸는지 다룬다.