Behind the Scenes: Evolving Netflix’s Ads Event Pipeline for Live — Part II

Author: Netflix Technology Blog | Source: netflixtechblog.medium.com | Published: 2026-08-18


한 줄 요약

광고 서빙의 hot path에서 must-succeed 데이터베이스 쓰기를 걷어내고 실시간 스트리밍 조인으로 대체한 것 — 가장 큰 승리는 raw 성능이 아니라 blast radius였다.

핵심 주장/내용

  • 문제 — Live가 forcing function: 라이브 트래픽은 SVOD와 전혀 다르다. 단일 이벤트가 몇 분 만에 수천만 동시 시청자로 급증했다가 끝나면 그만큼 빠르게 빠진다. Jake Paul vs Mike Tyson 경기를 1억 명 넘게 스트리밍했을 때 그 규모의 단일 광고 브레이크는 엄청난 볼륨의 광고 이벤트를 생성한다
    • 옛 설계에서 그 광고들을 서빙하려면 각각의 메타데이터를 Metadata Registry에 쓰고 광고가 서빙되기 전에 ack를 기다려야 했다 — 광고 서빙 크리티컬 패스에 초당 수백만 건의 must-succeed DB 쓰기
    • 세 가지 핵심 문제: ① 스케일링 병목 — 모든 광고와 브레이크가 쓰기이고, 수천만 동시 시청자를 곱하고, 비손실이어야 하고(쓰기 하나를 떨어뜨리면 광고 하나를 떨어뜨리는 것이고 매출 손실), Live의 spiky on/off 패턴을 더하면 쓰기 부하가 엄청나다. 그런데 이벤트가 시작된 뒤에는 registry를 안전하게 스케일 업할 수 없다 — 노드 추가가 클러스터 전반에 데이터를 재분배하는데 라이브 이벤트 중에 하면 용량을 추가하려던 바로 그 쓰기를 느리게 하거나 실패시킬 수 있다. 남는 안전한 옵션은 미리 피크에 맞춰 pre-scaling하고 예측이 정확하기를 바라는 것뿐 ② 크리티컬 패스의 의존성 — registry에 fallback이 없어 unhealthy registry는 서빙 중단을 의미했고, 그것이 제거할 수 있는 가장 큰 hard dependency였다 ③ 이벤트 신선도 위험 — 쓰기가 느려지거나 조회가 타임아웃되면 이벤트가 재시도와 DLQ로 흘러 늦게 배달되는데, frequency capping 같은 시스템은 다음 선택 광고가 정책을 어기지 않고 돌 수 있는지 판단하려 엄격한 SLA 내의 이벤트가 필요하다
    • 던진 질문: 광고 서버가 이미 서빙한 광고에 대해 모든 것을 알고 로깅하는데, 정말 hot path에서 그것을 DB에 써야 하나? 항상 존재하는 다운스트림 클라이언트 이벤트 Kafka 스트림에 대해 사후에 매칭할 수 없나?
  • 새 아키텍처 — Flink 스트리밍 조인: 각 이벤트가 normalize(SVOD·live·playback·embedded 등 여러 소스에서 조금씩 다른 모양으로 오는 것을 공통 형태로) → re-route(클라이언트 이벤트와 response 이벤트가 다른 리전에 나타날 수 있어 성공적 조인을 위해 올바른 처리 리전으로) → join(핵심. KeyedCoProcessFunction으로 같은 impression의 광고 서빙 response와 매칭해 전체 서빙 컨텍스트로 enrich) → deduplicate(매출 수반 이벤트를 중복 제거) → publish를 거친다
    • 60분 윈도우의 세 경우: response가 이미 있으면 즉석 enrich / 이벤트가 먼저 오면 매칭 response가 나타날 때까지 보류 / 윈도우 내에 response가 안 오면 나중 복구를 위해 unmatched 표시
    • built-in windowed join이 아니라 커스텀 연산자를 쓴 이유: 다운스트림 소비자가 순서를 처리하므로 windowed join의 ordering과 time-window 시맨틱이 필요 없다. 단순히 각 클라이언트 이벤트를 키로 response에 매칭할 뿐이다. 가장 낮은 API 형태로 연산자를 소유하면 매칭 상태를 직접 통제할 수 있다 — 얼마나 오래 보유할지, 매칭되지 않는 이벤트를 어떻게 할지. 덕분에 조인을 재작성하지 않고 스트림별 보존을 튜닝하거나 나중에 unmatched 이벤트를 durable store에 영속화할 수 있다
    • 왜 60분인가: 약 99.999%의 이벤트가 그 전에 도달하므로 윈도우를 더 늘리면 state store만 키우고 체크포인트를 길게 할 뿐 이득이 사라질 만큼 작다. 나머지 0.001%는 정확한 청구와 리포팅에 여전히 중요하고 별도 경로로 복구한다
  • 크로스 리전 — 데이터가 있는 곳에서 이벤트를 매칭: Metadata Registry는 글로벌 복제 KV 스토어 위에 있어 크로스 리전 enrichment를 알아서 처리했다. 스트리밍 조인에는 그 편의가 없고 조인의 양쪽이 다른 곳에 산다 — 광고 서빙 response는 서빙된 리전에 머물지만 클라이언트 이벤트는 기기가 발화하고 기기는 어디든 있을 수 있다(집 wifi에서 광고를 시작해 중간에 모바일 데이터로 바꾸면 그 네트워크 변경이 이벤트를 다른 리전으로 라우팅할 수 있다)
    • 유혹적인 해법은 모든 리전의 서빙 response를 다른 모든 리전으로 스트리밍하는 것인데 거부했다 — 모든 리전이 전 세계 모든 서빙 response를 저장하게 되고 그 대다수가 조인에 쓰이지 않아 비용과 컴퓨트 양쪽에서 낭비다
    • 대신 라우팅 문제로 취급해 response를 복제하는 대신 클라이언트 이벤트를 라우팅한다. 서빙 리전을 ad token에 인코딩해 클라이언트 이벤트가 잘못된 리전에 도착하면 그 response를 소유한 리전으로 forward한다. 진짜 크로스 리전인 소량의 이벤트만 추가 홉 비용을 낸다
  • 긴 꼬리 잡기: 기기가 오프라인이었거나 광고가 일시정지되었거나 engagement 이벤트가 몇 시간 지연될 수 있다. 이 낙오자들을 버리는 대신 Maestro가 오케스트레이션하는 시간별 Spark 배치 잡이 각 낙오자를 데이터 웨어하우스(in-stream 윈도우보다 훨씬 긴 보존)의 서빙 response와 다시 짝지어 같은 다운스트림 sink로 발행한다. 스트리밍 잡의 메모리를 bounded하게 유지하면서 긴 꼬리를 잡는다. 재사용을 염두에 두고 설계해 DLQ replay와 히스토리 백필도 할 수 있어 긴 꼬리 복구와 운영 복구에 단일 메커니즘을 제공
  • 어려웠던 것 3가지:
    • 코어를 lean하게 유지하기 — 라이브 규모의 stateful 스트리밍 조인 운영은 진짜 튜닝이 필요했고 어렵게 얻은 교훈 대부분이 한 아이디어로 돌아온다: 잡의 유일한 일은 이벤트를 매칭하는 것이다. 외부 조회나 부수적 side-processing처럼 붙이고 싶었던 것은 hot path 밖으로 옮기는 게 나았고, 아니면 결국 부하 하에서 불안정성으로 나타났다. 이것을 제대로 하는 것이 라이브 트래픽을 편안히 따라가는 잡과 뒤처지는 잡의 차이였다. 화려하지 않은 작업이고 일의 대부분이다
    • 중복 제거 — 클라이언트가 이벤트를 재시도할 수 있고 업스트림 배달이 at-least-once라 같은 이벤트가 여러 번 도착할 수 있다. 단순하고 idempotent한 키로 해결 — 이벤트를 고유 식별하는 몇 필드(광고 ID, 이벤트 타입, 반복 가능한 이벤트는 offset이나 타임스탬프)를 해싱해 안정적 ID를 계산하고, 본 적 있는 ID면 반복을 버린다
      • Flink로 바뀐 것은 그 ID를 어디에 기억하느냐다. 이전 설계는 외부 KV 스토어에 두고 모든 이벤트마다 네트워크로 check-and-write를 했지만, Flink는 그 상태를 잡에 로컬로 둘 수 있게 한다. 이벤트 ID로 스트림을 key하면 주어진 이벤트의 모든 복사본이 같은 곳으로 라우팅되고 짧은 TTL의 로컬 state store가 최근 본 ID를 기억한다. ID로 key하는 것이 외부 스토어가 방어해야 했던 race도 제거한다(주어진 ID가 한 번에 한 곳에서만 처리되므로) — hot path의 네트워크 홉 없이
      • 보장에 대해 의도적이다: exactly-once가 아니라 at-least-once 배달 + best-effort 중복 제거. in-stream dedup이 윈도우 내 대다수 중복을 제거하지만 크로스 리전 재라우팅, 늦은 복구, 최근 이벤트를 replay하는 재시작으로 몇 개가 빠져나갈 수 있다. 그래서 이벤트 ID를 enrich된 광고 이벤트 자체에도 실어 정확한 카운트가 필요한 소비자(청구 등)가 자기의 더 긴 윈도우에서 자체 idempotent 중복 제거를 돌릴 수 있게 한다
    • 프로덕션 롤아웃 — 가장 어려운 부분. 청구와 frequency capping을 먹이는 시스템에 “믿어라, 새 것이 맞다”는 충분하지 않으므로 Flink 파이프라인을 레거시와 나란히 돌리고 트래픽을 벌게 했다
      • 두 파이프라인이 전체 이벤트 스트림을 지속 처리해 실시간 비교. 출력을 나란히 발행하고 둘이 어긋나면 알리는 자동 감사를 돌렸고, 그 감사가 값을 했다 — 대시보드만으로는 절대 못 잡았을 미묘한 불일치를 표면화
      • 두 시스템의 enrichment 로직이 같았음을 어떻게 아나? 개발 초기에 그 로직을 레거시 서비스와 Flink 잡이 둘 다 의존하는 공유 모듈로 추출해 이벤트 enrich 방식의 어떤 변경도 동시에 양쪽에 적용되게 했다. 따라서 감사 실패는 파이프라인 메커니즘의 진짜 차이를 가리켰다
      • 컷오버는 비교와 별개 질문이고 여기가 정밀함을 요하는 부분이다. 같은 이벤트가 양쪽에 랜딩하면 다운스트림에서 이중 계산되므로 랜덤 비율로 나눌 수 없었다. 대신 분할이 결정적이었다 — 각 이벤트의 stable key가 어느 파이프라인이 발행을 책임질지 결정해, 양쪽이 모든 이벤트를 보면서도 모든 이벤트가 정확히 한 번 발행되었다. 임계를 올리면 이벤트가 조각씩 레거시에서 Flink로 옮겨가고 뭔가 이상하면 즉시 되돌릴 수 있었다

