Blog
pysparksparkdata-skewperformanceaqedata-engineering

PySpark 데이터 스큐 완전 정복 — 99%에서 멈추는 잡 살리기

한 태스크만 끝없이 도는 데이터 스큐 문제를 진단부터 해결까지 정리합니다. 스큐의 원인, Spark UI 로 식별하는 법, AQE Skew Join, Salting, 브로드캐스트, 사전 집계 등 실전 패턴을 코드와 함께 다룹니다.

Data Dynamics2026년 6월 5일14 min read

택배 분류 센터를 상상해 보세요. 직원 200명이 각자 상자를 처리하는데, 199명은 10분 만에 끝냈는데 딱 한 명 앞에 상자가 산더미처럼 쌓여 있습니다. 전체 작업은 그 한 명이 끝날 때까지 기다려야 합니다. Spark 잡이 "99%에서 멈춘 것처럼 보이는" 경험은 바로 이 상황입니다. 200개 태스크 중 199개는 몇 초 만에 끝났는데 마지막 하나가 30분째 돌고 있거든요. 거의 항상 원인은 하나 — 데이터 스큐(Data Skew) 입니다.

이 글은 스큐가 왜 생기는지, Spark UI 로 어떻게 식별하는지, 그리고 AQE Skew Join 부터 Salting 까지 실전 해결 패턴을 코드와 함께 정리합니다.

이 글에서 배우는 것

  • 데이터 스큐가 무엇이고, 왜 Spark 잡을 멈추게 만드는지
  • Spark UI 에서 스큐를 눈으로 확인하는 방법
  • AQE Skew Join — 코드 한 줄로 자동 해결하는 첫 번째 무기
  • Salting, Broadcast Join, NULL 키 분리 등 상황별 실전 해결 패턴
  • 기법 선택 기준과 흔히 빠지는 함정

1. 스큐란 무엇인가 — 파티션 불균형

Spark 는 데이터를 파티션으로 나눠 태스크에 분배합니다. 셔플(조인·groupBy)이 일어나면 같은 키가 같은 파티션으로 모입니다. 특정 키에 데이터가 몰려 있으면, 그 키를 담당하는 파티션만 거대해집니다. 아래 그림이 정상과 스큐의 차이를 한눈에 보여줍니다.

정상:   [50MB][52MB][48MB][51MB] ...  → 모든 태스크 비슷한 시간
스큐:   [50MB][48MB][9GB!!][51MB] ...  → 한 태스크만 30분, 나머지는 끝남

이 상황이 되면 세 가지 증상이 나타납니다.

  • 한두 개 태스크만 유독 오래 걸림(straggler)
  • 그 태스크에서 spill 폭증 또는 OOM
  • 전체 잡 시간이 "가장 느린 태스크 하나"에 묶임

2. 스큐는 어디서 생기나

스큐의 원인은 의외로 단순합니다. 대부분은 "의미 없는 값 하나에 수억 건이 몰리는" 패턴에서 비롯됩니다.

원인예시
특정 키에 데이터 집중user_id = 0(미로그인), null, 게스트 계정
핫 키(인기 상품·인기 유저)이벤트의 10%가 한 셀러에 몰림
NULL 조인 키NULL 끼리 한 파티션에 다 모임
저카디널리티 groupBycountry 로 group → 'KR' 파티션만 거대
불균등한 소스 파티션Kafka 파티션·파일 크기 편차

⚠️ 가장 흔한 범인은 NULL 또는 기본값(0, '', 'unknown') 키입니다. 의미 없는 값이 수억 건 쌓여 한 파티션으로 몰립니다.

3. 진단 — Spark UI 로 스큐 확인

스큐를 해결하기 전에 먼저 "정말 스큐가 맞는지" 확인해야 합니다. 추측하지 말고 Spark UI 의 Stage 상세에서 Task 분포를 직접 살펴보세요.

  • Summary MetricsDuration, Shuffle Read Size, Spill 에서 Max 가 Median(중앙값)의 수십~수백 배라면 스큐입니다.
  • 75th percentileMax 의 격차가 크면 소수 태스크가 전체를 끌고 있다는 신호입니다.
# 어떤 키가 몰렸는지 직접 확인
(df.groupBy("join_key")
   .count()
   .orderBy(F.desc("count"))
   .show(20, truncate=False))

