포스트

아이패드에서만 소리가 늦게 나왔다 — 재생 버튼을 누른 뒤에야 시작되던 네 단계

한글 몬스터가 단어를 읽어주기까지 눈에 띄게 늦었습니다. 안드로이드와 PC에서는 멀쩡했고 아이패드 사파리에서만 그랬습니다. 원인은 코드 한 줄이었습니다. 소리를 낼 때마다 오디오 엘리먼트를 새로 만들고 있었고, 그래서 엘리먼트 생성부터 디코드까지 네 단계가 전부 '재생 버튼을 누른 뒤에' 일어났습니다

아이패드에서만 소리가 늦게 나왔다 — 재생 버튼을 누른 뒤에야 시작되던 네 단계

한글 몬스터는 단어를 소리로 읽어 줍니다. 듣기 모드에서는 소리만 듣고 답을 고르니까, 소리가 늦게 나오면 게임이 성립하지 않습니다.

그런데 아이패드에서 돌려 보니 눈에 띄게 늦었습니다. 안드로이드 앱에서도, PC 브라우저에서도 멀쩡했습니다. 아이패드 사파리에서, 그것도 홈 화면에 바로가기로 추가해 전체화면으로 띄웠을 때 그랬습니다.

소리를 내는 코드

한글 몬스터는 기기의 TTS를 쓰지 않습니다. 미리 만들어 둔 mp3 클립 1,077개를 번들에 넣어 두고 그걸 재생합니다. 기기마다 목소리가 달라지는 걸 막으려고 그렇게 했습니다.

재생하는 코드는 이랬습니다.

1
2
3
4
5
6
7
function playClip(text, fallback) {
  const name = ttsClips?.[text];
  if (!name) return fallback();
  clipAudio?.pause();
  clipAudio = new Audio('./tts/' + name);
  clipAudio.play().catch(() => fallback());
}

읽어야 할 때마다 new Audio(...)로 오디오 엘리먼트를 새로 만들고 곧바로 play()를 부릅니다. 짧고 명확해 보입니다. 실제로 안드로이드와 데스크톱에서는 아무 문제가 없었습니다.

네 단계가 전부 재생 시점에 있었다

문제는 이 한 줄이 브라우저에게 시키는 일의 양입니다. play()가 소리를 내기까지 브라우저는 이만큼을 합니다.

  1. 미디어 엘리먼트를 만들고
  2. 소스 URL을 가져오고
  3. mp3를 디코드하고
  4. 재생을 시작한다

그리고 이 네 단계가 전부 재생 버튼을 누른 뒤에 일어납니다. 미리 해 두는 게 하나도 없습니다.

데스크톱과 안드로이드는 이 과정이 빨라서 티가 안 납니다. iOS는 미디어 엘리먼트를 다루는 비용이 훨씬 비쌉니다. 홈 화면에 추가해 실행하는 경우는 브라우저 탭과 분리된 환경이라 캐시도 덜 데워져 있어서 더 나쁩니다.

여기서 한 가지를 짚고 가야 합니다. 클립 하나는 평균 14KB입니다. 1,077개를 합쳐도 15MB고요. 네트워크가 느려서가 아니었습니다. 14KB를 받는 데 걸리는 시간은 무시할 만합니다. 병목은 엘리먼트를 만들고 디코드하는 쪽이었습니다.

미리 만들어 두고, 재생만 한다

고친 방향은 단순합니다. 네 단계 중 앞의 세 개를 재생 시점에서 들어내는 겁니다.

Web Audio API는 오디오를 두 단계로 나눠서 다룰 수 있습니다. 파일을 받아서 AudioBuffer로 디코드해 두는 일과, 그 버퍼를 재생하는 일이 분리돼 있습니다. 디코드를 미리 해 두면 재생은 그야말로 재생만 남습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 받아서 디코드해 캐시에 넣어 둔다
function loadClipBuffer(file) {
  const hit = clipBuffers.get(file);
  if (hit) return Promise.resolve(hit);
  return fetch('./tts/' + file)
    .then((r) => r.arrayBuffer())
    .then((raw) => new Promise((res, rej) => ctx.decodeAudioData(raw, res, rej)))
    .then((buf) => { clipBuffers.set(file, buf); return buf; });
}

// 재생은 버퍼를 물려서 시작하는 것뿐이다
function startClipBuffer(ctx, buf) {
  const node = ctx.createBufferSource();
  node.buffer = buf;
  node.connect(ctx.destination);
  node.start();
}

여기에 언제 미리 받아 둘 것인가가 남습니다. 한 판은 20문제입니다. 20개면 280KB니까 한 번에 받아도 부담이 없습니다. 그래서 판이 시작될 때 이번 판에 나올 단어를 통째로 데워 둡니다.

1
speech.prefetch(queue.map((q) => q.tts));

듣기 모드의 보기도 탭하면 소리가 나니까 보기를 그릴 때 같이 데웁니다.

재어 본 결과

로컬에서 듣기 모드를 한 판 돌리면서 실제로 어느 경로를 타는지 계측했습니다.

 결과
판 시작 시 미리 받은 클립23개
재생 경로전부 Web Audio (new Audio() 호출 0회)
캐시 적중 시 탭 → 재생 시작1.8ms

1.8ms는 사실상 즉시입니다. 디코드가 이미 끝나 있으니 남은 일이 버퍼를 물려 start()를 부르는 것뿐이라 그렇습니다.

곁가지로 정리한 것

iOS는 AudioContext를 만들 때마다 자원을 잡고, 자동재생 정책 때문에 첫 사용자 터치에서 깨워 주지 않으면 정지 상태로 남습니다. 이 앱은 효과음도 AudioContext를 쓰고 있었는데 서로 다른 걸 만들고 있었습니다. 그래서 효과음과 음성이 같은 컨텍스트를 쓰도록 하나로 합치고, 첫 터치에서 한 번만 깨우게 했습니다.

그리고 Web Audio가 없거나 디코드가 실패하는 환경을 위해 기존 new Audio() 경로를 폴백으로 남겨 뒀습니다. 새 경로가 안 되는 기기에서 소리가 아예 안 나는 것보다는 늦게라도 나는 게 낫습니다.

남는 이야기

이 버그는 “특정 기기에서만” 재현됐습니다. 그리고 제가 개발하면서 쓰는 환경에서는 한 번도 보이지 않았습니다.

돌아보면 원인이 된 코드에는 잘못된 부분이 없습니다. new Audio()로 만들어서 play()하는 건 교과서적인 방법이고, 대부분의 환경에서 잘 돕니다. 다만 그 한 줄이 브라우저에게 얼마나 많은 일을, 언제 하라고 시키는지를 생각해 본 적이 없었을 뿐입니다.

느린 것을 고칠 때 저는 보통 “무엇이 느린가”를 찾습니다. 이번에는 그것보다 “언제 하는가” 가 답이었습니다. 같은 일을 하더라도 사용자가 기다리는 순간에 하느냐, 그 전에 미리 해 두느냐의 차이였습니다.

웹 버전에는 이미 반영했습니다. 안드로이드(Play)와 앱인토스 버전도 같은 수정으로 올려 두었고, 지금은 검수를 기다리는 중입니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.