조인 성능은 엔진이 아니라 플래너가 정한다 (Clickhouse 26.4+)
ClickHouse는 조인에 약하다는 통념이 있습니다. 정말 그런지, 어떤 조건에서 그런지를 같은 조건에서 직접 재보고 싶어서 DuckDB와 ClickHouse를 컨테이너에 나란히 세워 TPC-H를 SF100까지 돌렸습니다. 결론은 제목 그대로 조인 성능을 가르는 건 엔진이 아니라 플래너였고, 그 통념은 특정 버전까지의 얘기였습니다. 거기까지 가는 길에 예상 못 한 것들이 몇 개 있었습니다.

왜 다시 재봤나
고객 데이터 파이프라인을 설계하다 보면 분석용 엔진을 고르는 순간이 꼭 옵니다. 로그와 이벤트를 쌓고 대시보드를 돌리는 쪽은 ClickHouse가 익숙하고, 정규화된 업무 테이블을 이리저리 붙여 보는 애드혹 분석은 DuckDB가 편하죠. 그런데 이 둘을 가르는 기준으로 늘 나오는 말이 하나 있습니다. ClickHouse는 조인에 약하다. 이 말을 저도 몇 번 들었는데, 정작 제 손으로 같은 조건에서 재본 적은 없더라고요. 벤더 블로그 수치는 각자 유리한 환경에서 나온 것들이라 그대로 믿기가 좀 어렵습니다.
그래서 조건을 하나로 맞췄습니다. 엔진마다 Docker 컨테이너를 따로 띄우고 cgroup으로 8 vCPU와 16 GiB를 똑같이 고정했습니다. cgroup은 리눅스가 프로세스 묶음에 CPU와 메모리 상한을 거는 기능이라, 세 엔진이 정확히 같은 크기의 상자 안에서 도는 셈입니다. 쿼리 하나가 쓸 수 있는 메모리 상한은 양쪽 다 12 GiB로 맞췄고, 한 시점에 한 엔진만 돌렸습니다. 데이터는 TPC-H를 DuckDB의 dbgen으로 만들어 Parquet로 뽑은 다음 두 엔진이 같은 파일을 읽어 각자 네이티브 스토리지에 적재했습니다[4]. 스케일 팩터는 1, 10, 30, 100까지 갔는데 SF100이면 lineitem이 6억 행입니다. 22개 쿼리의 결과 집합은 행 단위로 대조해서 세 엔진이 같은 답을 내는지도 확인했습니다. 결국에는 누가 빠른가보다 왜 다른가를 보려고 이렇게까지 맞춘 겁니다.
TPC-H를 모르는 분을 위해 한 줄만 적으면, 주문 데이터를 흉내 낸 표준 벤치마크입니다. 고객이 주문을 넣고 주문 안에 품목이 여러 줄 있고 품목마다 부품과 공급자가 붙는 구조라, 테이블 여덟 개를 이리저리 붙여야 답이 나오는 22개 질의로 돼 있어요. 제일 큰 lineitem이 주문 상세이고, 스케일 팩터(SF)는 데이터 크기 배율입니다. SF10이면 lineitem이 6천만 행, SF100이면 6억 행입니다. 정규화된 업무 데이터를 조인하는 워크로드를 흉내 내기에 이만한 게 없더라고요.
처음엔 ClickHouse 25.8 LTS 하나면 되겠지 했어요. 아니죠, 최신 26.8의 설정 목록을 열어보니 25.8에는 없는 설정 같은 것들이 줄줄이 나오더라고요. 통계 기반 조인 리오더링, 런타임 조인 필터, hash 조인 스필. 조인에 관해서는 같은 버전대로 묶을 수 없었습니다. 그래서 DuckDB 1.5.5, ClickHouse 25.8, ClickHouse 26.8 세 개를 놓고 비교했습니다.
용어 몇 개만 먼저
뒤에서 계속 나올 말이라 여기서 풀고 갑니다.
조인은 두 테이블을 키로 맞춰 붙이는 일이고, 분석 엔진은 거의 다 해시 조인으로 합니다. 한쪽 테이블을 전부 읽어 키를 색인으로 하는 해시 테이블을 메모리에 만들고, 다른 쪽 테이블을 한 행씩 흘리면서 그 해시 테이블에서 짝을 찾는 방식이에요. 해시 테이블을 만드는 쪽을 빌드 사이드, 흘리면서 찾는 쪽을 프로브 사이드라고 부릅니다. 빌드 사이드는 통째로 메모리에 올라가니까 작아야 하고, 프로브 사이드는 흘러가기만 하니까 아무리 커도 됩니다. 이 글의 절반은 결국 누가 빌드 사이드가 되느냐 얘기입니다.
플래너는 SQL을 실행 계획으로 바꾸는 부품입니다. SQL에는 무엇을 원하는지만 적고 어떻게 구할지는 안 적잖아요. 테이블 여섯 개를 어떤 순서로 붙일지, 조인마다 어느 쪽을 빌드로 삼을지, 어떤 알고리즘을 쓸지를 플래너가 정합니다. 옵티마이저라고도 부르는데 이 글에서는 같은 뜻으로 씁니다. 좋은 플래너는 테이블 행 수와 컬럼 값 분포 같은 통계로 중간 결과가 몇 행이 될지 어림잡아서 제일 싼 순서를 고르는데, 그 어림값을 카디널리티 추정이라고 하고 그런 방식을 비용 기반이라고 합니다. 조인 순서를 적힌 대로 하나씩 붙이면 왼쪽 깊은 트리가 되고, 작은 것들끼리 먼저 묶어 두 덩어리를 붙이면 bushy 트리가 됩니다.
실행 엔진은 그 계획을 그대로 수행하는 쪽입니다. 벡터화, 병렬 스캔, 압축 같은 것들이 여기 속해요. 결국에는 같은 SQL이라도 플래너가 어떤 계획을 내느냐에 따라 실행 엔진이 할 일의 양이 100배씩 달라질 수 있다는 게 이 글의 뼈대입니다.
숫자부터
SF10에서 세 엔진이 모두 완주한 21개 쿼리의 기하평균은 DuckDB 65ms, 26.8 129ms, 25.8 226ms였습니다. 기하평균은 쿼리마다의 배율을 곱해서 평균 낸 값이라 긴 쿼리 한두 개가 전체를 끌고 가지 않습니다. DuckDB가 25.8보다 3.5배, 26.8보다 2배 빠릅니다. 25.8은 Q21 하나를 12 GiB 안에서 끝내지 못했고요.
SF100으로 올리면 그림이 되게 달라집니다. 25.8은 22개 중 9개를 완주하지 못했습니다. Q3, Q4, Q5, Q7, Q8, Q9, Q10, Q13, Q21. 전부 다중 조인이거나 상관 서브쿼리가 있는 것들입니다. 상관 서브쿼리는 EXISTS처럼 바깥 행마다 안쪽 질의를 다시 보는 구문인데, 엔진은 이걸 짝이 있는지만 확인하는 semi 조인이나 짝이 없는 것만 남기는 anti 조인으로 바꿔서 실행합니다. 26.8은 22개를 다 끝냈고 합계 67.8초, DuckDB는 56.2초였습니다. 그런데 쿼리별로 보면 26.8이 이기는 자리가 꽤 있어요. Q13은 26.8이 2.6초, DuckDB가 7.7초였고 Q21도 26.8이 빨랐습니다. 반대로 Q2, Q6, Q10 같은 것들은 DuckDB가 3-4배 빠릅니다. 결국에는 SF100에서 26.8과 DuckDB는 같은 급이고, 25.8은 따로 놓고 봐야 한다는 게 첫 번째 관찰입니다.
순서만 바꿨는데 110배
TPC-H 공식 쿼리는 ClickHouse 저장소에 이미 유리한 순서로 적혀 있습니다. 그래서 22개 표만 보면 플래너의 차이가 잘 안 보여요. 진짜 차이는 같은 쿼리를 다르게 적어봤을 때 나왔습니다.
Q5는 여섯 테이블 조인입니다. 이걸 세 가지 순서로 적었습니다. 공식 순서, lineitem을 맨 앞에 둔 순서, 그리고 region과 nation 같은 차원 테이블을 맨 앞에 둔 순서. 의미는 완전히 같은 SQL입니다. 25.8은 0.56초, 0.26초, 30.8초가 나왔습니다. 110배입니다. 26.8은 세 순서 모두 0.15초, DuckDB는 0.06초였고요.
실행 계획을 열어보니 이유가 그대로 보였습니다. 25.8은 FROM 절에 적힌 순서 그대로 왼쪽 깊은 트리를 만듭니다. region, nation, supplier까지 붙인 다음 customer를 붙이는데, supplier와 customer는 nationkey로만 묶이니까 다대다 조인이 됩니다. 아시아 다섯 나라마다 supplier 4천 개에 customer 6만 명이 곱해져서 중간 결과가 수억 행이 되고, 그 다음에야 orders와 lineitem을 만나는 겁니다.
26.8은 통계를 보고 lineitem을 프로브 쪽에 두고 나머지를 작게 묶어 빌드 사이드로 넘겼고, DuckDB는 세 순서 모두 같은 물리 계획을 냈습니다.
25.8에도 조인 순서를 손보는 기능이 하나 있긴 합니다. 24.12에 들어온 2-테이블 스왑인데, 조인 하나에서 두 입력 중 행 수가 적어 보이는 쪽을 빌드로 삼도록 좌우만 바꿉니다. 실제로 lineitem과 orders 둘만 조인할 때 순서를 뒤집어 적어도 25.8은 같은 시간을 냈어요. 스왑을 끄면 lineitem 6천만 행이 해시 테이블에 올라가서 0.18초가 0.69초로, 메모리는 1 GiB에서 4.2 GiB로 뜁니다. 그런데 이 스왑은 조인 하나의 좌우만 볼 뿐 여섯 테이블의 순서는 건드리지 않습니다. 3개 이상 테이블의 리오더링은 25.9에서야 들어왔고[1] 25.8에는 그 설정 자체가 없습니다. 결국에는 25.8 사용자는 FROM 절 순서를 성능 튜닝 항목으로 관리해야 한다는 뜻입니다.
DuckDB의 옵티마이저를 꺼봤다
여기서 하나 더 해보고 싶은 게 생겼습니다. DuckDB가 빠른 게 실행 엔진이 좋아서인지, 플랜을 잘 짜서인지. DuckDB에는 조인 순서 옵티마이저와 빌드 사이드 선택을 끄는 설정이 있습니다[2]. 끄면 적힌 순서대로 왼쪽 깊은 트리를 만드니까 25.8과 같은 조건이 됩니다.
같은 j07c를 돌렸더니 67.2초가 나왔습니다. 25.8의 30.8초보다 두 배 느립니다. 두 테이블 조인의 순서를 뒤집은 것도 0.22초에서 0.36초로, 메모리는 0.2 GiB에서 2.4 GiB로 갔고요. 25.8이 겪는 일을 DuckDB도 똑같이 겪고, 심지어 더 심하게 겪는 겁니다.
이게 이번 벤치에서 되게 중요한 결과였습니다. 조인 성능을 정하는 건 실행 엔진이 아니라 플래너다. 나쁜 플랜을 실행하는 능력은 오히려 ClickHouse가 낫습니다. DuckDB가 빠른 이유는 좋은 플랜을 스스로 찾기 때문이고, 25.8에서는 그 플랜을 사용자가 SQL 작성 순서로 정해 줘야 했던 겁니다. 그리고 그 몫이 26.x에서 플래너로 넘어갔습니다. 분석 엔진에서 플래너가 곧 제품인 거죠.
26.8에서도 같은 실험을 했습니다. 리오더링만 끄면 j07c가 0.15초에서 14.3초로 돌아갑니다. 런타임 필터까지 끄고 스왑까지 끄면 Q9가 1.30초에 4.9 GiB로, 25.8 기본값의 1.28초에 6.5 GiB와 거의 같아집니다. 26.8이 빨라진 만큼이 정확히 플래너 기능 세 개의 합이라는 얘기입니다.
해시 테이블과 메모리 상한
플래너 다음은 메모리입니다. ClickHouse의 hash 조인은 오른쪽 테이블을 전부 읽어 RAM에 해시 테이블을 만들고 왼쪽을 흘려보내며 찾습니다. 오른쪽이 메모리에 안 들어가면 25.8은 메모리 초과 오류로 멈춥니다. SF100에서 lineitem과 orders를 한 번 조인하는 가장 단순한 쿼리도 25.8은 12 GiB 안에서 못 끝냈습니다. orders 1억5천만 행의 해시 테이블이 안 들어가는 겁니다.