상위 몇 개 키가 전체의 상당 비율을 차지하면 핫 키 스큐가 확정됩니다.

4. 해결 1 — AQE Skew Join (먼저 시도할 것)

Spark 3.0+ 의 Adaptive Query Execution(AQE) 은 런타임에 스큐 파티션을 감지해 자동으로 더 작은 서브파티션으로 쪼갭니다. 코드를 거의 바꾸지 않아도 되니, 다른 기법보다 무조건 먼저 시도해 보세요.

spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true")
 
# 스큐 판정 임계값 (기본값 — 필요 시 조정)
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionFactor", "5")
spark.conf.set("spark.sql.adaptive.skewJoin.skewedPartitionThresholdInBytes", "256MB")

판정 규칙(대략): 한 파티션이 중앙값의 skewedPartitionFactor(기본 5)배 보다 크고 thresholdInBytes(기본 256MB) 도 넘으면 스큐로 보고 분할합니다.

장점한계
코드 변경 거의 없음sort-merge join 에만 적용(broadcast 제외)
런타임 자동 적응극단적 스큐는 분할만으로 부족할 수 있음

AQE 만으로 해결되면 더 손댈 필요 없습니다. 부족하면 아래 기법을 더합니다.

5. 해결 2 — Salting (소금 뿌리기)

AQE 로 해결되지 않는 극단적 핫 키라면 직접 개입이 필요합니다. 핫 키에 랜덤 접미사(salt)를 붙여 여러 파티션으로 퍼뜨리고, 작은 쪽 테이블은 salt 만큼 복제하는 방식입니다. 마치 한 창구에 몰린 줄을 여러 창구로 분산시키는 것과 같습니다.

from pyspark.sql import functions as F
 
N = 16  # salt 개수
 
# 큰(스큐) 테이블: 조인 키에 랜덤 salt 부여
big_salted = big.withColumn("salt", (F.rand() * N).cast("int"))
 
# 작은 테이블: 0..N-1 로 복제(explode)하여 모든 salt 와 매칭되게
small_salted = (small
    .withColumn("salt", F.explode(F.array([F.lit(i) for i in range(N)]))))
 
# salt 를 조인 키에 포함
joined = big_salted.join(small_salted, ["join_key", "salt"]).drop("salt")

핵심 아이디어: 한 핫 키 → key#0 ~ key#15 로 16조각으로 나눠 16개 태스크가 나눠 처리. 작은 테이블을 16배 복제하는 비용이 들지만, 스큐로 멈추는 것보다 훨씬 낫습니다.

핫 키만 골라 Salting (최적화)

전체에 salt 를 주면 셔플이 늘어납니다. 핫 키에만 salt 를 적용하고 나머지는 일반 조인하면 효율적입니다.

hot_keys = [0, None]  # 진단으로 찾은 핫 키
is_hot = F.col("join_key").isin(hot_keys)
 
# 핫 키 행만 salt, 나머지는 그대로 → union 후 조인하는 패턴

6. 해결 3 — Broadcast Join

조인 상대가 충분히 작다면(수백 MB 이하), 셔플 자체를 없애버리는 방법도 있습니다. 작은 쪽 테이블을 모든 익스큐터에 복제해 두면 데이터를 이동할 필요가 없으니 스큐도 생길 일이 없습니다.

from pyspark.sql.functions import broadcast
 
joined = big.join(broadcast(small_dim), "join_key")
 
# 자동 브로드캐스트 임계값 조정 (기본 10MB)
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "100MB")

차원 테이블처럼 한쪽이 작은 별-스키마 조인에서 스큐의 근본 해법입니다. 단, 너무 큰 테이블을 broadcast 하면 익스큐터 OOM 이 나므로 크기를 확인하세요.

한 문장으로: 조인 대상이 작으면 Broadcast 가 가장 단순하고 확실한 해법입니다.

7. 해결 4 — NULL/기본값 키 분리

NULL 키는 조인에서 서로 매칭되지 않는데도, 파티셔닝 시 같은 파티션에 모두 모입니다. 결국 아무 의미도 없는 행들이 스큐를 만드는 셈이죠. 조인 전에 미리 분리해 주면 됩니다.

# NULL 키는 어차피 inner join 에서 매칭 안 되므로 미리 제외
matched = big.filter(F.col("join_key").isNotNull()).join(small, "join_key")
 
