Running Apache Spark experiments in my sleep (and on a plane)

Source: netflixtechblog.medium.com (Netflix) | Published: 2026-08-31


한 줄 요약

전환점은 “단일 실행을 빠르게 만들려는 것”에서 “여러 테스트를 동시에 돌리고 모니터하고 모든 결과를 추적하는 방법을 만드는 것”으로 목표를 바꾼 것이었고, branch = 실험 하나 격리 + AI 에이전트의 운영 대행 + tmux 세션 지속이 그것을 가능하게 했다.

핵심 주장/내용

  • 문제 — small sample은 scale 버그를 잡지 못한다: Netflix의 가장 크고 중요한 파이프라인 중 하나인 member-sessionizer는 시간당 수백억 개 클라이언트 이벤트(탭, 재생, engagement 신호)를 프로필별 시청 세션으로 통합한다
    • 2주짜리 튜닝 작업이 표준 접근이 벽에 부딪히며 2개월로 늘어났다. 실패가 전체 프로덕션 규모에서만 일어나서 각 테스트에 몇 시간이 걸렸고, 하루 서너 번 시도로 제한되었다
    • “작은 데이터셋에서 버그를 격리해 빠르게 반복하라”는 표준 엔지니어링 지혜는 일반 소프트웨어 버그에는 통하지만 빅데이터 성능 병목에는 완전히 실패한다
    • 새 잡이 executor OOM, 심한 shuffle spillage, 수천 번의 stage 재시도를 겪었는데 어느 것도 샘플링된 데이터로는 재현할 수 없었다. 데이터 skew 때문에 전체 프로덕션 규모에서만 트리거된다 — 밤새 스트리밍 상태로 방치된 유휴 Netflix 탭이 수만 개 텔레메트리 이벤트를 생성하는 반면 전형적 세션은 몇 개만 만든다. 표준 1% 샘플은 이 극단적 long-tail 세션을 완전히 놓친다
  • 전략 — branch-driven isolation: 가장 큰 승리는 Netflix의 파이프라인 CLI 도구 Dataflow의 branch-driven development 기능에서 왔다
    • 워크플로가 Maestro(Netflix 워크플로 오케스트레이터) 위에서 돌고 이미 sanitized branch name(${dataflow.sanitizedbranch} 플레이스홀더)을 쓴다. pgn/cap-experiment 같은 브랜치를 push하면 배포된 워크플로, 그 테이블, run namespace가 전부 그 브랜치 이름으로 태그된다. 팀원과의 충돌도, 자기 자신의 동시 실행 간 충돌도 사라진다
    • 모델은 단순하다 — 하나의 브랜치가 하나의 독립 실험. 스케줄러를 파라미터화(cap size, shuffle partition, heavy-device 임계, 피처 플래그)하고 각 변형에 자기 브랜치와 출력 테이블을 준다. 10시간에 걸쳐 5개 테스트를 순차 큐잉하는 대신 같은 시간 창에 대해 5개 변형을 동시에 발사. 총 실행 시간이 모든 실행의 합에서 가장 느린 잡 하나의 길이로 떨어졌다
    • 브랜치별 테이블 관리에 오버헤드가 전혀 없었다 — Dataflow가 선언된 스키마에서 수동 DDL 없이 테이블을 프로비저닝·갱신한다. 실험 간에 event bag 구조를 바꿔도 테이블 스키마가 매끄럽게 갱신되고 CREATE TABLE도 ALTER TABLE도 필요 없다
    • 유일한 실제 제약은 클러스터 경합이었다. 이 무거운 Spark 잡들이 회사의 나머지와 리소스를 공유하므로 피크 시간에는 5개 대신 2개 동시 실행으로 제한되기도 했다. 그래도 2~3개 병렬이 단일 스레드 디버깅보다 엄청난 개선
    • 핵심 교훈: 단일 다중 시간 실행을 빠르게 만드는 데 집중하지 말고, 서로 밟지 않으면서 많은 실험을 동시에 돌릴 수 있는 셋업을 만들어라
  • AI 에이전트의 역할 — 영리한 코드가 아니라 운영 잡일: 5개 병렬 실험은 금방 지저분해진다. 인스턴스 ID, 설정, 메모리 spill, stage 실패를 변형 간에 추적하다 보면 놓치기 쉽다
    • 변형을 평문 영어로 기술하면 에이전트가 스케줄러 설정을 갱신하고, 브랜치에 배포하고, 잡을 트리거하고, 메트릭을 폴링했다. 완료되면 변형 간 spill과 실패 수를 요약 표로 비교해 “어느 실행이 피크 부하에서 OOM-free에 가장 가까운가?” 를 물으면 즉답을 받을 수 있었다
    • 가치는 깊은 Spark 전문성이 아니라 operational memory였다. 지루한 Spark UI 교차 확인을 프롬프트 하나로 바꿨다
    • 결국 재사용 가능한 agent skill로 패키징했다 (.agents/skills/sandbox-deploy-and-run/, /sandbox-deploy-and-run <module> <scheduler-file> [PARAM=value]). preflight, 배포, 백그라운드 폴링, 자동 진단, 재진입 가능한 resume <instance_id> 지원까지 전체 라이프사이클을 다뤄 다중 시간 실험을 명령 하나로 만들었다
  • tmux가 중요했던 이유: 전부 Netflix 클라우드 dev 환경 Workbench에서 돌고 노트북은 얇은 터미널일 뿐이었다. 세션 지속성이 없으면 SSH 끊김이나 노트북 sleep이 다중 시간 Spark 실행을 중간에 죽인다
    • Workbench에서 직접 돌리는 tmux가 해결했다. 모든 실험이 이름 붙은 클라우드 세션에 살아서 노트북을 닫고 몇 시간 뒤 재접속해 이어갈 수 있었다. 브랜치 격리와 결합해 “5개 시작하고 자리를 뜨는 것”이 가능해졌다
    • 지금의 기본 셋업: 로컬 터미널(Ghostty) + 브랜치별 전용 git worktree + Workbench의 지속 tmux 세션. 로컬 노트북은 일회용이다 — 닫거나 네트워크를 잃어도 잡이 중단되지 않는다
    • 긴 여행일이 능동적 반복 시간으로 바뀌었다 — 게이트에서 배치를 시작하고, 불안정한 기내 Wi-Fi로 AI 에이전트를 통해 결과를 확인하며, 24시간 비행에 4~5번의 전체 실험 사이클을 밀어 넣었다
  • 실험이 밝혀낸 것 — 근본 원인은 collect_list 하나: Spark가 각 전체 event bag을 메모리에 버퍼링하므로 long-session OOM은 총 데이터셋 볼륨이 아니라 개별 bag 크기가 주도했다. 레거시 파이프라인이 동일한 행 수를 크래시 없이 처리했다는 것이 새 bag의 각 이벤트가 크게 무거워졌음을 증명했다
    • 이벤트 무게를 부풀린 두 요인:
      • 필드 타입과 Option boxing — 세션 ID가 8바이트 Long에서 UUID 문자열로 바뀌었다. 더 나쁜 것은 필드를 Scala Option으로 감싼 것이 Spark로 하여금 typed Dataset을 JVM 객체(Some 래퍼)로 역직렬화하게 강제해 세션당 수만 개 이벤트에 걸쳐 객체 할당과 GC 압력을 끌어올렸다
      • 부풀려진 문자열 속성 — 낮은 가치 필드(referrer URL, test allocation)가 이벤트당 수백 바이트를 더했다. collect_list에 걸쳐 곱해지면 다운스트림이 거의 쓰지도 않는데 시간당 테라바이트를 낭비했다
    • 수정: never-null 필드에서 Option 래퍼를 제거해 peak executor 메모리가 슬롯의 112% → 58%로, disk spill 0 / event bag 슬림화 — 아무도 안 쓰는 per-event 속성을 버리고 세션 불변 메타데이터(앱 버전, 국가)를 세션 헤더로 hoist
    • 교훈: 개별 이벤트 무게를 줄이는 것이 클러스터 메모리를 더 던지는 것보다 훨씬 나은 안정성을 제공했다
  • 가정을 뒤집은 두 발견:
    • 발견 1 — 행을 좁히면 OOM이 더 빨리 올 수 있다. 테이블을 슬림화하자 크기가 Spark의 auto-broadcast 임계 아래로 떨어져 쿼리 플래너가 shuffle join에서 broadcast join으로 전환했다. broadcast join은 build-side 테이블을 executor 메모리에 직접 고정하므로 좁은 행이 오히려 총 메모리 압력을 증가시켰다
    • 발견 2 — broadcast hash join이 조용히 메모리를 빨아먹고 있었다. AQE(Adaptive Query Execution)가 런타임에 9개의 sort-merge join을 broadcast hash join으로 다시 썼다. 각 join은 개별적으로는 400MB broadcast 한도 아래였지만, AQE가 단일 executor에 누적 약 3.1GB를 동시에 고정해 컨테이너를 한계 너머로 밀었다
    • 외과적 수정: broadcast join을 전역으로 끄는 대신 AQE의 런타임 임계(spark.sql.adaptive.autoBroadcastJoinThreshold)만 10MB로 낮추고 컴파일 타임 임계는 건드리지 않았다. 그 변경 하나로 초기 쿼리 플래닝에 영향 없이 약 3.1GB executor 메모리를 확보
    • 교훈: broadcast join은 쿼리를 빠르게 하지만 고정된 메모리는 공짜가 아니다. 데이터셋 크기만이 아니라 실행 계획의 누적 broadcast를 보라
  • 단일 실험 로그 유지: 수십 개 병렬 테스트를 돌릴 때 가장 큰 위험은 잡 실패가 아니라 이미 시도한 것을 놓치거나 어떤 JAR과 설정이 그 결과를 냈는지 혼동하는 것
    • AI 에이전트가 매 실행 후 저장소 Markdown 표에 행을 추가하게 했다 — Run # | Jar ver | Change vs. Previous Run | Config | Key Metrics(Spill, Failed Tasks, Peak Memory, Duration). 핵심 발견이 있으면 무엇을 증명·반증했는지 짧은 노트 추가
    • 단일 진실 원천 — 전체 조사에 대한 감사 가능한 이력
    • 규율 강제 — “Change vs. Previous Run” 을 요구한 것이 변수를 격리하도록 강제했다. 여러 변경을 묶으면 근본 원인을 격리할 수 없어 재실행해야 했고, 로그 포맷 자체가 그 실수를 즉시 드러냈다
    • 별도 문서가 distilled findings를 “symptom-to-knob” 조회표로 담아 팀원이 시간순 전체 이력을 헤집지 않아도 되게 했다
  • off-peak에서만 통한 수정 — 배운 실수: 세션 크기를 제한하려 row_number().over(Window...) 로 처음 N개 이벤트를 필터했다. off-peak 테스트는 깔끔히 통과했지만 피크 부하에서 OOM으로 크래시했다
    • 구조적 문제였다: Spark의 Window 연산자는 행 번호를 평가하고 필터하기 전에 세션 전체를 메모리에 버퍼링한다. cap이 다운스트림 출력은 잘랐지만 버퍼링 중 메모리 사용을 제한하지 못해 skew된 세션 하나가 executor 메모리를 압도했다
    • 수정과 교훈: truncation을 Window 단계 앞으로 옮겨 버퍼링이 일어나기 전에 이벤트를 제한. 최악의 입력 skew에 대해 수정을 테스트하라. off-peak 실행은 피크 규모가 즉시 트리거할 구조적 병목을 숨긴다

