Skip to content

Ad Prefetching, 광고 서버 폭주를 앞당겨 막는 구조 ​

스포츠 중계나 대형 콘서트, 개표 방송처럼 동시 시청자가 수만에서 수십만 명까지 몰리는 라이브 스트림에서는 광고 구간(avail, SCTE-35 마커로 표시되는 광고 삽입 지점) 하나가 열리는 순간 문제가 생깁니다. SSAI(Server-Side Ad Insertion, 서버가 플레이어 대신 광고를 스트림에 끼워 넣는 방식)는 이 순간 ADS(Ad Decision Server, 어떤 광고를 내보낼지 결정하는 광고 서버)에 "이 시청자에게 보여줄 광고를 달라"고 실시간으로 요청합니다. 문제는 같은 광고 구간에 들어간 세션이 전부 동시에 이 요청을 보낸다는 점입니다. Tencent Cloud StreamPackage는 이 문제를 요청 시점 자체를 앞당기는 방식으로 풉니다. 이번 글에서는 이 Ad Prefetching 기능이 어떻게 동작하는지, 그리고 실제로 잘 동작하고 있는지를 CLS(Cloud Log Service, Tencent Cloud의 로그 서비스) 로그로 어떻게 확인하는지 정리합니다.

실시간 요청이 왜 문제가 되는가 ​

Prefetching이 없는 기본 SSAI 흐름은 이렇습니다. 광고 구간이 도착하면 그 순간 ADS에 실시간으로 광고를 요청하고 VAST(광고 메타데이터를 담은 XML 규격) 응답을 파싱하고 크리에이티브(실제 광고 영상 소재)를 준비한 다음에야 매니페스트에 광고를 끼워 넣습니다. 이 과정이 전부 광고 구간이 열리는 그 순간 실시간으로 일어납니다.

문제는 규모가 커질수록 드러납니다. 수만에서 수십만 세션이 같은 광고 슬롯에서 동시에 ADS를 호출하면 ADS가 요청을 감당하지 못해 타임아웃이 나기 시작합니다. 타임아웃이 난 세션은 광고 대신 슬레이트(대체 화면)로 넘어갑니다. 그러면 광고가 나가야 할 자리에 광고가 안 나갔으니 그만큼 광고 수익이 사라집니다. VAST 파싱과 크리에이티브 준비까지 실시간으로 처리하다 보니 매니페스트 생성 자체가 느려지는 것도 함께 따라오는 문제입니다.

Retrieval Window와 Consumption Window ​

Ad Prefetching은 이 요청을 두 개의 시간 창으로 나눠서 문제를 해결합니다.

Retrieval Window(인출 창) 안에서 StreamPackage는 광고 구간이 실제로 열리기 전에 미리 ADS에 요청을 보내고 받아온 결과를 캐시에 저장해둡니다. 이 요청은 한 번에 몰리지 않도록 Retrieval Window 시간 안에서 분산됩니다. 실제 광고 구간(avail)이 도착하면 **Consumption Window(소비 창)**가 시작됩니다. 이 구간에서는 ADS를 다시 부르는 대신 미리 캐시에 저장해둔 광고를 그대로 꺼내 씁니다. 두 창은 겹칠 수 있습니다. 한쪽에서는 다음 구간을 위한 인출이 진행되는 동안 다른 쪽에서는 이미 준비된 광고가 소비되고 있는 구조입니다.

SSAI Ad Prefetch 파이프라인

이렇게 하면 광고 구간이 열리는 순간에는 캐시에서 꺼내 쓰기만 하면 되니 ADS 타임아웃 위험이 사라지고 VAST 파싱과 크리에이티브 준비도 이미 끝나 있어 매니페스트 생성이 느려지지 않습니다. 참고로 AWS MediaTailor 같은 다른 SSAI 플랫폼도 비슷한 Retrieval/Consumption Window 모델을 씁니다. 크게 새로운 개념이라기보다는 라이브 광고 삽입에서 반복적으로 마주치는 문제에 업계가 내놓은 공통의 답에 가깝습니다.

Single과 Recurring, 두 가지 예약 방식 ​

Prefetch Schedule은 예약 방식에 따라 두 가지로 나뉩니다.

구분SingleRecurring
적용 대상정확한 시간에 발생하는 광고 슬롯 하나라이브 이벤트 전체 기간의 모든 슬롯
삽입 시점알려진 시간(하프타임, 프로그램 종료 후 등)SCTE-35로 동적 트리거, 예측 불가
구성avail 하나당 스케줄 하나스케줄 하나가 전체 기간을 커버
적합한 상황고정 편성 프로그램, 알려진 광고 지점장시간 라이브 스포츠, 24시간 뉴스 채널

하프타임처럼 시간이 딱 정해진 광고 슬롯은 Single로 정밀하게 잡고 경기 중 언제 터질지 모르는 SCTE-35 마커는 Recurring으로 전체 방송 시간을 커버하는 식으로 섞어 쓸 수 있습니다.

아무 광고나 아무 구간에 넣지 않는다: Avail Matching ​