# NULL 행이 결과에 필요하면(outer) 따로 처리 후 union
null_rows = big.filter(F.col("join_key").isNull())
result = matched.unionByName(null_rows, allowMissingColumns=True)

기본값(0, 'unknown')도 같은 원리로, 의미 없는 값이면 분리하거나 별도 처리합니다.

8. 해결 5 — 사전 집계로 조인 데이터 줄이기

groupBy 스큐가 문제라면 데이터 자체를 조인 전에 줄이는 전략이 효과적입니다. 큰 fact 테이블을 키별로 먼저 집계하면 조인에 들어가는 행 수가 대폭 줄어 스큐가 자연스럽게 완화됩니다.

# fact 를 먼저 집계 → 작아진 결과를 차원과 조인
agg = fact.groupBy("seller_id").agg(F.sum("amount").alias("total"))
result = agg.join(dim_seller, "seller_id")

9. 기법 선택 가이드

어떤 기법을 써야 할지 헷갈린다면 아래 표를 기준으로 삼으세요. 대개 AQE 켜기 → 그래도 남으면 broadcast/salting 순서로 접근합니다.

상황1순위 해법
일반적 스큐AQE Skew Join 켜기
한쪽이 작음Broadcast Join
극단적 핫 키(소수)핫 키 Salting
NULL/기본값 키키 분리 후 처리
groupBy 스큐사전 집계 / 2단계 집계
반복되는 같은 조인버킷팅(bucketing)으로 사전 셔플

10. 흔한 함정

스큐를 처음 만나면 직관적으로 시도하기 쉽지만 효과 없는 방법들이 있습니다. 아래 함정은 미리 알아 두면 시간을 아낄 수 있습니다.

함정결과
repartition(n) 으로 해결 시도균등 재분배일 뿐, 키 스큐는 그대로
salt 개수를 너무 작게분산 부족
salt 개수를 너무 크게작은 테이블 복제 비용 폭증
broadcast 대상이 실제로 큼익스큐터 OOM
AQE 꺼둠자동 스큐 처리 못 받음

팁: repartition 은 파티션 개수를 바꿀 뿐, 특정 키가 한 파티션에 몰리는 건 못 막습니다. 키 스큐에는 salt/broadcast 가 정답입니다.

11. 정리

해법핵심적합
AQE Skew Join런타임 자동 분할거의 모든 경우 먼저
Broadcast셔플 제거한쪽이 작을 때
Salting핫 키 인위 분산극단적 핫 키
키 분리NULL/기본값 격리의미 없는 키 집중
사전 집계조인 데이터 축소groupBy 스큐

데이터 스큐 해결의 출발점은 "추측하지 말고 Spark UI 에서 태스크 분포를 보는 것"입니다. Max 가 Median 의 수십 배라면 스큐를 의심하고, 어떤 키가 몰렸는지 확인한 뒤 AQE → broadcast → salting 순으로 대응하세요. 99%에서 멈추던 잡이 균등하게 끝나는 순간, 스큐를 이해한 보람을 느끼게 됩니다.

마치며 — 핵심 요약

  • 데이터 스큐 = 특정 파티션만 거대해지는 불균형 — 한 태스크가 전체 잡을 인질로 잡습니다.
  • 진단 먼저, 최적화 나중 — Spark UI 의 Max vs Median 비교로 스큐 여부를 눈으로 확인하세요.
  • AQE 가 첫 번째 무기spark.sql.adaptive.enabled=true 한 줄로 많은 경우가 해결됩니다.
  • Broadcast 는 가장 깔끔한 해법 — 한쪽이 작다면 셔플을 아예 없애는 게 최선입니다.
  • Salting 은 극약처방 — 작은 테이블 복제 비용이 따르지만, 극단적 핫 키에는 확실한 해결책입니다.
  • NULL 과 기본값 키는 항상 의심 — 의미 없는 키에 데이터가 쌓이는 패턴이 가장 흔한 원인입니다.

스큐는 데이터 규모가 커질수록 반드시 마주치는 문제입니다. 오늘 배운 도구들을 순서대로 꺼내 쓰다 보면, 30분짜리 잡을 3분으로 줄이는 경험을 분명 하게 될 겁니다.


이 글은 Spark 3.5 기준으로 작성되었습니다. 대규모 Spark 잡의 스큐·성능 문제 진단이 필요하시면 언제든 문의해 주세요.

— Data Dynamics 엔지니어링 팀