Blog
pysparksparkmemoryoomtuningdata-engineering

PySpark Executor OOM 정복 — Container killed 에러 끝내기

"Container killed by YARN for exceeding memory limits" 에러의 진짜 원인을 파헤칩니다. Executor 메모리 구조(heap/overhead/off-heap), spill, GC, 파티션 크기, PySpark 특유의 Python 메모리까지 이해하고 OOM 을 구조적으로 해결하는 법을 정리합니다.

Data Dynamics2026년 6월 5일11 min read

Spark 운영에서 가장 악명 높은 에러 두 가지가 있습니다. java.lang.OutOfMemoryError, 그리고 Container killed by YARN for exceeding memory limits. X GB of Y GB physical memory used. 둘 다 "메모리 부족"이지만 원인과 해법이 다릅니다. 무작정 executor.memory 만 올리면 자원만 낭비하고 문제는 반복됩니다.

이 글은 Spark Executor 의 메모리가 어떻게 나뉘는지부터 시작해, 두 OOM 의 차이, PySpark 특유의 Python 메모리 문제, 그리고 OOM 을 구조적으로 없애는 접근을 정리합니다.

1. Executor 메모리 구조 — 먼저 그림부터

executor.memory 하나만 보면 OOM 을 이해할 수 없습니다. 컨테이너 하나의 메모리는 여러 영역으로 나뉩니다.

┌─────────────────── 컨테이너 (YARN/K8s 가 죽이는 단위) ───────────────────┐
│                                                                          │
│  ┌──────────────── JVM 힙 (spark.executor.memory) ───────────────┐      │
│  │  Reserved (300MB)                                              │      │
│  │  ┌─ Unified Memory (spark.memory.fraction, 기본 0.6) ──────┐  │      │
│  │  │   Execution (셔플·정렬·조인 버퍼)  ⇄  Storage (캐시)     │  │      │
│  │  └─────────────────────────────────────────────────────────┘  │      │
│  │  User Memory (UDF·사용자 자료구조)                             │      │
│  └────────────────────────────────────────────────────────────────┘      │
│                                                                          │
│  Overhead (spark.executor.memoryOverhead)  ← 네이티브·셔플·Python 일부   │
│  Off-heap (spark.memory.offHeap.size, 선택)                              │
│  ⟵ PySpark: Python 워커 프로세스 메모리는 힙 밖! (overhead 압박)         │
└──────────────────────────────────────────────────────────────────────────┘

핵심: YARN/K8s 는 "컨테이너 전체 물리 메모리"를 보고 죽입니다. 힙(executor.memory)이 멀쩡해도 overhead + Python 프로세스가 컨테이너 한도를 넘으면 "Container killed" 가 납니다.

2. 두 OOM 은 다르다

에러발생 위치의미1차 대응
java.lang.OutOfMemoryError: Java heap spaceJVM 힙 내부힙 부족파티션 작게, 메모리↑, 캐시 줄이기
Container killed ... exceeding memory limits컨테이너 전체overhead/off-heap/Python 초과memoryOverhead↑, Python 메모리 관리

가장 흔한 오해: "Container killed" 를 보고 executor.memory(힙)만 올리는 것. 이 에러는 보통 overhead 부족이라, 힙을 올리면 오히려 컨테이너가 더 커져 악화될 수 있습니다. memoryOverhead 를 올리는 게 정답인 경우가 많습니다.

3. 핵심 설정 한눈에

spark = (SparkSession.builder
    .config("spark.executor.memory", "8g")              # JVM 힙
    .config("spark.executor.memoryOverhead", "2g")      # 네이티브+셔플+Python
    .config("spark.executor.cores", "4")                # 코어당 동시 태스크
    .config("spark.memory.fraction", "0.6")             # execution+storage 비율
    .config("spark.sql.shuffle.partitions", "400")      # 셔플 파티션 수
    .getOrCreate())
설정역할튜닝 방향
executor.memoryJVM 힙너무 크면 GC 악화
executor.memoryOverhead힙 밖 영역Container killed 시 1순위 ↑
executor.cores동시 태스크 수많을수록 메모리 경합↑
memory.fraction작업/캐시 풀 비율캐시 안 쓰면 execution 여유
sql.shuffle.partitions셔플 후 파티션 수늘리면 파티션당 메모리↓

4. OOM 의 진짜 원인 — 파티션이 너무 크다

대부분의 힙 OOM 은 메모리가 작아서가 아니라 파티션 하나가 익스큐터 메모리에 안 들어가기 때문입니다. 한 태스크는 파티션 하나를 통째로 다룹니다.

파티션당 데이터 ≈ 입력 크기 / 파티션 수
파티션이 크면 → 태스크 하나가 거대 데이터를 메모리에 올림 → OOM

해법은 보통 "메모리 키우기"가 아니라 "파티션 잘게 쪼개기" 입니다.

# 셔플 후 파티션 수 늘리기 → 파티션당 데이터 감소
spark.conf.set("spark.sql.shuffle.partitions", "800")
 
# 입력 단계에서 재분배
df = df.repartition(800, "key")
 
# AQE 가 런타임에 적정 파티션으로 자동 조정 (권장)
spark.conf.set("spark.sql.adaptive.enabled", "true")
spark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", "true")

경험칙: 파티션당 처리 데이터를 100~200MB 수준으로 맞추는 것을 목표로 합니다. OOM 이 나면 파티션 수부터 늘려보세요. 단, 스큐로 특정 파티션만 큰 경우라면 파티션 수를 늘려도 안 되니, 스큐 해결(별도 글 "PySpark 데이터 스큐 완전 정복")이 먼저입니다.

