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

DuckDB의 오브젝트 스토리지 캐싱(Object-Store Caching)

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

https://2stndard.tistory.com/notice/203

 

[발간예정][EPL과 유튜브로 배우는 DuckDB] 실습 코드와 데이터

EPL과 유튜브 데이터로 배우는 DuckDB에서 사용되는 실습 데이터와 코드를 제공합니다. EPL_DATA&samplefile.zip : 책에서 사용하는 영국 프리미어리그 데이터 셋과 샘플로 사용하는 파일espn.duckdb.zip : 책

2stndard.tistory.com

 

DuckDB를 사용하여 S3나 GCS에 있는 거대한 Parquet 데이터셋에 대해 필터 쿼리를 실행하면, 놀라울 정도로 빠르다는 느낌을 받을 수 있을 것입니다. 같은 쿼리를 다시 실행하거나 약간 수정하면 훨씬 더 빨라져서 마치 데이터가 로컬에 있는 것처럼 느껴지죠. 웨어하우스 클러스터도, 부팅 시간도, 긴 계획 단계도 없이 결과가 바로 나옵니다.

이것은 마법이 아니라 캐싱(Caching)입니다. 특히 Parquet의 메타데이터, 통계 정보, 그리고 오브젝트 스토리지 읽기를 캐싱하여는 "웜 스타트(Warm Start)"를 사용하는 것인데요. 여기서 웜(Warm)이라 함은 한번 쿼리가 발생하여 관련된 캐시 정보가 있는 상태를 말하고 콜드(Cold)는 쿼리가 처음 실행되어 쿼리 관련 캐시가 없는 상태입니다. 이제 웜 스타트의 원리를 자세히 살펴보겠습니다.

 

DuckDB + 오브젝트 스토리지: 캐싱이 중요한 이유

S3, GCS 등 클라우드 오브젝트 스토리지는 내구성과 비용 면에서 우수하지만, 지연 시간(Latency) 측면에서는 효율이 떨어집니다.

  • 각 읽기 작업마다 확연히 네트워크 지연이 발생합니다.
  • 수많은 작은 파일을 나열하는 것은 매우 번거롭습니다.
  • 동일한 Parquet 푸터(Footer)를 반복해서 읽는 오버헤드가 발생합니다.

DuckDB는 데이터를 읽을 때 열 기반 프루닝(Columnar Pruning)과 통계 정보에 크게 의존합니다. 모든 데이터를 스캔하는 대신, 쿼리에 필요한 행 그룹(Row groups)과 열만 스캔합니다. 이 과정에서 다음의 정보를 사용합니다.

  • Parquet 메타데이터: 스키마, 행 그룹, 컬럼 청크.
  • 컬럼별 통계: 최소/최대값, null 개수.
  • 파일 레이아웃: 오프셋, 크기, 압축 방식.

매 쿼리마다 이 메타데이터를 클라우드에서 새로 가져와야 한다면 큰 오버헤드가 발생합니다. 이를 방지하려면 쿼리 사이에 이 정보를 프로세스 캐시나 디스크 캐시에 최대한 많이 기억해두어야 합니다.

"Warmth"의 3가지 레이어

DuckDB의 웜 스타트는 다음의 세 가지 겹쳐진 레이어로 생각할 수 있습니다.

1. 실행 웜 (Execution warm): 커널 캐시, 버퍼 (OS와 하드웨어의 영역)

2. DuckDB 캐시: 메타데이터, 페이지, 오브젝트 (유저가 제어 가능한 영역)

3. 오브젝트 스토리지: cold S3/GCS/HTTP 데이터

레이어 3은 크게 제어할 수 없지만(이는 OS와 하드웨어에 달려 있음), 레이어 2는 확실히 조정할 수 있습니다:

메타데이터 캐시: Parquet 푸터, 스키마, 행 그룹 메타데이터
통계 캐시: 컬럼 청크별 최소/최대 값을 저장하여 프루닝(pruning)을 지원
오브젝트 캐시: S3에서 가져온 실제 범위 데이터를 쿼리 간에 재사용

이 세 가지를 제대로 설정하면 DuckDB는 데이터 레이크에 직접 연결된 임베디드 데이터 웨어하우스처럼 사용할 수 있습니다.

