라이브 방송을 몇 분 전으로 되감아 보는 기능이 있습니다. 그런데 되감아 둔 자리의 영상 조각을 서버가 이미 지워 버렸다면, 플레이어는 무엇을 해야 할까요? 저는 처음에 「서버에 지금 볼 수 있는 가장 오래된 지점을 물어서 그리로 옮긴다」 로 만들었습니다. 그 답을 해석하는 갈래가 너무 많았고, 리뷰마다 그 갈래에서 버그가 나왔습니다. 결국 CDN 이 이미 주고 있는 재생 목록을 다시 받아 그 목록 안에서 이어 가는 한 줄 흐름으로 바꿨습니다. 이 글은 그 과정과, 바꾸면서 얻은 것과 내준 것을 정리합니다.

먼저 범위를 밝힙니다. 설계 방향과 바꾸자는 판단은 제가 했고, 구현 · 시험 · 리뷰는 AI(Claude Code · Codex)가 제 지시로 했습니다. hls.js 의 동작은 라이브러리 소스로, 앱의 동작은 시험으로 확인했습니다. 운영 환경에서 실제 404 를 관측하지는 않았습니다(7절).

1. 먼저 용어 몇 개

용어 뜻
HLS 영상을 몇 초짜리 조각(segment) 파일로 잘라 두고, 그 목록(재생 목록, .m3u8)을 플레이어가 받아 차례로 내려받아 이어 붙이는 방식
hls.js 브라우저에서 HLS 를 재생하는 오픈소스 라이브러리. 이 글은 1.7.3 기준
되감기(DVR) 라이브 중에 몇 분 · 몇십 분 전으로 돌려 보는 기능
되감기 창 돌려 볼 수 있는 범위. 창보다 오래된 조각은 지워지거나 CDN 이 막는다
하한 창의 가장 오래된 끝. 시간이 흐르면 계속 앞으로 간다
시작 하한 플레이어가 라이브 목록에서 재생을 시작할 수 있는 가장 앞자리. 목록의 첫 조각에서 세 조각 안쪽
엣지(CDN) 시청자 가까이에서 파일을 대신 내주는 캐시 서버
오리진 · 제어면 원본 서버. 제어면은 「지금 되감기 상태가 어떤가」 를 알려 주는 API
404 「그런 파일 없음」 응답. 지워진 조각을 요청하면 받는다
다시보기(VOD) 방송이 끝난 뒤 녹화본을 보는 것
회차 방송 안에서 송출이 한 번 이어진 구간. 송출이 끊겼다 다시 이어지면 새 재생 목록 주소(다음 주소)가 생길 수 있고, 앱은 제어면을 정기적으로 조회해 이를 안다
치명 오류 hls.js 가 스스로 회복할 수 없다고 보고 받기를 멈추는 오류
화면이 붙다 새 플레이어가 첫 영상 화면을 실제로 보여 준 순간

2. 문제 — 언제 404 가 나나

되감기 창은 1시간입니다. CDN 엣지에 「창보다 오래된 조각은 막는다」 는 규칙을 넣기로 하면서 이 문제가 생겼습니다. 시청자가 창 끝 근처를 보고 있거나, 일시정지를 오래 했거나, 플레이어가 들고 있는 목록이 낡았으면, 이미 창 밖으로 밀려난 조각을 요청하게 됩니다. 결과는 404 입니다.

일시정지한 사이 새 조각이 생기며 되감기 창이 밀리고, 다시 재생하면 다음 조각이 이미 창 밖이라 404 를 받습니다. 설명용 움직이는 그림이며 실제 화면이 아닙니다. 소리는 없습니다.

손대기 전 hls.js 1.7.3 은 이 404 에 이렇게 반응했습니다. 라이브러리 소스로 확인한 동작이고, 바꿔 받을 다른 화질이 없는 스트림 기준입니다. 화질이 여럿이면 다른 화질로 바꾸는 갈래로 갑니다(error-controller.ts 459–466).

