How we cut our largest Spark pipeline’s cost by 52%
Author: Tommy Watts, Tommaso Peresson (Depop) | Source: engineering.depop.com | Published: 2026-08-11
한 줄 요약
파이프라인이 비싸지면 무엇을 계산하는지 보는 게 본능이지만, 실제 추가 비용의 대부분은 필요 여부와 무관하게 매 실행마다 하는 일에서 왔다 — 신선도가 두 번 배가되는 동안 파이프라인 코드는 한 줄도 바뀌지 않았다.
핵심 주장/내용
- 문제의 구조: Silver Ingest는 medallion 구조 데이터 레이크의 핵심에 있는 최대 인제스션 파이프라인(Scala Spark, Airflow 오케스트레이션, Delta 테이블 쓰기). 신선도가 하루 2회 → 4회 → 8회로 두 번 배가되었고, 각 배가는 소비자에게 큰 이점이었지만 동시에 파이프라인이 매 실행마다 기본으로 하는 모든 것을 배가시켰다 — 모든 유지보수 패스, 모든 추가 스캔, 바뀌지 않은 row의 모든 재작성. 하루 두 번 잡일 때는 반올림 오차였던 작업이 파이프라인 코드 한 줄 바뀌지 않은 채 조용히 청구서가 되었다
- 데이터 skew의 실체: 단일 고volume 스트림이 전체 row의 70% 이상을 차지하고 상위 10개가 90% 이상. 반면 중앙값 스트림은 하루 수천 row만 쓴다 — 150,000배의 볼륨 격차. row 크기도 심하게 skew되어 대부분은 수백 바이트로 압축되지만 복잡한 것은 row당 12kB를 넘어, row 수 기준 2위 스트림이 총 데이터 볼륨 기준으로는 1위
- 아키텍처가 아니라 파이프라인을 최적화하라는 결정: 빈도 증가 요인이 회사 전체가 아니라 소수 팀에서 왔으므로, 순진한 경로는 데이터셋마다 신선도 요구에 따라 자기 빈도를 갖는 tiered processing으로 스케줄을 재설계하는 것이었다. 각 데이터셋의 사용량도 조사해 처리된 데이터가 다 쓰이는지 확인하려 했고 opt-in silver layer로 이어질 수 있었다
- 설계 단계 초반에 이 변경이 훨씬 증가한 복잡도라는 대가를 치를 것임을 깨달았다. 먼저 기존 변환을 현재 볼륨에서 더 잘 확장하게 만드는 것으로 얼마나 갈 수 있는지 보기로 했고, 결국 더 갈 필요가 없을 만큼 멀리 갔다
- 개선 1 — 데이터가 바뀌는 것보다 훨씬 자주 돌던 유지보수 (-38%): 대부분의 테이블 포맷이 유지보수(작은 파일 compaction, 디스크 재클러스터링, 메타데이터·인덱스 재계산)를 insert 경로와 분리된 프로세스로 돌리게 해준다. 쓰기가 빨라야 하고 이 무거운 배치형 작업에 블록될 수 없기 때문이다. 유지보수가 컬럼 통계(min/max)를 다시 유도해 현재 파일 레이아웃을 정확히 반영하게 하고, 그것이 리더가 쿼리 술어를 그 통계에 매칭해 무관한 파일을 건너뛰게 해준다
- 트레이드오프로 이 통계는 eventually correct해도 되고, 테이블의 head가 잠깐 최적화되지 않은 채 남는 것은 감내할 수 있다
- Depop의 경우 유지보수가 매 스케줄마다 돌았는데,
event_time에 계산된 z-order 인덱스를 blanket read 최적화로 쓰는 것을 감안하면 말이 안 됐다. 대부분의 이벤트가 이미 순서대로 랜딩하고 통계가 패스 사이에 대체로 정확하게 유지되며, 늦게 도착하는 이벤트가 유일한 예외다. 유지보수를 하루 한 번으로 줄이자 비용 38% 감소, 다운스트림 쿼리 성능은 저하되지 않음
- 개선 2 — 측정 대상보다 비쌌던 지표 (-17%): 계측이 파이프라인이 이미 하고 있지 않던 작업을 강제하면 조용히 파이프라인에서 가장 비싼 부분이 될 수 있다. 대부분의 현대 데이터 엔진이 lazy하게 평가하므로 계산이 이미 돌아간 뒤에 count를 요청하는 것은 그 계산을 재사용하지 않고 새 계산을 촉발한다
- 쓰기가 끝난 뒤
df.count()를 호출해 기록된 레코드를 추적하는 Datadog 지표가 있었는데, 그 호출이 Spark로 하여금 bronze 레이어 전체를 두 번째로 스캔하게 만들었다 — 잡 후반에 쓰기 자체가 이미 필요로 하는 스캔에 더해서. 이미 일어나고 있는 쓰기의 부수효과로 count를 포착하는observe()로 대체하니 두 번째 스캔이 완전히 사라졌다. 비용 17% 감소, DBU로 측정한 컴퓨트는 21% 감소
- 쓰기가 끝난 뒤
- 개선 3 — 바뀌지 않은 row를 다시 쓰던 upsert (-15%): merge 연산은 기본적으로 매칭된 모든 row를 비교하고 양쪽에 존재하면 값이 실제로 다르든 아니든 다시 쓴다. 업데이트가 흔할 때는 말이 되지만 드물 때는 비싸다 — 엔진이 전혀 바뀌지 않은 row에 대해서도 기저 파일을 다시 쓰기 때문이다
- Depop의 merge는 매 실행마다 모든 중복 row를 업데이트했고 사실상 인제스션 시각만 갱신하고 있었다. 소비자와 영향을 검증한 뒤 append-only 전략으로 옮겨 중복 row를 대체하는 대신 버렸다. 특히 row 크기가 큰 이벤트에서 큰 개선인데, Spark가 대체를 수행하려고 그것들을 해시맵으로 메모리에 들고 있을 필요가 없어지기 때문. 이것만으로 추가 15% 절감
주요 수치 / 사실
- 지난 몇 년간 데이터 스트림 수 50% 증가, 파이프라인이 하루 약 20억 row(3시간마다 약 2.5억)를 쓰고 일일 1.5TB 이상의 압축 columnar 데이터
- 10주에 걸쳐 배포된 세 변경으로 비용 약 52% 감소. 수치가 단순 합산되지 않는데 각 변경이 앞 변경이 이미 낮춰놓은 baseline 대비 측정되었기 때문이고, 각각이 자기 clean billing window에 대해 독립적으로 측정되었으므로 괜찮다고 본다
- 절감을 실제로 측정하는 게 만드는 것보다 어려웠다: 클라우드 청구는 실시간으로 정산되지 않으므로 지난 이틀의 비용 대시보드를 읽으면 실제로는 아직 도착하지 않은 비용일 뿐인 하락처럼 보일 수 있다. 최근 48시간은 잠정으로 취급하는 법을 배웠다. 백필도 다른 함정 — 백필 하나가 파이프라인의 정규 동작으로 인한 것보다 훨씬 높게 하루 비용을 튀게 하고, 그런 날을 비교에 포함하면 무관한 변경이 실제보다 훨씬 효과적이거나 비효과적으로 보일 수 있다. 백필 활동을 배제한 clean billing window를 정의하고 각 변경을 그 window 전후로 측정
- 다음 팀에 전할 것: 다른 무엇보다 먼저 변환을 이해하라 — 누가 출력을 소비하고, 실제로 얼마나 신선해야 하고, 데이터가 어떻게 생겼는지. 우리가 찾은 모든 승리가 코드 자체가 아니라 그 맥락에서 나왔다 / 최적화하기 전에 엔진이 실제로 어떻게 작업을 계획하는지 배워라 — 세 승리 중 둘은 코드가 요청하는 것처럼 보이는 게 아니라 잡이 실제로 하고 있는 것을 이해하고 나서야 보였다 / 재작성 충동에 저항하라 — 결국 필요할 수도 있지만 작고 타겟된 변경이 훨씬 빨리 원하는 곳에 데려다줬다 / 데이터의 도달 범위가 넓을수록 소비자 동의를 일찍 받아야 한다
- 파라미터 튜닝을 아직 하지 않기로 한 판단: Spark config·클러스터 크기·머신 크기를 조정해 마지막 한 방울까지 짜내는 것은 모든 다른 것을 이미 살펴보고 변환을 잘 이해하고 제대로 하기 위한 지루한 작업을 할 준비가 된 다음에 보상이 나오는 경향이 있다
관련 위키
- Data Engineering FinOps
- Spark at Scale
- Medallion Architecture
- Partition Sizing and Management
- Query Optimization
- Incremental Materialization
- Data Pipeline Fundamentals
Source: 원문 보기