LUVIT♥ EPL과 유튜브 데이터로 배우는 DuckDB | LUVIT(러빗) 시리즈 | 이기준
DuckDB를 활용한 SQL 기반 데이터 분석 입문서다. SQL 기초부터 고급 활용, 파이썬 연동, 데이터 시각화와 대시보드 제작까지 단계적으로 학습할 수 있다. EPL 데이터, 유튜브 트렌드, 「케이팝 데몬
www.aladin.co.kr
DuckDB EXPLAIN 출력을 단계별 디버거처럼 읽는 방법을 배웁니다. 느린 조인을 찾아내고, 왜 느린지 이해하며, 실제로 성능을 개선하는 변경으로 수정합니다.
솔직히 말해 쿼리 계획은 신비로운 것이 아닙니다. 쿼리 계획은 단지 하나의 이야기입니다.
DuckDB가 여러분의 쿼리를 어떻게 해석했는지, 그리고 그것을 어떤 방식으로 실행할 계획인지를 설명하는 이야기입니다.
그리고 분석 워크로드에서 성능 문제는 대부분 한 가지로 귀결됩니다. 바로 문제를 일으키는 조인 하나입니다. 빌드(build) 측이 지나치게 크거나, 프로브(probe) 측이 잘못 선택되었거나, 혹은 여러분이 기대했던 것처럼 필터가 푸시다운(pushdown)되지 않았기 때문입니다.
이 글은 DuckDB의 쿼리 계획을 디버거처럼 읽는 실전 가이드입니다. 무작정 이것저것 시도하는 대신, 문제를 일으키는 조인 하나를 정확히 찾아내고 수정하는 방법을 설명합니다.
쿼리 계획에 대한 사고방식: 트리가 아닌 파이프라인
DuckDB의 실행 계획은 보통 트리 형태로 표시되지만, 이를 작업 파이프라인으로 생각하는 것이 더 좋습니다.
- 데이터 읽기
- 필터 적용(가능하면 최대한 일찍)
- 필요한 컬럼만 선택
- 조인 수행(build/probe)
- 집계 또는 정렬
- 결과 출력
이 글에서 단 하나만 기억해야 한다면 이것을 기억하십시오.
조인 연산자는 잘못된 가정이 비싼 비용으로 바뀌는 지점입니다.
“디버거” 워크플로: EXPLAIN, 그다음 EXPLAIN ANALYZE
먼저 구조부터 확인합니다.
실행 계획의 형태를 보려면 EXPLAIN을 사용합니다.
EXPLAIN
SELECT ...
FROM ...
JOIN ...
WHERE ...
GROUP BY ...;
그다음 실제로 무슨 일이 일어났는지 측정합니다.
실행 시간과 행 수를 확인하려면 EXPLAIN ANALYZE를 사용합니다.
EXPLAIN ANALYZE
SELECT ...
FROM ...
JOIN ...
WHERE ...
GROUP BY ...;
EXPLAIN은 DuckDB가 무엇을 계획했는지 알려줍니다.
EXPLAIN ANALYZE는 DuckDB가 실제로 무엇을 했는지 알려줍니다.
그리고 성능 디버깅에서 “실제로 한 일”이 진실입니다.
사람처럼 EXPLAIN 읽기: 정말 중요한 5가지 정보
DuckDB의 실행 계획 출력은 버전과 표시 형식에 따라 조금씩 달라질 수 있지만, 중요한 신호는 대부분 일관적입니다.
1) 카디널리티(Cardinality, 행 수)
행 수 추정치(EXPLAIN)와 실제 행 수(EXPLAIN ANALYZE)는 데이터 양이 어디서 폭증하는지 알려줍니다.
예를 들어 다음과 같은 상황을 보겠습니다.
- 스캔 결과: 2억 행
- 필터 적용 후: 200만 행
- 조인 후: 1억 8천만 행
이 경우 해당 조인이 가장 먼저 의심해야 할 대상입니다.
2) 필터 푸시다운(Filter Pushdown) 여부
필터가 스캔 단계에서 적용되는지 확인하십시오.
필터가 조인 이후에 적용된다면 경고 신호입니다.
원하는 형태는 다음과 같습니다.
SEQ_SCAN
Filters: event_date >= '2026-01-01'
다음과 같은 형태는 바람직하지 않습니다.
HASH_JOIN
...
FILTER
event_date >= '2026-01-01'
왜냐하면 조인 이후에 필터가 적용된다는 것은 필요 이상으로 많은 데이터를 먼저 조인했다는 의미이기 때문입니다.
3) 프로젝션(Projection, 컬럼 선택)
실행 계획에는 선택된 컬럼 정보가 표시되는 경우가 많습니다.
넓은(wide) 테이블을 초기에 조인하면 실제로 사용하지도 않는 컬럼 때문에 다음과 같은 비용을 지불하게 됩니다.
- 메모리 대역폭 증가
- 캐시 효율 저하
- 디스크 스필(spill) 위험 증가
필요한 컬럼만 가능한 한 빨리 선택하는 것이 중요합니다.
4) 조인 유형과 조인 조건
DuckDB는 분석 워크로드에서 주로 해시 조인(Hash Join)을 사용합니다.
다음 사항들을 주의 깊게 살펴보십시오.
- HASH_JOIN인지 NESTED_LOOP인지
- 조인 키에 형 변환(CAST)이 발생하는지
- INNER JOIN인지 LEFT JOIN인지
특히 NESTED_LOOP가 보인다면 성능 문제가 있다는 강력한 신호일 수 있습니다.
또한 조인 키에 형 변환이 포함되어 있으면 최적화가 제한될 수 있으므로 확인이 필요합니다.
LEFT JOIN은 행을 보존하기 때문에 결과 집합이 예상보다 커질 수 있다는 점도 고려해야 합니다.
5) 스필(Spill) 및 임시 저장소 사용량 (EXPLAIN ANALYZE)
EXPLAIN ANALYZE 결과에서 스필의 흔적이 보이거나 실행 시간이 비정상적으로 길다면 메모리가 부족했을 가능성이 있습니다.
대부분의 경우 원인은 해시 조인의 빌드(build) 측 데이터가 메모리 한계를 초과했기 때문입니다.
이러한 상황이 바로 성능을 크게 저하시키는 "문제가 되는 조인 하나"인 경우가 많습니다.
구체적인 예제: 문제를 일으키는 조인 하나 (그리고 그 이유)
다음과 같은 상황을 가정해 보겠습니다.
- events : 매우 큰 클릭스트림 테이블
- users : 사용자 차원 테이블
- 특정 기간의 국가별 매출을 계산하려고 합니다.
SELECT
u.country,
COUNT(*) AS events,
SUM(e.revenue) AS revenue
FROM events e
JOIN users u ON e.user_id = u.user_id
WHERE e.event_date BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
GROUP BY 1;
EXPLAIN ANALYZE를 실행했더니 다음과 같은 결과가 나왔다고 가정해 보겠습니다.
- events 스캔: 3억 행
- 필터가 늦게 적용되거나 푸시다운되지 않음
- users에 대한 해시 테이블 생성(문제 없음)
- 하지만 조인 결과가 여전히 매우 크고 그 후에 집계 수행
그렇다면 무엇이 문제일까요?
대부분 다음 중 하나입니다.
- 날짜 필터가 스캔 단계로 푸시다운되지 않았습니다. (특히 쿼리 내에서 timestamp를 date로 변환하는 경우 흔합니다.)
- 조인 키의 데이터 타입이 서로 다릅니다. (CAST가 발생하면 빠른 실행 경로를 사용하지 못하고 비용이 증가할 수 있습니다.)
- 행 수나 컬럼 수를 줄이기 전에 조인을 수행했습니다. (필터링이나 집계를 먼저 수행할 수 있습니다.)
이제 도박하듯이 이것저것 시도하지 말고, 디버거처럼 문제를 해결해 봅시다.
수정 #1: 필터를 푸시다운 가능하게 만들기 (특히 Parquet)
Parquet 데이터를 사용할 때 파생 컬럼(derived column)에 대해 필터링하면 DuckDB가 항상 효과적으로 row group pruning을 수행할 수 있는 것은 아닙니다.
좋지 않은 예:
WHERE CAST(e.event_ts AS DATE) BETWEEN ...
더 좋은 방법은 Parquet 데이터셋에 실제 event_date 컬럼을 저장하고 이를 직접 필터링하는 것입니다.
좋은 예:
WHERE e.event_date BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
만약 저장 구조를 바로 변경할 수 없다면 2단계 접근 방식을 사용할 수 있습니다.
먼저 필터링과 컬럼 선택을 수행한 뒤 조인합니다.
WITH filtered_events AS (
SELECT user_id, revenue
FROM events
WHERE event_date BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
)
SELECT
u.country,
COUNT(*) AS events,
SUM(fe.revenue) AS revenue
FROM filtered_events fe
JOIN users u ON fe.user_id = u.user_id
GROUP BY 1;
논리적으로는 같은 쿼리이지만 실제 실행 계획은 크게 달라질 수 있습니다.
수정 #2: 조인 키 CAST 제거하기 (조용한 성능 킬러)
만약 e.user_id가 VARCHAR이고 u.user_id가 BIGINT라면 DuckDB는 형 변환을 수행해야 합니다.
이로 인해 다음과 같은 문제가 발생할 수 있습니다.
- 최적화 차단
- CPU 사용량 증가
- 메모리 사용량 증가
- 조인 결과 폭증 가능성
가장 좋은 방법은 데이터 적재 시 타입을 통일하는 것입니다.
또는 상위 단계에서 한 번만 CAST를 수행할 수도 있습니다.
WITH e2 AS (
SELECT
CAST(user_id AS BIGINT) AS user_id,
revenue
FROM events
WHERE event_date BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
)
SELECT ...
FROM e2
JOIN users u
ON e2.user_id = u.user_id;
실행 계획 깊숙한 곳에서 반복적으로 CAST하는 것보다 한 곳에서 한 번 수행하는 것이 훨씬 효율적입니다.
수정 #3: 조인 전에 데이터 줄이기 (사전 집계 패턴)
이것은 실무에서 자주 사용하는 고급 기법입니다.
한 달 동안 사용자별 총 매출만 필요하다면 먼저 이벤트를 집계합니다.
WITH per_user AS (
SELECT
user_id,
SUM(revenue) AS revenue,
COUNT(*) AS events
FROM events
WHERE event_date BETWEEN DATE '2026-01-01' AND DATE '2026-01-31'
GROUP BY user_id
)
SELECT
u.country,
SUM(p.events) AS events,
SUM(p.revenue) AS revenue
FROM per_user p
JOIN users u
ON p.user_id = u.user_id
GROUP BY 1;
이제 조인은 이벤트 수가 아니라 활성 사용자 수를 기준으로 수행됩니다.
이는 종종 10배에서 100배 수준의 데이터 감소 효과를 가져옵니다.
실행 계획에서는 다음과 같은 흐름을 보고 싶습니다.
scan
→ filter
→ aggregate
→ join
즉, 먼저 데이터를 줄인 후 더 작은 테이블을 users와 조인해야 합니다.
만약 조인이 성능 문제를 일으킨다면, 이 패턴이 가장 효과적인 해결책인 경우가 많습니다.
수정 #4: 스필(Spill)을 막아주는 "한 가지 설정"
쿼리 구조가 좋아도 대규모 조인은 메모리 한계를 초과할 수 있습니다.
DuckDB는 설정과 실행 환경에 따라 디스크 스필을 수행합니다.
실무에서는 메모리 동작을 명시적으로 설정하는 것이 좋습니다.
SET memory_limit = '8GB';
SET threads = 8;
SET temp_directory = '/var/tmp/duckdb';
화려한 최적화는 아니지만, 조인의 빌드 측 데이터가 메모리 한계에 근접한 경우 예측 불가능한 실패를 안정적이고 예측 가능한 동작으로 바꿔줍니다.
간단한 치트시트: 실행 계획에서 확인해야 할 것
조인을 제대로 수정했다면 실행 계획은 다음과 같이 “읽혀야” 합니다.
- 스캔 단계에서 필터 적용
- 초기 단계에서 필요한 컬럼만 선택
- 조인 전에 행 수 감소
- 더 작은 쪽을 기준으로 해시 조인 빌드
- 조인 후 집계 수행 또는 로직에 따라 조인 전에 사전 집계 수행
그래도 조인이 여전히 느리다면 다음을 확인하십시오.
- 실수로 고유하지 않은 키에 조인하여 행이 곱해지고 있지는 않습니까?
- LEFT JOIN이 너무 많은 행을 보존하고 있지는 않습니까?
- 조인 조건에서 필요한 키가 빠져 있지는 않습니까?
이는 전형적인 카티전 곱에 가까운 폭증 문제입니다.
결론: 실행 계획을 예술 작품처럼 보지 말고, 증거처럼 읽으십시오
DuckDB 쿼리 계획은 디버거처럼 다루기 시작하면 매우 읽기 쉬워집니다.
- 행 수가 폭증하는 지점을 찾습니다.
- 필터가 푸시다운되는지 확인합니다.
- 조인 키에 CAST가 있는지 확인합니다.
- 조인 전에 데이터를 줄입니다.
- 메모리와 임시 저장소 동작을 제어합니다.
열 번 중 아홉 번은 성능 문제가 문제를 일으키는 조인 하나로 귀결됩니다. 그리고 그 해결책은 특별한 것이 아닙니다. 구조적인 수정입니다.
EXPLAIN ANALYZE 출력이나 스크린샷, 그리고 쿼리를 붙여넣어 주시면 어떤 연산자가 문제인지, 어떤 변경이 가장 큰 개선을 가져올지 정확히 짚어드리겠습니다.
'EPL과 유튜브 데이터로 배우는 DuckDB' 카테고리의 다른 글
| AI 우선 분석을 위한 DuckDB 확장 (0) | 2026.06.14 |
|---|---|
| DuckDB: 소규모 Spark의 종말 (0) | 2026.06.14 |
| 2026년 데이터 분석가를 위한 완전한 SQL 로드맵 (0) | 2026.06.14 |
| SQL을 쉽게 배우기: 데이터 과학자를 위한 입문 가이드 (0) | 2026.06.14 |
| AI 시대의 DuckDB 확장 전략 (0) | 2026.06.13 |
댓글