상황 hls.js 1.7.3 동작 시청자가 보는 것
라이브 목록 가운데 조각 404 「틈」 으로 보고 다음 조각으로 건너뜀(재시도 없음)¹ 몇 초가 말없이 빠짐
라이브 목록 마지막 조각, 또는 끝난 목록의 조각 404 같은 조각을 6번 더 요청(1 · 2 · 4 · 8 · 8 · 8초 간격)² → 치명 오류³ → 앱이 한 번 더 시작 → 또 6번 약 62초 멈춘 뒤 「끊김」 알림

「6번」 은 첫 실패 직전 오류 횟수가 0 이고, 기다리는 동안 라이브 목록의 끝이 그대로일 때의 값입니다. 그사이 목록 끝이 늘어나면 그 조각은 더 이상 「마지막」 이 아니라서, 다음 실패부터 「틈」 갈래로 넘어갑니다. 62초는 재시도 대기 31초(1+2+4+8+8+8)가 두 번 돈 값이고, 요청에 걸리는 시간은 뺐습니다. 다시 시작하면 6번을 처음부터 다시 세는 이유는 치명 오류 때 부르는 stopLoad() 가 오류 횟수를 0 으로 되돌리기 때문입니다(level-controller.ts 82–92).

404 를 받았을 때의 갈래 어디에도 「창 밖이니 하한으로 옮겨서 보여 준다」 는 답은 없습니다. hls.js 에는 재생점이 많이 뒤처지면 라이브 끝 근처로 당겨 오는 장치가 따로 있지만(stream-controller.ts 704–745), 이것은 하한이 아니라 라이브 끝 쪽으로 옮기고 404 처리와도 별개입니다. 하한으로 옮기는 답은 앱이 만들어야 했습니다.

3. 처음 해법 — 404 가 나면 제어면에 다시 묻는다

처음 설계는 이랬습니다.

  1. 플레이어가 조각 404 를 가로채 hls.js 의 받기를 멈춥니다. 같은 조각을 다시 요청하지 않게 합니다.
  2. 제어면에 「지금 방송이 어떤 국면이고 하한이 어디냐」 를 다시 묻습니다.
  3. 답에 따라 갈래가 나뉩니다.
    • 같은 회차 · 라이브이고 제어면이 「준비됨」 이라고 답함 → 하한에 여유를 더한 자리로 플레이어를 새로 만들고 「가장 오래된 지점으로 옮겼어요」 알림.
    • 같은 회차지만 준비가 안 됨 → 기다림.
    • 회차를 모름 · 응답 실패 · 다른 회차 · 라이브가 아님 → 「막힘」 상태로 두고 다시보기 준비 중 화면을 띄움 → 다시보기가 나오면 같은 자리로, 방송이 끝났으면 녹화로, 20분이 지나면 녹화로.
    • 옮긴 자리에서 또 404 → 제어면이 아는 하한이 실제보다 늦은 것이므로 바로 묻지 않고 다음 정기 조회(10초)를 기다림. 끝없이 되풀이하지 않게 하는 장치입니다.
  4. 끝난 방송의 404 는 회차별 조회를 한 번 더 해서 다시보기나 녹화로 보냅니다.

이 설계로 구현 커밋 여섯 개와 PR 직전 수정 넷까지 만들었고, 모든 커밋이 두 AI 리뷰어(Claude · Codex)의 동시 리뷰를 통과했습니다.

4. 갈래가 버그의 원천이었다

돌아보니 리뷰에서 나온 심각한 지적 대부분이 「제어면 답을 해석하는 갈래」 에서 나왔습니다.

