[DIP] WebView TTS

webview

최근 서비스 내 TTS(Text To Speech) 지원 가능에 대한 문의가 있었다. 당시에 직접 대응이 필요한 부분이 없어 요청에 대한 검토를 깊게 하지 않았지만, TTS 기능을 서비스에서 지원한다면 좋지 않을까 하여 추가로 자료 조사를 진행하고 정리해 보고자 한다.

DIP-WebView-TTS-1



Web Speech API

Web Speech API는 웹 브라우저상에서 자체 내장된 TTS 엔진 혹은 접근을 허용하는 OS에 한에서 OS 음성 엔진을 통하여 텍스트를 음성 오디오로 변환하여 출력한다.

Web Speech API에서 제공되는 기능은 텍스트 음성 변환(SpeechSynthesis)과 비동기 음성 인식(SpeechRecognition)을 제공한다.



SpeechSynthesis

TTS 기능을 위한 가장 필요한 API로 텍스트를 음성으로 변환시켜 주는 인터페이스이다. speak, pause, cancel 등 간단한 메소드를 통해 텍스트를 입력받아 음성으로 출력하고 정지 및 제거가 가능하다.


제공되는 속성

  • paused: 일시 정지 상태인지 반환 (Boolean)
  • pending: 발화 대기열에 발화 포함 여부 (Boolean)
  • speaking: 발화 진행 여부 (Boolean)

제공되는 메서드

  • cancel: 발화 대기열의 모든 발화 제거
  • getVoices: 장치에서 사용 가능한 모든 음성 목록 반환
  • pause: 일시 정지 상태로 만듦
  • resume: 일시 정지 상태에서 재개
  • speak: 발화 대기열에 발화 추가

Web Speech API를 설명하며 텍스트를 입력받아 음성으로 출력한다고 설명했었다. 그런데 위 설명을 본다면 발화를 대기열에 추가하고 발화를 말하고 제거한다는 설명을 볼 수 있다. Web Speech API에서는 단순히 텍스트를 받아 음성으로 변환하는 것이 아닌 발화문(SpeechSynthesisUtterance)이라는 별도의 객체 인스턴스를 생성하여 음성으로 변환 처리한다.



SpeechSynthesisUtterance

SpeechSynthesisUtterance는 음성으로 출력할 텍스트와 출력 방식에 대한 속성을 가진 객체로 Web Speech API의 SpeechSynthesis에서 주입받아 처리되는 TTS에서의 핵심 객체이다.


제공되는 속성

  • text: 음성으로 출력할 텍스트
  • lang: 사용할 언어 (ex. en-US, ko-KR)
  • voice: 사용할 음성 객체 (브라우저에서 제공되는 음성)
  • volume: 음량 (0.0 ~ 1.0, default: 1)
  • rate: 말하는 속도 (0.1 ~ 10, default: 1)
  • pitch: 음조 높낮이 (0 ~ 2, default: 1)

제공되는 이벤트

  • onstart: 음성 재생 시작시 호출
  • onend: 음성 재생 완료시 호출
  • onerror: 음성 재생중 오류 발생시 호출
  • onpause: 음성 일시 정지시 호출
  • onresume: 일시 정지 후 다시 시작시 호출
  • onmark: SSML mark 태그 사용시 해당 위치에 음성 도달하는 경우 호출
  • onboundary: 단어 또는 문장 경계에 도달시 호출

이제 위 설명한 두 가지 SpeechSynthesis, SpeechSynthesisUtterance를 사용하여 텍스트를 음성으로 출력하는 간단한 예시를 만들어 보자.