미리 받아온 광고가 엉뚱한 avail에 잘못 들어가면 광고주 클레임으로 직결됩니다. 그래서 Prefetch Schedule에는 Avail Matching Criteria(매칭 조건)를 걸 수 있습니다. 예를 들어 avail.event_id = "halftime" 같은 조건을 걸어두면 해당 event_id를 가진 avail에만 이 스케줄로 준비한 광고가 소비됩니다. 매칭 조건은 스케줄 하나에 최대 5개까지 설정할 수 있어서 여러 조건을 조합하면 원하는 avail만 정확히 겨냥할 수 있습니다.

Traffic Shaping: ADS를 한 번에 몰아치지 않게 ​

Retrieval Window 안에서도 수만 세션이 한 시점에 몰리면 결국 ADS는 또 폭주합니다. Traffic Shaping은 이 요청을 시간축에 걸쳐 실제로 펼칩니다. 세 가지 방식이 있습니다.

  • None: 제한 없이 요청을 즉시 전송합니다. 소규모 테스트에 적합합니다.
  • Retrieval window 방식: 지정한 시간(30초에서 1,800초 사이) 동안 요청을 균일하게 분산합니다. ADS 처리 용량을 정확히 모를 때 쓰기 좋습니다.
  • TPS 방식: 초당 트랜잭션 수(TPS)와 동시 사용자 수(CCU) 기준으로 정밀하게 제한합니다. "우리 ADS는 초당 5,000건이 한계"라는 걸 알고 있다면 Peak TPS를 5,000, Peak CCU를 100,000으로 잡아두는 식으로, ADS의 실제 처리 한계를 넘지 않도록 요청을 자동으로 분배합니다.

ADS의 실제 처리 능력을 알고 있다면 TPS 방식이 가장 정밀하게 맞출 수 있는 선택지입니다.

CLS 로그 4종에 실제로 뭐가 남는가 ​

Prefetch가 실제로 잘 동작하고 있는지는 CLS에 남는 로그로 확인합니다. 파이프라인은 크게 세 단계로 나뉘고 각 단계마다 이벤트가 하나씩 찍힙니다.

1단계, 미리 받기 — ADS에 요청해서 결과를 캐시에 넣는 단계. PREFETCH_RETRIEVAL이 찍힙니다. 2단계, 캐시 소비 시도 — 실제 avail이 도착해서 캐시에 맞는 광고가 있는지 확인하는 단계. PREFETCH_CONSUMPTION이 찍히고 여기서 적중(hit)과 빗나감(miss)이 갈립니다. 3단계, 결과 확인 — 적중했다면 그 광고가 실제 재생분으로 나갔는지 확인하는 PREFETCH_FILL이, 빗나갔다면 실시간 요청으로 강등된 PREFETCH_FALLBACK이 찍힙니다.

네 이벤트는 뼈대가 모두 같습니다. event_type, channel_id, uin(계정 식별자), timestamp, 그리고 이벤트별 세부 필드가 담기는 event_data입니다. CLS에서 event_data 안의 중첩 필드를 검색하려면 event_data.필드명 형태로 쿼리하면 됩니다.

PREFETCH_RETRIEVAL: 미리 받기 ​

미리 받기 작업이 ADS에 요청을 보내고 결과를 받은 시점에 찍힙니다. result는 started/ok/failed 셋 중 하나이고 성공했을 때는 받아온 광고 길이(duration_ms)와 ADS 응답 지연(latency_ms)이 함께 남습니다.

json
{"event_type": "PREFETCH_RETRIEVAL", "session_id": "0000aaaa1111bbbb2222cccc3333dddd", "channel_id": "channel-0000aaaa1111", "uin": "100000000001", "event_data": {"result": "ok", "duration_ms": 15000, "latency_ms": 584}}

15초짜리 광고를 584밀리초 만에 받아 캐시에 넣었다는 뜻입니다. latency_ms가 크면 ADS 응답 자체가 느려지고 있다는 신호이고 failed가 늘면 그게 그대로 이후 적중률 하락의 원인이 됩니다.

PREFETCH_CONSUMPTION: 캐시 소비, 가장 중요한 지표 ​

실제 avail이 도착해서 캐시 사용을 시도한 시점에 찍힙니다. hit은 적중 여부(true/false), result는 hit(적중)/miss(빗나감)/duration_mismatch(광고 길이와 구간 길이 불일치) 중 하나이고 break_id(광고 구간 ID)와 avail_duration_ms(실제 구간 길이)가 함께 남습니다.

적중한 경우:

json
{"event_type": "PREFETCH_CONSUMPTION", "session_id": "0000aaaa1111bbbb2222cccc3333dddd", "event_data": {"break_id": "10000001", "hit": true, "result": "hit", "avail_duration_ms": 15000}}

길이가 안 맞아 빗나간 경우:

json
{"event_type": "PREFETCH_CONSUMPTION", "session_id": "0000aaaa1111bbbb2222cccc3333dddd", "event_data": {"break_id": "10000002", "hit": false, "result": "duration_mismatch", "avail_duration_ms": 1000}}