리뷰에서 나온 문제 원인이 된 갈래
막힘 상태가 새 주소 자동 연결이나 늦게 도착한 조회 결과 때문에 저절로 풀림 막힘(제어면 실패 · 회차 모름) 진입 갈래
플레이어를 새로 만들면 막힘 화면 아래에서 몰래 재생됨 막힘 갈래 + 플레이어 수명
「옮겼어요」 알림이 두 경우에 뜨지 않음 옮긴 자리 표시로 되풀이를 판정
10분 멈춘 뒤 404 면 아무 표시 없이 약 20초 정지 되풀이 속도 상한(같은 하한 대기)
시작 자리 「하한 + 여유」 가 추정이라 실제와 어긋날 수 있음 제어면 값과 목록 값을 섞음

하나씩 고칠 수는 있었습니다. 하지만 고칠 때마다 갈래가 하나씩 더 붙었습니다.

5. 질문 하나가 구조를 바꿨다

리뷰를 보다가 저는 이렇게 물었습니다. 「404 날 때마다 오리진에 계속 묻는 게 말이 되냐?」

부하 문제로는 틀린 걱정이었습니다. 상태 API 는 CDN 에 1초 동안 캐시되어서, 오리진이 받는 요청은 스트림당 초당 최대 한 번이었습니다. 하지만 이 질문이 다른 질문으로 이어졌습니다. 애초에 제어면에 물을 필요가 있나?

답은 엣지가 하한을 정하는 방식에 있었습니다.

  • 엣지 하한은 「60초 전까지 발행에 성공한 재생 목록의 첫 조각 번호 중 가장 큰 값」 입니다. 목록은 5분마다 올라가고, 엣지까지 퍼지는 데 30–60초가 걸립니다.
  • 그래서 엣지 하한은 늘 재생 목록보다 늦게 따라옵니다. 방금 받은 목록에 실린 조각은 엣지가 막지 않습니다. 정확히는 그 회차의 가장 최근 목록만 보장됩니다 — 송출이 끊겼다 다시 이어져 한 회차 안에서 목록이 둘로 갈리면, 앞 목록은 이 보장 밖입니다(7절 한계). 이것은 설정값으로 따진 성질이고, 엣지 차단이 아직 배포 전이라 운영에서 재 보지는 않았습니다.
  • 재생 목록 자체도 1초 캐시라 낡지 않습니다.

재생 목록의 첫 조각이 5분마다 계단처럼 오르고, 엣지 하한이 같은 계단을 최대 약 2분 늦게 따라와 방금 받은 목록의 첫 조각은 막히지 않음을 보여 주는 그래프

방금 받은 목록의 첫 조각은 늘 엣지 하한 이상이라 막히지 않습니다. 설명용 그림이며 실제 측정 결과가 아닙니다 — 계단 높이(조각 번호)는 모양만 나타내고, 늦게 따라오는 간격은 가장 늦은 경우(약 2분)로 그렸습니다.

정리하면, 404 가 났다는 건 플레이어가 들고 있는 목록이 낡았다는 뜻입니다. 그러면 할 일은 목록을 다시 받아 그 목록 안에서 이어 가는 것뿐입니다. 제어면에 물을 이유가 사라졌습니다.

이 성질은 엣지 쪽 설정(발행 주기 · 여유 시간 · 전파 시간)에 기댑니다. 그래서 엣지를 맡은 쪽과 이것을 지켜야 할 약속(계약)으로 묶었습니다. 그쪽이 설정을 바꿀 때 이 약속을 점검합니다.

6. 바꾼 해법 — 같은 목록을 다시 받는다

뼈대는 한 줄입니다. 조각 404 → 받기 멈춤 → 같은 주소로 플레이어를 새로 세워 목록을 다시 받음 → 그 목록 안에서 이어 재생. 제어면에는 묻지 않습니다.

