본문 바로가기
카테고리 없음

미니앱 개발하기 3 - 사운드 효과 추가

by 테크구루스 2026. 8. 19.

소리가 촉감을 완성한다. 실녹음 그라뉼러와 WebView 오디오 언락 사투는, 3D 슬라임 토이 "쫀득 슬라임"에서 어떤 크런치 소리를 고를지의 시행착오와 그 소리를 실기기 첫 제스처부터 나게 만든 모바일 WebView 오디오 정책과의 씨름을 담은 개발기입니다.

슬라임을 잡을 때 손끝에 남는 감각의 절반은 눈이 아니라 귀와 진동에서 옵니다. 화면 속 젤리가 아무리 그럴듯하게 출렁여도, 잡는 순간 "푹" 소리가 없으면 그건 그냥 움직이는 그림이죠. 그래서 이 앱에서 소리는 장식이 아니라 촉감의 나머지 절반이었습니다. 그런데 정작 그 소리를 WebView 위에서 "제대로" 내는 일이 물리 엔진을 짜는 것보다 더 큰 산이었습니다. 오늘은 어떤 소리를 고를지의 시행착오와, 그 소리를 실기기에서 첫 제스처부터 울리게 만든 고생을 풀어보려 합니다.

합성음은 왜 장난감처럼 들렸나 — 실녹음 그라뉼러로 간 이유

처음에는 소리를 코드로 만들 생각이었습니다. 오디오 파일 없이 AudioContext만으로 합성하면 용량도 0이고 무한히 변주할 수 있으니까요. 문제는 그렇게 만든 소리가 하나같이 "슬라임 같지 않았다"는 데 있었습니다. 시도한 순서대로 적어보면 이렇습니다.

  • 노이즈 기반 스퀴시: 화이트노이즈를 짧게 감싸 만든 소리는 "챱챱"에 가까웠습니다. 슬라임보다는 물 먹은 스펀지를 짜는 느낌이라 물성이 어긋났죠.
  • 오실레이터 톤: 주파수를 흔들어 낸 끽끽음은 "뿅뿅"으로 들렸습니다. 8비트 효과음이나 삑삑이 장난감 같아서, 손에 쥔 젤리의 무게감이 전혀 실리지 않았습니다.
  • 절차 생성 크런치: 미세한 파열 임펄스 수백 개를 심고 저역 필터를 두 번 통과시켜 "눈 밟는 소리"를 흉내 냈습니다. 앞의 둘보다는 훨씬 그럴듯했지만, 반복해 들으면 결국 만들어진 티가 났습니다.

결론은 분명했습니다. 합성음은 아무리 다듬어도 어딘가 "장난감처럼" 들립니다. 실제 물질이 부서지고 눌리는 소리에만 있는 불규칙한 질감이 빠져 있기 때문이죠. 그래서 방향을 틀어 실녹음 샘플로 갔습니다. 후보 16개를 한 페이지에 모아 시청해 비교한 끝에 고른 것이 freesound #554726(CC0, 눈 밟는 부츠 크런치)였습니다.

다만 원본은 120초짜리였습니다. 통째로 넣으면 무겁고, 무엇보다 "발소리 리듬"이 그대로 들려 슬라임과 어울리지 않았죠. 그래서 50ms 단위 RMS로 크런치가 실제로 살아 있는 활성 구간 15개만 추려 30ms 크로스페이드로 이어붙였습니다. 결과물은 11.6초 모노 AAC 96kbps, 143KB로 정리됐습니다(실제 public/sounds/crunch.m4a 파일 크기 146,040바이트, 길이 11.68초로 확인됩니다).

이 짧은 샘플을 매번 똑같이 재생하면 금세 반복감이 느껴집니다. 그래서 그라뉼러 방식을 썼습니다. 재생할 때마다 읽는 구간, 재생 속도, 저역 필터 컷오프, 길이를 전부 랜덤으로 굴려 같은 소리가 두 번 나지 않게 한 것이죠.

// 낮은 재생속도 = 낮고 굵은 크런치. 셀수록 살짝 또렷하게
src.playbackRate.value =
  (deep ? rand(0.5, 0.7) : rand(0.7, 1.0)) * (1 + k * 0.15);

const lp = ctx.createBiquadFilter();
lp.type = "lowpass";
lp.frequency.value =
  (deep ? rand(500, 900) : rand(800, 1600)) * (1 + k * 0.3);

소리 구조도 초반에 많이 헤맸습니다. 얕은 그레인을 드래그 내내 연쇄로 뿌려보기도 하고 "푹" 소리를 체인으로 이어보기도 했는데 전부 시끄럽고 지저분했습니다. 최종적으로 남은 규칙은 단순합니다. 잡는 순간 "푹" 한 번, 드래그 중에는 무음, 떼었다 다시 잡으면 다시 푹. 참고로 샘플 로드에 실패하면 앞서 기각했던 절차 생성 크런치가 폴백으로 돌아, 어떤 상황에서도 무음이 되지는 않도록 남겨뒀습니다.

첫 제스처부터 소리 나게 — 모바일 WebView 오디오 언락 사투

소리를 골랐으니 절반은 끝났다고 생각했습니다. 착각이었죠. 실기기에 올리자 첫 진입 무음 버그가 튀어나왔습니다. 앱을 켜고 슬라임을 아무리 주물러도 소리가 안 나는데, 이상하게도 하단의 소리 토글 버튼을 한 번 껐다 켜면 그때부터 멀쩡히 들렸습니다. 이 "껐다 켜면 된다"가 원인을 찾는 결정적인 실마리였습니다.

