← 학습 카테고리

Learn

Spark

84개 모듈 · 현재 40번째

Spark 모듈 40/84 spark-learn-40

Executor 내부 메모리 모델 — Unified Memory Manager와 페이지(세그먼트) 단위 할당

Apache Spark 실행자/드라이버 자원 할당 및 메모리 관리 — 공식 문서·기술 블로그 종합 (웹 리서치, 2026) — Apache Spark 공식 문서(configuration.html, running-on-kubernetes.html) + luminousmen.com "Dive into Spark Memory" + japila.pl "The Internals of Spark Core"(TaskMemoryManager) + 기타 기술 블로그 종합 웹 리서치 종합 — Unified Memory Manager 구조, TaskMemoryManager/MemoryBlock 페이지 할당 메커니즘(japila.pl Spark Internals 기준)

이 모듈을 다 읽으면

  • executor 힙 메모리가 Reserved/User/Execution/Storage로 어떻게 나뉘고, 그 비율이 어떤 설정값에서 나오는지 설명할 수 있다
  • Execution 메모리와 Storage 메모리 중 어느 쪽이 우선순위를 갖는지, 그리고 그로 인해 캐시가 왜 '안전하지 않은지' 설명할 수 있다
  • 여러 태스크가 하나의 execution 메모리 풀을 공유할 때 태스크 하나가 실제로 받는 메모리 범위를 판단할 수 있다
  • Tungsten의 '페이지(세그먼트)' 단위 메모리 할당이 무엇이고, 이게 실제로 셔플/정렬 연산의 스필(spill)과 어떻게 연결되는지 설명할 수 있다

executor 힙 안에서 메모리가 실제로 어떻게 구획되고(Unified Memory Manager), 태스크들이 그 메모리를 어떻게 나눠 쓰며(TaskMemoryManager), 가장 작은 실제 할당 단위인 '페이지(세그먼트)'가 무엇인지, 그리고 이 모든 게 Spark UI의 'Spill to Disk' 신호와 어떻게 이어지는지를 다룬다.

Unified Memory Manager — 힙을 4개로 나누는 방법

executor의 JVM 힙 전체가 Spark 연산에 다 쓰이는 게 아니다. Spark는 먼저 300MB를 '예약 메모리(Reserved Memory)'로 떼어 놓는다(로그, 내부 메트릭, 자체 구조체용으로 하드코딩된 고정값). 나머지(Usable Memory = 전체 힙 - 300MB)를 spark.memory.fraction(기본 0.6)만큼 'Execution+Storage' 풀로, 나머지 1-0.6=0.4만큼을 'User Memory'(UDF, 사용자 객체, Arrow 버퍼처럼 Spark가 추적하지 않는 영역)로 나눈다.

Execution+Storage 풀(전체의 60%) 안에서는 다시 spark.memory.storageFraction(기본 0.5)으로 절반씩 Execution과 Storage 몫을 정한다. 결과적으로 기본값 기준 대략 힙의 30%가 Execution, 30%가 Storage, 40%가 User Memory로 나뉘는 셈이다. 이 비율은 실행 계획(셔플/조인/집계가 많은지, 캐싱이 많은지)에 따라 조정할 필요가 생길 수 있다.

핵심 포인트

  • executor 힙에서 먼저 300MB를 Reserved Memory로 고정 차감한다
  • 나머지 중 spark.memory.fraction(기본 0.6)만큼이 Execution+Storage 풀, 나머지가 User Memory
  • Execution+Storage 풀 안에서 spark.memory.storageFraction(기본 0.5)으로 다시 절반씩 나뉜다 — 기본값 기준 대략 30%/30%/40%(Execution/Storage/User)

Execution이 Storage보다 우선한다 — 캐시가 '안전하지 않은' 이유

Execution 메모리(셔플, 조인, 정렬, 집계 등 '연산'에 쓰이는 메모리)와 Storage 메모리(cache()/persist()로 저장한 데이터)는 같은 풀을 동적으로 나눠 쓰지만, 둘 사이 관계는 대등하지 않다 — Execution이 항상 Storage보다 우선권을 갖는다. Execution 쪽에서 메모리가 더 필요해지면 Spark는 Storage에 캐시된 블록을 밀어내서(evict) 그 공간을 Execution에 내준다.

이게 실무적으로 의미하는 바는: df.persist(StorageLevel.MEMORY_ONLY)로 뭔가를 캐시해뒀더라도, 그 뒤에 무거운 셔플이 발생하는 wide transformation(조인, groupBy 등)을 실행하면 Spark가 조용히 캐시된 블록 일부를 축출할 수 있고, 나중에 그 캐시를 다시 참조하면 Spark는 원본 연산을 처음부터 재계산해야 한다는 것이다. '한 번 캐시하면 계속 메모리에 남아있다'는 가정은 틀렸다 — Execution 압박이 있으면 캐시는 언제든 밀려날 수 있는 이등 시민이다.

핵심 포인트

  • Execution 메모리는 Storage 메모리보다 항상 우선순위를 갖는다
  • Execution이 메모리가 부족하면 Storage에 캐시된 블록을 축출(evict)해서 공간을 확보한다
  • 축출된 캐시는 나중에 재참조 시 처음부터 재계산돼야 한다 — '캐시하면 계속 남아있다'는 가정은 틀렸다

