[독일어 학습 앱 개발기 6편] TTS와 음성 인식 구현 – Android에서 가장 까다로운 기능 다루기

2026. 1. 18. 06:40AI Product Building/HalloGerman

반응형

이 글의 핵심:
Android 앱에서 TextToSpeech(TTS)와 SpeechRecognizer(음성 인식)는 구현 자체보다 “안정성”이 훨씬 중요하다. 잘못 다루면 크래시, 메모리 누수, 사용자 이탈로 직결된다. 6편에서는 HalloGerman에서 실제로 사용한 TTS·음성 인식 구조와, 실패를 줄이기 위해 적용한 설계 기준을 공유한다.


1. 왜 TTS와 음성 인식은 별도로 다뤄야 하는가

Android에서 TTS와 음성 인식은 단순한 유틸 기능이 아니다. 둘 다 시스템 리소스를 직접 사용하며, 생명주기 관리가 조금만 어긋나도 문제가 발생한다.

  • Context 누수
  • 화면 전환 시 크래시
  • 백그라운드에서 음성 재생 지속
  • 인식 실패 후 앱 멈춤

그래서 HalloGerman에서는 이 두 기능을 UI에서 완전히 분리하고, 전용 매니저 클래스로 관리했다.


2. 구조 요약: UI는 요청만, 실제 처리는 매니저가 담당

전체 구조는 매우 단순하다.

UI
 → SpeechManager
   → TtsEngine
   → SpeechRecognitionEngine

UI는 “재생해줘”, “말하기 시작해줘” 같은 요청만 보낸다. 실제 Android API 호출은 모두 speech 패키지 내부에서 처리한다.


3. TextToSpeech(TTS) 구현 – 가장 중요한 초기화 타이밍

TTS에서 가장 흔한 실수는 생성과 동시에 speak()를 호출하는 것이다. TTS는 비동기 초기화가 필요하다.

HalloGerman에서는 다음 구조를 사용했다.

class TtsManager(context: Context) {

    private val tts = TextToSpeech(context) { status ->
        if (status == TextToSpeech.SUCCESS) {
            tts.language = Locale.GERMAN
        }
    }

    fun speak(text: String) {
        tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null)
    }

    fun shutdown() {
        tts.stop()
        tts.shutdown()
    }
}

핵심 포인트는 다음 세 가지다.

  • Context는 Activity가 아닌 ApplicationContext 사용
  • 언어 설정 실패 시 재생하지 않음
  • 화면 종료 시 반드시 shutdown()

4. TTS 크래시를 막기 위해 추가한 방어 코드

실제 테스트 중 가장 많이 발생한 문제는 “언어 미지원”과 “엔진 오류”였다.

그래서 speak() 호출 전 다음 검사를 추가했다.

if (tts.isLanguageAvailable(Locale.GERMAN)
    >= TextToSpeech.LANG_AVAILABLE) {
    tts.speak(text, TextToSpeech.QUEUE_FLUSH, null, null)
}

이 한 줄이 앱 안정성을 크게 높여준다.


5. 음성 인식 구현 – 성공률보다 중요한 것은 실패 대응

SpeechRecognizer는 정확도보다 “실패했을 때 어떻게 보이느냐”가 중요하다. 인식 실패는 흔한 일이다.

HalloGerman에서는 다음 원칙을 적용했다.

  • 실패는 기본값으로 가정
  • 점수 비교하지 않기
  • 즉시 재시도 가능하게 만들기

6. SpeechRecognizer 기본 구조

class SpeechRecognitionManager(
    private val context: Context
) {

    private val recognizer = SpeechRecognizer.createSpeechRecognizer(context)

    fun startListening(onResult: (String) -> Unit) {
        val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
            putExtra(
                RecognizerIntent.EXTRA_LANGUAGE,
                Locale.GERMAN.toLanguageTag()
            )
        }

        recognizer.setRecognitionListener(object : RecognitionListener {
            override fun onResults(results: Bundle) {
                val text =
                    results.getStringArrayList(
                        SpeechRecognizer.RESULTS_RECOGNITION
                    )?.firstOrNull()
                text?.let { onResult(it) }
            }

            override fun onError(error: Int) {}
            override fun onReadyForSpeech(params: Bundle?) {}
            override fun onBeginningOfSpeech() {}
            override fun onEndOfSpeech() {}
            override fun onPartialResults(partialResults: Bundle?) {}
            override fun onEvent(eventType: Int, params: Bundle?) {}
            override fun onRmsChanged(rmsdB: Float) {}
            override fun onBufferReceived(buffer: ByteArray?) {}
        })

        recognizer.startListening(intent)
    }

    fun destroy() {
        recognizer.destroy()
    }
}

