지금까지 카메라를 누르면 촬영 시점이 제각각인 정적 파노라마가 떴습니다. 위치를 확인하기엔 충분하지만 “지금 저기가 어떤 상황인지”는 알 수 없었습니다. 국가교통정보센터(ITS)가 공개하는 고속도로·국도 교통상황 CCTV를 별도 레이어로 붙였습니다.
고속도로
4,587대
국도
6,502대
합계
11,089대
교통상황을 비추는 CCTV라 단속카메라와는 설치 위치도 목적도 다릅니다. 그래서 기존 단속카메라 상세에 끼워넣지 않고, 켜고 끌 수 있는 독립 레이어로 만들었습니다.
CCTV의 위치는 거의 바뀌지 않고, 영상 주소는 수시로 바뀝니다. 성질이 다르니 처리도 나눴습니다.
주기적 배치
전국을 격자로 나눠 CCTV 위치만 수집해 찍캠 DB에 저장
지도 탐색
찍캠 DB에서 마커를 렌더 — 외부 API 호출 0회
마커 클릭
그때 딱 한 번 최신 영상 주소를 받아 재생
위치 수집 배치는 단속카메라처럼 매일 돌 필요가 없어 한 달에 한 번 전국을 훑고, 이전 수집분과 비교해 새로 생긴 CCTV와 사라진 CCTV를 판정합니다.
영상 주소를 DB에 같이 저장해두면 편했겠지만, 그 주소에는 유효 기간이 있어서 저장해둔 순간부터 썩기 시작합니다. 그래서 위치와 이름만 저장하고 영상 주소는 클릭 시점에 새로 받습니다.
중간에 걸린 문제가 하나 더 있었습니다. ITS는 같은 영상을 HTTP와 HTTPS 두 가지로 제공하는데, 찍캠은 HTTPS 사이트라 HTTP 영상은 브라우저가 아예 차단합니다. 재생이 안 되는 게 아니라 요청 자체가 막히는 문제라, 처음부터 HTTPS 변형만 쓰도록 고정했습니다.
초기 설계
45초
주소 캐시
실측 후
90분
주소 캐시
실제 발급되는 주소의 유효 시간을 재보니 처음 잡은 값보다 훨씬 길었습니다. 단 실패한 응답은 60초만 캐시합니다 — 실패를 오래 물고 있으면 잠깐의 오류가 오래가기 때문입니다.
라이브 영상은 창을 열어둔 만큼 계속 데이터를 받습니다. 켜놓고 다른 일을 하면 사용자 데이터와 서버 대역폭이 조용히 새어나갑니다.
30초 후 자동 정지
재생 30초가 지나면 스트림을 끊고 계속 볼지 확인합니다
탭을 가리면 즉시 중단
다른 탭으로 넘어가거나 화면을 끄면 그 순간 재생을 멈춥니다
안 보이면 시작도 하지 않음
화면이 가려진 채로는 브라우저가 재생기를 준비해주지 않습니다. 헛도는 대신 시작을 미루고 돌아왔을 때 이어서 봅니다
보이지 않는 영상에는 데이터를 쓰지 않는다는 기준 하나로 정리했습니다.
출시 후 일부 환경에서 영상이 뜨지 않고, 얼마 지나면 제공처가 403으로 접속을 끊는 일이 있었습니다. 같은 미디어 플레이리스트를 1초에 30~57회 다시 요청하고 있었습니다. 라이브 플레이리스트는 세그먼트 길이만큼 기다렸다 다시 읽는 게 규칙이라, 이 스트림의 정상 간격은 1,422~6,000ms입니다. 관측값은 그 100배였습니다.
첫 조치는 재요청 간격에 상한을 거는 것이었는데 효과가 전혀 없었습니다. 상한에 걸린 횟수가 계속 0인데도 버스트는 그대로였죠. 상한이 닿지 않는 경로로 요청이 나가고 있다는 뜻이었습니다. 차단된 요청의 헤더가 그 경로를 정확히 지목했습니다.
| 헤더 | hls.js (XHR) | <video> 직접 | 차단된 요청 |
|---|---|---|---|
| sec-fetch-dest | empty | video | video |
| sec-fetch-mode | cors | no-cors | no-cors |
| Range | 없음 | bytes=0- | bytes=0- |
전부 오른쪽 열이었습니다. 이 요청은 저희 재생 코드가 아니라 브라우저의 미디어 엔진이 직접 보낸 것이었고, 우리 로더를 거치지 않으니 상한이 걸릴 리가 없었습니다.
왜 브라우저에 직접 맡겼을까요? 코드가 브라우저에게 이렇게 물어보고 있었습니다.
video.canPlayType("application/vnd.apple.mpegurl")이 API는 "" / "maybe" / "probably" 세 값을 돌려줍니다. 문제는 이걸 참/거짓처럼 썼다는 것입니다.
Chrome 150 데스크톱
"maybe"
실제로는 재생 불가
iOS Safari
"maybe"
실제로 재생됨
크롬은 “아마도”라고 답해 놓고 실제로는 HLS를 해석하지 못합니다. 그 값이 truthy라는 이유로 크롬이 네이티브 경로에 들어갔고, 미디어 엔진은 파싱에 실패한 채 같은 주소를 초당 수십 번 다시 요청했습니다. canPlayType은 애초에 재생 가능성의 힌트이지 판정이 아닙니다. 그래서 순서를 뒤집었습니다.
부수 효과로 모든 요청이 우리 로더를 지나가게 되어, 걸어둔 상한이 비로소 의미를 갖게 됐습니다.
HLS 재생은 성격이 다른 두 요청으로 이뤄집니다. 플레이리스트는 주소가 고정이고 주기적으로 다시 읽습니다. 세그먼트는 주소가 매번 다르고 연달아 받는 게 정상입니다. 그래서 같은 상한을 쓸 수 없습니다.
플레이리스트
1초 1건
정상 1.4~6초에 1건
URL별 최소 간격
주소가 고정이라 간격이 성립
세그먼트
2초 8건
정상 2~3초에 1건
슬라이딩 창
주소가 매번 달라 간격이 무의미
상한 상태는 인스턴스가 아니라 모듈에 둔다
재생기가 누수되거나 다시 만들어져도 상한이 이어져야 합니다. 인스턴스마다 카운터를 두면 새로 만들어 리셋하는 우회로가 생깁니다. 같은 이유로 살아있는 재생기는 앱 전체에서 1개로 고정합니다
세션 총량은 미루지 않고 버린다
간격 상한은 요청을 지연시키지만, 세션 총량 60건을 넘으면 요청을 아예 버리고 재생을 실패로 떨어뜨립니다. 최악의 경우에도 한 세션이 유한하게 끝난다는 보장이 필요했습니다
이 구조의 핵심은 원인을 몰라도 학대가 불가능하다는 것입니다. 실제로 상한을 먼저 걸어두고 원인은 나중에 찾았습니다.
버스트가 잡힌 뒤에도 “다시 시도”를 누르면 재발한다는 제보가 있었습니다. 흥미로운 건 “계속 시청하기”에서는 멀쩡했다는 점입니다. 둘 다 같은 함수를 부르는데도요. 차이는 진입 상태였습니다.
계속 시청하기
성공했던 세션의 재시작
오류 없음
다시 시도
실패 직후의 재시작
치명적 오류가 연달아 발화 중
오류 복구 코드가 재시작을 타이머로 예약하는데, 참조는 하나인데 취소 없이 덮어쓰고 있었습니다. 오류가 연속으로 오면 이전 타이머가 취소되지 않은 채 살아남아 재시작이 겹쳐 예약됩니다. 재시작이 중첩되면 플레이리스트도 세그먼트도 배로 요청되고요. 실패 경로에서만 나타난 이유가 이것입니다.
예약 전에 이전 타이머를 지우는 한 줄로 끝났지만, 찾는 데는 오래 걸린 종류의 버그입니다. 정상 경로에서는 절대 재현되지 않으니까요.
디버깅 중에 예상 못 한 사실을 측정했습니다. 화면이 가려진 탭에서는 브라우저가 재생 버퍼를 여는 이벤트를 아예 발화하지 않습니다.
document.hidden : true
sourceopen : 발화 안 함
MediaSource : "closed" 상태로 유지그런데 플레이리스트 폴링은 아무 일 없다는 듯 계속 진행됩니다. 결과적으로 세그먼트를 단 한 개도 받지 못한 채 20초 워치독이 끊을 때까지 요청만 나가고, 사용자는 돌아와서 “재생할 수 없어요”를 보게 됩니다. 로딩 중에 앱을 전환하면 100% 이 경로입니다.
그래서 숨겨진 상태에서는 시작하지 않고, 로딩 중에 가려지면 즉시 끊습니다. 성공할 수 없는 작업에 외부 API 쿼터와 대역폭을 쓸 이유가 없으니까요.
이 문제에서 가장 비쌌던 건 버그 자체가 아니라 어디가 터졌는지 몰랐던 시간이었습니다. 상한을 걸어놓고도 효과가 없던 이유를 헤맨 게 그 증거고요.
그래서 계측을 나눴습니다. 플레이리스트와 세그먼트의 요청 수·지연 수·폐기 수를 따로 셉니다. 재생 결과 이벤트에도 함께 실어 보내 특정 환경에서만 튀는 경우를 분포로 볼 수 있게 했고, 콘솔에서 진단을 켜면 프로덕션에서도 같은 로그를 남길 수 있습니다 — 개발 빌드 전용으로 두면 “프로덕션에서만 재현되는데 로그가 없다”에 다시 갇히니까요.
구분해서 보지 않으면 “막았는데 또 터졌다”를 반복하게 됩니다.