5. Spill — OOM 직전의 신호

Execution 메모리가 부족하면 Spark 는 데이터를 디스크로 흘립니다(spill). spill 자체는 OOM 을 막아주지만, 과도한 spill 은 "메모리가 빠듯하다"는 경고등입니다.

Spark UI 의 Task 메트릭에서 Spill (Memory), Spill (Disk) 가 크면:

  • 파티션을 더 잘게 (shuffle.partitions ↑)
  • 불필요한 컬럼 제거(select 로 일찍 좁히기)
  • 캐시를 줄여 execution 풀 확보

spill 이 디스크 I/O 로 잡을 느리게 하므로, 약간의 메모리 여유가 큰 성능 차이를 냅니다.

6. PySpark 특유의 함정 — Python 메모리

PySpark 에서 Python UDF·pandas_udf·applyInPandas 를 쓰면, JVM 힙 밖에서 별도 Python 워커 프로세스가 뜹니다. 이 메모리는 executor.memory(힙)가 아니라 컨테이너 overhead 를 압박합니다.

Python UDF 실행 → JVM ↔ Python 직렬화 → Python 프로세스가 데이터 메모리 점유
→ 힙은 여유인데 컨테이너가 죽음 (Container killed)

대응:

# Python 워커 메모리 상한 (이 값을 넘으면 spill)
spark.conf.set("spark.executor.pyspark.memory", "2g")
# 그만큼 overhead 도 넉넉히
spark.conf.set("spark.executor.memoryOverhead", "3g")

근본 해법은 Python UDF 를 줄이는 것입니다. 가능하면 내장 함수로 대체하고, 불가피하면 행 단위 UDF 대신 벡터화된 pandas_udf 를 쓰세요. (자세한 내용은 별도 글 "PySpark Python UDF vs Pandas UDF"에서 다룹니다.)

7. collect / toPandas — 드라이버 OOM

익스큐터가 아니라 드라이버가 죽는 경우도 흔합니다. 거대한 결과를 드라이버 메모리로 끌어오는 액션이 범인입니다.

# 위험: 전체 결과를 드라이버 메모리로
data = df.collect()
pdf = df.toPandas()
 
# 안전: 집계 후 작은 결과만, 또는 직접 저장
df.write.parquet("...")            # 익스큐터가 분산 저장
small = df.limit(1000).toPandas()  # 필요한 만큼만

collect(), toPandas(), 큰 broadcast 변수는 드라이버 메모리를 먹습니다. 결과가 크면 드라이버로 모으지 말고 분산 저장하세요.

8. 캐시 관리 — Storage 가 Execution 을 굶긴다

cache()/persist() 한 데이터가 Storage 풀을 점유하면 Execution 풀이 줄어 OOM 위험이 커집니다.

# 정말 여러 번 재사용하는 것만 캐시
df_reused.persist(StorageLevel.MEMORY_AND_DISK)  # 메모리 부족 시 디스크로
...
df_reused.unpersist()  # 다 쓰면 즉시 해제
 
# 한 번만 쓰는데 캐시하는 건 낭비 + 위험

MEMORY_ONLY 는 캐시가 메모리를 못 넣으면 재계산하거나 압박을 키웁니다. 큰 데이터는 MEMORY_AND_DISK 가 안전합니다.

9. 진단 → 처방 빠른 표

증상원인 후보처방
Java heap space파티션 과대shuffle.partitions↑, repartition, 컬럼 축소
Container killedoverhead/Python 초과memoryOverhead↑, Python UDF 축소
드라이버 OOMcollect/toPandas분산 저장, limit
spill 폭증execution 부족파티션↑, 캐시↓
한 태스크만 OOM스큐스큐 해결(salt/broadcast)
GC 시간 과다힙 과대/객체 과다힙 적정화, 객체 줄이기

10. OOM 튜닝 순서 (권장)

1. AQE 켜기 (자동 파티션 조정)
2. 에러 종류 구분 (heap vs container killed)
3. heap → 파티션 잘게 + 불필요 컬럼/캐시 제거
4. container killed → memoryOverhead ↑ + Python 메모리 점검
5. 한 태스크만 죽으면 → 스큐 의심
6. 그래도 부족하면 그때 executor.memory ↑

핵심은 메모리를 키우는 건 마지막 수단이라는 것입니다. 파티션·스큐·캐시·Python 을 먼저 잡으면 같은 자원으로 더 큰 데이터를 처리할 수 있습니다.

11. 정리

영역핵심
메모리 구조힙 + overhead + off-heap + Python, 컨테이너 단위로 죽음
두 OOM 구분heap=파티션, container killed=overhead/Python
1차 해법메모리↑ 아니라 파티션 잘게
PySpark 함정Python 워커가 overhead 압박 → UDF 축소
드라이버collect/toPandas 금지, 분산 저장

Executor OOM 의 핵심 통찰은 "OOM = 메모리 부족"이 아니라 "OOM = 데이터 단위가 메모리에 안 맞음"이라는 것입니다. 파티션을 적정 크기로 쪼개고, 컨테이너 메모리의 구조를 이해해 heap 과 overhead 를 구분하며, PySpark 라면 Python 메모리까지 시야에 넣으면 — Container killed 에러는 더 이상 미스터리가 아닙니다.


이 글은 Spark 3.5 기준으로 작성되었습니다. 대규모 Spark 잡의 메모리·안정성 튜닝이 필요하시면 언제든 문의해 주세요.

— Data Dynamics 엔지니어링 팀