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日14 min read
This post is not yet translated. The original Korean version is shown below.

아파트 관리실에서 누전 차단기가 내려가면 "전기 공급을 늘리면 된다"고 바로 결론 내리지 않습니다. 어느 라인에서, 왜 초과가 났는지 먼저 찾습니다. Spark OOM 도 똑같습니다. 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 을 구조적으로 없애는 접근을 정리합니다.

이 글에서 배우는 것

  • Executor 메모리 구조(힙/overhead/off-heap/Python)를 그림으로 이해하기
  • Java heap space 에러와 Container killed 에러의 차이와 각각의 1차 대응
  • OOM 의 진짜 원인이 "메모리 부족"이 아니라 "파티션 과대"인 이유
  • PySpark 에서 Python UDF 가 Container killed 를 유발하는 메커니즘
  • 진단 → 처방 빠른 표와 권장 튜닝 순서

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

OOM 을 제대로 잡으려면 먼저 컨테이너 안이 어떻게 생겼는지 알아야 합니다. executor.memory 하나만 보면 전체 그림이 보이지 않습니다.

┌─────────────────── 컨테이너 (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 은 메모리가 작아서가 아니라 파티션 하나가 익스큐터 메모리에 안 들어가기 때문입니다. 한 태스크는 파티션 하나를 통째로 다루거든요.

파티션당 데이터 ≈ 입력 크기 / 파티션 수
파티션이 크면 → 태스크 하나가 거대 데이터를 메모리에 올림 → 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 직전의 신호

Spark 는 메모리가 부족해지면 바로 죽지 않고 데이터를 디스크로 흘려보냅니다(spill). 일종의 안전장치인 셈이죠. 하지만 과도한 spill 은 "메모리가 빠듯하다"는 경고등이기도 합니다.

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

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

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

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

Java/Scala Spark 에는 없는 PySpark 만의 함정이 있습니다. Python UDF·pandas_udf·applyInPandas 를 쓰면 JVM 힙 밖에서 별도 Python 워커 프로세스가 뜹니다. 이 메모리는 executor.memory(힙) 계정에 잡히지 않고, 컨테이너 overhead 를 압박합니다. 힙은 여유로운데 컨테이너가 갑자기 죽는다면 이 함정을 의심해야 합니다.

Loading diagram…

대응:

# 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

Executor 가 멀쩡한데도 드라이버(Driver) 가 죽는다면 다른 이유가 있습니다. 거대한 결과를 드라이버 메모리로 끌어오는 액션이 범인인 경우가 많습니다.

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

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

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

캐시는 편리한 도구이지만, 잘못 쓰면 오히려 OOM 의 씨앗이 됩니다. 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 에러는 더 이상 미스터리가 아닙니다.

마치며 — 핵심 요약

  • Java heap spaceContainer killed 는 원인이 다릅니다. 먼저 어느 에러인지 구분하세요.
  • Container killed 가 났을 때 executor.memory(힙)를 올리면 오히려 악화될 수 있습니다. memoryOverhead 를 먼저 보세요.
  • 힙 OOM 의 1차 해법은 메모리를 키우는 것이 아니라 파티션을 잘게 쪼개는 것입니다.
  • PySpark 에서 Python UDF 는 힙 밖에서 메모리를 씁니다. pyspark.memorymemoryOverhead 를 함께 조정하세요.
  • collect() / toPandas() 는 드라이버 OOM 의 주범입니다. 가능하면 분산 저장으로 대체하세요.
  • AQE 를 켜두면 런타임에 파티션을 자동 조정해 많은 상황을 스스로 해결합니다. 튜닝 전에 AQE 부터 활성화하세요.

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

— Data Dynamics 엔지니어링 팀