주요 수치 / 사실

  • 완전히 컷오버할 때 두 파이프라인이 99.99% 이상의 이벤트에서 일치했고 한 번에 다 하지 않고 리전별로 롤아웃했다. 그다음 광고 서버의 Metadata Registry 쓰기를 끄고 레거시 서비스와 registry 자체를 폐기했다. 광고 서빙 크리티컬 패스의 단일 실패 지점이 그냥 사라졌고, Netflix에서 가장 오래된 광고 서빙 컴포넌트 중 하나가 삭제되었다
  • 최근 로드 테스트에서 단일 AWS 리전이 대규모 동시 청중의 광고 브레이크 하나를 처리해 초당 수십만 이벤트를 처리하고 대다수를 실시간으로 서빙 response와 조인하고 비슷한 속도로 enrich된 이벤트를 출력했으며 타임아웃과 에러는 무시할 수준. 아직 천장에 도달하지 않았다
  • 체크포인트 초기 문제: 첫 체크포인트가 특히 오래 걸렸고 필요 이상의 상태를 메모리에 들고 있었다. 플랫폼 팀과 함께 상태를 다듬고 체크포인트 시간을 줄이고 로드 테스트로 이득을 검증했다. 그것이 Live 버스트 중 잡이 stall하지 않게 하는 요소
  • Netflix는 25,000개 이상의 Flink 잡을 운영하고, 그 성숙도 위에 커스텀 canary 지표, 스태거 롤아웃 CI/CD, 새 잡이 건강한 체크포인트를 탐지할 때까지 옛 잡을 계속 돌리는 배포 흐름을 얹었다
  • 핵심 정리 3가지: 가능하면 데이터베이스를 hot path에서 걷어내라 — 가장 큰 승리는 raw 성능이 아니라 blast radius였다. 동기 write-and-wait를 fire-and-forget 로그 + 다운스트림 조인으로 교환한 것이 광고 서빙에서 실패 모드 하나를 통째로 제거했다 / 데이터가 있는 곳에서 이벤트를 매칭하라 — 실시간 조인이 데이터를 어디에나 복사하는 대신 이벤트를 그 데이터로 라우팅함으로써 글로벌 복제를 이긴다 / 컷오버를 벌어라 — 두 시스템을 병렬로 돌리며 라이브 트래픽에서 출력을 diff한 것이 flag-day 전환보다 느렸고, 그것이 결과를 신뢰하는 유일한 이유다

관련 위키


Source: 원문 보기