404 를 받으면 받기를 멈추고 같은 주소로 플레이어를 새로 세워 목록을 다시 받습니다. 시작 자리가 새 목록의 시작 하한보다 앞이면 시작 하한으로 자르고, 실제로 밀렸을 때만 알립니다. 설명용 움직이는 그림이며 실제 화면이 아닙니다. 소리는 없습니다.
  1. 가로채기. 처음 설계와 같습니다. 조각 404 를 받으면 hls.js 의 받기를 멈춰 같은 조각을 다시 청하지 않게 합니다.
  2. 같은 주소로 플레이어를 새로 세웁니다. 새 플레이어는 목록을 처음부터 받으므로 CDN 이 주는 최신 목록(1초 캐시)을 받습니다. 다른 방법 셋은 버렸습니다.
    • 같은 플레이어에서 startLoad() 를 다시 부르기: hls.startLoad() 는 목록을 받는 쪽과 조각을 받는 쪽을 한꺼번에 다시 움직입니다(hls.ts 593–611). 그래서 새 목록이 오기 전에 들고 있던 낡은 목록으로 같은 조각을 다시 청할 수 있습니다.
    • 조각 요청만 잠깐 막는 꼼수: hls.js 내부 순서에 기대는 방법이라, 라이브러리 판을 올리면 조용히 깨질 수 있습니다.
    • 앱이 목록을 직접 받아 해석하기: 결국 플레이어를 갈아야 해서 목록을 두 번 받게 됩니다.
  3. 어디서 다시 시작하나. 「지금 플레이어에서 실제로 확인된 위치 → 아직 끝나지 않은 시크 목표나 이 플레이어를 시작할 때 요청한 위치 → 새 목록의 시작 하한」 순서로 고릅니다. 이전 화면(예: 라이브)에서 보던 위치는 쓰지 않습니다. 고른 자리가 시작 하한(첫 조각에서 세 조각 안쪽)보다 앞이면 시작 하한으로 잘라 내고, 시작 하한 안이면 그 자리를 그대로 지킵니다. 목록을 다시 받는다고 늘 목록 맨 앞으로 가는 것이 아닙니다. 「세 조각 안쪽」 은 우리 앱이 정해 둔 규칙이고, 이 자르기는 원래 있던 장치라 새로 만들지 않았습니다. 다시보기도 같은 순서로 이어 볼 자리를 고릅니다. 다만 다시보기 목록은 끝 표시가 붙어 창이 밀리지 않으므로, 다시보기의 시작 하한은 세 조각 안쪽이 아니라 첫 조각 그 자체입니다. 되감기에서 다시보기로 옮겨진 직후 플레이어를 다시 세우면 앞서 보던 라이브 자리로 튀는 버그가 구현 검수에서 나와서, 다시보기도 이 순서로 자리를 고르게 고쳤습니다. 라이브 화면만 지금처럼 마지막 자리를 씁니다.
  4. 알림은 실제로 밀렸을 때만. 새 플레이어가 첫 목록을 받는 순간, 자르기와 같은 식으로 「요청한 자리가 앞으로 밀렸는가」 를 판정해 「가장 오래된 지점으로 옮겼어요」 를 한 번만 띄웁니다.
  5. 되풀이 상한. 새 플레이어가 화면을 붙이기 전에 또 404 를 받으면(엣지 쪽 약속이 깨진 경우) 다시 받지 않고 「다시보기 준비 중」 상태(막힘)로 보냅니다. 막힘은 보던 구간과 겹치는 다시보기가 생기거나, 보던 회차가 끝났다고 판정되거나(회차별 조회가 종료라고 답하거나, 재접속을 기다리다 404 를 받으면 녹화로), 20분이 지나거나, 사람이 단추를 누르면 풀립니다. 화면이 붙은 뒤의 404 는 새 사건으로 보고 다시 받습니다. 그래서 화면 없이 같은 주소를 자동으로 다시 받는 일은 많아야 한 번입니다. 그 사이 정기 조회로 다음 주소를 알게 되어도, 다시 받는 중이라는 표시와 고른 시작 자리는 그대로 지킵니다.
  6. 목록에 끝 표시(ENDLIST)가 붙어 오면 그 재생 목록이 닫혔다는 뜻입니다. 방송 자체가 끝났다고 단정하지 않고, 기존의 끝남 대기로 가서 회차별 조회로 다시보기 · 녹화 여부를 판정합니다. 끝남 대기 중에 404 가 나면, 아직 녹화로 가기로 정하기 전이면 막힘으로, 정한 뒤면 끝까지 기다릴 수 없으니 회차별 조회를 한 번만 더 합니다. 이것을 「마지막 확인」 이라고 부릅니다. 그 답이 보던 구간과 겹치는 다시보기면 같은 자리의 다시보기로, 그 밖이면 곧바로 녹화로 갑니다. 다시보기를 권하는 화면이 떠 있는 동안의 404 는 같은 자리의 다시보기로 옮깁니다.
  7. 목록 다시 받기 자체가 실패하면(404 · 5xx · 시간 초과) 「영상이 끊겼어요. 새로고침하면 다시 연결해요」 알림으로 끝냅니다. 403(출입증 만료)은 지금처럼 출입증을 새로 받는 쪽으로 갑니다.
  8. 사람이 멈춘 동안의 404 는 받기만 멈추고 기다립니다. 화면은 그대로이고 저절로 재생되지 않습니다. 다시 재생을 누르면 늘 목록을 다시 받습니다. 붙잡아 둔 404 가 있다는 건 들고 있던 목록이 낡았다는 뜻이기 때문입니다.
  9. 다시보기 · 되감기의 첫 목록 받기가 실패하면(받기 오류 · 시간 초과 · 해석 오류) 곧바로 끊김 알림을 띄웁니다(8-2 의 무한 로딩을 막는 처리). 출입증이 걸린 첫 목록의 403 은 지금처럼 출입증 쪽이 먼저 받습니다.

