워크로드 튜닝

워크로드 튜닝 (Tuning Workloads - how-to-tune-workloads)

쿼리가 예상만큼 빠르지 않다면, 설정 옵션부터 병렬 처리, 프로파일링까지 점검해 볼 수 있는 요소가 많아요. 이 페이지는 DuckDB 워크로드를 튜닝하는 방법을 기능별로 정리해 드릴게요.

출처: DuckDB 공식 문서 — how-to-tune-workloads

preserve_insertion_order 옵션 (The preserve_insertion_order Option)

메모리보다 훨씬 큰 데이터셋을 Parquet 또는 CSV 포맷으로 가져오거나 내보낼 때, 메모리 부족 오류가 발생할 수 있어요.

Out of Memory Error: failed to allocate data of size ... (.../... used)

이런 경우 preserve_insertion_order 구성 옵션false로 설정하는 걸 고려해 보세요.

SET preserve_insertion_order = false;

이렇게 하면 ORDER BY 절이 없는 결과는 시스템이 순서를 재배열할 수 있게 되어, 잠재적으로 메모리 사용량을 줄여줘요.

병렬 처리 (Parallelism / Multi-Core Processing)

Row Group이 병렬 처리에 미치는 영향 (The Effect of Row Groups on Parallelism)

DuckDB는 저장 레벨에서 함께 저장되는 행 그룹인 row group 단위로 워크로드를 병렬화해요. DuckDB 데이터베이스 포맷의 기본 row group 크기는 122,880 행이에요. 병렬 처리는 row group 레벨에서 시작되므로, 쿼리가 _k_개 스레드로 실행되려면 최소 k × 122,880 행을 스캔해야 해요.

row group 크기는 ATTACH 문의 옵션으로 지정할 수 있어요.

ATTACH '/tmp/somefile.db' AS db (ROW_GROUP_SIZE 16384);

Parquet 파일의 ROW_GROUP_SIZE 선택 시 성능 고려사항은 DuckDB 자체 데이터베이스 포맷에도 그대로 적용돼요.

너무 많은 스레드 (Too Many Threads)

특정 경우(예: HyperThreading 때문에) DuckDB가 너무 많은 스레드를 실행해 오히려 느려질 수 있어요. 이럴 땐 SET threads = X로 스레드 수를 수동으로 제한하는 게 좋아요.

메모리보다 큰 워크로드 (Larger-than-Memory Workloads, Out-of-Core Processing)

DuckDB의 핵심 강점 중 하나는 메모리보다 큰 워크로드 지원이에요. 즉 사용 가능한 시스템 메모리보다 큰 데이터셋(일명 out-of-core processing)도 처리할 수 있고, 중간 결과가 메모리에 맞지 않는 쿼리도 실행할 수 있어요. 이 섹션에서는 메모리보다 큰 처리의 전제 조건, 범위, 알려진 제한을 설명할게요.

디스크로 스필 (Spilling to Disk)

메모리보다 큰 워크로드는 디스크로 스필하여 지원돼요. 기본 설정에서는 DuckDB가 ⟨database_file_name⟩.tmp 임시 디렉터리(영구 모드) 또는 .tmp 디렉터리(인메모리 모드)를 만들어요. 이 디렉터리는 temp_directory 구성 옵션으로 바꿀 수 있어요.

SET temp_directory = '/path/to/temp_dir.tmp/';

블로킹 연산자 (Blocking Operators)

일부 연산자는 입력의 마지막 행을 볼 때까지 단일 행조차 출력할 수 없어요. 이러한 연산자를 블로킹 연산자라고 해요. 전체 입력을 버퍼링해야 하기 때문이죠. 관계형 데이터베이스 시스템에서 가장 메모리 집약적인 연산자예요. 주요 블로킹 연산자는 다음과 같아요.

DuckDB는 이 모든 연산자에 대해 메모리보다 큰 처리를 지원해요.

제한 사항 (Limitations)

DuckDB는 메모리보다 큰 워크로드도 항상 완료하려고 노력해요. 다만 현재 몇 가지 제한이 있어요.

  • 같은 쿼리에 여러 블로킹 연산자가 나타나면, 이 연산자들의 복잡한 상호작용 때문에 DuckDB가 여전히 메모리 부족 예외를 던질 수 있어요.
  • list()string_agg() 같은 일부 집계 함수는 디스크로 오프로드하는 것을 지원하지 않아요.
  • 정렬을 사용하는 집계 함수는 총체적(holistic)이라서, 집계가 시작되기 전에 모든 입력이 필요해요. DuckDB는 아직 일부 복잡한 중간 집계 상태를 디스크로 오프로드할 수 없어서, 큰 데이터셋에서 실행하면 메모리 부족 예외가 발생할 수 있어요.
  • PIVOT 연산은 내부적으로 list() 함수를 사용하므로 같은 제한을 받아요.

프로파일링 (Profiling)

쿼리가 예상만큼 좋지 않다면 쿼리 플랜을 연구해 볼 가치가 있어요.

  • EXPLAIN으로 쿼리를 실행하지 않고 물리적 쿼리 플랜을 출력해요.
  • EXPLAIN ANALYZE로 쿼리를 실행하면서 프로파일링해요. 쿼리의 각 단계가 걸리는 CPU 시간을 보여줘요. 멀티스레딩 때문에 개별 시간을 다 더하면 총 쿼리 처리 시간보다 커진다는 점을 기억하세요.

