iOS Safari AudioContext 가 침묵하는 이유 — 제스처 콜백의 비밀
다른 브라우저에서는 다 되는데 iPhone 에서만 오디오가 재생 안 되던 문제. 사용자 제스처 콜백 직접 안에서만 resume() 이 성공하는 이유와 더미 unlock 트릭.
시작 — "왜 아이폰에서만 소리가 안 나요?"
Web Audio 로 합성한 BGM/SFX 를 붙인 뒤, macOS Safari / Chrome / Firefox / Android Chrome 에서 모두 잘 작동했습니다. 그런데 iPhone Safari 에서만 첫 번째 터치 후 시작한 오디오가 무음이었습니다.
AudioContext.state 를 확인해보니 'suspended'. ctx.resume() 을 호출해도 여전히 suspended. 원인을 찾는 데 꽤 시간이 걸렸습니다.
iOS 의 규칙 — "제스처 콜백 직접 안" 에서만 resume 성공
iOS Safari 는 자동 재생을 엄격히 차단합니다. AudioContext 가 unsuspended 상태로 가려면 사용자 제스처 콜백 바로 그 안에서 resume() 을 호출해야 합니다.
문제는 "바로 그 안" 의 정의가 생각보다 좁다는 점입니다. 예를 들어 이런 React 코드:
onClick={() => {
setGameState('playing'); // React state 업데이트
// 여기서 오디오 시작하고 싶음
}}
// 다른 컴포넌트의 useEffect:
useEffect(() => {
if (gameState === 'playing') {
startBGM(); // ← 여기서 ctx.resume() 호출
}
}, [gameState]);
iOS 에서 이 코드는 작동하지 않습니다. 왜냐하면:
onClick콜백이 실행되며setState호출- React 가 이 setState 를 batch 처리
- 이벤트 루프가 다음 tick 으로 넘어간 뒤 re-render 발생
useEffect가 거기서 실행됨- 이 시점엔 사용자 제스처 컨텍스트가 이미 끊긴 상태
- iOS: "이건 자동 재생이네" → 차단
macOS Safari 와 Chrome 은 관대해서 괜찮지만, iOS 는 엄격합니다.
해결 패턴 — 더미 AudioContext unlock 트릭
핵심 아이디어: onClick 시작부에서 아무것도 안 하는 AudioContext 하나를 즉시 만들어 resume 합니다. 이게 iOS 의 "user-gesture 카운터" 를 활성화시켜, 이후 실제 AudioContext 도 resume 가능해집니다.
onClick={(e) => {
// iOS Safari unlock — 반드시 onClick 최상단에, 동기적으로
try {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (AC) {
const u = new AC();
void u.resume().finally(() => u.close());
}
} catch {}
// 이후 기존 로직
setGameState('playing');
}}
위 코드의 특징:
- 동기적: Promise 체인 같은 비동기 기다림 없음
- 즉시 close: 더미 ctx 는 실제로 쓰지 않으니 resume 호출 후 바로 닫음
- try/catch: 일부 구형 브라우저 대응
이 더미 unlock 이 한 번 성공하면, 같은 탭의 이후 AudioContext 들은 (useEffect 에서 만들어도) 전부 resume 가능해집니다.
왜 이게 통하는가
iOS 의 판단 기준은 "해당 탭에서 사용자 제스처 내에 AudioContext 가 한 번이라도 resume 에 성공했는가" 입니다.
- 성공 이력 없음 → 모든 새 ctx 가 suspended
- 성공 이력 있음 → 이후 모든 ctx 가 자동 허용
더미 ctx 의 resume 호출이 이 "성공 이력" 플래그를 켜줍니다. 실제 사용은 뒤에서 별개로 해도 됩니다.
다른 함정들
Module-level new AudioContext() 금지
// ❌ 절대 안 됨
const _ctx = new AudioContext();
이유는 두 가지:
- SSR 환경에서
AudioContext is not defined에러 - iOS 에서 제스처 밖에서 생성되어 suspended 상태로 고정
해결: 생성은 반드시 사용자 이벤트 핸들러 안에서 lazy 하게.
AudioContext vs webkitAudioContext
구형 iOS Safari 는 webkitAudioContext prefix 만 지원했습니다. 최근 버전은 AudioContext 도 인식하지만 레거시 호환성을 위해 fallback 을 남겨둡니다.
const AC = window.AudioContext || (window as any).webkitAudioContext;
visibilitychange 복귀 시 재 resume
iOS Safari 는 탭 백그라운드로 갔다가 돌아올 때 ctx 가 다시 suspended 로 돌아가는 경우가 있습니다. visibilitychange 이벤트에서 한 번 더 resume:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && _ctx?.state === 'suspended') {
void _ctx.resume();
}
});
이 경우는 이미 unlock 된 상태라 자동 resume 가능합니다.
디버깅 체크리스트
iOS 에서 오디오가 안 나오면 순서대로 확인:
-
AudioContext.state가'running'인가 (DevTools 원격 디버깅 필요) -
onClick핸들러 맨 윗줄에 더미 unlock 이 있는가 - AudioContext 생성이 모듈 레벨이 아닌 사용자 이벤트 안에서 일어나는가
-
webkitAudioContextfallback 이 있는가 - visibilitychange 복귀 시 resume 재시도하는가
회고
이 문제는 다른 플랫폼에서 전부 통과한 뒤에 iPhone 에서 실기기 테스트하다 발견했습니다. iOS 시뮬레이터에서도 재현되지 않는 경우가 있어 실기기가 꼭 필요했습니다.
"자동 재생 차단" 이라는 명시적 정책은 대부분 개발자가 알고 있지만, "React batched flush 가 제스처 컨텍스트를 끊는다" 는 점은 자주 간과됩니다. 그래서 다른 OS 에서 정상 작동하는 코드가 iOS 에서만 조용히 실패하는 거죠.
모바일 오디오를 다루는 프로젝트라면 iPhone 실기기 테스트를 반드시 체크리스트에 포함하시길 권합니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...