바꾸지 않은 것도 있습니다. 다시보기를 재생하는 중의 조각 404 는 hls.js 기본 재시도에 앱의 다시 받기 한 번을 더한 지금 동작 그대로이고, 라이브 플레이어도 그대로입니다.

위 줄은 제어면이 알려 준 한 조각 늦은 하한에 여유 세 조각을 더해 추정한 시작 자리가 실제 시작 하한과 어긋나는 모습, 아래 줄은 다시 받은 목록의 첫 조각에 세 조각을 더해 실제 시작 하한에 바로 서는 모습을 견준 그림

처음 설계는 제어면이 알려 준 하한으로 시작 자리를 추정해 실제와 어긋날 수 있고, 바꾼 설계는 다시 받은 목록에서 바로 읽어 어긋날 일이 없습니다. 제어면 값이 늦으면 추정한 자리가 이미 막힌 조각이라 또 404 가 날 수 있고(그래서 처음 설계에 「옮긴 자리에서 또 404」 갈래가 있었습니다), 빠르면 필요보다 더 앞으로 밀립니다. 설명용 그림이며 실제 측정 결과가 아닙니다 — 제어면 값이 한 조각 늦은 경우로 그렸습니다.

갈래를 견주면 차이가 분명합니다. 처음 설계는 404 하나에서 다섯 갈래로 뻗었고, 바꾼 설계는 한 줄 흐름에 출구 셋(끝 표시, 다시 받기 실패, 화면 전 404)이 붙은 꼴입니다.

왼쪽은 404 뒤 제어면에 묻고 답에 따라 다섯 갈래로 나뉘며 그중 셋에 리뷰에서 나온 버그 다섯 개가 찍힌 나무, 오른쪽은 404, 받기 멈춤, 같은 주소로 목록 다시 받기, 목록 기준 이어 재생의 한 줄 흐름과 옆으로 빠지는 회색 출구 셋을 견준 그림

제어면 답을 해석하던 갈래 다섯이 「목록 다시 받기」 한 줄 흐름과 출구 셋으로 줄었습니다. 붉은 점 하나가 리뷰에서 나온 버그 하나입니다. 설명용 그림이며 실제 측정 결과가 아닙니다.

7. 얻은 것과 내준 것, 남은 한계

