포스트

"안 된다"고 적어 둔 코드가 사실은 되고 있었습니다 — 토스 웹뷰 마이크 이야기

한글 몬스터 토스판은 말하기 모드를 아예 숨기고 듣기 전용으로 돌고 있었습니다. 토스 웹뷰에 음성 인식이 없다고 판단해 코드에 available:false를 박아 둔 탓입니다. 브라우저에서는 되는데 같은 웹앱인 토스에서 왜 안 되냐는 의문에서 시작해, 실제로 측정해 보니 인식은 처음부터 있었습니다. 잘못된 전제 하나가 기능을 반쪽으로 만든 이야기

"안 된다"고 적어 둔 코드가 사실은 되고 있었습니다 — 토스 웹뷰 마이크 이야기

한글 몬스터에는 소리를 듣고 글자를 고르는 듣기 모드와, 마이크에 소리 내어 읽는 말하기 모드가 있습니다. 그런데 앱인토스에 올린 토스판에서는 말하기 모드가 아예 보이지 않았습니다. 마이크 버튼도 없고, 문장 읽기 모드는 목록에서 빠져 있었습니다.

이유는 코드에 이렇게 적혀 있었습니다.

1
2
3
4
5
function createTossSpeech(): Speech {
  return {
    available: () => false, // 마이크 인식 경로 없음 — 말하기 변형은 숨겨진다
    listen: () => Promise.resolve({ ok: false, text: '', error: 'unsupported' }),
    ...

토스 웹뷰에는 Web Speech API가 없다고 판단해서, 있는지 물어보지도 않고 false를 돌려주게 해 둔 것입니다. 주석에도 “인식·합성 모두 없다”고 써 두었습니다.

의문은 여기서 시작됐습니다

브라우저에서는 됩니다. 브라우저에서 바로 하는 PLAY 페이지에서 한글 몬스터를 열면 마이크 권한을 묻고 말하기 모드가 정상 동작합니다.

그러면 이상합니다. 토스 미니앱도 웹앱입니다. 같은 번들, 같은 코드가 토스 앱 안의 웹뷰에서 돌아갑니다. 브라우저에서 되는 것이 왜 토스에서는 안 되나요?

되짚어 보니 근거가 부실했습니다. 안드로이드 래퍼 앱에서 웹 음성 인식이 동작하지 않는 것을 확인한 적이 있고, 토스도 안드로이드 웹뷰이니 마찬가지일 것이라고 추정했던 것입니다. 토스 웹뷰에서 직접 확인한 기록은 없었습니다.

측정해 봤습니다

추정을 걷어내는 방법은 하나뿐입니다. 실제 값을 찍어 보는 것입니다. 안드로이드 시스템 웹뷰에 디버거를 붙여 두 객체의 존재를 확인했습니다.

1
2
webkitSpeechRecognition   function     ← 있다
speechSynthesis           undefined    ← 이쪽만 없다

음성 인식은 처음부터 있었습니다. 없는 것은 음성 합성(TTS)이었습니다.

인식을 실제로 시작해 보니 startaudiostartend까지 오류 없이 진행됐습니다. 조건은 하나였습니다. 웹뷰를 띄운 호스트 앱이 마이크 권한 요청을 허용해 주기만 하면 됩니다. 안드로이드 웹뷰는 페이지가 마이크를 요청할 때 호스트 앱에게 WebChromeClient.onPermissionRequest로 물어보는데, 기본 구현은 그냥 거부합니다. 우리 자체 래퍼가 기본 구현이라 거부됐던 것이고, 그것을 “웹뷰에는 API가 없다”로 잘못 읽었습니다.

TTS가 없다는 부분은 사실이었지만 문제가 되지 않았습니다. 한글 몬스터는 이미 녹음해 둔 음성 클립을 번들해서 기기 TTS보다 먼저 재생합니다. 아이가 듣는 발음이 기기마다 달라지는 걸 막으려고 그렇게 해 두었는데, 그 선택이 여기서 그대로 도움이 됐습니다.

고친 것은 세 줄이었습니다

첫째, 토스 전용 껍데기를 지우고 웹 구현을 그대로 쓰게 했습니다.

1
2
3
4
case 'toss':
  // 토스 웹뷰도 안드로이드 System WebView라 webkitSpeechRecognition이 있다.
  // 호스트가 onPermissionRequest만 허용하면 start→audiostart→end까지 오류 없이 진행된다.
  return createWebSpeech();

둘째, 토스 래퍼 설정에 마이크 권한을 선언했습니다. 앱인토스는 미니앱이 쓰는 권한을 설정 파일에 적게 되어 있습니다.

1
permissions: [{ name: 'microphone', access: 'access' }],

셋째가 이 사고의 핵심입니다. 게임 쪽 코드는 이렇게 되어 있었습니다.

1
const kListenOnlyBuild = detectPlatform() === 'toss';

플랫폼 이름으로 능력을 판단하고 있었습니다. 그래서 실제로 마이크가 되는 환경까지 함께 막혀 있었습니다. 이렇게 바꿨습니다.

1
const kListenOnlyBuild = !speech.available();

플랫폼이 아니라 실제 능력을 물어보게 했습니다. 앞으로 토스 웹뷰가 바뀌거나 다른 환경이 추가돼도 코드를 고칠 일이 없습니다.

열어 보니 다른 버그가 나왔습니다

토스에서 말하기 모드를 열자 사용자 제보가 왔습니다. 권한 팝업에서 ‘허용’을 누른 그 판은 인식이 전혀 안 되고, 나갔다 다시 들어와야 동작한다는 것이었습니다. 웹에서도 같은 증상이 있었습니다.

파고 보니 원인이 하나가 아니라 넷이었습니다.

하나. 팝업이 닫히자마자 인식을 시작했습니다. 권한 팝업이 떠 있는 동안 페이지는 백그라운드로 내려갑니다. 게다가 권한을 얻으려고 열었던 마이크 트랙을 방금 닫은 직후라 장치가 아직 정리되는 중입니다. 그 상태에서 시작하면 오디오가 붙지 않습니다. 그래서 처음 허용한 직후에만 페이지가 돌아오기를 기다리고 400밀리초를 쉬도록 했습니다. 두 번째부터는 이 경로를 타지 않습니다.

둘. 시작 실패를 전부 “지원하지 않음”으로 뭉갰습니다. 이게 “그 판 내내 안 됨”의 정체였습니다. 호출하는 쪽은 “지원하지 않음”을 치명적 오류로 보고 말하기 모드를 통째로 닫아버립니다. 그런데 실제 예외는 InvalidStateError, 즉 앞 인식이 아직 정리 중이라는 뜻이었습니다. 재시도하면 되는 상황을 영구적 불가로 보고한 셈입니다. 이제는 이 경우를 busy로 따로 분류해 재시도 경로로 보냅니다.

셋. 이전 인식 인스턴스를 정리하지 않았습니다. 앞 판의 인스턴스가 마이크를 붙들고 있으면 새 인식이 조용히 죽습니다. 시작 전에 abort()를 넣었습니다.

넷. 안드로이드 네이티브 경로에는 타임아웃 문제가 있었습니다. OS 권한 창이 떠 있는 동안 네이티브는 아무 콜백도 주지 않는데 자바스크립트의 12초 타이머는 계속 돌고 있었습니다. 사용자가 팝업을 읽고 버튼을 누르는 사이에 대기가 먼저 끝나 버립니다. 화면이 돌아오면 타이머를 다시 걸도록 고쳤습니다.

남은 것

한글 몬스터 vc19(1.7.1)와 토스 번들을 올려 실기기에서 확인했습니다. 토스에서도 말하기 모드가 보이고, 처음 허용한 그 판부터 인식이 됩니다.

토스 앱이 권한 요청을 거부하는 경우도 처리해 두었습니다. 그때는 denied가 돌아오고 키보드 입력 폴백으로 넘어가서, 마이크가 없어도 문제는 계속 풀 수 있습니다.

배운 것

“안 된다”고 적어 둔 주석은 검증되지 않은 추정일 수 있습니다. 이 코드는 몇 달 동안 available: () => false를 돌려주고 있었고, 그 옆에는 이유까지 적혀 있었습니다. 근거가 문서화되어 있으니 더 의심하지 않았습니다. 실제로는 비슷한 환경에서 관찰한 것을 다른 환경에 옮겨 적은 것이었습니다.

플랫폼 이름으로 능력을 판단하면 이런 실수가 굳습니다. platform === 'toss'는 검증할 수 없는 주장입니다. speech.available()은 그 자리에서 확인됩니다. 후자로 쓰면 전제가 틀렸을 때 코드가 스스로 바로잡습니다.

“왜 안 되지?”를 한 번 더 물어보는 게 가장 값싼 디버깅입니다. 이번 일은 브라우저에서 되는 것이 토스에서 안 되는 이유를 설명할 수 없다는 데서 출발했습니다. 설명이 안 되는 지점을 그냥 넘기지 않은 것이 전부였습니다. 측정 자체는 5분이 걸렸습니다.

한글 몬스터는 Google Play와 앱인토스에서 받을 수 있고, 브라우저에서 바로 해볼 수도 있습니다. 두 몬스터 앱은 모든 모드가 무료입니다. 소식은 이 블로그와 인스타그램(@fadongkwon.soft) (새 창에서 열림)에서 전해드립니다.

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