여기서 중요한 점은 onError()에서 아무것도 하지 않는다는 것이다. 실패는 UI에서 조용히 처리한다.


7. UI에서는 결과를 어떻게 다루는가

UI는 음성 인식 결과를 “정답/오답”으로 판단하지 않는다.

  • 결과 텍스트 표시
  • 다시 말해볼 수 있는 버튼 제공

이 방식은 사용자의 부담을 크게 줄인다.


8. 생명주기 관리: 반드시 해줘야 할 것

TTS와 음성 인식 모두 생명주기 관리가 필수다.

  • onStop() 또는 onDispose에서 release
  • 중복 생성 방지

이 원칙을 지키지 않으면, 장시간 사용 시 앱이 불안정해진다.


9. 이 구조의 장점

  • UI와 음성 로직 완전 분리
  • 크래시 발생 지점 명확
  • 문장 학습, 뉴스 읽기에도 재사용 가능

덕분에 HalloGerman에서는 음성 기능을 여러 화면에서 안정적으로 재사용할 수 있었다.

 

[주요코드]

//TTS 초기화 + 해제 루틴
//ListenScreen.kt
val tts = remember {
    lateinit var inst: TextToSpeech
    inst = TextToSpeech(context) { status ->
        if (status == TextToSpeech.SUCCESS) {
            inst.language = Locale.GERMAN
            ttsReady = true
        }
    }
    inst
}
DisposableEffect(Unit) {
    onDispose { tts.stop(); tts.shutdown() }
}
//TTS는 반드시 stop()/shutdown()을 lifecycle에서 해줘야 누수 방지 가능.

//STT 오류 대응과 부분 결과 처리
//SpeechToTextEngine.kt
override fun onError(error: Int) {
    if (lastPartialResult.isNotEmpty() &&
        (error == SpeechRecognizer.ERROR_NO_MATCH || error == SpeechRecognizer.ERROR_SPEECH_TIMEOUT)) {
        releaseAudioFocus()
        currentOnResult?.invoke(listOf(lastPartialResult))
        return
    }

    releaseAudioFocus()
    val errorMsg = when (error) {
        SpeechRecognizer.ERROR_AUDIO -> context.getString(R.string.stt_error_audio)
        SpeechRecognizer.ERROR_NETWORK -> context.getString(R.string.stt_error_network)
        SpeechRecognizer.ERROR_NO_MATCH -> context.getString(R.string.stt_error_no_match)
        else -> context.getString(R.string.stt_error_unknown) + ": $error"
    }
    currentOnError?.invoke(errorMsg)
}
//실제 서비스에서는 partial 결과를 살려주는 게 체감 품질을 크게 올린다.

//언어·무음 길이 튜닝
//SpeechToTextEngine.kt
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
    putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
    putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
    putExtra(RecognizerIntent.EXTRA_LANGUAGE, "de-DE")
    putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 10)
    putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_COMPLETE_SILENCE_LENGTH_MILLIS, 800L)
    putExtra(RecognizerIntent.EXTRA_SPEECH_INPUT_POSSIBLY_COMPLETE_SILENCE_LENGTH_MILLIS, 800L)
}
//독일어 STT는 언어 설정과 무음 길이를 안 잡으면 실패율이 급격히 올라간다.

10. 다음 편 예고

7편. 뉴스 읽기 기능 구현 – 독일어 콘텐츠를 학습 도구로 바꾸기
다음 글에서는 독일어 뉴스 콘텐츠를 앱에 불러오고, TTS로 읽어주며, 단어를 추출해 단어장으로 연결하는 전체 흐름을 다룬다.


참조 라이브러리 및 도구 모음

 

반응형