PySpark 대규모 피벗·와이드 변환 — 수천 컬럼으로 펼치기
고카디널리티 피벗이 느리거나 터지는 이유와 해법. pivot 의 2단계 비용, values 명시로 단일 패스 만들기, 수천 컬럼 와이드 테이블의 함정, map/array 대안, 그리고 unpivot(stack) 으로 되돌리는 패턴까지 정리합니다.
"상품별 매출을 월별 컬럼으로 펼쳐라", "사용자별로 이벤트 타입을 컬럼으로 만들어라" — 이런 피벗(pivot) 요청은 분석에서 흔하지만, 카디널리티가 크면 PySpark 에서 느려지거나 터집니다. 피벗 대상 값이 수천 개면 컬럼이 수천 개인 와이드 테이블이 되고, Spark 는 이걸 잘 다루지 못합니다.
이 글은 피벗이 왜 비싼지, values 명시로 비용을 절반으로 줄이는 법, 와이드 테이블의 함정, 그리고 map/array 대안과 unpivot 패턴을 정리합니다.
1. 기본 피벗과 숨은 비용
from pyspark.sql import functions as F
# 상품 × 월 피벗
pivoted = (df
.groupBy("product")
.pivot("month") # month 값들이 컬럼이 됨
.agg(F.sum("amount")))여기엔 숨은 비용이 있습니다. Spark 는 어떤 컬럼을 만들지 모릅니다. month 에 어떤 고유값이 있는지 알아야 컬럼을 정할 수 있으므로, 피벗은 내부적으로 두 번 스캔합니다.
1차 스캔: pivot 컬럼의 고유값 수집 (distinct month)
2차 스캔: 실제 집계
→ 데이터를 두 번 읽음2. 해법 ① values 명시 — 단일 패스로
피벗할 값을 미리 알고 있다면 명시하세요. 1차 distinct 스캔이 사라져 비용이 절반이 됩니다.
months = ["2026-01", "2026-02", "2026-03", "2026-04",
"2026-05", "2026-06"]
pivoted = (df
.groupBy("product")
.pivot("month", months) # 값 명시 → 단일 패스
.agg(F.sum("amount")))| values 미지정 | values 명시 | |
|---|---|---|
| 스캔 | 2회(distinct + 집계) | 1회 |
| 컬럼 결정 | 런타임 추론 | 고정 |
| 속도 | 느림 | 빠름 |
실무 규칙: 피벗 대상 값이 알려진 유한 집합(월, 카테고리, 상태값)이면 항상 명시하세요. 모르면 별도로 distinct 를 구한 뒤 명시하는 게, pivot 에 맡기는 것보다 제어가 쉽습니다.
3. 해법 ② 고카디널리티 피벗을 막기
피벗 대상 카디널리티에 상한이 있습니다. Spark 는 기본적으로 피벗 컬럼이 너무 많으면(기본 1만) 거부합니다 — 이건 보호 장치입니다.
pivot 컬럼 수가 수천~수만 → 와이드 테이블
→ 행마다 수천 컬럼, 대부분 NULL(희소) → 메모리·성능 재앙# 한계를 늘릴 수는 있지만 (보통 나쁜 신호)
spark.conf.set("spark.sql.pivotMaxValues", "10000")피벗 카디널리티가 크면, 피벗이 잘못된 도구라는 신호입니다. 다음 대안을 고려하세요.
4. 해법 ③ map / array — 와이드 대신 구조화
수천 개의 키-값을 굳이 컬럼으로 펼치지 말고, 하나의 map 컬럼에 담으면 희소 데이터를 효율적으로 표현합니다.
# 피벗(와이드) 대신 map 으로 집계
as_map = (df
.groupBy("product")
.agg(F.map_from_entries(
F.collect_list(F.struct("month", "amount"))).alias("by_month")))
# 조회는 map 접근
as_map.select("product", F.col("by_month")["2026-06"].alias("jun"))| 표현 | 적합 |
|---|---|
| 피벗(와이드 컬럼) | 카디널리티 작음(수십), BI 친화 |
| map 컬럼 | 카디널리티 큼, 희소, 동적 키 |
| long format(그대로) | 집계·필터가 주목적 |
핵심 통찰: "컬럼으로 펼쳐야 한다"는 요구는 대개 표현(presentation) 단계의 필요입니다. 처리·저장은 long format 이나 map 으로 두고, 최종 표시 직전에만 작은 범위로 피벗하는 것이 효율적입니다.
5. 와이드 테이블의 함정
수백~수천 컬럼 테이블 자체가 Spark 에 부담입니다.
| 함정 | 이유 |
|---|---|
| 플래너 지연 | 컬럼 수에 비례해 계획 수립 느려짐 |
| 코드젠 한계 | Whole-Stage CodeGen 이 컬럼 많으면 비효율/폴백 |
| 희소·NULL 낭비 | 대부분 NULL 인 컬럼 저장·처리 비용 |
| 직렬화 비용 | 행마다 수천 필드 |
수천 컬럼이 정말 필요한지 자문하세요. 분석·ML 입력이라면 보통 필요한 피처만 선택하거나 벡터로 묶는 게 낫습니다.
6. Unpivot — 와이드를 다시 long 으로
반대로 와이드 테이블(컬럼이 값인)을 long format 으로 되돌려야 할 때가 많습니다(정규화, 집계 용이). stack 또는 Spark 3.4+ 의 unpivot 을 씁니다.
# Spark 3.4+ unpivot (melt)
long_df = wide_df.unpivot(
ids=["product"],
values=["jan", "feb", "mar"],
variableColumnName="month",
valueColumnName="amount")
# 또는 stack 표현식 (구버전)
long_df = wide_df.select(
"product",
F.expr("stack(3, 'jan', jan, 'feb', feb, 'mar', mar) as (month, amount)"))long format 은 집계·필터·조인에 훨씬 유리합니다. "저장·처리는 long, 표시만 wide" 원칙의 실천 도구입니다.
7. 다중 집계 피벗
여러 지표를 동시에 피벗하면 컬럼이 곱으로 늘어납니다(값 × 지표).
pivoted = (df
.groupBy("product")
.pivot("month", months)
.agg(
F.sum("amount").alias("sum"),
F.count("*").alias("cnt")))
# → month당 sum, cnt 두 컬럼 → 컬럼 수 2배지표가 늘수록 와이드 폭발이 가속되므로, 다중 집계 피벗은 카디널리티를 더 보수적으로 잡으세요.
8. 정리
| 해법 | 핵심 |
|---|---|
values 명시 | 2스캔 → 1스캔 |
| 카디널리티 상한 인지 | 수천 컬럼은 잘못된 신호 |
| map/array | 희소·동적 키를 구조화 |
| long format 유지 | 처리·저장은 long, 표시만 wide |
unpivot/stack | 와이드 → long 복원 |
대규모 피벗의 핵심은 "정말 컬럼으로 펼쳐야 하는가"를 먼저 묻는 것입니다. 피벗 대상이 작고 알려진 집합이면 values 를 명시해 단일 패스로 처리하고, 카디널리티가 크면 피벗 대신 map/long format 으로 두었다가 표시 직전에만 좁게 펼치세요. 수천 컬럼 와이드 테이블은 Spark 가 가장 싫어하는 형태라는 점만 기억하면, 피벗은 더 이상 잡을 터뜨리는 연산이 아닙니다.
이 글은 Spark 3.5 기준으로 작성되었습니다. 대규모 데이터 reshape·집계 파이프라인 설계가 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