7 min read

ShareAux 개발기 #2 — 실시간 오디오 스트리밍

ffmpeg 옵션 하나하나가 전쟁이었고, iOS Safari에서 audio.load() 한 줄 때문에 이틀을 날렸습니다. 실시간 오디오 스트리밍 파이프라인 구현 회고.

오디오를 다루는 일은 생각보다는.. 어려운 일.

1편에서 "WebSocket으로 오디오를 보내겠다"고 결정했는데, 막상 구현하려니 중간 과정이 꽤 많았습니다. YouTube에서 음원을 가져오고, 브라우저가 이해하는 포맷으로 변환하고, 실시간으로 쪼개서 보내고, 받는 쪽에서 끊김 없이 재생하고, 중간에 들어온 사람도 바로 들을 수 있어야 하고.

각 단계마다 "이게 왜 안 되지?" 하는 순간이 있었습니다. 고민의.. 연속이었습니다.


ffmpeg 를 사용해서 하는 개발은 처음이었고.. 난해함.

ffmpeg를 spawn해서 fMP4 AAC로 변환하는 건 개념적으로는 단순합니다. 근데 옵션 하나 빠지면 브라우저가 재생을 거부해요.

-movflags empty_moov+default_base_moof — 이게 없으면 MSE가 데이터를 받아도 "이거 뭔데?" 하고 무시합니다. MSE 호환 fMP4를 만들기 위한 필수 플래그인데, 이걸 찾기까지 반나절을 날렸습니다. AI 도 처음부터 알려주진 않았어요. 질문이 잘못됐었을까..? ㅠ

-frag_duration 1000000 — 1초마다 fragment를 끊으라는 뜻. 값이 작으면 지연이 줄지만 오버헤드가 늘어납니다. 0.5초, 1초, 2초를 다 테스트해봤는데, 1초가 체감 지연과 안정성의 균형점이었어요.

-flush_packets 1 — 이거 안 넣으면 ffmpeg가 내부 버퍼에 쌓아두다가 한꺼번에 뱉어서 지연이 뜁니다. 처음에 "왜 3초나 밀리지?" 하고 한참 헤맸는데 이 옵션 하나로 해결됐습니다.


init segment: 중간에 들어온 사람은 어떻게 되는걸까..?

fMP4는 처음에 한 번 나오는 init segment(코덱 정보)와 이후 반복되는 media segment로 나뉩니다. 브라우저는 init segment를 먼저 받아야 이후 데이터를 디코딩할 수 있어요.

문제는 방에 중간에 들어온 사람. 이 사람은 init segment를 못 받았으니 오디오 청크가 와도 재생할 수 없습니다.

해결은 단순했어요. init segment를 서버에 캐싱해두고, 새로 들어온 사람에게 먼저 보내줍니다. 보통 500~2000바이트밖에 안 되니까 부담도 없고요.

근데 여기서 한 가지 더 — init segment를 안 받은 사람에게 오디오 청크를 보내면 디코딩이 실패합니다. 그래서 각 리스너에 synced 플래그를 두고, init을 받은 사람에게만 청크를 전송하도록 했습니다. 이 규칙을 안 지키면 클라이언트가 멈춰버렸습니다..(아...).


프리로드: 곡 전환의 공백을 매우려면?

곡이 끝나고 다음 곡으로 넘어갈 때, yt-dlp로 URL을 새로 받으면 2~3초가 걸립니다. 이 침묵이 체감상 꽤 길어요. "앱이 멈춘 건가?" 싶은 느낌.

대기열의 다음 3곡을 미리 다운로드해서 메모리에 들고 있기로 했습니다. 곡 전환 시 URL 재획득 없이 바로 ffmpeg에 Buffer를 넘기면 전환 지연이 0.5초 이내로 줄어듭니다.

메모리 제한은 전체 50MB, 방당 3곡, TTL 30분. 초과하면 가장 오래된 것부터 해제. 홈서버 메모리가 넉넉하지 않으니까 이 정도가 적당했습니다.

다시 한다면 — 프리로드를 디스크 캐시로 바꿀 것 같아요. 메모리 50MB가 아깝진 않지만, 서버 재시작하면 다 날아가니까요.


