브라우저 녹음MediaRecorder경합오진

iOS 탓이라고 두 번 틀렸다

신규 사용자가 가입 3분 뒤 다섯 번 연속 실패했다. 컨테이너를 바꿨더니 같은 사람이 또 실패했다.

2026년 9월 9일5

내가 운영하는 오픽 말하기 연습 앱은 사용자의 답변을 녹음해서 모델에 보내고 평가를 받아온다. 녹음이 안 되면 제품이 없다.

9월 8일, 신규 사용자 한 명이 가입 3분 뒤부터 다섯 번 연속으로 분석에 실패했다. 서버 로그에는 모델 API 가 400 INVALID_ARGUMENT 를 돌려준 기록만 남아 있었다.

첫 번째 오진

로그에 남은 실패 두 건이 있었고, 둘 다 iOS 였다. 18.7 과 18.7.1.

그럴듯한 가설이 바로 섰다. iOS 사파리의 MediaRecorder 는 컨테이너 지원이 유별나기로 유명하다. webm 으로 녹음하고 있었는데, iOS 18 에서 뭔가 어긋나서 모델이 못 읽는 파일이 만들어지는 게 아닐까.

그래서 iOS 18 을 감지해 mp4 로 녹음하도록 바꿨다. iPad 데스크톱 모드 감지도 함께 넣었다. 사파리가 iPad 에서 맥인 척하기 때문에 iOS 감지가 그냥은 안 된다.

배포했다. 같은 사용자가 같은 증상으로 또 실패했다.

컨테이너를 바꿨는데 결과가 같다는 건 컨테이너가 원인이 아니라는 뜻이다. 가설이 틀린 게 아니라, 애초에 증거가 그 가설을 지목한 적이 없었다. iOS 라는 공통점 하나로 원인을 정했을 뿐이다.

추측을 멈추고 계측을 붙였다

두 번 틀리고 나서야 순서가 잘못됐다는 걸 인정했다. 실패한 요청이 실제로 무엇을 업로드했는지 아무도 본 적이 없었다.

업로드된 파일의 앞부분 바이트, 컨테이너 헤더, 실효 비트레이트를 실패 시 함께 기록하도록 진단 코드를 붙였다.

다음 날, 답이 나왔다. 9월 9일 실패 5건의 업로드 파일이 전부 같은 바이트로 시작하고 있었다.

000001346d6f6f66…
             m o o f

moof 는 fragmented MP4 의 조각 박스다. 파일의 시작이 아니라 중간이다. 앞에 있어야 할 ftypmoov 가 없다. 코덱과 트랙 구성을 담은 초기화 세그먼트가 통째로 빠진 파일이었다.

이런 파일은 어떤 디코더도 읽을 수 없다. 모델 서버가 400 을 준 건 정확한 판단이었다.

진짜 원인

초기화 세그먼트는 MediaRecorder 가 시작할 때 첫 청크로 딱 한 번 준다. 그게 없다는 건 첫 청크를 버렸다는 뜻이다.

녹음 시작 함수는 이렇게 생겼었다. 버퍼를 비우고, 마이크 권한을 받고, 레코더를 만들어 시작한다. 문제는 가운데의 getUserMediaawait 라는 것이다. 수백 밀리초가 걸리고, 그 사이 함수는 열려 있다.

사용자시작 함수레코더 A청크 버퍼첫 번째 탭레코더 A 시작두 번째 탭버퍼 비우기100ms 마다 조각moof 로 시작하는 파일
1 / 6

01버퍼를 비우고 getUserMedia 를 기다린다. 수백 밀리초.

수정 전후로 청크 순서를 확인했다.

수정 전  [moof-A, moof-A, INIT-B, …]
수정 후  [INIT-B, moof-B]

고친 것은 셋이다. await 구간에 재진입 잠금을 걸어 중복 진입을 막고, 이전 레코더가 살아 있으면 데이터 핸들러부터 떼고 멈추고, 세션마다 지역 배열을 만들어 핸들러가 그 지역 변수에 쌓게 했다.

가운데 항목에 함정이 하나 있었다. 이전 레코더를 정리할 때 onstop 까지 떼면 진행 중이던 정지 절차의 프로미스가 영영 안 풀린다. 데이터 핸들러만 떼고 onstop 은 남겨야 한다.

숫자를 다시 봤더니

원인을 찾고 나서 그동안 세던 실패 건수를 다시 봤다.

날짜성공실패실패율
9/0526500%
9/0623300%
9/0722073.1%
9/0827351.8%
9/0910854.4%

9월 7일의 7건이 제일 커 보인다. 그런데 그중 6건이 내가 직접 돌린 로컬 테스트였다. 실제 사용자 실패는 1건이다.

전체를 다시 세면 이렇게 된다.

11건
전체 실패 17건 중 실사용자 실패. 나머지 6건은 내가 만든 테스트 요청이었다
2명
그 11건을 겪은 실제 사람 수. 같은 사람이 여러 번 재시도했다
iOS 18.7
11건 전부 아이폰, 전부 18.7 계열. 첫 오진이 왜 그럴듯했는지가 여기 있다

내 테스트 요청이 프로덕션 실패 지표에 섞여 들어가 있었다. 지표를 급하게 보면 3.1% 가 보이고, 실제 사용자에게 일어난 일은 0.45% 다. 자기가 만든 잡음을 자기 지표에서 못 빼면 우선순위를 잘못 잡는다.

남은 것

아직 고쳐졌다고 말할 수 없다. 배포는 오늘 12시 30분이었고 지금까지 3시간 15분 동안 39건 성공, 0건 실패다. 배포 직전 구간은 70건 중 5건 실패였다. 좋아 보이지만, 원래 하루 5건 수준으로 나던 실패다. 3시간 무사고는 우연으로도 충분히 나온다. 최소 하루는 봐야 한다.

iOS 18 이 무죄인지는 아직 모른다. 실사용자 실패 11건이 전부 아이폰 18.7 계열인 건 사실이다. 결함 자체는 플랫폼과 무관한 경합이지만, 마이크 권한 처리가 느린 기기일수록 await 구간이 길어지고 경합 창이 넓어진다는 설명은 가능하다. 다만 이건 추정이고 측정하지 않았다. 기기별 getUserMedia 소요 시간을 재면 확인할 수 있는데 아직 안 했다.

사용자는 아무도 신고하지 않았다. 이 앱에서 지금까지 발견한 장애는 전부 로그와 알림으로 먼저 알았고, 사용자 문의로 안 것은 하나도 없다. 가입 3분 만에 다섯 번 실패한 사람은 문의를 보내지 않는다. 그냥 앱을 지운다. 신고가 없다는 것은 문제가 없다는 뜻이 아니라, 신고할 만큼 남아 있지 않다는 뜻일 수 있다.

처음부터 계측을 붙였다면 이틀이 걸리지 않았다. 두 번의 오진은 둘 다 "그럴듯한 공통점"에서 나왔다. iOS 라는 공통점은 진짜였지만 원인이 아니었다. 업로드된 파일의 첫 16바이트를 찍어보는 데 필요한 코드는 한 줄이었고, 그 한 줄이 이틀치 추측을 끝냈다.