2026. 1. 21. 07:22ㆍAI 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가 어떻게 달라지는지를 실제 코드 흐름으로 설명할 예정이다.
'AI Product Building > HalloGerman' 카테고리의 다른 글
| [독일어 학습 앱 개발기 11편] 쓰기 학습 ① 타이핑 입력은 왜 가장 먼저 구현했을까 (0) | 2026.01.23 |
|---|---|
| [독일어 학습 앱 개발기 10편] 읽기 학습과 음성 인식은 왜 분리했을까 – 사용자가 말한 단어를 처리하는 구조 (0) | 2026.01.22 |
| [독일어 학습 앱 개발기 8편] CSV 단어장 업로드 UX – 사용자가 실패하지 않게 만드는 설계 (0) | 2026.01.20 |
| [독일어 학습 앱 개발기 7편] 뉴스 읽기 기능 구현 – 독일어 콘텐츠를 학습 도구로 바꾸는 구조 (0) | 2026.01.19 |
| [독일어 학습 앱 개발기 6편] TTS와 음성 인식 구현 – Android에서 가장 까다로운 기능 다루기 (0) | 2026.01.18 |