주요 수치 / 사실

  • 최종 4개 수정, 각각 착지 전에 단일 실험으로 격리:
    1. never-null 필드를 Option → primitive로 unbox — peak executor 메모리 약 112% → 58%, spill 0
    2. event bag 슬림화 — 안 쓰는 per-event 필드 제거, 세션 불변 필드(버전, 국가, 연결 타입)를 세션 레벨로 hoist해 이벤트당이 아니라 세션당 한 번 저장
    3. Window 단계 앞에서 세션 크기 cap — 버퍼링 전에 과대 세션을 버리고, 세션 전체를 버리는 대신 bag을 cap까지 잘라냄
    4. AQE 런타임 broadcast 임계를 10MB로 — 컴파일 타임 임계는 유지, 약 3.1GB 확보
  • 해결되지 않은 병목: 이 수정들이 파이프라인을 깨끗이 돌게 했지만 근본적 스케일링 문제는 풀지 못했다 — 세션 전체의 event bag을 단일 행으로 집계하는 것에는 하드 실링이 있다. 상류 텔레메트리 소스가 진화하고 새 속성을 추가하면 bag의 각 이벤트가 무거워지고, 우리 쪽 코드는 한 줄도 안 바꿔도 이 메모리 압력이 결국 돌아온다
    • 슬림화와 unboxing이 헤드룸을 샀지만 근본적 데이터 모양을 바꾸지는 않았다. 가장 큰 교훈은 튜닝이 시간을 살 뿐 무한한 스케일을 사지는 않는다는 것이다. 기저 데이터 모델이 무제한 행 성장을 허용하면 튜닝만으로는 구할 수 없다 — 결국 아키텍처 자체가 바뀌어야 한다
  • 요약 체크리스트:
    • 버그가 규모에서만 나타나면 샘플링을 그만두고 전체 규모 실행을 병렬로 많이 돌릴 방법을 만들어라
    • 스케줄러가 브랜치 이름을 쓸 수 있으면 하나의 브랜치가 하나의 격리된 실험이 된다
    • AI 에이전트를 코드 작성만이 아니라 운영에 써라. 배포·폴링·메트릭 수집·변형 비교 — 시간의 대부분이 여기 간다
    • 루프가 반복되면 skill로 패키징하라
    • 긴 실행에는 tmux. 노트북을 닫아도 실행이 살아남아야 한다
    • 단일 실험 로그를 유지하고 모든 항목이 이전 실행 대비 단 하나의 변경을 진술하게 하라. 포맷 자체가 한 번에 한 변수만 바꾸도록 강제한다
    • 메모리 문제에서는 총 데이터 볼륨만이 아니라 per-row 폭을 먼저 보라
    • 튜닝은 헤드룸을 살 뿐 새 실링을 사지 않는다

관련 위키


Source: 원문 보기