얻은 것 내준 것
404 뒤 갈래가 「목록 다시 받기 → 이어 재생」 한 줄로 줄어듦 이미 리뷰를 마친 제어면 경로를 걷어내고 계획과 리뷰를 다시 함. PR 이 반나절쯤 늦어짐
제어면 일시 장애나 「회차를 모름」 이 404 처리에 끼어들지 않음 「목록 첫 조각은 엣지가 막지 않는다」 는 다른 팀 쪽 성질에 기대게 됨(계약으로 묶음)
시작 자리가 추정이 아니라 실제 목록 기준 끝난 방송 판정은 여전히 제어면 조회를 씀. 제어면 의존이 0 이 되지는 않음
되풀이 속도 상한 같은 장치가 거의 필요 없어짐 목록 다시 받기가 실패하면(404 · 5xx · 시간 초과) 끊김 알림으로 끝남. 같은 주소로 저절로 다시 시도하지 않아서 LIVE 단추나 새로고침이 출구
화면 없이 같은 주소를 다시 받는 횟수가 구조로 한 번에 묶임 다시 받을 때 플레이어를 갈아 끼우므로 화면이 잠깐 비고, 목록과 첫 조각을 다시 받음

남은 한계도 적어 둡니다.

  • 엣지의 조각 차단이 아직 배포 전이라 운영에서 실제 404 를 관측하지 않았습니다. Safari · iOS 를 포함한 브라우저 실측도 남았습니다.
  • Safari 의 기본 HLS 재생 경로는 hls.js 를 거치지 않아 이 처리 밖입니다.
  • 위의 엣지 성질은 엣지 쪽 설정에 기댑니다. 그쪽이 바뀌면 깨질 수 있어서 계약으로 점검합니다.
  • 한 회차 안에서 재연결로 목록이 둘로 갈린 드문 경우, 앞 목록을 보던 시청자는 보던 목록을 그대로 다시 받으므로 뒤 목록으로 옮겨지지 않습니다. 뒤 목록 주소가 이미 와 있으면 앞 목록을 끝까지 본 뒤 그쪽으로 이어지고, 화면을 붙이기 전에 또 404 가 나면 막힘으로 가서 다시보기 · 녹화 · LIVE 로 빠져나갑니다. 다만 목록 끝에 닿은 막힘 상태에서는 LIVE 단추 · End 키 · 시크바가 동작하지 않는 한계가 남아 있습니다(원래 있던 규칙).
  • 플레이어를 새로 만들면서 재생 시작을 막아 둔 동안에는, 조작 띠의 「실시간」 표시나 재생 아이콘이 실제 상태와 다르게 보일 수 있습니다.
  • 404 직후 아주 짧은 틈(목록 왕복 한 번 안쪽)에 사람이 멈추면, 새 플레이어가 저절로 재생할 수 있습니다.
  • 목록을 다시 받는 아주 짧은 사이의 시크는 무시될 수 있습니다.
  • 멈춘 동안 404 를 붙잡았다가 플레이어를 새로 세운 경우에는 「가장 오래된 지점으로 옮겼어요」 알림이 뜨지 않습니다. 반대로 시작 하한 앞으로 시크한 직후 404 가 나면 같은 알림이 두 번 뜰 수 있습니다(목록이 60초 넘게 낡은 경우).
  • 정기 조회로 새 주소를 받아 플레이어를 갈아 끼울 때 시작 하한으로 잘리는 것은 알리지 않습니다(원래 있던 교체와 같은 한계).
  • 다시보기가 정말 없어진 경우(만료 등)에도 알림 문구는 「새로고침하면 다시 연결해요」 입니다.
  • 구현은 병합됐습니다. 남은 것은 엣지 차단을 켠 뒤의 실제 404 관측(되감기 · 끝남 대기 · 다시보기 제안 · 멈춘 동안)과 브라우저 실측(Chrome · Safari · iOS)입니다.

8. 덤 — hls.js 1.7.3 에서 만난 함정 둘