태스크 단위로 내려가면 — 동시 실행 태스크들의 메모리 분배

Execution 메모리 풀은 executor 하나 안에서 동시에 실행 중인 모든 태스크가 공유한다. TaskMemoryManager는 현재 동시 실행 중인 태스크 개수를 n이라 할 때, 태스크 하나가 요청할 수 있는 메모리를 대략 (전체 Execution 메모리)/(2n) ~ (전체 Execution 메모리)/n 사이로 소프트 제한한다 — 엄격한 하드 격리가 아니라, 태스크마다 최소한의 몫은 보장하면서도 상황에 따라 유동적으로 더 받을 수 있게 하는 방식이다.

이 구조의 함정은, 태스크 간에 하드 격리가 없다 보니 먼저 실행을 시작한 태스크가 필요 이상으로 많은 메모리를 선점해버리면, 뒤늦게 시작한 태스크가 굶주릴 수 있다는 점이다. executor당 코어 수(=동시 태스크 슬롯 수)를 늘릴수록 n이 커져서 태스크 하나가 받는 몫은 작아진다 — 이게 바로 '코어 수를 늘렸더니 OOM/스필이 오히려 늘었다'는 현상의 근본 원인 중 하나다. 반대로 코어 수를 줄이면 n이 작아져 태스크당 가용 메모리가 늘어난다.

핵심 포인트

  • TaskMemoryManager는 동시 태스크 수 n에 대해 태스크 하나에 1/(2n)~1/n 사이의 Execution 메모리를 소프트 할당한다
  • 태스크 간 하드 격리가 없어 먼저 시작한 태스크가 과점하면 나중 태스크가 메모리 기근을 겪을 수 있다
  • executor당 코어 수(=n)를 늘리면 태스크 하나의 몫이 줄어든다 — 코어를 늘렸는데 OOM/스필이 늘어나는 원인이 될 수 있다

실제 '세그먼트' 단위 — 페이지(MemoryBlock)와 pageTable

TaskMemoryManager가 실제로 메모리를 배분하는 최소 물리 단위가 바로 '페이지(page)'다. 페이지 하나는 MemoryBlock이라는 객체로 표현되고, TaskMemoryManager는 최대 PAGE_TABLE_SIZE(=8192개, 13비트로 인덱싱)개의 페이지를 pageTable이라는 배열에 등록해 관리한다. 페이지 하나의 최대 크기(pageSizeBytes)는 spark.buffer.pageSize로 직접 지정할 수도 있고, 지정하지 않으면 사용 가능한 Execution 메모리와 코어 수를 기준으로 Spark가 자동 계산한다(이론적 상한은 MAXIMUM_PAGE_SIZE_BYTES, 약 15GB — 페이지 내부 오프셋 인코딩에 51비트를 쓰는 주소 체계의 한계에서 나오는 값이다).

메모리를 요청하는 쪽(BytesToBytesMap, UnsafeExternalSorter, ShuffleExternalSorter 같은 MemoryConsumer들)은 allocatePage()를 호출해 페이지를 받고, 다 쓰면 freePage()로 반납한다. allocatePage가 요청한 만큼의 execution 메모리를 확보하지 못하면(다른 태스크/컨슈머들이 이미 많이 쓰고 있으면), TaskMemoryManager는 현재 등록된 MemoryConsumer들에게 메모리 사용량 순으로 스필(디스크로 밀어내기)을 요청해서 공간을 확보한 뒤 재시도한다. 즉 Spark UI에서 보이는 'Spill to Disk'는 이 페이지 할당이 실패해서 어떤 컨슈머가 자기 데이터(정렬 버퍼, 해시 맵 등)를 디스크에 임시로 밀어낸 결과다.

이 '페이지' 개념까지 이해하고 나면, 왜 파티션 수를 늘리거나(태스크당 처리할 데이터량↓, 필요한 페이지 수↓) executor당 코어 수를 줄이는(동시 태스크 수 n↓, 태스크당 가용 Execution 메모리↑) 튜닝이 스필을 줄이는지가 '경험칙을 외우는 것'이 아니라 '메모리 할당 메커니즘에서 논리적으로 도출되는 결론'으로 이해된다.

핵심 포인트

  • 페이지(MemoryBlock)가 TaskMemoryManager의 실제 최소 할당 단위이고, 최대 8192개까지 pageTable에 등록된다
  • 페이지 크기(pageSizeBytes)는 spark.buffer.pageSize로 지정 가능, 미지정 시 가용 메모리·코어 수 기반 자동 계산, 이론적 상한은 약 15GB
  • BytesToBytesMap(해시조인/집계)·UnsafeExternalSorter·ShuffleExternalSorter가 allocatePage/freePage로 페이지를 빌리고 반납한다
  • 페이지 할당이 실패하면 TaskMemoryManager가 다른 컨슈머들에게 스필을 요청한다 — Spark UI의 'Spill to Disk'가 바로 이 순간을 가리킨다
  • 파티션 수 증가나 executor당 코어 수 감소가 스필을 줄이는 이유는 결국 '태스크당 필요 페이지 수/가용 메모리'를 바꾸는 것이라는 메커니즘으로 설명된다