Cloudflare Stream 도입기 3: 시청 구간으로 계산하는 진도와 수료

정기창·

Cloudflare Stream 플레이어를 연결한 뒤에는 진도 기록이 남았습니다. 재생 위치, 인정한 시청 구간, 토큰 수명, 수료 판정은 서로 다른 상태로 다뤄야 했습니다.

온라인 교육의 수료 조건은 모든 공개 회차를 각각 100% 시청하는 것으로 정했습니다. 영상의 종료 이벤트 하나로 이 조건을 판단하면 중간 탐색을 구분할 수 없습니다. 이 글에서는 브라우저 이벤트와 서버 기록 사이에서 적용한 계산 방식과 비동기 처리 원리를 정리합니다.

마지막 위치가 곧 진도는 아닙니다

100초 영상에서 10초까지 본 뒤 90초로 이동해 끝까지 봤다고 가정하겠습니다. 마지막 위치는 100초지만 인정할 수 있는 구간은 0~10초와 90~100초입니다. 마지막 위치를 전체 길이로 나누면 이런 경우도 100%가 됩니다. 따라서 이어보기 위치와 누적 시청 범위를 분리했습니다.

누적 시청 범위는 구간의 합집합으로 저장합니다. 예를 들어 [0,10], [8,20], [90,100]을 받으면 앞의 두 구간을 합쳐 [0,20], [90,100]으로 정규화합니다. 합계는 30초입니다. 같은 부분을 반복해서 봐도 이미 인정한 구간을 두 번 더하지 않습니다.

W = 겹침을 제거한 각 구간 길이의 합
D = 현재 영상의 전체 길이
진도율 = min(100, 100 × W / D)

서버가 받아들일 구간의 조건이 먼저 정해져야 이 계산도 의미가 있습니다. 브라우저가 보냈다는 이유만으로 임의의 구간 전체를 인정하지는 않았습니다.

네트워크 도착 시간과 관측 시간을 구분합니다

플레이어는 재생 중 약 2초마다 위치를 관측합니다. 이때 위치뿐 아니라 재생 상태, 배속, 증가하는 순번, performance.now()를 기준으로 한 경과 시간을 함께 고정합니다. 서버 요청은 순차 큐로 보내지만 관측 시각을 실제 전송 직전에 다시 만들지는 않습니다.

응답이 5초 지연되면 여러 관측값이 큐에 쌓일 수 있습니다. 이들이 서버에 도착한 간격으로 진도를 계산하면, 실제 관측 간격과 통신 지연을 섞게 됩니다. 반대로 전송 시점으로 시간을 덮어쓰면 지연된 요청이 한꺼번에 도착했을 때 정상 시청도 불연속 탐색처럼 보일 수 있습니다. 사건이 발생한 시간과 처리된 시간을 구분한 이유입니다.

서버는 이전보다 큰 순번만 새 관측값으로 처리합니다. 관측 시간이 뒤로 가거나 서버에서 허용한 경과 범위를 넘으면 거부합니다. 이것은 일반적인 재시도와 요청 역전에 대응하는 장치입니다. 클라이언트가 악의적으로 모든 보고를 조작하는 경우까지 진실한 시청으로 증명하는 장치는 아닙니다.

연속 재생으로 인정하는 범위를 정합니다

이전 상태가 재생 중이고, 관측 간격이 0초보다 크며 15초 이하일 때 구간 추가를 검토합니다. 위치 증가량은 ‘관측 간격 × 이전 배속 + 0.5초’ 이하이어야 합니다. 2배속으로 2초 동안 4초 전진한 샘플은 인정하지만 같은 시간에 40초가 바뀌었다면 정상 연속 재생으로 인정하지 않습니다.

탐색 이벤트에서도 기준점을 나눕니다. seeking에서는 이동 직전의 안정된 위치를 보고하고, seeked에서는 새 위치를 이후 재생의 기준으로 삼습니다. SDK가 제공하는 상태와 이벤트는 Stream Player API를 기준으로 연결했습니다. 구간 인정 규칙과 시간 허용 폭은 교육 서비스가 정한 정책입니다.

시작 이벤트가 정확히 0초에 오지 않을 수 있어 처음 위치가 0.5초 이하이면 0을 보고합니다. 종료 시에는 등록된 영상 길이를 보고합니다. 이를 통해 아주 작은 시작·종료 오차를 다루지만, 모든 밀리초를 직접 관찰했다는 뜻은 아닙니다. 절전이나 탭 중단으로 관측 간격이 15초를 넘으면 실제로 재생됐더라도 해당 공백은 진도로 인정되지 않을 수 있습니다.

