← DEVLOG
기술 노트2025.09.126 min read

iOS Safari AudioContext 가 침묵하는 이유 — 제스처 콜백의 비밀

다른 브라우저에서는 다 되는데 iPhone 에서만 오디오가 재생 안 되던 문제. 사용자 제스처 콜백 직접 안에서만 resume() 이 성공하는 이유와 더미 unlock 트릭.

iossafariaudioweb-audiomobile

시작 — "왜 아이폰에서만 소리가 안 나요?"

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 에서 이 코드는 작동하지 않습니다. 왜냐하면:

  1. onClick 콜백이 실행되며 setState 호출
  2. React 가 이 setState 를 batch 처리
  3. 이벤트 루프가 다음 tick 으로 넘어간 뒤 re-render 발생
  4. useEffect 가 거기서 실행됨
  5. 이 시점엔 사용자 제스처 컨텍스트가 이미 끊긴 상태
  6. 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();

이유는 두 가지:

  1. SSR 환경에서 AudioContext is not defined 에러
  2. 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 생성이 모듈 레벨이 아닌 사용자 이벤트 안에서 일어나는가
  • webkitAudioContext fallback 이 있는가
  • visibilitychange 복귀 시 resume 재시도하는가

회고

이 문제는 다른 플랫폼에서 전부 통과한 뒤에 iPhone 에서 실기기 테스트하다 발견했습니다. iOS 시뮬레이터에서도 재현되지 않는 경우가 있어 실기기가 꼭 필요했습니다.

"자동 재생 차단" 이라는 명시적 정책은 대부분 개발자가 알고 있지만, "React batched flush 가 제스처 컨텍스트를 끊는다" 는 점은 자주 간과됩니다. 그래서 다른 OS 에서 정상 작동하는 코드가 iOS 에서만 조용히 실패하는 거죠.

모바일 오디오를 다루는 프로젝트라면 iPhone 실기기 테스트를 반드시 체크리스트에 포함하시길 권합니다.

방명록

이 글에 대한 한 줄을 남겨주세요

0 / 140

불러오는 중...