26.8은 끝내긴 했는데 20.4초가 걸렸습니다. 스필은 메모리에 다 못 올리는 데이터를 디스크에 임시로 내려놓고 나눠서 처리하는 걸 말합니다. grace hash 조인은 그걸 조인에 적용한 방식이에요. 키 해시로 양쪽 테이블을 버킷 몇 개로 나눠서 메모리에 들어가는 버킷은 바로 조인하고 나머지는 디스크에 썼다가 버킷 하나씩 다시 읽어 처리합니다. 디스크를 오가는 만큼 느려지지만 오류 대신 답이 나옵니다.
26.5부터 오른쪽 데이터가 가용 메모리의 절반을 넘으면 grace hash로 바꿔 디스크로 내리는 경로가 생겼고[1], 이 쿼리가 그 경로를 탄 겁니다. 같은 조인을 DuckDB는 4.4초에 인메모리로 끝냈습니다. 해시 테이블 피크가 1.83 GiB였어요. 키 하나만 필요한 1억5천만 행에 ClickHouse는 6 GiB 넘게 쓰니까 행당 40바이트 이상이고, DuckDB는 행당 13바이트쯤입니다. 행당 오버헤드가 세 배 차이 나면 스필 임계에 닿는 시점도 세 배 앞당겨집니다.
상한을 4 GiB로 더 조이면 차이가 완주 여부로 나옵니다. SF100에서 조인 쿼리 넷을 돌렸는데 ClickHouse는 두 버전 모두 넷 다 상한 안에서 끝내지 못했고, DuckDB는 넷 다 끝냈습니다. j01이 15.8초로 12 GiB 때의 3.6배가 걸리면서 4.4 GiB를 디스크로 내렸고, 나머지 셋은 1.2-1.5배 안에서 끝났어요. 결국에는 메모리가 조인 입력보다 작아지면 ClickHouse는 설정을 다시 잡으라는 신호를 주고 DuckDB는 느려진 답을 줍니다. 배치 리포트라면 후자가 편하고, 지연 시간이 중요한 서빙이라면 어느 쪽이든 메모리를 맞추거나 미리 집계하는 게 답입니다.
grace_hash를 쓸 때 알아야 할 것
여기서 좀 헤맸습니다. 문서에는 grace_hash가 메모리를 묶는 알고리즘이라고 돼 있는데, 25.8에서 알고리즘만 바꾸니까 기본값과 정확히 같은 지점에서 멈추더라고요. 내가 뭘 잘못 걸었나 싶었죠. 알고 보니 grace_hash는 max_bytes_in_join을 넘을 때만 버킷을 늘려 디스크로 내리는데 그 기본값이 0, 그러니까 무제한이었습니다. 무제한이니 스필할 일이 없고, 그 사이에 max_memory_usage에 먼저 걸리는 겁니다.
max_bytes_in_join을 1 GiB로 주니까 j01은 2 GiB 상한에서도 완주했습니다. 대신 1.8초로, parallel_hash가 여유 있을 때 낸 0.26초의 7배입니다. Q9는 4 GiB에서 11초가 걸렸는데 26.8이 같은 상한에서 0.29초니까 38배입니다. Q21은 어떤 설정으로도 못 끝냈습니다. grace_hash가 INNER와 OUTER 조인만 지원하고, EXISTS나 NOT EXISTS가 풀려서 나오는 semi, anti 조인은 hash로 되돌아가기 때문입니다.
말이 좀 돌아갔는데, 제일 효과가 좋았던 건 알고리즘이 아니라 SQL이었습니다. SF30에서 25.8이 상한 안에서 끝내지 못하던 Q5를 grace_hash로 살리면 25초에서 32초가 걸리는데, FROM 절을 lineitem부터 시작하게 고쳐 적으니 기본 알고리즘으로 1.1초에서 2.2초에 끝났습니다. 메모리도 1.3 GiB였고요. 결국에는 25.8에서는 사용자가 플래너 역할의 일부를 맡아야 하고, 그 몫이 26.x에서 엔진 안으로 들어갔습니다.
26.8에서 무엇이 달라졌나
ClickHouse 팀은 정확히 이 지점을 2년에 걸쳐 바꿔 왔습니다. 바이너리 안의 설정 변경 이력을 뽑아보면 순서가 그대로 보여요. 24.12에 parallel_hash가 기본이 되고 2-테이블 스왑이 들어왔고, 25.9에 다중 테이블 리오더링, 26.2에 런타임 조인 필터, 26.3과 26.4에 컬럼 통계와 자동 수집, 26.5에 hash 조인 스필, 26.8에 INSERT 시점 통계 materialize가 붙었습니다. 공식 블로그는 이 과정을 조인에 빠른 ClickHouse가 되기까지의 2년으로 정리하고 있고[1], TPC-H SF100에서 25.4 대비 26.4가 6배 빨라졌다고 씁니다. 제 측정과 방향이 같습니다.
몇 가지는 직접 보고 나서야 이해가 됐습니다. 통계가 하는 일은 lineitem에만 필터가 있고 orders에는 없는 조인에서 보였어요. 통계가 없으면 플래너가 아는 건 테이블 행 수뿐이라, 6천만 행짜리 lineitem보다 1천5백만 행짜리 orders가 작다고 보고 orders를 빌드로 잡습니다. 26.8은 l_shipdate 값 분포로 필터 후 lineitem이 71만 행이라고 추정해 그쪽을 빌드로 삼았습니다. 25.8은 같은 쿼리에서 orders를 올려 1.2 GiB를 썼고 26.8은 0.04 GiB였습니다.
런타임 필터는 빌드가 끝난 뒤에 생기는 정보를 프로브 쪽에 돌려주는 장치입니다. 해시 테이블을 다 만들면 오른쪽에 어떤 키가 있는지 알게 되니까, 그 키 집합을 블룸 필터로 압축해서 왼쪽 테이블을 읽을 때 집합에 없는 행은 조인까지 가기 전에 버립니다. 차원 테이블을 필터해서 2천 개만 남았는데 팩트 테이블 6천만 행을 전부 조인에 밀어 넣을 이유가 없거든요.
예상과 좀 달랐던 건 읽는 행 수 자체는 두 버전이 같았다는 점입니다. 26.8의 런타임 필터는 기본 설정에서 읽은 뒤 파이프라인 안에서 거르고 스토리지 단계의 프루닝은 꺼져 있거든요. 대신 조인 연산자에 닿는 행이 줄어서 Q5의 메모리가 1.4 GiB에서 0.06 GiB로 내려갔습니다. DuckDB는 같은 정보를 zonemap 프루닝까지 연결해서 읽는 양을 줄입니다. zonemap은 저장 블록마다 컬럼의 최소값과 최대값을 적어 둔 것들이라, 찾는 키가 그 범위 밖이면 블록을 읽지도 않습니다[3].
아직 손볼 자리도 있었습니다. NOT EXISTS anti 조인은 SF100에서 25.8이 2.9초, DuckDB가 5.1초인데 26.8은 17초가 걸렸습니다. 1억5천만 그룹을 만드는 GROUP BY도 26.8이 8.5초로 25.8의 4.7초보다 느렸고요. 새 플래너가 모든 자리에서 이기는 건 아니라는 것들을 이런 데서 봅니다. 그래도 큰 그림은 분명합니다. 조인에 관한 한 26.8은 25.8과 다른 엔진이죠.
집계에서는 ClickHouse의 강점이 그대로다
조인이 없는 쿼리에서는 ClickHouse가 원래 잘하던 것들이 그대로 나옵니다. SF100에서 count(DISTINCT) 두 개는 ClickHouse가 2.4초, DuckDB가 10.1초였고, GROUP BY l_suppkey 상위 10개는 0.9초 대 6.8초였습니다. DuckDB는 집계 해시 테이블이 12 GiB 상한까지 차올라 스필했더라고요. LIKE로 comment 컬럼을 훑는 것도 ClickHouse가 두 배 빠릅니다. 반대로 행마다 같은 그룹 안의 순번을 매기는 윈도우 함수와 1억5천만 그룹짜리 GROUP BY 같은 것들은 DuckDB가 앞섰고요.
집계 워크로드에서는 어느 한쪽이 일방적으로 우세하지 않고, 조인처럼 한쪽이 완주조차 못 하는 일도 없었습니다. 결국에는 두 엔진이 같은 데이터를 비슷한 속도로 읽고, 조인에서만 플래너와 메모리 구조 때문에 갈리는 겁니다.
결국에는 어떻게 고를 것인가
제가 정리한 기준은 이렇습니다.
고객이 ClickHouse 25.8 이하를 쓰고 있고 조인이 느리다고 하면, 쿼리 튜닝보다 26.4 이상으로 올리는 게 먼저입니다.
통계, 리오더링, 런타임 필터, 스필이 전부 기본값으로 따라옵니다. 올릴 수 없는 환경이면 작은 테이블을 오른쪽에 두고, 필터는 서브쿼리로 미리 걸고, EXPLAIN PIPELINE에서 FillingRightJoinSide가 어느 테이블에 붙는지 확인하는 것들을 손으로 해야 합니다. grace_hash를 쓸 거면 max_bytes_in_join을 같이 잡고, semi나 anti 조인에는 안 먹힌다는 걸 알고 써야 하고요.
정규화 스키마 위에서 애드혹으로 여러 테이블을 붙이는 노트북 작업이라면 DuckDB가 손이 덜 갑니다. 순서를 어떻게 적어도 같은 시간이 나오고, 메모리를 넘어도 느려질지언정 답은 나옵니다. 서버로 적재하고 서빙하는 워크로드는 ClickHouse 26.x가 조인까지 같이 감당하고, 비정규화한 wide 테이블과 딕셔너리, 머티리얼라이즈드 뷰로 조인을 줄이는 설계와 같이 쓰면 원래의 강점이 더 살아납니다.
가장 크게 남은 건 이겁니다. 엔진을 고를 때 조인이 빠르냐를 물을 게 아니라, 플래너가 내 대신 순서를 정해주느냐를 물어야 합니다. 그 답이 버전 하나 차이로 갈리는 걸 이번에 봤고, 그래서 고객 환경에 들어갈 때 제일 먼저 확인하는 게 버전이 됐죠.
주석
[1] ClickHouse, How ClickHouse became fast at joins (2026-06-03). 24.12의 parallel hash 기본화와 로컬 리오더링, 26.4의 자동 통계와 통계 기반 리오더링, TPC-H SF100 배율이 정리돼 있습니다. https://clickhouse.com/blog/clickhouse-fast-joins. 25.9의 다중 테이블 리오더링 도입은 공식 가이드 Joining Tables에 적혀 있습니다. https://clickhouse.com/docs/guides/joining-tables. 버전별 설정 변경 이력은 각 버전 바이너리의 system.settings_changes 테이블에서 직접 뽑았습니다.
[2] DuckDB 성능 가이드의 Join Operations. disabled_optimizers에 join_order와 build_side_probe_side를 주면 적힌 순서대로 왼쪽 깊은 트리를 만듭니다. https://duckdb.org/docs/current/guides/performance/join_operations.html. 조인 순서 최적화 알고리즘은 DPhyp이며 DuckDB Internals 문서에 있습니다. https://duckdb.org/docs/current/internals/overview.html
[3] DuckDB 블로그, Optimizers: The Low-Key MVP (2024-11-14). 빌드 사이드 키의 min/max를 프로브 스캔에 밀어 넣는 조인 필터 푸시다운을 설명합니다. https://duckdb.org/2024/11/14/optimizers.html
[4] 벤치의 스키마와 22개 쿼리는 ClickHouse 저장소의 tests/benchmarks/tpc-h(ClickHouse용)와 DuckDB tpch 확장(DuckDB용)을 그대로 썼습니다. 환경은 Apple M4 Pro, Docker Desktop 4.61, 컨테이너당 --cpus=8 --cpuset-cpus=0-7 --memory=16g, 쿼리 메모리 상한 12 GiB, 워밍업 1회 후 3회 측정 중앙값(SF100은 2회)입니다.