본문 바로가기
Flutter

Flutter TTS, 그냥 speak() 쓰면 생기는 일 — 3단계 우선순위 큐 설계

by s_hoonee 2026. 5. 13.
반응형

안녕하세요! FlutterNeverDie 개발자입니다.

시각 장애인을 위한 배리어프리(Barrier-Free) 키오스크를 개발할 때 가장 중요한 것은 TTS(Text-To-Speech)입니다. 화면을 볼 수 없는 사용자는 오직 음성 안내에만 의지해 결제를 진행해야 합니다.

Flutter에서 flutter_tts 패키지를 사용해 보신 분들은 알겠지만, 단순히 tts.speak('안녕하세요')를 호출하는 것만으로는 실제 프로덕션을 만들 수 없습니다. 사용자가 화면을 빠르게 터치하거나, 시스템 경고가 발생할 때 음성이 겹치거나 끊기는 "TTS 충돌 현상"이 발생하기 때문입니다.

오늘은 저희 키오스크 프로젝트에서 이 문제를 해결하기 위해 도입한 TTS 3단계 우선순위 큐(Queue) 아키텍처를 소개합니다.


1. 문제 정의 — speak()를 연속 호출하면 생기는 일

장바구니 화면에서 시각 장애인이 상품 삭제 버튼을 연타한다고 가정해 봅시다.

tts.speak('아메리카노가 삭제되었습니다.');
tts.speak('카페라떼가 삭제되었습니다.');
tts.speak('총 결제 금액은 0원입니다.');

일반적인 TTS 엔진은 이전 음성이 끝나기도 전에 새로운 speak()가 호출되면 이전 음성을 뚝 끊어버리고 새 음성을 재생합니다. 사용자는 "아메리.. 카페.. 총 결제 금액은 0원입니다"라는 알아들을 수 없는 소리를 듣게 됩니다.

반대로 화면 전환이 일어났을 때는 이전 화면의 안내를 즉시 끊고 새 화면의 안내를 시작해야 합니다. 즉, 상황에 따라 "기다렸다가 순서대로 말해야 할 때""즉시 말을 끊고 새로 말해야 할 때"를 구분해야 합니다.


2. Queue + Completer 조합으로 순서 보장하기

저희는 TTSUtil이라는 싱글톤 클래스 내부에 Queue<String>Completer를 도입하여 재생 순서를 완벽하게 제어했습니다.

class TTSUtil {
  final FlutterTts _tts = FlutterTts();
  final Queue<String> _speechQueue = Queue<String>();
  bool _isProcessing = false;
  Completer<void>? _currentSpeechCompleter;

  // TTS 완료 콜백에서 Completer 해제
  Future<void> init() async {
    _tts.setCompletionHandler(() {
      _currentSpeechCompleter?.complete();
    });
    // ...
  }
}

음성 재생이 시작될 때 Completer를 생성하고, flutter_ttssetCompletionHandler가 호출될 때 complete()를 실행합니다. 이렇게 하면 Dart의 비동기 제어를 통해 "현재 음성이 끝날 때까지 정확히 기다리는" 로직을 만들 수 있습니다.


3. 3단계 우선순위 설계 (speak / speakInterrupt / forceSpeak)

가장 핵심이 되는 3가지 재생 메서드입니다. 각 메서드는 큐를 다루는 방식이 완전히 다릅니다.

① speak() — 일반적인 UI 안내 (순차 재생)

가장 기본이 되는 메서드입니다. 들어온 텍스트를 무조건 큐의 맨 뒤에 넣고, 앞선 음성이 끝날 때까지 얌전히 기다립니다. 메뉴 설명이나 긴 안내문을 읽을 때 사용합니다.

② speakInterrupt() — 사용자 액션 피드백 (치기)

사용자가 버튼을 눌렀을 때의 피드백입니다. 현재 재생 중인 음성은 끝까지 듣게 내버려 두되, 큐에 대기 중이던 다른 잡다한 안내문은 모두 지워버리고 이 피드백을 다음 순서로 끼워 넣습니다.

Future<void> speakInterrupt(String text) async {
  _speechQueue.clear(); // 대기 중인 잡음 제거
  _speechQueue.add(text); // 내 할 말 추가
  if (!_isProcessing) _processQueue();
}

③ forceSpeak() — 화면 전환, 시스템 에러 (즉시 중단)

가장 강력한 메서드입니다. 큐 시스템을 완전히 무시하고, 현재 재생 중인 음성마저 강제로 멈춘 뒤 즉시 재생합니다. 에러 팝업이 뜨거나, 결제 화면으로 넘어갔을 때 이전 화면의 안내가 계속 흘러나오는 것을 막기 위해 사용합니다.

Future<void> forceSpeak(String text) async {
  _speechQueue.clear();
  _isProcessing = false;
  
  if (_currentSpeechCompleter != null && !_currentSpeechCompleter!.isCompleted) {
    _currentSpeechCompleter!.complete();
  }
  
  await _tts.stop(); // 현재 음성 강제 종료
  // 즉시 새 음성 재생 로직 시작...
}

4. 특수문자와 영문 브랜드명 필터링 (_filterSpeechText)

TTS 엔진의 또 다른 문제는 특수문자나 영어를 자연스럽게 읽지 못한다는 점입니다. 예를 들어 "ICE 아메리카노 SIZE L"을 "아이스 아메리카노 사이즈 엘"이 아니라 "아이스 아메리카노 사이즈 리터"처럼 엉뚱하게 읽는 경우가 많습니다.

저희는 TTS 엔진에 텍스트를 넘기기 직전, _filterSpeechText라는 내부 함수를 거치도록 설계했습니다.

String _filterSpeechText(String text) {
  final filters = <String, String>{
    '₩': '원',
    r'$': '달러',
    'COUPONID': '쿠폰 아이디',
    'ONLY ICED': '온리 아이스',
    'SIZE L': '사이즈 라지',
    'SIZE M': '사이즈 미디움',
  };

  String filteredText = text.toUpperCase();
  filters.forEach((key, value) {
    filteredText = filteredText.replaceAll(key, value);
  });
  return filteredText;
}

이 단순한 필터링 딕셔너리 덕분에, 서버에서 내려오는 메뉴 데이터(DB)를 전혀 건드리지 않고도 시각 장애인에게 완벽하게 자연스러운 한국어 안내를 제공할 수 있게 되었습니다.


마치며

배리어프리 키오스크에서 TTS는 단순한 '부가 기능'이 아니라 시각 장애인의 '눈' 그 자체입니다.

QueueCompleter를 결합한 3단계 우선순위 설계는, 화면을 보지 못하는 사용자가 키오스크와 대화하는 흐름을 끊지 않고 자연스럽게 이어주는 핵심 아키텍처가 되었습니다. Flutter로 TTS 기반 앱을 만드시는 분들께 이 구조가 도움이 되길 바랍니다.