구체적인 예시: 콜드 쿼리에서 밀리초 단위 웜 스타트까지

S3에 로그 데이터가 있다고 가정해 봅시다. event_type = 'login'이면서 country = 'IN'인 데이터를 찾는 경우입니다.

s3://my-bucket/logs/date=2025-11-01/part-0001.parquet
...
s3://my-bucket/logs/date=2025-11-30/part-0123.parquet

각 파일에는 다음과 같은 열이 있습니다:

  • timestamp
  • user_id
  • country
  • event_type

“11월 동안 인도에서 발생한 모든 로그인 이벤트”를 검색합니다.

Step 1: Cold query

SET s3_region='ap-south-1';
-- credentials setup omitted for brevity

SELECT COUNT(*) 
FROM read_parquet('s3://my-bucket/logs/date=2025-11-*/part-*.parquet')
WHERE event_type = 'login'
  AND country = 'IN';

콜드 쿼리로 실행되면 DuckDB는 다음을 실행합니다.

  1. 일치하는 오브젝트 목록 나열.
  2. 각 파일의 Parquet 푸터를 가져옴.
  3. 행 그룹 및 통계 파싱.
  4. 관련 있는 행 그룹 결정.
  5. 필요한 실제 데이터 페이지를 가져옴.

많은 작은 파일이 있다면 상당한 지연 시간이 발생합니다.

Step 2: Warm query

이제 다음의 쿼리를 실행하면 DuckDB는 다음과 같은 단계로 실행합니다.

SELECT COUNT(DISTINCT user_id)
FROM read_parquet('s3://my-bucket/logs/date=2025-11-*/part-*.parquet')
WHERE event_type = 'login'
  AND country = 'IN';

조건은 동일하지만 집계 방식이 다릅니다. 그러면

  • DuckDB는 파일 레이아웃을 다시 파악할 필요가 없습니다.
  • 캐시된 메타데이터와 통계 정보를 재사용할 수 있습니다.
  • 메모리 부하와 캐시 구성에 따라 캐시된 데이터 페이지를 재사용할 수도 있습니다.

결과적으로, 논리적으로는 “새로운” 쿼리임에도 불구하고 밀리초 단위의 웜 스타트가 가능합니다.

오브젝트 스토리지 읽기를 위한 영구 캐싱 활성화

embed DuckDB를 사용한다면 다음과 같은 명령어로 영구 캐시를 설정할 수 있습니다.

-- Enable a persistent cache directory
PRAGMA enable_object_cache;
PRAGMA object_cache_directory='/var/tmp/duckdb_cache';

-- Or in some environments:
-- PRAGMA global_directory_cache_size='10GB';

 

파이썬 어플리케이션에서는 다음과 같이 설정합니다.

import duckdb from 'duckdb';

const db = new duckdb.Database(':memory:');
const conn = db.connect();

conn.run("PRAGMA enable_object_cache");
conn.run("PRAGMA object_cache_directory='/tmp/duckdb_object_cache'");

 

이렇게 설정하면 DuckDB는 읽어온 원격 오브젝트 범위를 캐싱하고, 프로세스가 유지되는 동안 Parquet 메타데이터를 메모리에 상주시켜 향후 쿼리 시 네트워크 통신을 최소화합니다.

 

실무를 위한 조언

  • 캐시 크기를 현명하게 설정하세요: 캐시가 너무 작으면 데이터가 계속 교체되어 다시 다운로드하게 됩니다.
  • 작은 파일은 병합하세요: 수만 개의 5MB 파일은 메타데이터 접근에 치명적입니다. 적절한 크기로 통합하거나 파티셔닝하세요.
  • 중요 데이터셋 고정: 매일 사용하는 핵심 지표 테이블은 별도의 캐시 디렉토리를 사용하는 것이 좋습니다.
  • 실패 모드에 정직해지기: 캐시는 최적화 도구일 뿐입니다. 파일이 변경되면 DuckDB는 다시 원격 읽기로 돌아가며 지연 시간이 변할 수 있음을 인지해야 합니다.

<출처: https://medium.com/@komalbaparmar007/duckdb-object-store-caching-parquet-metadata-statistics-and-millisecond-warm-starts-ce2afd9bc33f>

댓글