8-1. init 조각 404 는 「미디어 조각 404」 로 둔갑한다

init 조각은 영상의 머리 정보(코덱 · 해상도 등)를 담은 파일입니다. 이 파일 받기가 실패하면, hls.js 는 오류 자료에 담긴 조각을 해당 미디어 조각으로 바꿔 끼운 뒤 오류를 올립니다(base-stream-controller.ts 815–823).

// hls.js v1.7.3 src/controller/base-stream-controller.ts 819–822 (발췌)
if ('data' in reason) {
  reason.data.frag = mediaFrag;
  this.fragmentLoader.abort();
  this.handleFragLoadError(reason);
}

그래서 오류 자료의 조각이 init 인지(frag.sn !== 'initSegment')를 보고는 init 404 를 가려낼 수 없습니다. 실패한 파일 주소(data.response.url)를 그 조각의 init 주소와 비교해야 합니다. 이 주소는 조각 로더가 오류 자료에 실어 줍니다(fragment-loader.ts 118–126). 기본 XHR 로더 기준이고, 직접 만든 로더가 응답에 url 을 넣으면 덮일 수 있습니다.

처음 짠 시험은 바꿔 끼우기 전 의 원본 자료만 봐서 이 결함을 놓쳤습니다. 리뷰어가 잡았습니다.

8-2. 첫 목록이 404 면 「다시 시작」 이 아무것도 하지 않는다

hls.js 는 화질이 하나뿐이어도 첫 목록 요청을 「목록 문서(manifest)」 요청으로 다룹니다. 여기서 404 가 나면 곧바로 치명 오류입니다(playlist-loader.ts 653–659).

이때는 재생 정보(levels)가 아직 없어서 startLoad() 를 불러도 요청이 나가지 않습니다. 목록 로더의 startLoad() 는 빈 함수이고(playlist-loader.ts 86), 스트림 컨트롤러는 levels 가 없으면 멈춤 상태로만 둡니다(stream-controller.ts 141–195). 첫 목록을 다시 받으려면 loadSource() 를 다시 불러야 합니다(playlist-loader.ts 158–173).

앱은 「치명 오류 → 한 번 다시 시작 → 또 치명이면 끊김 알림」 으로 짜여 있었습니다. 그런데 다시 시작해도 요청이 나가지 않으니 두 번째 치명 오류가 오지 않았습니다. 결과는 알림 없는 무한 로딩이었습니다. 처음 짠 시험은 오지 않는 두 번째 치명 오류를 직접 지어 넣고 「끊김 알림이 뜬다」 를 확인했기 때문에 이것을 놓쳤습니다. 이것도 리뷰어가 잡았습니다.

8-3. 알아 두면 좋은 것

  • 조각 재시도 기본값은 6회, 1초에서 시작해 두 배씩, 최대 8초입니다(config.ts 596–611, 계산은 error-helper.ts 37–48). 4xx 는 「재시도할 상태」 에서 빠지고(error-helper.ts 86–92), 바꿀 다른 화질이 없을 때 다른 갈래로 다시 예약됩니다(base-stream-controller.ts 2142–2174).
  • 재시도 전용 타이머는 없습니다. 재시도는 「언제 다시 받을지」 시각만 적어 두고, 주기적으로 도는 틱이 그 시각을 보고 다시 요청합니다. stopLoad() 는 이 틱 타이머를 지우고 상태를 멈춤으로 바꿉니다(base-stream-controller.ts 176–194). 그래서 앱이 404 를 가로채 stopLoad() 를 부르면 같은 조각 재요청이 0 이 됩니다.
  • hls.js 내부의 마지막 오류 처리기는 다른 내부 처리기 뒤, 앱의 처리기보다 앞에 등록됩니다(hls.ts 351–357). 치명 오류면 이 처리기가 이미 stopLoad() 를 부른 뒤에 앱 처리기가 불립니다(error-controller.ts 502–505). 처리기가 등록 순서대로 불리는 것은 hls.js 가 쓰는 eventemitter3 의 동작입니다.

