PySpark 설정 튜닝 종합 — 어떤 config를 언제 바꿔야 하는가
수많은 Spark 설정 중 실제로 효과 있는 것만 정리합니다. 익스큐터 사이징(코어·메모리·인스턴스), AQE, 셔플 파티션, 직렬화, 동적 할당, 그리고 "메모리부터 올리는" 안티패턴 대신 근거 기반으로 튜닝하는 순서를 다룹니다.
잡이 느릴 때 여러분은 어떻게 하시나요? 많은 팀이 executor.memory 를 올려 보고, 그래도 안 되면 인스턴스를 늘립니다. 비용은 올라가는데 속도는 그대로인 경험, 한 번쯤 해보셨을 거예요. Spark 설정은 수백 개이지만 실제로 효과 있는 설정은 십여 개뿐이고, 무엇을 언제 바꿔야 하는지에는 명확한 순서가 있습니다.
이 글은 정말 중요한 Spark 설정만 골라, 무엇을 어떤 근거로 조정하는지 정리합니다. (개별 문제 — 스큐·OOM·작은 파일 — 의 상세 튜닝은 각 전용 글에서 다뤘으니, 여기서는 전체 그림과 우선순위를 봅니다.)
이 글에서 배우는 것
- 추측 대신 측정에 기반한 Spark 튜닝 순서
- AQE(Adaptive Query Execution)가 자동으로 해결해 주는 것들
- 익스큐터 코어·메모리·인스턴스를 합리적으로 정하는 방법
- 셔플 파티션 기본값 200의 함정과 대처법
- 설정이 아니라 코드를 바꿔야 하는 상황 구분하기
1. 튜닝의 황금률 — 측정하고, 가장 큰 병목부터
❌ 추측: 느리다 → memory 올린다 → 안 된다 → 인스턴스 늘린다 → 비용만↑
✅ 근거: Spark UI 측정 → 병목 식별(스큐/셔플/OOM) → 해당 설정만 조정의사가 진단 없이 처방을 내리지 않듯이, 설정을 바꾸기 전에 Spark UI 로 병목을 먼저 식별하세요(별도 글 "PySpark 느린 잡 디버깅"). 스큐인데 메모리를 올리거나, 셔플 문제인데 인스턴스를 늘리는 건 효과가 없습니다.
한 문장으로: 측정 없는 튜닝은 처방 없는 치료입니다.
2. 가장 먼저 — AQE 켜기
Spark 3.x 의 Adaptive Query Execution 은 런타임에 파티션 병합·스큐 조인·조인 전략을 자동 조정합니다. 코드 한 줄 추가 없이 적용 가능한, 가장 적은 노력으로 가장 큰 효과를 주는 설정입니다.
spark.conf.set("spark.sql.adaptive.enabled", "true") # 마스터 스위치
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true") # 작은 파티션 자동 병합
spark.conf.set("spark.sql.adaptive.skewJoin.enabled", "true") # 스큐 조인 자동 분할
spark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", "128MB")최신 Spark 는 AQE 가 기본 활성이지만, 명시적으로 확인해 두는 것이 좋습니다. AQE 하나로 작은 파일·스큐·파티션 수 문제의 상당 부분이 자동 완화됩니다.
3. 익스큐터 사이징 — 가장 중요한 결정
자원 배분의 핵심은 세 값의 균형입니다: 익스큐터 코어, 메모리, 인스턴스 수. 피자를 나눌 때처럼, 조각 수(코어)와 한 조각 크기(메모리), 그리고 피자 판 수(인스턴스)를 함께 고민해야 합니다.
spark.conf.set("spark.executor.cores", "4") # 익스큐터당 동시 태스크
spark.conf.set("spark.executor.memory", "8g") # JVM 힙
spark.conf.set("spark.executor.memoryOverhead", "2g")# 힙 밖(셔플·네이티브·Python)
spark.conf.set("spark.executor.instances", "20") # 익스큐터 수코어 수 — 4~5가 스위트스폿
코어 너무 적음(1~2) → 익스큐터 오버헤드 대비 비효율
코어 너무 많음(>5) → HDFS/스토리지 I/O 처리량 저하, GC 경합
→ 익스큐터당 4~5 코어가 일반적 권장메모리 — 힙과 오버헤드 구분
PySpark 는 Python 워커가 JVM 힙 밖의 메모리를 별도로 사용하기 때문에, memoryOverhead 를 넉넉히 잡아야 "Container killed" 오류를 피할 수 있습니다(별도 글 "PySpark Executor OOM 정복").
| 설정 | 의미 | 지침 |
|---|---|---|
executor.cores | 동시 태스크 | 4~5 |
executor.memory | JVM 힙 | 태스크당 충분히, GC 고려 |
executor.memoryOverhead | 힙 밖 | 힙의 ~25%, PySpark 면 더 |
executor.instances | 익스큐터 수 | 병렬도 = cores × instances |
흔한 실수: "큰 익스큐터 몇 개" vs "작은 익스큐터 많이". 너무 큰 익스큐터는 GC pause 가 길고, 너무 작으면 오버헤드가 큽니다. 중간 크기(4
5코어, 816GB) 다수가 보통 안전합니다.
4. 셔플 파티션 — 200 기본값의 함정
spark.sql.shuffle.partitions 의 기본값 200 은 거의 모든 워크로드에 맞지 않는 값입니다. 마치 신발 사이즈가 "모두 260"인 것과 같죠 — 데이터가 크면 파티션당 너무 커서 OOM, 작으면 빈 파티션만 200개입니다.
# AQE 가 켜져 있으면 런타임에 자동 조정되므로 기본값 신경 덜 써도 됨
spark.conf.set("spark.sql.adaptive.enabled", "true")
# AQE 없이 수동이라면: 파티션당 ~128MB 목표로
# num = 총 셔플 데이터 / 128MB
spark.conf.set("spark.sql.shuffle.partitions", "800")AQE 의
coalescePartitions가 켜져 있으면 이 값을 크게 잡아도(예: 2000) 런타임에 적정 수로 병합해주므로 안전합니다. AQE 시대에는 이 값을 일일이 맞추는 부담이 크게 줄었습니다.
5. 직렬화·압축
직렬화는 데이터를 네트워크로 주고받을 때 어떻게 포장하는지에 관한 설정입니다. 기본 Java 직렬화보다 Kryo 를 쓰면 짐을 더 작고 빠르게 포장할 수 있습니다.
# Kryo 직렬화 (Java 기본보다 빠르고 작음)
spark.conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
# 셔플 압축(기본 켜짐) — 네트워크·디스크 절약
spark.conf.set("spark.shuffle.compress", "true")
spark.conf.set("spark.shuffle.spill.compress", "true")Kryo 는 거의 항상 이득입니다. 압축은 기본 활성이며 대부분 그대로 두면 됩니다.
6. 동적 할당 — 자원 효율
고정 익스큐터를 항상 켜두는 것은 빈 고속도로에 차선을 영구 예약하는 것과 같습니다. 동적 할당을 쓰면 부하에 따라 익스큐터 수가 자동으로 조절됩니다(특히 공유 클러스터·K8s 환경에서 효과적입니다).
spark.conf.set("spark.dynamicAllocation.enabled", "true")
spark.conf.set("spark.dynamicAllocation.shuffleTracking.enabled", "true") # K8s 필수
spark.conf.set("spark.dynamicAllocation.minExecutors", "2")
spark.conf.set("spark.dynamicAllocation.maxExecutors", "50")
spark.conf.set("spark.dynamicAllocation.executorIdleTimeout", "60s")(K8s 에서의 셔플 추적·스팟 이슈는 별도 글 "PySpark on Kubernetes" 참고.)
7. 브로드캐스트·조인
작은 테이블과 큰 테이블을 조인할 때, 작은 테이블을 모든 익스큐터에 복사해 두면 셔플 없이 로컬에서 처리할 수 있습니다. 이것이 브로드캐스트 조인입니다.
# 자동 브로드캐스트 임계값 (기본 10MB) — 작은 차원 조인 최적화
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "50MB")
# -1 로 끄면 broadcast 안 함 (의도적 비활성 시)너무 크게 잡으면 큰 테이블이 broadcast 돼 OOM 위험이 있으니 주의하세요(별도 글 "Broadcast 변수와 대형 Lookup"). 50~100MB 정도가 보통 안전합니다.
8. 설정 우선순위 — 무엇부터 손대나
어디서 시작해야 할지 막막하다면, 아래 순서를 따라가면 됩니다.
1. AQE 켜기 (skew/coalesce/skewJoin) → 가장 큰 효과, 가장 적은 노력
2. 익스큐터 사이징 (cores 4~5, 메모리+오버헤드)
3. shuffle.partitions (AQE 있으면 크게 둬도 OK)
4. memoryOverhead (PySpark Container killed 대응)
5. 직렬화(Kryo), 동적 할당
6. autoBroadcastJoinThreshold
───
그래도 안 되면 → 코드/데이터 문제 (스큐·UDF·작은파일) — 설정으로 못 고침⚠️ 설정 튜닝에는 한계가 있습니다. 스큐, 잘못된 조인, 비싼 UDF, 작은 파일은 설정으로 못 고칩니다 — 코드·데이터 레이아웃을 바꿔야 합니다. 설정을 다 만져봐도 안 되면, 문제는 설정이 아니라 코드입니다.
9. 워크로드별 프로파일
워크로드 성격에 따라 어느 설정을 먼저 살펴볼지 달라집니다. 아래 표를 참고하면 빠르게 우선순위를 잡을 수 있습니다.
| 워크로드 | 강조 설정 |
|---|---|
| 대형 ETL 배치 | 큰 익스큐터, shuffle.partitions↑, FTE |
| 대화형/짧은 쿼리 | 동적 할당, 작은 지연, broadcast |
| 스트리밍 | 트리거·백프레셔, 체크포인트, 적정 익스큐터 |
| ML 학습 | 메모리↑, 캐시, 적은 셔플 |
| 고동시성 공유 | 동적 할당, 공정 스케줄러 |
10. 정리
| 영역 | 핵심 설정 | 효과 |
|---|---|---|
| AQE | adaptive.* | 자동 스큐·파티션·조인 |
| 사이징 | cores 4~5, 메모리, 오버헤드 | 안정성·처리량 |
| 셔플 | shuffle.partitions(AQE면 덜 중요) | 파티션 크기 |
| 직렬화 | Kryo | 속도 |
| 동적 할당 | dynamicAllocation.* | 자원 효율 |
| 조인 | autoBroadcastJoinThreshold | 셔플 회피 |
Spark 설정 튜닝의 핵심은 "측정으로 병목을 찾고, 가장 효과 큰 것부터 조정하되, 설정의 한계를 인정하는 것"입니다. AQE 를 켜고 익스큐터를 합리적으로 사이징하는 것만으로 대부분의 잡이 안정화됩니다. 그래도 느리다면 — 설정을 더 만지기 전에 스큐·UDF·작은 파일 같은 코드·데이터 문제를 의심하세요. 좋은 설정은 좋은 코드를 빠르게 하지만, 나쁜 코드를 고쳐주지는 못합니다.
마치며 — 핵심 요약
- 측정이 먼저입니다. Spark UI 에서 병목을 확인하기 전에 설정을 건드리지 마세요.
- AQE 가 가장 강력한 한 방입니다. 켜는 것만으로 스큐·파티션·조인 문제의 상당 부분이 자동 해결됩니다.
- 익스큐터는 중간 크기 다수가 안전합니다. 코어 4
5개, 메모리 816GB 를 여러 개 두는 패턴을 기본으로 삼으세요. - 셔플 파티션 200은 함정입니다. AQE 가 켜져 있다면 크게 잡아도 괜찮고, 없다면 데이터 크기에 맞게 조정하세요.
- PySpark 는
memoryOverhead를 꼭 챙겨야 합니다. Python 워커 메모리를 JVM 힙과 별도로 확보하지 않으면 "Container killed" 가 반복됩니다. - 설정의 한계를 아는 것도 실력입니다. 코드·데이터 레이아웃 문제는 설정으로 해결되지 않습니다 — 그 경계를 알면 시간 낭비를 크게 줄일 수 있습니다.
이 순서대로 하나씩 확인해 보면, 대부분의 Spark 잡은 생각보다 훨씬 빠르게 안정됩니다. 여러분의 클러스터를 응원합니다!
이 글은 Spark 3.5 기준으로 작성되었습니다. Spark 클러스터·잡의 종합 성능 튜닝이 필요하시면 언제든 문의해 주세요.
— Data Dynamics 엔지니어링 팀