리액트로 만든 게임에 사운드 추가하기

🗓 2022-01-27

최근 주말마다 아이를 위해 리액트로 간단한 게임을 만들고 있습니다. 무한의 계단이란 게임을 너무 집중해서 하길래 게임에서 아이한테 위해가 될 수 있는 중독적인 요소는 빼고 핵심 재미(?)만 갖고 있는 게임을 만들어볼 심산이었죠. 단순히 계단을 좌우로 오르면서 얻은 점수로 아바타를 사거나 해서 캐릭터를 꾸밀 수 있는 게임인데 여기서 점수로 게임 내의 재화를 사는 기능을 뺐습니다. 제목은 "유한의 계단"입니다. 공을 들여 계단도 만들고 캐릭터도 만들고 하면서 재미있게 만들고 있었는데요. 생각지도 못한 부분에서 문제가 발생했어요.

이 게임은 계단을 오를 때 그리고 실패했을 때 효과음을 출력하는데요. 여기서 크로스 브라우저 이슈가 발생했습니다. 사실 크롬만 쓰면 상관없는 문제였지만 호기심에 조금 파봤습니다. 사이드 프로젝트의 장점이죠. 하고 싶으면 그닥 필요하지 않아도 그냥 하는 거고 필요해도 하기 싫으면 안 하는 거죠. :)

이 글에서는 리액트로 만든 웹 게임에 효과음을 사용하다가 만난 문제와 "Web Audio API(이하 웹 오디오)" 를 활용해 쉽고 안전하게 효과음을 사용하는 방법을 소개하려고 합니다.

문제가 무엇인가?

아무리 리액트와 웹 기술로 만든 간단한 게임이라지만, 게임을 구성하는 필수 요소들은 모두 구현해야 했는데요. 사운드 출력도 마찬가지였습니다. 제일 처음 생각해 볼 수 있는 게 <audio>엘리먼트를 사용해 사운드를 출력하는 것이겠죠. 네 저도 그렇게 생각하고 그냥 쉽게 <audio>를 사용해서 필요한 소리 2 개를 미리 만들어두고 사운드 출력이 필요할 때마다 play() 메서드를 이용해 플레이를 했습니다. 개발 중에 사용했던 크롬에서는 아무런 문제가 없었는데요. 사파리에서 말썽이었어요.

처음 한 번은 play() 함수를 실행하는 것만으로 문제없이 소리가 재생됐는데요. 두 번째 부터는 play()가 먹질 않는 거예요. 이 문제는 맥 사파리뿐 아니라 iOS 사파리에서도 동일하게 발생하고 있습니다. 실제 아이가 플레이할 기기는 아이패드였기에 큰 문제였죠.

사실 두 동작 방식의 차이점에서 어느 것이 스펙상 옳은 것 인지는 잘 모르겠어요. 거기 까지는 알아보지 않았어요. "implementation-defined"(브라우저 개발자 마음대로...) 스펙이거나 그냥 버그 일수도 있겠죠. 제 생각엔 버그일 것 같지만 오랜 시간 해결 하지 않는 걸 보니 잘 모르겠네요. 어쩌면 이 문제는 비단 게임에서만의 문제는 아닌 것 같아요. 웹사이트에서도 어떤 버튼에 효과음을 주고 싶을 수 있잖아요. 다양한 곳에서 발생할 수 있는 문제인 것 같습니다.

다양한 시도

처음 사파리에서 이런 문제가 발생하는 것을 발견하고는 여러 가지 방법을 생각해 봤어요. 가장 쉽게 생각할 수 있는 것은 HTMLMediaElement인터페이스의 프로퍼티인 currentTime을 조작해 보는 것이었죠. currentTime 은 현재 재생 중인 위치를 초 단위로 표시합니다. 값이 0이면 시작 지점이고 재생 중에 실시간으로 업데이트되며 재생이 완료되면 사운드의 길이만큼의 값을 갖습니다. 스펙상으로는 Read/Write가 가능합니다.

function playSound(sound) {
	sound.currentTime = 0;
	sound.play();
}

실제 사용했던 코드는 아니지만 이해를 돕고자 더 간단하게 만들어 봤어요. 사운드가 다시 재생되지 않는 이유로 currentTime이 시작점으로 초기화되지 않아서라고 추측하고 currentTime0으로 초기화했습니다. 왠지 해결될 것 같다는 생각이 들지 않으세요? 그런데 안됐어요. 콘솔에서 currentTime을 확인해 보면 0으로 나오는데 정상적으로 플레이가 되지 않았어요. 여러 가지 삽질 끝에 이상한 해결 방법을 찾았어요. 바로 load()를 강제로 실행하는 겁니다.

function playSound(sound) {
	sound.load();
	sound.play();
}

사운드를 플레이할 때마다 load()를 실행하게 되면 매번 파일을 다시 불러오면서 정상적으로 사운드를 플레이합니다. 이 문제의 원인을 추측해 보면 사운드의 메타정보와 그것을 모델링 한 자바스크립트 객체 사이의 연동에 문제가 있는 것 같아요. 새로 로드하면서 매번 초기화되겠죠. 하지만 찜찜해요. 개발자 도구를 보면 네트워크 요청을 계속합니다. 캐시로 커버는 되겠지만 아무튼 정답은 아닌 것 같아요. 버그를 피하려고 또 다른 잠재적인 버그를 만들어내는 방법인 것 같습니다.

