Blog
pysparksparkdata-skewperformanceaqedata-engineering

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

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

Data Dynamics2026年6月5日14 min read
This post is not yet translated. The original Korean version is shown below.

택배 분류 센터를 상상해 보세요. 직원 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 엔지니어링 팀