PySpark 버킷팅 — 반복되는 큰 조인의 셔플을 제거하기
같은 큰 테이블을 매번 조인하느라 셔플이 반복된다면 버킷팅(bucketing)이 답입니다. 버킷팅이 셔플을 없애는 원리, 생성·조인 방법, 버킷 수 설계, 그리고 Iceberg/Delta 시대에 버킷팅을 언제 쓰고 언제 피해야 하는지 정리합니다.
매일 수십 번씩 같은 두 테이블을 조인하는 파이프라인을 운영해 본 적 있으신가요? 로그가 쌓일 때마다, 배치가 돌 때마다, Spark는 두 테이블을 조인 키 기준으로 네트워크를 통해 재분배합니다 — 이른바 셔플이죠. 데이터 크기가 크다면 이 비용이 잡 전체 시간의 절반을 넘기도 합니다. 버킷팅(Bucketing) 은 이 셔플을 데이터를 쓸 때 미리 한 번 해두어, 이후 조인에서는 셔플을 완전히 없애는 기법입니다.
이 글에서 배우는 것
- 버킷팅이 셔플을 제거하는 원리
- 버킷 테이블을 생성하고 조인에 활용하는 방법
- 버킷 수를 어떻게 설계해야 하는지
- 버킷팅의 함정과 피해야 할 상황
- Iceberg/Delta Lake 환경에서 버킷팅을 대체할 수 있는 선택지
1. 문제 — 반복되는 셔플
broadcast로 해결하기엔 두 테이블 모두 너무 크고, 조인 키는 매번 같은 상황이라면 어떨까요? Spark의 SortMergeJoin은 매번 양쪽 테이블을 네트워크로 뒤섞어야 합니다. 이 비용이 날마다, 시간마다 누적되면 막대한 클러스터 자원이 낭비됩니다.
한 문장으로: 같은 셔플을 반복하는 게 문제라면, 그 셔플을 딱 한 번만 하도록 미리 해두면 됩니다.
2. 버킷팅의 아이디어 — 셔플을 미리 해두기
버킷팅은 테이블을 쓸 때 조인 키의 해시로 N개 버킷에 미리 나눠 저장합니다. 같은 키는 항상 같은 버킷 번호로 갑니다. 두 테이블을 같은 키·같은 버킷 수로 버킷팅해 두면, 조인 시 "같은 버킷끼리만" 맞추면 되므로 셔플이 필요 없습니다.
| 버킷팅 없음 | 버킷팅 | |
|---|---|---|
| 조인 시 셔플 | 매번 양쪽 | 없음(미리 해둠) |
| 쓰기 비용 | 낮음 | 높음(쓸 때 셔플) |
| 적합 | 일회성 조인 | 반복되는 조인 |
핵심은 이겁니다. 셔플 비용을 "쓰기 시점 한 번"으로 옮기는 것이죠. 한 번 버킷팅해두면 이후 조인에서는 네트워크 재분배 없이 같은 버킷끼리 바로 맞춥니다.
3. 버킷 테이블 생성
버킷팅은 메타스토어 테이블(saveAsTable) 로 저장해야 합니다. 단순 write.parquet 경로 저장으로는 버킷 정보가 보존되지 않습니다.
⚠️
write.parquet(path)방식은 파일만 남기고 버킷 메타데이터는 잃어버립니다. 반드시saveAsTable로 저장하세요.
# user_id 로 32개 버킷, 버킷 내 정렬까지
(big_A.write
.bucketBy(32, "user_id")
.sortBy("user_id") # 정렬해두면 SMJ 의 정렬 단계도 절약
.mode("overwrite")
.saveAsTable("analytics.events_bucketed"))
(big_B.write
.bucketBy(32, "user_id") # 같은 키, 같은 버킷 수!
.sortBy("user_id")
.mode("overwrite")
.saveAsTable("analytics.users_bucketed"))4. 버킷 조인 — 셔플이 사라지는지 확인
버킷 테이블끼리 조인하면 Spark는 자동으로 셔플 없이 처리합니다. explain()으로 실제로 셔플이 사라졌는지 꼭 확인해 봅시다.
a = spark.table("analytics.events_bucketed")
b = spark.table("analytics.users_bucketed")
joined = a.join(b, "user_id")
joined.explain()
# → Exchange(셔플) 노드가 사라지고 SortMergeJoin 만 남으면 성공EXPLAIN 에서 조인 양쪽의 Exchange 가 없으면 버킷팅이 동작한 것입니다(별도 글 "PySpark 느린 잡 디버깅"의 EXPLAIN 읽기 참고). 버킷이 다르면(버킷 수 불일치 등) 셔플이 다시 등장합니다.
5. 버킷 수 설계
버킷 수는 한 번 정하면 쉽게 바꿀 수 없는 결정입니다 — 나중에 바꾸려면 전체 재작성이 필요하기 때문이죠. 처음부터 신중하게 설계하는 것이 중요합니다.
| 고려 | 지침 |
|---|---|
| 버킷당 크기 | 파티션처럼 128MB~1GB 목표 |
| 버킷 수 | 너무 적으면 버킷이 거대(스큐·OOM), 너무 많으면 작은 파일 |
| 병렬성 | 버킷 수 ≥ 익스큐터 코어 수 정도 |
| 양쪽 일치 | 조인할 두 테이블의 버킷 수가 같아야 셔플 제거 |
# 대략적 추정: 총 크기 / 목표 버킷 크기
# 예: 256GB / 512MB ≈ 512 버킷 (2의 거듭제곱 선호)⚠️ 흔한 함정: 한쪽은 32 버킷, 다른 쪽은 64 버킷으로 만들면 셔플 제거가 안 됩니다. 함께 조인할 테이블들은 버킷 수를 통일하세요.
6. 버킷팅의 한계와 함정
버킷팅이 항상 정답은 아닙니다. 다음 상황에서는 오히려 손해가 날 수 있습니다.
| 함정 | 결과 |
|---|---|
| 버킷 수 불일치 | 셔플 다시 발생 |
| 버킷 키 스큐 | 특정 버킷만 거대 → 스큐 |
| 작은 테이블에 버킷팅 | 불필요 — broadcast 가 나음 |
| 일회성 조인에 버킷팅 | 쓰기 셔플 비용만 손해 |
| 버킷 수 변경 | 전체 재작성 필요 |
write.parquet 경로 저장 | 버킷 정보 소실 → 메타스토어 테이블 필수 |
버킷팅은 반복되는 조인에서만 이득입니다. 한 번 쓰고 마는 조인이라면 쓰기 시 셔플 비용만 추가될 뿐입니다.
7. Lakehouse 시대 — Iceberg/Delta 와 버킷팅
이 블로그 독자에게 중요한 부분입니다. Iceberg/Delta 는 테이블 포맷 차원의 버킷 파티셔닝을 제공해, Hive 식 버킷팅보다 유연합니다.
Iceberg bucket transform
Hive 버킷팅 대신 Iceberg의 bucket transform을 쓰면 나중에 버킷 수를 바꿀 수 있고, Trino 같은 다른 엔진과도 일관되게 동작합니다.
-- Iceberg: bucket transform 으로 hidden partitioning
CREATE TABLE analytics.events (
user_id BIGINT, event_time TIMESTAMP, ...
) USING iceberg
PARTITIONED BY (days(event_time), bucket(32, user_id));Iceberg 의 bucket(N, col) 은 파티션 전략의 일부로, 파티션 진화로 나중에 바꿀 수 있고 메타데이터 기반 프루닝과 함께 동작합니다. (Iceberg 파티션 transform 은 별도 글 "Trino + Iceberg 는 파티션 문제를 어떻게 해결하는가"에서 다뤘습니다.)
| Hive 버킷팅 | Iceberg bucket | |
|---|---|---|
| 변경 | 전체 재작성 | 파티션 진화 |
| 프루닝 | 제한적 | manifest 기반 |
| 엔진 호환 | Spark 중심 | Spark·Trino 공통 |
팁: Lakehouse 를 쓴다면, 순수 Hive 버킷팅보다 Iceberg/Delta 의 bucket 파티셔닝을 우선 검토하세요. 더 유연하고 Trino 등 다른 엔진과도 일관되게 동작합니다.
8. 버킷팅 vs 다른 셔플 절감 기법
셔플을 줄이는 방법은 버킷팅만이 아닙니다. 상황에 맞는 기법을 고르는 것이 중요합니다.
| 기법 | 언제 |
|---|---|
| Broadcast Join | 한쪽이 작을 때 (가장 먼저) |
| 버킷팅 | 큰 테이블끼리 반복 조인 |
| 사전 집계 | 조인 전 데이터를 줄일 수 있을 때 |
| AQE 파티션 병합 | 셔플 후 작은 파티션 정리 |
| Iceberg bucket 파티션 | Lakehouse + 반복 조인 |
순서: broadcast 로 되면 broadcast → 안 되고 반복 조인이면 버킷팅/Iceberg bucket.
9. 정리
| 항목 | 핵심 |
|---|---|
| 원리 | 셔플을 쓰기 시점에 미리 → 조인 시 셔플 0 |
| 생성 | bucketBy(N, key).sortBy(key).saveAsTable |
| 필수 조건 | 두 테이블 같은 키·같은 버킷 수, 메타스토어 테이블 |
| 적합 | 반복되는 큰 테이블 조인 |
| 부적합 | 일회성 조인, 작은 테이블(→broadcast) |
| Lakehouse | Iceberg/Delta bucket 파티셔닝 우선 |
버킷팅은 "셔플 비용을 미래의 모든 조인에서 한 번으로 줄이는" 투자입니다. 같은 큰 테이블을 반복 조인하는 워크로드라면 강력하지만, 일회성 조인이나 작은 테이블에는 손해입니다. 그리고 Lakehouse 환경이라면 더 유연한 Iceberg/Delta 의 bucket 파티셔닝을 먼저 고려하는 것이 현명합니다 — 셔플 제거라는 목적은 같으면서, 파티션 진화와 멀티 엔진 호환이라는 이점이 따라오기 때문입니다.
마치며 — 핵심 요약
- 버킷팅 = 쓰기 시점에 미리 셔플 — 이후 모든 조인에서 셔플 비용이 0이 됩니다.
- 같은 키, 같은 버킷 수가 필수 — 하나라도 다르면 셔플이 다시 살아납니다.
saveAsTable만 버킷 정보를 보존 —write.parquet은 버킷 메타를 잃어버립니다.- 반복되는 큰 조인에만 투자할 것 — 일회성 조인이나 작은 테이블엔 broadcast가 더 낫습니다.
- Lakehouse 환경이라면 Iceberg/Delta bucket 파티셔닝을 먼저 — 파티션 진화와 멀티 엔진 호환이라는 보너스가 따라옵니다.
반복 조인이 잡 시간을 잡아먹고 있다면, 버킷팅 한 번으로 극적인 개선을 경험해 보세요 — 셔플 비용을 한 번에 끊어내는 가장 직접적인 방법입니다.
이 글은 Spark 3.5 + Iceberg/Delta 기준으로 작성되었습니다. 반복 조인 최적화나 Lakehouse 파티션 설계가 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