네트워크 요청 이미지

나름 찾아본 결과 각자 추측만 있을 뿐 공식적인 내용은 못 찾았어요. 아무튼 아주 오래전부터 발생했던 문제였어요. 그냥 사파리는 currentTime에 버그가 있다고 기억하기로 했습니다.

한 번만 재생되는 문제는 load()의 실행으로 해결했다 치겠지만 사운드 레이턴시도 문제였어요. 점프 키를 누르면 약간의 딜레이 후에 사운드가 출력이 됐습니다. 아무리 아이용 게임이라지만 저는 용납 할 수 없는 범위의 딜레이였습니다. 게임할 맛이 나질 않겠죠. 그래서 이런 작은 프로젝트에서 굳이 쓰려고 하지 않았던 웹 오디오를 사용하기로 마음을 먹게 됩니다. 사실 별거 없긴 합니다. :)

문제의 코드

기존의 load()를 매번 실행했던 방법을 사용한 풀 소스 먼저 볼게요.

function useAudio(data: Record<string, string>): { loaded: boolean; audios: typeof audios } {
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    const promises: Promise<unknown>[] = [];

    Object.keys(data).forEach((key) => {
      const sourceUrl = data[key];

      promises.push(
        new Promise((resolve) => {
          const sound = new Audio(sourceUrl); 

          sound.load();

          sound.addEventListener('canplaythrough', resolve);

          audios.set(key, () => { // [1]
            sound.load(); // 사파리에서 중복 출력이 안되는 문제 해결 꼼수
            void sound.play();
          });
        })
      );
    });

    Promise.all(promises).then(() => { // [2]
      setLoaded(true);
    });
  }, []);

  return {
    loaded,
    audios,
  };
}

사운드를 로딩하고 플레이도 할 수 있게 해주는 커스텀 이펙트를 만들었습니다. 사운드 이름과 경로를 키와 값으로 갖는 객체 data를 전달받아서 로딩 상태 플래그(loaded)와 사운드를 플레이하는 함수를 담은 맵(audios)을 리턴합니다.

const { loaded, audios } = useAudio({ jump: '/sound/jump.wav', fail: '/sound/fail.wav' });

사용자는 맵에서 필요한 사운드의 클로저 함수([1]) 를 찾아 사운드를 출력할 수 있습니다.

audios.get('jump')(); // "띠용"

사운드 출력 딜레이를 제외하면 정상적으로 동작하고 있는 코드입니다.

이 이펙트를 사용하는 측에서는 loaded를 플레그 변수를 이용해 로딩바나 관련 UI를 노출하고 로딩이 완료되면 게임을 진행시킵니다. 프로미스 리졸브 신공으로 간단하게 loaded 플래그를 구현했습니다. ([2])

어떻게 해결했는가?

MDN에서 검색해 보면 웹 오디오 API에 대한 소개가 제일 첫머리에 나와요.

"The Web Audio API provides a powerful and versatile system for controlling audio on the Web, allowing developers to choose audio sources, add effects to audio, create audio visualizations, apply spatial effects (such as panning) and much more."

웹에서 다양한 형태로 강력한 오디오를 제공할 수 있는 API에요. 오디오 소스에 리버브나 딜레이 같은 오디오 이펙트를 추가할 수 있고요. 음악이나 사운드가 재생될 때 데이터에 접근해 화려한 웨이브 파형이나 음향 정보를 시각화할 수도 있어요. 심지어 저수준의 오디오 프로그래밍까지 가능해서 전자 악기도 만들 수 있어요. 리버브 같은 이펙트도 직접 만들 수 있죠. 마이크를 통해서 목소리를 입력받아 노래방같이 실시간으로 에코도 먹일 수 있어요. 심지어 웹에서 음악을 만드는 어플리케이션까지 만들 수 있죠. 실제로 상용화된 서비스도 많습니다.

웹 오디오는 오디오 노드들의 결합으로 구성되며 이를 오디오 그래프라고 해요. 예전에 오디오 프로그래밍에 관심이 많아서 애플의 AudioUnit 프레임웍을 사용했던 적이 있는데요. 웹 오디오와 AudioUnit은 상당히 흡사한 형태의 추상을 갖고 있습니다. 오디오 프로세싱과 관련된 독립된 기능의 모듈들이 있고 이들이 노드로 사용돼 그래프 형태로 연결되면서 소리를 출력하는 일련의 오디오 기능을 수행합니다. 노드와 그래프의 모습은 MDN에서 찾은 이미지가 잘 설명해주고 있어서 첨부합니다.

AudioContext

웹 오디오는 저수준의 오디오 프로그래밍이 가능할 정도로 기능이 많아요. 책이 따로 나올 정도죠. 지금 필요한 것은 딱 원하는 사운드를 특별한 이펙트 없이 재빠르게 출력하는 것입니다. 맛보기 정도가 될 것 같네요.

