[독일어 학습 앱 개발기 9편] 말하기 학습 구현 – Android 음성인식(STT)을 실제 서비스에 적용하는 방법

2026. 1. 21. 07:22AI Product Building/HalloGerman

반응형

이 글의 핵심
- Android SpeechRecognizer를 학습 앱에 안정적으로 적용한 실제 구조
- 말하기 학습에서 사용자 이탈을 줄이기 위해 적용한 예외 처리 전략


HalloGerman의 말하기 학습 기능은 Android 기본 음성 인식(STT)을 그대로 사용하는 것처럼 보이지만, 실제 서비스 환경에서는 단순한 예제로는 절대 충분하지 않다.

이 글에서는 HalloGerman에서 실제로 사용 중인 코드를 기준으로, 말하기 학습 기능이 어떻게 구성되어 있는지 단계별로 설명한다.


1. 말하기 학습 구조 개요

말하기 학습의 전체 흐름은 다음과 같다.

[학습 화면 진입]
→ [STT 엔진 초기화]
→ [사용자 발화 대기]
→ [부분 결과(partial) 수신]
→ [최종 결과 or 예외 처리]
→ [학습 판정 및 다음 단계 이동]

이 흐름에서 가장 중요한 점은 “실패해도 학습이 멈추지 않아야 한다”는 것이다.


2. SpeechRecognizer 초기화 – 엔진은 한 번만 만든다

HalloGerman에서는 화면마다 SpeechRecognizer를 새로 만들지 않는다. 하나의 엔진을 생성하고, 콜백만 상황에 맞게 주입하는 구조를 사용한다.

핵심 코드 ① SpeechRecognizer 생성


recognizer = SpeechRecognizer.createSpeechRecognizer(context).apply {
    setRecognitionListener(object : RecognitionListener {

        override fun onReadyForSpeech(params: Bundle?) {
            currentOnReady?.invoke()
        }

        override fun onError(error: Int) {
            if (
                lastPartialResult.isNotEmpty() &&
                (error == SpeechRecognizer.ERROR_NO_MATCH ||
                 error == SpeechRecognizer.ERROR_SPEECH_TIMEOUT)
            ) {
                currentOnResult?.invoke(listOf(lastPartialResult))
            } else {
                currentOnError?.invoke(
                    context.getString(R.string.stt_error_no_match)
                )
            }
        }

        override fun onResults(results: Bundle?) {
            val matches =
                results?.getStringArrayList(
                    SpeechRecognizer.RESULTS_RECOGNITION
                )

            if (!matches.isNullOrEmpty()) {
                currentOnResult?.invoke(matches)
            } else if (lastPartialResult.isNotEmpty()) {
                currentOnResult?.invoke(listOf(lastPartialResult))
            } else {
                currentOnError?.invoke(
                    context.getString(R.string.stt_error_no_match)
                )
            }
        }
    })
}

이 코드에서 중요한 포인트는 두 가지다.

  • STT 실패(ERROR_NO_MATCH, TIMEOUT) 시에도 학습을 끊지 않는다
  • 부분 인식 결과(partial result)를 최종 결과 대체로 활용한다

3. partial result를 살린 이유

실제 사용자 환경에서는

  • 마이크 감도 문제
  • 잡음
  • 짧은 발화

때문에 음성 인식이 자주 실패한다. 이때 단순히 “다시 말해보세요”를 반복하면 사용자는 빠르게 이탈한다.

그래서 HalloGerman에서는

[부분 결과가 하나라도 있다면]
→ [이를 최종 발화로 간주]
→ [학습 판정으로 이동]

이라는 정책을 코드 레벨에서 강제했다.


4. UI와 STT 엔진의 분리

STT 엔진은 화면(UI)을 전혀 알지 못한다.

대신 다음과 같은 콜백만 주입된다.

  • onReady → “지금 말하세요” UI 표시
  • onResult → 발화 결과 전달
  • onError → 에러 메시지 처리

이 구조 덕분에

  • 말하기 학습
  • 읽기(발음 확인)

모두 같은 STT 엔진을 재사용할 수 있다. (10편에서 이 구조를 그대로 이어서 설명한다.)


5. 말하기 학습에서 가장 중요했던 판단

기술적으로 가장 중요한 결정은 “정확도”가 아니었다.

HalloGerman의 말하기 학습은

  • 100% 정확한 인식 ❌
  • 학습 흐름 유지 ⭕

를 우선한다.

이 기준이 있었기 때문에 partial result 활용, 에러 허용 범위 확대, 재시도 부담 감소 같은 설계가 가능했다.

 

[주요 코드]

//엔진 생성/해제 (메모리 안정성)
//ReadScreen.kt
val speechEngine = remember { AndroidSpeechToTextEngine(context) }
DisposableEffect(Unit) { onDispose { speechEngine.release() } }
//STT는 항상 생명주기와 함께 다뤄야 한다.

//TTS 간섭 방지용 딜레이
//ReadScreen.kt
// 100ms delay: Ensures TTS is completely silent prevents 'Echo' from being recognized.
val runnable = Runnable {
    speechEngine.start(
        onResult = { results -> finalizeRecording(results, currentMeaning) },
        onError = { error -> finalizeRecording(listOf(error), currentMeaning, isError = true) },
        onReady = { /* Managed manually */ }
    )
    startRunnable = null
}
handler.postDelayed(runnable, 100)
//TTS 직후 STT를 바로 시작하면 에코가 들어와 오인식이 발생한다.

//권한 UX까지 포함
//ReadScreen.kt
val permissionLauncher = rememberLauncherForActivityResult(
    contract = ActivityResultContracts.RequestPermission(),
    onResult = { granted ->
        if (granted) {
            startRecording(uiState.current, computeMeaning(uiState.current))
        } else {
            Toast.makeText(context, context.getString(R.string.tts_warning_message), Toast.LENGTH_SHORT).show()
        }
    }
)
//음성 인식은 권한 UX까지 포함해야 “실제 서비스”로 쓸 수 있다.

6. 다음 편 예고

다음 10편에서는 이 STT 엔진을 그대로 활용해 읽기(발음 확인) 학습 모드를 어떻게 구성했는지를 다룬다.

엔진은 같지만, 판정 기준과 UX가 어떻게 달라지는지를 실제 코드 흐름으로 설명할 예정이다.

 

반응형