클라이언트 MSE: 이건 왜.. API가 애플 따로 구글 따로..

서버에서 잘 보내는 건 절반이고, 브라우저에서 잘 재생하는 게 나머지 절반이었습니다.

빈 SourceBuffer에서 play() 호출하면 안 됩니다. 데이터가 없는 상태에서 재생하면 브라우저가 바로 ended로 전환해버려요. 버퍼가 2초 이상 쌓일 때까지 기다렸다가 재생을 시작합니다.

끊김(stall) 감지가 까다롭습니다. waiting 이벤트가 발생하면 끊김인데, play() 직후 500ms 이내의 waiting은 디코더 초기화 과정이라 무시해야 합니다. 이걸 안 하면 매번 곡 시작할 때 false positive가 뜹니다.

서버와 동기화 보정. 끊김 복구 후에 재생 위치가 서버보다 5초 이상 뒤처지면 최신 위치로 강제 점프합니다. "같은 순간을 듣는다"를 유지하기 위한 장치예요.

적응형 버퍼링도 넣었습니다. 처음엔 2초 버퍼를 채우고, 정상 재생 중엔 0.4초만 유지하고, 끊김이 반복되면 기준을 점진적으로 올립니다. 네트워크가 불안정한 모바일에서 효과가 있었어요.

솔직히 말하면 이부분은 디버그 지옥이었습니다. 설계 상으로는 AI 의 도움을 받아 구멍이 없어보여도 브라우저별로, 데스크탑이냐 모바일이냐 또 달랐기 때문에 수차례 반복해서 직접 확인해 볼 수밖에 없었거든요.


iOS Safari: 걍 말이 안나옴..ㅋㅋ

iOS Safari는 일반 MediaSource를 지원하지 않습니다. ManagedMediaSource라는 별도 API를 써야 해요. 동작은 비슷한데 연결 방식이 다릅니다.

if (ms.handle) {
  audio.srcObject = ms.handle;  // iOS (ManagedMediaSource)
} else {
  audio.src = URL.createObjectURL(ms);  // 나머지
}

그리고 iOS에서 audio.load()를 호출하면 사용자 제스처 토큰이 소비됩니다. 한 번 소비되면 다음 play()가 자동재생 정책에 걸려서 실패해요. 이거 찾는 데 이틀 걸렸습니다. Cast 버튼 구현할 때 load()를 무심코 넣었다가 "왜 iOS에서만 소리가 안 나지?" 하고 한참 헤맸어요.

결론: audio.load()는 절대 호출하지 않는다. 곡 전환 시에도 SourceBuffer만 비우고 재사용합니다.


동시 출력: 하나의 ffmpeg으로 두 포맷

Cast/AirPlay 지원을 추가할 때, 이 기기들은 WebSocket을 모르니까 HTTP 스트림이 필요했습니다. ffmpeg를 하나 더 띄울까 고민했는데, 같은 프로세스에서 출력을 두 개 만드는 게 가능했어요.

stdout은 fMP4(WebSocket용), fd3은 ADTS AAC(HTTP용). 하나의 ffmpeg으로 두 포맷을 동시에 생성하니 CPU를 아낄 수 있었습니다. 홈서버 Celeron에서는 이런 절약이 의미 있어요.

저는 집에서 외부 스피커를 이용하는 상황이 많아서 이걸 구현할때 아 괜히 웹소켓으로 구현했나 솔직히 후회했습니다..


너무 어지러웠던 상황들이 많았다

좋았던 점: 프리로드 + 듀얼 출력 구조가 잘 맞아떨어졌습니다. 곡 전환이 매끄럽고, Cast도 추가 리소스 없이 지원됩니다.

아쉬웠던 점: MSE 관련 엣지 케이스를 너무 많이 만났어요. 브라우저마다 동작이 미묘하게 달라서, "Chrome에서 되는데 Safari에서 안 된다"를 수십 번 반복했습니다.

다시 한다면: MSE 래퍼를 처음부터 브라우저별 분기를 고려해서 설계했을 겁니다. 나중에 iOS 대응을 끼워넣느라 코드가 좀 지저분해졌어요.


참고 자료