[StreamRec] 멀쩡히 잘 받아지는 스트림인데 계속 강제 종료되던 문제 — “정체 감지” 로직의 숨은 버그

증상

셀프호스팅 스트림 자동 녹화 서비스(yt-dlp + FastAPI 기반)에서, 특정 채널들의 자동 녹화가 시작한 지 2~3분만 지나면 거의 매번 강제 종료됐다. 로그를 보면 이렇게 찍혀 있었다.

[StreamRec] WARNING — no data received for 120s. Stream may have ended silently.
[StreamRec] Auto-stop-stalled enabled — stopping recording.

“120초 동안 데이터가 안 들어와서 정체(stall)로 판단하고 자동으로 껐다”는 내용이었다. 이 자동 종료 기능은 원래 다른 문제(다운로드 프로세스가 죽지도 살지도 않은 채 무한정 방치되는 것)를 막기 위해 얼마 전에 직접 켠 기능이었는데, 이번엔 그게 오히려 정상적으로 진행 중인 녹화를 계속 끊어버리고 있었다.

1차 진단 — 네트워크 문제라고 의심

가장 먼저 의심한 건 네트워크나 YouTube 쪽 세션 문제였다. 앱을 거치지 않고 같은 yt-dlp 명령을 터미널에서 직접 실행해봤다.

yt-dlp -v --hls-use-mpegts --extractor-args "youtubepot-bgutilhttp:base_url=..." \
  --cookies cookies.txt -o "test_%(id)s.%(ext)s" "https://.../live"

결과는 뜻밖이었다. 직접 실행하면 7분 넘게 끊김 없이 잘 받아진다. 즉 네트워크나 YouTube, 쿠키 문제가 아니라, 앱이 판정하는 방식 자체에 문제가 있다는 쪽으로 좁혀졌다.

2차 진단 — 녹화 중인 폴더를 실시간으로 관찰

앱이 뭘 보고 “정체됐다”고 판단하는지 알아보려고, 녹화가 진행되는 동안 폴더 안 파일들을 5초 간격으로 지켜봤다.

watch -n 5 'find <녹화폴더> -newer /tmp -mmin -5 -exec ls -la {} \;'

이걸로 확인한 사실: yt-dlp는 화질(video)과 음성(audio)을 별도의 두 스트림으로 나눠서 동시에 다운로드하고 있었고, 각 스트림마다 파일이 두 개씩(.mp4 실제 데이터 + .mp4.ytdl 진행상황 메타파일, 이건 항상 몇십 바이트로 고정) — 즉 한 녹화당 파일이 총 4개가 동시에 존재하고 있었다.

xxx.f303.mp4        52,652,538 bytes  ← 화질 스트림, 계속 커지는 중
xxx.f303.mp4.ytdl    76 bytes         ← 메타파일, 크기 고정
xxx.f140.mp4         18,493,502 bytes ← 음성 스트림, 계속 커지는 중
xxx.f140.mp4.ytdl    77 bytes         ← 메타파일, 크기 고정

근본 원인 — 소스코드 확인

정체 감지 로직을 찾아보니 이랬다.

for f in rec_dir.glob(f"{stem}.*"):
    try:
        sz = f.stat().st_size
        # ... 크기 변화 추적 ...
    except Exception:
        pass
    break   # 매칭된 파일 중 첫 번째 하나만 보고 끝

glob으로 매칭되는 파일 여러 개 중, 순서상 처음 걸리는 파일 딱 하나만 보고 판단을 끝내버리는 구조였다. 그런데 이 순서는 사실상 임의적이라, 하필 크기가 절대 안 변하는 몇십 바이트짜리 메타파일(.ytdl)을 감시 대상으로 잘못 골랐을 때마다 “정체됐다”고 오판하고 있었던 것이다. 실제 영상/음성 데이터는 멀쩡히 잘 쌓이고 있는데도.

패치

파일 하나만 보고 끝내는 대신, 메타파일(.ytdl, .part)은 걸러내고 나머지 실제 미디어 파일들의 크기를 전부 합산하도록 고쳤다.

sz = 0
for f in rec_dir.glob(f"{stem}.*"):
    if f.name.endswith(".ytdl") or f.name.endswith(".part"):
        continue
    try:
        sz += f.stat().st_size
    except Exception:
        pass
if sz > 0:
    # 이하 크기 변화 추적 로직은 동일
    ...

수정 후 Docker 이미지를 재빌드하고 컨테이너를 재시작해서 반영했다. 이후로는 몇 시간짜리 녹화도 중간에 끊기지 않고 정상적으로 완주하는 것을 확인했다.

부수 피해 정리 — 이미 조각나버린 과거 녹화들

이 버그가 얼마 동안 있었는지는 정확히 모르지만, 최소 몇 달 치 녹화 중 상당수가 화질/음성이 분리된 채로 남아있었다. 다행히 두 조각 다 온전한 상태로 저장은 돼 있어서, ffmpeg -c copy(재인코딩 없이 컨테이너만 합치는 방식)로 복구 가능했다.

전체를 훑어서 자동으로 병합/정리하는 스크립트를 만들어 처리했다.

# 개념만 정리하면:
# 1. "stem.f<숫자>.mp4" 패턴의 조각 파일들을 stem 기준으로 그룹핑
# 2. 그룹 중 병합 완료본(stem.mp4, 태그 없는 버전)이 없는 것만 골라냄
# 3. ffmpeg -i 조각1 -i 조각2 -c copy stem.mp4 로 병합
# 4. 병합 검증(ffprobe로 재생시간 확인) 후, 원본 조각 + 딸린 .ytdl 메타파일까지 정리

한 채널에서만 70건 넘게, 수십 GiB 분량이 이런 식으로 방치돼 있었다. 스크립트로 일괄 병합 후 재생 검증까지 마치고 정리했다.

교훈

  • “파일 크기로 다운로드 진행 여부를 판단”하는 로직을 짤 때는, 감시 대상 디렉토리에 여러 종류의 파일(데이터 파일 + 메타/임시 파일)이 섞여 있을 수 있다는 걸 항상 염두에 둬야 한다. glob()으로 매칭되는 파일이 하나뿐이라고 가정하고 짠 코드가, 나중에 스트림이 여러 트랙으로 분리 다운로드되는 케이스를 만나면서 문제가 터졌다.
  • 증상만 보고 바로 네트워크/외부 요인을 의심하기 전에, 앱을 거치지 않고 동일한 조건을 직접 재현해보는 게 원인 범위를 빠르게 좁히는 데 효과적이었다. 직접 실행해서 문제가 재현 안 되는 순간, 원인이 앱 내부 로직에 있다는 게 거의 확정됐다.
  • 자동화 로직(이번 경우 “정체 감지 후 자동 종료”)이 오히려 데이터 손실을 유발할 수 있다는 걸 실감했다. 안전장치를 켰다고 안심하지 말고, 실제로 의도대로 동작하는지 한 번은 눈으로 검증해볼 필요가 있다.