짧은 영상 토큰과 학습 세션은 따로 유지합니다

영상 토큰은 발급부터 15분 동안 유효합니다. 애플리케이션의 playback은 별도 상태이며 정상 관측값을 저장할 때 유효기간을 연장합니다. 영상 토큰을 갱신할 때마다 playback을 새로 만들면 구간 저장의 순번과 시계가 끊길 수 있습니다. 그래서 같은 playback ID, 시작 시계, 관측 순번을 유지했습니다.

갱신은 만료 약 5분 전에 예약합니다. 정상적인 15분 토큰이라면 약 10분 후입니다. 현재 위치와 배속을 보관하고 재생을 멈춘 뒤 마지막 저장을 기다립니다. 기존 iframe을 제거하고 새 토큰으로 플레이어를 만든 다음 메타데이터가 준비되면 위치와 배속을 복원합니다.

이전에 재생 중이었다면 다시 재생을 시도합니다. 다만 브라우저의 자동 재생 정책에 따라 사용자의 조작이 다시 필요할 수 있습니다. 따라서 이 구현을 무중단 재생으로 표현하지 않았습니다. 만료 전에 권한을 다시 확인하면서 학습 상태를 최대한 이어가는 절차입니다.

플레이어가 사라져도 저장할 기록은 남을 수 있습니다

가장 조심한 상황은 갱신과 저장 실패가 겹칠 때였습니다. 갱신 과정에서 iframe이 제거됐다고 해서 저장할 내용까지 사라지는 것은 아닙니다. 마지막 저장에 실패한 샘플을 플레이어 바깥의 상태로 보관하고, 강제 저장이 필요하면 남은 큐를 기다린 뒤 다시 전송하도록 했습니다.

로그아웃도 이 순서를 따릅니다. 마지막 시청 기록 저장에 실패했다면 로그아웃 API를 먼저 호출하지 않고 재시도할 수 있게 합니다. 반대로 저장은 성공했지만 로그아웃 API가 실패했고 그동안 토큰 갱신까지 시작됐다면, 갱신 종료 후 다시 연결하고 다음 갱신 타이머를 복구해야 합니다. 이 경우를 별도 브라우저 테스트로 재현했습니다.

일반적인 페이지 종료에서는 keepalive 요청을 사용하지만 브라우저 강제 종료나 네트워크 단절까지 저장을 보장하지는 않습니다. 사용자가 명시적으로 누른 로그아웃과 브라우저가 사라지는 상황을 같은 보장 수준으로 설명해서는 안 된다고 생각했습니다.

화면의 100%와 수료증의 근거도 나눕니다

수료 판정은 표시용으로 반올림한 퍼센트에 의존하지 않습니다. 정규화된 구간이 하나이고, 시작이 0이며 끝이 현재 영상 길이에 도달했는지 검사합니다. 100초 영상에서 [0,99.999]나 [0,49] + [50,100]은 수료 조건을 만족하지 않습니다. 현재 공개 회차 각각의 현재 영상 버전과 유효한 수강권을 함께 확인합니다.

발급할 때는 현재 커리큘럼과 진도를 트랜잭션 안에서 확인하고, 회차 ID·영상 버전·길이 목록과 정책 버전을 발급 근거로 사용합니다. 같은 조건으로 동시에 요청해도 중복 발급을 피하도록 설계했습니다. 발급 당시 정보와 PDF를 보관하는 것은 이후 커리큘럼 변경과 과거 발급 사실을 구분하기 위해서입니다.

엄격한 판정에도 측정의 한계는 남습니다

테스트에서는 짧게 만료되는 토큰을 모의해 갱신을 빠르게 재현했습니다. 지연된 응답, 탐색, 중복 순번, iframe 제거 후 저장 실패, 로그아웃과 갱신의 겹침도 확인했습니다. 로컬 DB와 모의 REST·SDK·iframe으로 확인한 결과이며, 실제 Cloudflare 영상의 인코딩·음성·기기별 재생 검수는 아직 남아 있습니다.

이 기록이 보여 주는 것은 서비스가 정한 규칙으로 인정한 재생 범위입니다. 학생의 집중도나 이해도, 본인이 화면을 보고 있었는지까지 증명하지는 않습니다. 수료 조건을 엄격하게 구현한다는 것은 무엇을 관측했고 어떤 공백을 제외했는지 설명할 수 있게 만드는 일에 가깝다고 생각했습니다.

Cloudflare StreamTypeScript상태 관리테스트