미니앱 개발하기 3 - 사운드 효과 추가
미니앱 개발하기 3 - 사운드 효과 추가

범인은 모바일 WebView의 자동재생 정책이었습니다. iOS 계열에서 AudioContext는 반드시 사용자 제스처 안에서 생성·재개(resume)해야 소리가 나는데, 일부 WebView는 캔버스 위의 pointer 이벤트를 "사용자 활성화"로 인정하지 않았습니다. 반면 토글 버튼(일반 DOM 버튼)의 클릭은 활성화로 쳐주니, 그것만 소리가 났던 것이죠. 그래서 언락 루틴을 2단계로 강화했습니다.

  • window 캡처 단계의 pointerup/touchend/click에서 unlock()을 재시도합니다. 여러 번 불려도 안전하도록 멱등하게 짰습니다.
  • 컨텍스트가 아직 suspended여도 그레인을 소량 예약해 둡니다. resume이 완료되는 즉시 예약분이 울려 "첫 제스처부터" 소리가 나게 하는 장치입니다. 다만 재개 순간 소리가 폭주하지 않도록 예약은 2개까지만 허용했습니다.
private ready(): boolean {
  if (!this.enabled || !this.ctx || !this.master || this.crunches.length === 0)
    return false;
  if (this.ctx.state === "running") return true;
  if (this.suspendedGrains >= MAX_SUSPENDED_GRAINS) return false;
  this.suspendedGrains++;
  return true;
}

그래도 안 풀리는 기기가 있었습니다. WebAudio를 resume하는 것만으로는 부족하고, 오디오 세션 자체를 깨우려면 media element 재생이 한 번은 있어야 하는 기종이 있었던 거죠. 그래서 초소형 무음 wav를 Blob으로 만들어 제스처 안에서 한 번 재생해 WKWebView 오디오 세션을 활성화했습니다. 그리고 최후의 수단으로, 제스처를 3회 반복해도 여전히 suspended제스처 안에서 컨텍스트를 아예 새로 만들었습니다. 생성 시점의 활성화 여부에 묶여버리는 WebView가 있기 때문인데, 이때 이미 디코드해 둔 sample AudioBuffer는 컨텍스트에 묶이지 않으므로 그대로 재사용해 다시 받지는 않게 했습니다. 마지막으로 백그라운드에 갔다 돌아왔을 때의 무음을 막으려 visibilitychange 복귀 시 resume을 걸어뒀습니다.

만질 때마다 렉 — 살리려던 코드가 성능을 갉아먹다

언락을 이렇게 공격적으로 짜자 이번엔 정반대 문제가 생겼습니다. 실기기에서 "만질 때마다 렉이 걸린다"는 보고가 온 것이죠. 처음에는 슬라임 크기 상한을 매 프레임 계산하는 부분을 의심했는데, 실측해 보니 스텝당 0.48ms로 원인이 아니었습니다. 진짜 범인은 방금 만든 언락 루틴이었습니다. 소리가 영영 안 풀리는 기기에서는 매 터치마다 무음 미디어 재생이 리스너 3중 발화로 세 번씩 돌고, 3회마다 컨텍스트를 재생성하며 크런치를 다시 굽고, 샘플까지 다시 내려받고 있었습니다. 살리려던 코드가 성능을 갉아먹은 셈이죠.

그래서 모든 언락 동작에 총량 상한과 스로틀을 걸었습니다.

const UNLOCK_THROTTLE_MS = 250; // pointerup/touchend/click 3중 발화 dedupe
const MAX_RECREATES = 3;        // 컨텍스트 재생성 총 횟수 상한
const MAX_SILENT_PLAYS = 8;     // 무음 미디어 재생 총 횟수 상한
const MAX_SAMPLE_TRIES = 3;     // 샘플 fetch+decode 재시도 상한

여기에 재생성 시에는 크런치·샘플 버퍼를 재사용해 재베이킹을 금지하고, 슬라임이 한계 근처에 있거나 잡기·누르기 중일 때만 크기 계산을 돌리도록 해 평상시 비용을 0으로 만들었습니다. 소리를 살리는 코드와 앱을 매끄럽게 유지하는 코드가 서로 싸우지 않게 균형을 잡은 것이죠.

소리의 짝은 햅틱입니다. 토스 SDK의 generateHapticFeedback로, 잡을 때는 softMedium, 더블탭으로 푹 누를 때는 basicMedium을 줍니다. 특히 늘림 비례 틱이 촉감을 살리는데, 드래그로 슬라임을 늘리는 동안 변형량이 쌓일 때마다 강도가 tickWeaktickMediumbasicMedium으로 승급하고 간격도 150ms에서 50ms로 촘촘해집니다. 대신 가만히 쥐고만 있으면 틱은 나지 않습니다. 움직일 때만 손끝이 반응하도록 한 것이죠.

돌아보면 소리는 "넣기"보다 "실기기에서 첫 순간부터 나게 만들기"가 훨씬 어려웠습니다. 합성음의 장난감스러움을 실녹음 그라뉼러로 걷어내고, WebView 오디오 정책이라는 흔한 함정을 언락 루틴으로 넘고, 그 언락이 부른 렉을 상한으로 다시 눌렀습니다. 이 셋을 다 통과하고 나서야 비로소 화면 속 젤리가 손에 잡히는 물건이 됐습니다. 소리가 촉감을 완성한다는 말은, 개발기 입장에서는 "소리를 완성하는 데 촉감만큼의 품이 든다"는 뜻이기도 했죠.