Jetpack Compose에서 TTS 간단하게 구현하기 – rememberTextToSpeech 커스텀 훅 만들기

최근에 어르신들을 위한 앱을 만들면서 음성 안내 기능을 넣어야 했어요. Compose로 작업하다 보니 TTS도 Compose스럽게 쓰고 싶더라구요. 그래서 간단한 커스텀 Composable을 만들어봤습니다.

왜 Compose에서 TTS가 까다로울까?

TTS는 Android의 전통적인 API라서 Compose의 선언적 패러다임과 잘 안 맞아요. 특히 이런 부분들이 신경 쓰였습니다:

  • 초기화가 비동기로 진행됨
  • Activity 생명주기에 맞춰 정리해야 함
  • Recomposition 때마다 새로 생성되면 안 됨

rememberTextToSpeech 만들기

그래서 이런 식으로 Compose에 맞게 래핑했어요:

@Composable
fun rememberTextToSpeech(
    onInit: (TextToSpeech) -> Unit = {}
): MutableState {
    val context = LocalContext.current
    val tts = remember { mutableStateOf(null) }
    
    DisposableEffect(context) {
        val textToSpeech = TextToSpeech(context) { status ->
            if (status == TextToSpeech.SUCCESS) {
                tts.value?.language = Locale.KOREA
                tts.value?.setSpeechRate(1.0f)
                tts.value?.setPitch(1.0f)
                onInit(tts.value!!)
            } else {
                Log.e("TTS", "TTS 초기화 실패: $status")
            }
        }
        tts.value = textToSpeech
        
        onDispose {
            textToSpeech.stop()
            textToSpeech.shutdown()
        }
    }
    return tts
}

포인트는 DisposableEffect를 사용한 거예요. Composable이 화면에서 사라질 때 자동으로 TTS 리소스를 정리해줍니다. 메모리 릭 걱정 없어요!

실제 사용 예시

@Composable
fun FaceRecognitionResultScreen(
    matchResult: MatchResult?,
    isAllSuccess: Boolean
) {
    val tts = rememberTextToSpeech()
    val settingViewModel = hiltViewModel()
    val ttsOn = settingViewModel.isSeniorTTS
    
    LaunchedEffect(Unit) {
        if(ttsOn) {
            if (isAllSuccess) {
                delay(500)  // UI 전환 후 잠시 대기
                tts.value?.speak(
                    matchResult?.message,
                    TextToSpeech.QUEUE_FLUSH,
                    null,
                    "description"
                )
            } else {
                delay(500)
                tts.value?.speak(
                    "안녕하세요.",
                    TextToSpeech.QUEUE_FLUSH,
                    null,
                    "description"
                )
            }
        }
    }
    
    // UI 구성...
}

LaunchedEffect(Unit)를 사용해서 화면이 처음 표시될 때 한 번만 음성 안내가 나가도록 했어요. delay를 준 건 화면 전환 애니메이션이 끝난 후에 말하게 하려고요.

설정 화면에서 TTS 켜기/끄기

어르신들은 음성 안내가 오히려 번거로울 수도 있어서 설정에서 끌 수 있게 했습니다:

@Composable
fun SettingsScreen() {
    val viewModel = hiltViewModel()
    var isTTSEnabled by remember { mutableStateOf(viewModel.isSeniorTTS) }
    
    Column(
        modifier = Modifier.padding(16.dp)
    ) {
        Row(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.SpaceBetween,
            verticalAlignment = Alignment.CenterVertically
        ) {
            Text("음성 안내")
            Switch(
                checked = isTTSEnabled,
                onCheckedChange = { 
                    isTTSEnabled = it
                    viewModel.updateTTSSetting(it)
                }
            )
        }
    }
}

개선하면서 배운 점들

1. null 체크가 중요해요

TTS 초기화가 비동기라서 tts.value가 null일 수 있어요. 항상 체크하고 사용해야 합니다:

tts.value?.let { textToSpeech ->
    textToSpeech.speak(message, TextToSpeech.QUEUE_FLUSH, null, null)
}

2. 적절한 딜레이 필요

화면 전환 직후 바로 말하면 사용자가 놀랄 수 있어요. 500ms 정도 딜레이를 주니 자연스럽더라구요.

3. 에러 처리

val result = textToSpeech.speak(message, TextToSpeech.QUEUE_FLUSH, null, null)
if (result == TextToSpeech.ERROR) {
    Log.e("TTS", "TTS 발화 오류 발생")
    // 대체 수단 (예: Toast 메시지)
}

더 나아가기

기본 기능은 이 정도로 충분했지만, 나중에 이런 것들도 추가해볼 생각이에요:

  • 음성 속도 조절 (어르신들은 느리게 듣고 싶어하심)
  • 다국어 지원 (영어 음성도 필요할 때가 있음)
  • 음성 종료 콜백 (다음 동작 연결)

마무리

Compose에서 TTS 쓰는 게 처음엔 어색했는데, DisposableEffectremember를 활용하니 깔끔하게 구현할 수 있었어요. 특히 생명주기 관리가 자동으로 되는 게 정말 편하더라구요.

간단한 음성 안내 기능 정도는 이 정도로도 충분한 것 같아요. 혹시 더 좋은 방법 있으면 알려주세요!

전체 코드가 궁금하신 분들은 댓글 남겨주세요 🙂

Leave a Comment