9. 배운 것

  • 시험은 「앱이 실제로 받는 모양」 으로 짭니다. 라이브러리 내부의 원본 자료로 확인하면 둔갑(8-1)과 무동작(8-2)을 놓칩니다. 그래서 진짜 hls.js 를 가짜 로더 위에서 돌리고, 앱 처리기가 실제로 받은 자료로 확인하도록 바꿨습니다.
  • 변이 시험으로 시험의 판별력을 잽니다. 코드를 일부러 틀리게 바꿔(조건 하나 빼기 등) 시험이 10번 모두 실패하는지 봅니다. 이렇게 해서 판별력이 없던 시험을 찾았습니다. 답이 빠져도 통과하던 지연 시험, 끝에 닿은 상태를 확인하지 않던 전환 시험이 그랬습니다. 마지막에는 변이 108가지를 각각 10번씩 돌렸습니다. 돌리기 전에 「어느 시험이 이 변이를 잡아야 하는가」 를 적어 두었는데, 104가지는 예측대로 잡혔고, 2가지는 다른 시험이 잡았고, 2가지는 아무 시험도 잡지 못해 시험을 더해 메웠습니다.
  • 같은 시험을 여러 번 돌려 흔들림을 찾습니다. 바뀐 시험 21개 파일(372건)을 10번 되풀이했더니 처음 한 번이 시간 초과로 실패했습니다. 가짜 시계로 60초를 흘려보내는 시험 하나가 걸음마다 플레이어를 새로 세워 600걸음을 돌고 있었습니다. 걸음을 1초로 넓히자 0.05–0.19초에 끝났습니다. 웹 시험 전체 3833건 중 3832건이 통과했고, 남은 1건은 바꾸기 전부터 나던 다른 화면 시험의 시간 초과였습니다.
  • 리뷰어 둘을 동시에 씁니다. 한쪽만 낸 심각한 지적도 원본 소스와 대조해 사실이면 고쳤습니다. init 둔갑과 지어낸 두 번째 치명 오류는 Claude 쪽이, 막힘 자동 해제와 플레이어를 새로 만들 때의 몰래 재생은 Codex 쪽이 잡았습니다. 리뷰 지적을 정리하던 AI 가 Codex 의 지적 하나(「마지막 확인은 목록 끝에 닿기 전에만 선다」)를 기각했다가, 다음 라운드에 두 리뷰어가 같은 지적을 내서 기각이 틀렸음을 인정한 일도 있었습니다. 한쪽만 낸 지적을 쉽게 버리면 안 된다는 것을 다시 배웠습니다.
  • 리뷰를 끝내는 기준도 상황에 맞춥니다. 평소에는 두 리뷰어가 모두 아무것도 찾지 못하는 라운드가 세 번 이어져야 리뷰를 닫습니다. 이번에는 둘째 라운드부터 설계 지적은 0 이었는데, 「상황에 따라 갈리는 결과를 한 가지로 단정한 주석」 같은 문서의 작은 지적이 라운드마다 1–5건씩 새로 나와 일곱 라운드를 돌았습니다. 주석을 한 번에 전부 훑어 정리한 뒤, 이 PR 에 한해 「심각 · 높음 · 중간 지적 0 이 세 번 이어지면 닫는다」 로 기준을 정해 리뷰를 닫았습니다.
  • 질문이 설계를 바꿉니다. 「오리진에 계속 묻는 게 말이 되냐」 는 부하 문제로는 틀린 걱정이었습니다. 하지만 「애초에 물을 필요가 있나」 로 이어져 구조를 단순하게 만들었습니다.

출처

  • hls.js v1.7.3 소스 — 태그 v1.7.3 이 가리키는 커밋 e5ff358 기준. 본문의 줄 번호와 링크는 모두 이 커밋의 src/ 입니다(2026-11-03 확인).