tsx
["Hello", "Workd"].forEach((text) => { const utter = new SpeechSynthesisUtterance(text); utter.lang = "ko-KR"; utter.volume = 1; // 0.0 ~ 1.0 utter.rate = 1; // 0.1 ~ 10 utter.pitch = 1; // 0 ~ 2 utter.onstart = () => console.log(`Start: ${text}`); utter.onend = () => console.log(`End: ${text}`); utter.onerror = (e) => console.log(`Error: ${e}`); speechSynthesis.speak(utter); });


이벤트 처리를 왜 SpeechSynthesisUtterance에서 할까?

자료 조사를 하며 실제 음성 출력에 대한 시작 및 일시 정지 메서드를 가진 SpeechSynthesis에서 이벤트를 관리하는 게 아닌 SpeechSynthesisUtterance에서 관리하는지 궁금증이 생겨 찾아보았다.

이유는 TTS에서 SpeechSynthesis의 역할은 발화에 대한 큐를 관리 및 제어하는 컨트롤로 역할을 수행하는 것이다. 그리고 SpeechSynthesisUtterance는 음성으로 출력할 텍스트 및 속성을 가진 발화문 개별 상태를 가진 객체이다.

각 발화문 개별마다의 속성 혹은 상태에 따른 이벤트 처리가 별개로 이루어질 수 있도록 설계가 필요하여 발화에 대한 시작, 끝 혹은 일시 정지에 대한 이벤트 처리는 SpeechSynthesisUtterance에서 관리된다.

해당 내용이 궁금했던 이유는 React와 비교했을 때 SpeechSynthesis는 렌더링 역할을 수행하는 컴포넌트라 생각했고, SpeechSynthesisUtterance는 컴포넌트 내에서 관리되는 상태라고 생각했다. 처음 Web Speech API를 보고 React에 해당 설계를 접목한다면 상태에서 컴포넌트의 이벤트를 관리하는 형태를 떠올렸기 때문에 궁금증이 생겼던 것이다.


*다시 생각해 봤을 때 React의 거대한 렌더링 로직에서 컴포넌트도 그 안에서의 상태로 볼 수 있지 않았나 싶다. 결국 렌더링 로직 내에서 관리되는 컴포넌트라는 상태에서 이벤트가 관리된다고 생각한다면 동일한 설계로 볼 수 있다 싶기도 하다.


마지막으로 Web Speech API를 정리하며 아쉽지만, TTS 기능 구현을 위해서 텍스트를 음성으로서 출력 역할을 하는 SpeechSynthesis와 발화 정보를 관리하는 SpeechSynthesisUtterance는 안드로이드 웹 뷰에서 지원되지 않는 것으로 확인하였다.



Google Cloud Text to Speech API

TTS 구현하는 또 다른 방법으로는 클라이언트에서 전달받은 텍스트를 기반으로 서버에서 Audio Content를 생성하고 클라이언트에서 반환받은 Audio Content 음성을 출력하는 방식이다.


Nodejs Server

tsx
// index.js import express from "express"; import textToSpeech from "@google-cloud/text-to-speech"; import dotenv from "dotenv"; import fs from "fs/promises"; dotenv.config(); const app = express(); const port = 3000; // GCP 인증 설정 const client = new textToSpeech.TextToSpeechClient({ keyFilename: "./google-credentials.json", }); app.get("/api/tts", async (req, res) => { const text = req.query.text; if (!text) { return res.status(400).send("text parameter is required"); } try { const [response] = await client.synthesizeSpeech({ input: { text }, voice: { languageCode: "ko-KR", ssmlGender: "FEMALE", // 남성: MALE, 중성: NEUTRAL }, audioConfig: { audioEncoding: "MP3", }, }); res.setHeader("Content-Type", "audio/mpeg"); res.send(response.audioContent); } catch (err) { console.error(err); res.status(500).send("TTS failed"); } }); app.listen(port, () => { console.log(`TTS server running on http://localhost:${port}`); });

Webview Javascript

tsx
const speak = async (text) => { const audio = new Audio(`/api/tts?text=${encodeURIComponent(text)}`); await audio.play(); };

Google Cloud Text to Speech API를 활용한 방식은 구글 클라우드에서의 프로젝트 생성 및 인증 방식이 추가로 필요하며 매월 400만 자 이상이 무료이지만 이후에는 과금이 필요하다. 실제 서비스 내에 도입하게 되는 경우 400만 자는 금방 소모되는 글자 수라 생각되어 실제 서비스 적용에 사용하는 것은 쉽지 않을 것으로 생각된다.



say & espeak TTS

Google Cloud Text to Speech API는 과금 정책으로 사용이 힘들 것으로 생각되어 TTS 무료 모듈이 없는지 찾아보았고 몇 가지의 무료 모듈을 찾을 수 있었다. 그중 Nodejs 환경에서 지원되는 say 모듈과 espeak 모듈을 확인하였다.



say.js

say 모듈은 내부적으로 실행된 시스템 OS에 맞는 오디오 생성 CLI를 생성하여 OS에 내장된 TTS 시스템을 활용하여 텍스트를 음성 오디오로 생성해 낸다.

say 모듈에서 처리되는 OS는 darwin, linux, win32로 각 OS별 커맨드는 darwin은 say, linux는 festival, win32는 powershell로 텍스트를 음성으로 변환하기 위한 CLI를 생성하도록 구현되어 있었다.


실제로 macOS 터미널 환경에서 아래와 같이 입력하면 음성이 출력되는 것을 간단하게 확인 할 수 있다.

$ say “안녕하세요”


아래는 say로 설정할 수 있는 옵션으로 신기했던 것은 사용할 음성 지정이 가능하다는 점이었다. 현재 환경에서 지원되는 음성은 리스트로 확인 할 수 있으며 한국어 기본 음성 지원은 “Yuna”로 설정되어 음성이 출력된다.

$ say -v “?”


옵션설명예시
-v [voice]사용할 음성 지정say -v Yuna "안녕하세요"
-o [file.aiff]음성 결과를 AIFF 오디오 파일로 저장say -o hello.aiff "파일로 저장됩니다"
-f [filename.txt]텍스트 파일에서 내용을 읽음say -f intro.txt
-r [number]발화 속도 설정 (words per minute)say -r 180 "속도 조절"
-i표준 입력(stdin)으로부터 텍스트를 읽음`echo “텍스트”
--progress긴 텍스트를 읽을 때 진행 표시 출력say --progress -f long.txt
--file-format=[format]저장 시 오디오 포맷 지정 (AIFF, caff, m4af 등)say -o out.aiff --file-format=aiff "hello"

아래는 express로 구현된 Nodejs 서버상에서 텍스트를 음성 오디오로 변환하여 응답하는 간단한 예시 코드이다. 앞서 말했듯 macOS 환경에서 say 커맨드를 CLI로 바로 사용할 수 있어 모듈 설치 없이 커맨드를 실행하는 형태로 구현할 수 있다.


Nodejs Server

tsx
// server.js const express = require("express"); const { exec } = require("child_process"); const fs = require("fs"); const app = express(); const port = 3000; app.get("/tts", (req, res) => { const text = req.query.text; const filePath = "output.aiff"; if (!text) { return res.status(400).send("Missing text query parameter"); } // say 명령어로 오디오 파일 생성 exec(`say -o ${filePath} "${text}"`, (err) => { if (err) { console.error("TTS 생성 실패:", err); return res.status(500).send("TTS 실패"); } // 생성된 파일을 스트리밍으로 응답 res.setHeader("Content-Type", "audio/aiff"); const stream = fs.createReadStream(filePath); stream.pipe(res); // 스트리밍 종료 후 파일 삭제 stream.on("close", () => { fs.unlinkSync(filePath); }); }); }); app.listen(port, () => { console.log( `✅ TTS 서버 실행 중: http://localhost:${port}/tts?text=안녕하세요` ); });


espeak

다음은 espeak로 say와 동일하게 커맨드를 만들어 음성 오디오 데이터를 생성해 내도록 구현되어 있었고 구현 자체가 너무 간단하여 해당 모듈은 사용하지 않고 커맨드로 직접 구현하는 게 더 좋아 보였다.

espeak는 Linux, Windows, macOS 등 다양한 플랫폼에서 CLI로 사용이 가능하다. 하지만 macOS에서 say로 생성되는 음성 오디오 데이터에 비해 품질이 많이 떨어지는 것을 확인 할 수 있었다.


아래 커맨드로 확인하면 로봇과 같은 아주 어색한 음성이 출력되는 것을 확인 할 수 있다.

$ espeak “안녕하세요”


아래는 espeak에서 지원되는 옵션으로, voices로 확인 시 지원되는 음성 목록을 확인 할 수 있었다. 그런데 한국어가 기본 목록에 포함되지 않아서인지 외국인 로봇이 한국어를 말하는 것과 같은 음성이 출력되고 영어를 사용하는 경우 그래도 나쁘지 않게 출력되는 느낌이 들었다.


옵션설명예시
-v voice사용할 음성/언어 지정espeak -v en-us "Hello"
-s speed말하기 속도 설정 (기본 175 wpm)espeak -s 120 "느리게 읽기"
-a amplitude음량 설정 (0~200, 기본 100)espeak -a 150 "좀 더 크게"
-p pitch피치(음 높낮이) 설정 (0~99, 기본 50)espeak -p 70 "높은 음으로"
-g pause문장 사이 정지 시간(ms)espeak -g 100 "문장. 사이."
-w filename.wav음성을 WAV 파일로 저장espeak -w hello.wav "파일 저장"
-f filename.txt텍스트 파일 읽기espeak -f input.txt
--stdout음성 데이터를 stdout으로 출력 (파이프 가능)espeak --stdout "텍스트" > out.wav
-bBash 특수 문자 무시espeak -b "use $HOME safely"
-x발음기호(IPA)로 변환 출력espeak -x "hello"
--voices설치된 음성 목록 보기espeak --voices
--voices=lang특정 언어의 음성 목록 보기espeak --voices=ko
--pho발음기호 파일 출력espeak --pho "test"

espeak 또한 커맨드로 텍스트를 음성 오디오 데이터로 생성할 수 있으며 아래와 같이 간단하게 코드로 구현할 수 있다.


Nodejs Server

tsx
// server.js const express = require("express"); const { exec } = require("child_process"); const fs = require("fs"); const path = require("path"); const { randomUUID } = require("crypto"); const app = express(); const port = 3000; app.get("/tts", (req, res) => { const text = req.query.text; if (!text) return res.status(400).send('Missing "text" query parameter'); const filename = `espeak-${randomUUID()}.wav`; const filePath = path.join(__dirname, filename); // espeak로 wav 파일 생성 const command = `espeak -v ko -w ${filePath} "${text}"`; exec(command, (err) => { if (err) { console.error("espeak error:", err); return res.status(500).send("TTS failed"); } // 파일 응답 res.setHeader("Content-Type", "audio/wav"); const stream = fs.createReadStream(filePath); stream.pipe(res); // 응답 후 임시 파일 삭제 stream.on("close", () => { fs.unlink(filePath, (err) => { if (err) console.warn("Temp file cleanup failed:", err); }); }); }); }); app.listen(port, () => { console.log( `✅ TTS server running: http://localhost:${port}/tts?text=안녕하세요` ); });

위 설명된 자료들을 조사하며 Web Speech API는 환경별 제약 그리고 Google Cloude Text to Speech API는 과금 정책, say와 espeak를 사용한 방법은 서버에서의 처리 필요와 플랫폼(OS)에 따른 품질 이슈를 확인 할 수 있었다. 이러한 이슈들을 이유로 FE 자체적으로 TTS 기능을 구현하여 서비스에 지원하는 것은 쉽지 않을 것으로 판단하였고, 클라이언트에서 구현된 기능을 FE에서 활용하는 방법을 추가로 조사해 보았다.



Android & iOS TTS

Android ↔ WebView 브릿지

첫 번째 방법으로는 안드로이드 내 제공되는 TextToSpeech API를 통해 텍스트를 음성으로 출력하도록 구현하고 해당 기능을 브릿지 함수로 웹 뷰 내 제공하는 방식이다.


Android Kotlin

tsx
class MainActivity : AppCompatActivity(), TextToSpeech.OnInitListener { private lateinit var webView: WebView private lateinit var tts: TextToSpeech override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) webView = WebView(this) setContentView(webView) // TTS 초기화 tts = TextToSpeech(this, this) // JavaScript 허용 webView.settings.javaScriptEnabled = true // JS ↔ Android 브릿지 연결 webView.addJavascriptInterface(JSBridge(), "**AndroidBridge**") // 웹 페이지 로드 webView.loadUrl("file:///android_asset/index.html") } override fun onInit(status: Int) { if (status == TextToSpeech.SUCCESS) { tts.language = Locale.KOREAN } } inner class JSBridge { @JavascriptInterface fun speak(text: String) { Log.d("JSBridge", "Speaking: $text") tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) } } override fun onDestroy() { tts.shutdown() super.onDestroy() } }

Webview Javascript

tsx
// Android function speak() { const text = document.getElementById("text").value; if (window.AndroidBridge?.speak) { window.AndroidBridge.speak(text); } else { alert("Android TTS 브릿지가 연결되지 않았습니다."); } }


iOS ↔ Webview 브릿지

iOS에서는 발화 객체인 AVSpeechUtterance와 사용할 음성을 담당하는 AVSpeechSynthesisVoice 객체를 통해 TTS 처리가 가능하다.


iOS Swift

swift
import UIKit import WebKit import AVFoundation class ViewController: UIViewController, WKScriptMessageHandler { var webView: WKWebView! let speechSynthesizer = AVSpeechSynthesizer() override func viewDidLoad() { super.viewDidLoad() // 1. JS → Native 브릿지 설정 let contentController = WKUserContentController() contentController.add(self, name: "speak") // 2. WebView 구성 let config = WKWebViewConfiguration() config.userContentController = contentController webView = WKWebView(frame: self.view.bounds, configuration: config) self.view.addSubview(webView) // 3. 웹 페이지 로드 if let url = Bundle.main.url(forResource: "index", withExtension: "html") { webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent()) } } // 4. JS 메시지 처리 (핸들러) func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "speak", let text = message.body as? String { let utterance = AVSpeechUtterance(string: text) utterance.voice = AVSpeechSynthesisVoice(language: "ko-KR") speechSynthesizer.speak(utterance) } } }

Webview Javascript

tsx
// iOS function speak() { const text = document.getElementById("text").value; if (window.webkit?.messageHandlers?.speak) { window.webkit.messageHandlers.speak.postMessage(text); } else { alert("iOS 브릿지가 연결되지 않았습니다."); } }


안드로이드에서는 왜 TTS 엔진 인스턴스에서 발화체에 대한 설정을 관리할까?

Web Speech API 설계 방식과 다르게 안드로이드에서는 TTS 엔진 컨텍스트에서 발화에 대한 속성을 설정하도록 설계되어 있다.

안드로이드의 TTS 엔진은 시스템 서비스로 등록되어 있으며 모든 앱/컴포넌트가 공유하는 구조이다. 시스템에서는 하나의 TTS 엔진이 동작하며 앱에서 여러 TTS 인스턴스를 생성하더라도 같은 엔진을 사용하게 된다. 이러한 구조는 시스템 자원이 절약되고 일관된 사용자 설정으로 사용될 수 있다는 장점을 가진다. 또한 안드로이드 TTS 엔진은 싱글톤 서비스로 동작하며 한 번의 설정으로 이어지는 발화에서 같은 설정을 유지하는 방식으로 재사용에 효율적이다.

iOS에서는 Apple API의 설계 철학이 객체 지향적인 것에 맞추어 Web Speech API와 동일하게 발화체 자체에 설정 상태를 관리하며 다양한 발화체 설정으로 처리된다. 또한 iOS TTS 엔진은 싱글톤 서비스가 아닌 독립적 동작이 가능하여 다양한 발화체가 유연하게 사용 가능하도록 설계되어있다고 한다.



In Conclusion

Webview 안에서 TTS 기능을 구현하기 위해 여러 가지 방법을 찾아보았다. 스마트폰이 대중화되면서 많은 서비스들이 Webview 기반으로 개발되는 것 같다.

스마트폰 OS 자체는 많지 않지만, 막상 OS별 처리를 하다 보면 클라이언트의 도움을 받아야 하는 경우가 생기고, 그 과정에서 의존성이 늘어나는 코드들도 종종 보이곤 한다. 이럴 때마다 예전에 PC 브라우저 환경에서 개발하던 기억이 떠오른다. IE, Chrome, Safari 등 다양한 브라우저 호환성을 맞추느라 짜증 났던 그 시절 말이다.

지금은 시대의 흐름과 기술의 발전 덕분에 개발 환경이 PC 브라우저에서 스마트폰으로 옮겨온 게 아닐까 싶다. 그렇다면 앞으로 우리 프론트엔드 개발자가 맞닥뜨릴 환경은 또 어떤 모습일까 생각해보며 기대가 된다.

DIP-WebView-TTS-2

"It is not the strongest of the species that survive, nor the most intelligent, but the one most responsive to change."

- Charles Darwin -