두 번째 예시는 실제 구간이 1초짜리인데 미리 받아둔 광고가 15초라서 못 쓴 경우입니다. duration_mismatch가 자주 보인다면 미리 받을 때 어떤 길이를 기준으로 요청하는지와 실제 구간 길이 분포가 어긋나 있다는 뜻이니, 그 기준부터 다시 살펴봐야 합니다.

PREFETCH_FILL과 PREFETCH_FALLBACK: 진짜 결과 확인 ​

적중(hit)했다고 해서 광고가 무조건 나간 건 아닙니다. 적중 직후 다른 조건이 안 맞아 실제 투입이 취소되는 경우가 있기 때문에, 적중과 실제 투입을 따로 세야 진짜 수익으로 이어지는 숫자가 나옵니다. 적중한 광고가 실제로 재생 가능한 산출물로 만들어졌을 때 PREFETCH_FILL이 찍힙니다.

json
{"event_type": "PREFETCH_FILL", "session_id": "0000aaaa1111bbbb2222cccc3333dddd", "event_data": {"break_id": "10000001", "filled_duration": 15.0}}

반대로 캐시를 못 써서 실시간 요청으로 강등되면 PREFETCH_FALLBACK이 찍힙니다.

json
{"event_type": "PREFETCH_FALLBACK", "session_id": "0000aaaa1111bbbb2222cccc3333dddd", "event_data": {"break_id": "10000002", "fallback_reason": "duration_mismatch", "avail_duration_ms": 1000}}

강등은 Prefetch가 없던 상태로 되돌아간 것과 같아서 ADS 지연 위험을 다시 그대로 떠안게 됩니다. fallback_reason이 무엇으로 몰리는지가 이후 개선 우선순위를 정하는 기준이 됩니다.

대시보드에서 봐야 할 지표 ​

로그 네 종류를 그대로 조합하면 이런 지표가 나옵니다.

지표산식볼 때 포인트
Prefetch 적중률적중 수 / 소비 시도 전체Prefetch 효과 자체. 낮으면 아래 항목부터 순서대로 확인
실제 Fill-rateFILL 수 / CONSUMPTION 적중 수적중했는데 실제로는 안 나간 유실을 잡아냄. 100% 미만이면 원인 추적 필요
강등 사유 분포FALLBACK을 fallback_reason으로 그룹핑개선 작업의 우선순위표
받기 성공률 / 지연RETRIEVAL의 result=ok 비율, latency_ms 평균과 피크ADS 자체의 건강도를 보여주는 상류 지표
길이 불일치 비율duration_mismatch 수 / 소비 시도 전체구간 길이를 예측하는 전략이 얼마나 정확한지

적중률을 뽑는 CLS 쿼리는 대략 이런 모양입니다.

event_type:"PREFETCH_CONSUMPTION" |
SELECT COUNT_IF(event_data.hit = 'true') * 100.0 / COUNT(*) AS hit_rate_pct,
       COUNT_IF(event_data.hit = 'true') AS hit_cnt,
       COUNT(*) AS total

강등 사유별로 몇 건씩 발생했는지 보고 싶다면 이렇게 묶으면 됩니다.

event_type:"PREFETCH_FALLBACK" |
SELECT event_data.fallback_reason, COUNT(*) AS cnt
GROUP BY event_data.fallback_reason
ORDER BY cnt DESC

흔한 오해 ​

오해실제로는
hit이면 광고가 무조건 나간 것이다FILL까지 찍혀야 실제로 투입된 것입니다. hit 대비 FILL 비율이 진짜 Fill-rate입니다
Prefetch 실패는 플레이어 쪽 문제다전 과정이 서버 안에서 일어납니다. 플레이어는 완성된 매니페스트만 받을 뿐입니다
miss는 전부 같은 원인이다cache_miss(아직 못 받았거나 요청 자체가 실패한 경우)와 duration_mismatch(길이가 안 맞는 경우)는 대책이 완전히 다릅니다
RETRIEVAL이 ok면 그 구간은 안전하다받아온 광고 길이가 실제 구간과 안 맞으면 결국 못 씁니다. CONSUMPTION 결과까지 봐야 확실합니다

마치며 ​

Ad Prefetching이 하는 일은 정확히 말하면 ADS 요청의 총량을 줄이는 게 아니라, 요청이 몰리는 타이밍을 시간축에 미리 펼쳐두는 것입니다. 세션별로 따로 나가야 하는 트래킹이나 클릭 비콘 같은 요청은 Prefetch를 켜도 여전히 세션마다 독립적으로 나갑니다. 그래도 광고 구간이 열리는 그 순간의 ADS 타임아웃 위험을 없애준다는 점만으로도 동시 시청자가 많은 라이브 이벤트에서는 Fill-rate와 매니페스트 응답 속도가 양쪽 다 실질적으로 달라집니다. Retrieval Window와 Traffic Shaping을 얼마나 세밀하게 잡느냐, 그리고 CLS 로그로 적중률과 강등 사유를 꾸준히 들여다보느냐가 결국 이 기능을 얼마나 효과적으로 활용하느냐를 가릅니다.

基于 VitePress 构建 · 部署于腾讯云 EdgeOne Pages