쿼리 플랜은 성능 문제의 근원을 알려줄 수 있어요. 몇 가지 일반적인 지침이에요.

  • 중첩 루프 조인(nested loop join) 대신 해시 조인(hash join)을 선호해요.
  • 나중에 적용될 필터 조건에 대한 필터 푸시다운이 없는 스캔은 불필요한 IO를 수행해요. 푸시다운을 적용하도록 쿼리를 다시 작성해 보세요.
  • 연산자의 카디널리티가 수십억 튜플로 폭발하는 잘못된 조인 순서는 어떻게 해서든 피해야 해요.

준비된 문 (Prepared Statements)

준비된 문(Prepared statements)은 같은 쿼리를 매개변수만 바꿔 여러 번 실행할 때 성능을 개선할 수 있어요. 문이 준비되면 쿼리 실행 과정의 초기 부분(파싱, 플래닝 등)을 완료하고 그 출력을 캐시해요. 실행할 때 그 단계를 건너뛸 수 있어 성능이 좋아지죠. 이는 주로 매개변수를 바꿔가며 작은 쿼리(런타임 100ms 미만)를 반복 실행할 때 유용해요.

참고로 DuckDB의 1차 설계 목표는 많은 작은 쿼리를 동시에 빠르게 실행하는 게 아니에요. 오히려 더 크고 빈도가 낮은 쿼리를 실행하도록 최적화됐어요.

원격 파일 쿼리 (Querying Remote Files)

DuckDB는 원격 파일을 읽을 때 동기 IO를 사용해요. 즉 각 DuckDB 스레드는 한 번에 HTTP 요청을 하나만 할 수 있어요. 쿼리가 네트워크로 많은 작은 요청을 보내야 한다면, DuckDB의 threads 설정을 CPU 코어 총 개수보다 크게(대략 CPU 코어의 2-5배) 늘리면 병렬 처리와 성능이 개선될 수 있어요.

불필요한 데이터 읽기 피하기 (Avoid Reading Unnecessary Data)

원격 파일을 읽는 워크로드의 주요 병목은 IO일 가능성이 높아요. 따라서 불필요하게 읽는 데이터를 최소화하는 것이 매우 유익할 수 있어요.

몇 가지 기본 SQL 요령이 도움이 돼요.

  • SELECT *를 피하고 실제로 사용하는 컬럼만 선택해요. DuckDB는 실제로 필요한 데이터만 다운로드하려고 할 거예요.
  • 가능하면 원격 Parquet 파일에 필터를 적용해요. DuckDB는 이 필터를 사용해 스캔되는 데이터 양을 줄여요.
  • 필터에 자주 쓰이는 컬럼으로 데이터를 정렬하거나 파티셔닝해서, 필터가 IO를 줄이는 효과를 높여요.

쿼리에 원격으로 전송되는 데이터 양을 조사하려면 EXPLAIN ANALYZE로 원격 파일 쿼리의 총 요청 수와 총 전송 데이터를 출력할 수 있어요.

캐싱 (Caching)

버전 1.3.0부터 DuckDB는 원격 데이터 캐싱을 지원해요. 외부 파일 캐시의 내용을 조사하려면 다음을 실행해요.

FROM duckdb_external_file_cache();

커넥션 사용 모범 사례 (Best Practices for Using Connections)

DuckDB는 같은 데이터베이스 커넥션을 여러 번 재사용할 때 가장 잘 작동해요. 매 쿼리마다 연결하고 다시 연결하면 오버헤드가 생겨, 많은 작은 쿼리를 실행할 때 성능이 떨어질 수 있어요. DuckDB는 일부 데이터와 메타데이터를 메모리에 캐시하는데, 마지막으로 열린 커넥션이 닫히면 그 캐시가 사라져요. 단일 커넥션이 대개 가장 잘 작동하지만, 커넥션 풀(connection pool)을 써도 돼요.

여러 커넥션을 쓰면 일부 연산을 병렬화할 수 있지만, 보통은 필요하지 않아요. DuckDB는 개별 쿼리 내에서 가능한 한 많이 병렬화하려고 하지만, 모든 경우에 병렬화할 수는 없어요. 여러 커넥션을 만들면 더 많은 연산을 동시에 처리할 수 있어요. 이는 DuckDB가 CPU에 제한받지 않고 네트워크 전송 속도 같은 다른 자원에 병목받는 경우에 더 도움이 될 수 있어요.

영구 vs. 인메모리 테이블 (Persistent vs. In-Memory Tables)

DuckDB는 경량 압축 기법을 지원해요. 기본적으로 압축은 영구(디스크 기반) 데이터베이스에만 적용되고 인메모리 테이블에는 적용되지 않아요.

어떤 경우에는 반직관적인 성능 결과가 나올 수 있는데, 인메모리 테이블보다 디스크 기반 테이블의 쿼리가 더 빠른 경우예요. SF30 데이터셋에서 TPC-H 워크로드의 Q1을 예로 들어볼게요.

CALL dbgen(sf = 30);
.timer on
PRAGMA tpch(1);

이 스크립트를 세 개의 DuckDB 프롬프트로 실행해 봤어요.

데이터베이스 설정 DuckDB 프롬프트 실행 시간
인메모리 DB (비압축) duckdb 4.22 s
인메모리 DB (압축) duckdb -cmd "ATTACH ':memory:' AS db (COMPRESS); USE db;" 0.55 s
영구 DB (압축) duckdb tpch-sf30.db 0.56 s

결과를 보면 압축된 데이터베이스가 비압축 인메모리 데이터베이스보다 약 8배 빠른 걸 확인할 수 있어요.

더 알아보기 (Learn more)