이제 이 코드를 웹 오디오를 사용해서 사운드를 출력하는 코드를 만들어 보겠습니다. 기존의 코드에 비하면 조금 길어집니다.

우선 AudioContext(이하 오디오 컨텍스트)의 인스턴스를 얻어옵니다. 이건 한 번 만들어 계속 재사용합니다. 오디오 컨텍스트는 웹 오디오의 실체이자 그 인스턴스는 오디오 프로세싱 그래프의 한 단위라고 생각하시면 됩니다. DOM에서 document 같은 친구입니다. 오디오 컨텍스트 인스턴스를 사용해 그래프도 구성하고 노드도 만들고 할 수 있습니다.

const audioContext = new AudioContext();

그리고 오디오 파일 경로를 이용해 사운드 받아옵니다. 경로는 sourceUrl이란 변수로 대체합니다.

const res = await fetch(sourceUrl); // [1]
const arrayBuffer = await res.arrayBuffer(); // [2]
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); // [3]

fetch를 이용해 데이터를 받아오고([1]) 바이너리 데이터를 다루기 위해 ArrayBuffer로 변경합니다.([2]) 여기서는 직접 바이너리 데이터를 건드는 작업까진 하지 않겠지만 AudioWorklet을 사용하면 저수준의 오디오 프로그래밍도 가능합니다. 마지막 라인에서 audioContext.decodeAudioData()로 바이너리 데이터를 오디오 데이터로 디코딩 합니다. 이제 audioBuffer는 재생 가능한 오디오 데이터입니다. ([3])

audioBuffer를 재생하기 위해선 오디오 그래프 안에서 오디오 데이터를 재생할 수 있는 버퍼 소스 노드를 만들고 audioBuffer를 전달해야 합니다.

const trackSource = audioContext.createBufferSource(); // 소스 노드를 만들고...
trackSource.buffer = audioBuffer; // 오디오 버퍼를 전달합니다.

이제 trackSource가 사운드를 출력하도록 start() 메서드를 실행합니다.

trackSource.start(); // ??

하지만 아직 소리가 나지 않습니다.

자바스크립트로 HTML 엘리먼트를 아무리 많이 만들어도 body에 붙이지 않으면 화면에 나타나지 않듯 trackSource의 사운드를 출력할 수 있는 무언가와 연결을 해줘야 합니다. 즉 사운드를 발생하는 노드인 trackSource와 스피커를 추상화한 노드와 연결합니다.

trackSource.connect(audioContext.destination);

출력하는 노드는 따로 만들 필요 없이 audioContext.destintion을 사용합니다. 이렇게 하면 현재 OS에 설정된 디바이스로 사운드를 출력할 수 있습니다.

자 이제 레츠고!

trackSource.start() // "띠용"

Web Audio를 사용해 변경된 useAudio는 아래와 같습니다.

const audios: Map<string, () => void> = new Map();

function useAudio(data: Record<string, string>): { loaded: boolean; audios: typeof audios } {
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    const promises: Promise<void>[] = [];

    const AudioContext = window.AudioContext;
    const audioContext = new AudioContext();

    Object.keys(data).forEach((key) => {
      const sourceUrl = data[key];

      promises.push(
        new Promise<void>(async (resolve, reject) => {
          try {
            const res = await fetch(sourceUrl);
            const arrayBuffer = await res.arrayBuffer();
            const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);

            audios.set(key, () => {
              const trackSource = audioContext.createBufferSource();
              trackSource.buffer = audioBuffer;
              trackSource.connect(audioContext.destination);

              if (audioContext.state === 'suspended') { // 하드웨어 자원 문제와 같은 이슈에 대한 처리
                audioContext.resume();
              }

              trackSource.start();
            });
            resolve();
          } catch (e) {
            reject(e);
          }
        })
      );
    });

    Promise.all(promises).then(() => {
      setLoaded(true);
    });
  }, []);

  return {
    loaded,
    audios,
  };
}

이렇게 하니 모든 문제가 말끔하게 해결됐습니다. 레이턴시도 크게 개선돼서 쫀쫀한 손맛이 느껴집니다.

마무리

개인적으로는 웹 오디오에 관심이 많은 편이라 평소 이것저것 구경만 해왔는데요. 간단하게라도 이렇게 사용해 보니 재미있네요. 웹 오디오는 그 자체로도 멋지지만 Webassembly와 연계해서 더 풍성하고 성능 면에서도 훌륭한 작업을 할 수 있습니다. Webassembly가 낼 수 있는 좋은 시너지 중 하나라고 생각합니다. 혹시나 웹 오디오에 관심이 있으시다면 Web Audio Weekly를 구독하시는 것을 강추합니다. 웹에서의 다양한 오디오 관련 시도들을 만나실 수 있습니다.

♥ Support writer ♥
with kakaopay

크리에이티브 커먼즈 라이선스이 저작물은 크리에이티브 커먼즈 저작자표시-비영리-변경금지 4.0 국제 라이선스에 따라 이용할 수 있습니다.

© Sungho Kim2023