ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 버튼 하나로 끝나는 UI — 음성 챗봇의 4-state 상태머신
    IT 2026. 5. 13. 22:00
    버튼 하나로 끝나는 UI — 음성 챗봇의 4-state 상태머신

    아들이 쓸 음성 챗봇의 화면을 그릴 때 가장 먼저 정한 규칙이 있었습니다. 화면에 입력 위젯은 마이크 버튼 하나만 있다. 메뉴도, 입력창도, 설정 톱니바퀴도, 사이드바도, 모달도 없습니다. 큰 동그라미 마이크 한 개, 그 위에 짧은 상태 텍스트, 그 옆에 점수 알약 — 끝.

    이 단순함은 순진해 보이지만 실은 까다롭습니다. 사용자가 누르고, 말하고, 듣고, 다시 누르고 — 이 흐름 안에서 매 순간 챗봇이 어떤 상태인지를 분명히 알려야 합니다. 잘못하면 "지금 말해도 되나?" 같은 망설임이 생기고, 그 망설임이 발화를 어색하게 만들어 STT 인식률까지 영향을 줍니다. 4가지 상태로 압축한 상태머신과, 그 안에서의 인터럽트 처리에 대한 이야기입니다.

    왜 4가지인가

    음성 대화에는 네 단계가 있습니다.

    1. IDLE — 챗봇이 아무것도 안 하는 상태. 사용자가 누르면 다음 단계로.
    2. LISTENING — 마이크가 켜져 사용자 발화를 녹음하는 중.
    3. THINKING — 발화 끝. STT·LLM이 결과를 만드는 중. 사용자는 침묵.
    4. SPEAKING — 챗봇이 답을 음성으로 말하는 중. 사용자는 듣고 있음.

    이 네 단계 사이의 전이가 5가지뿐입니다. IDLE→LISTENING(클릭), LISTENING→THINKING(다시 클릭하면 녹음 종료), THINKING→SPEAKING(LLM 응답 도착), SPEAKING→IDLE(재생 끝), 그리고 SPEAKING/THINKING 중 다시 클릭해서 곧장 LISTENING으로 돌아가는 인터럽트(내부적으로는 IDLE을 한 박자 거쳐 새 녹음 시작). 머릿속에 그릴 수 있는 작은 그래프입니다.

    4-state 상태머신 다이어그램

    그림 설명 — 네 상태가 시계방향으로 돌아가는 자연스러운 흐름이 검은 화살표(① → ② → ③ → ④)입니다. 빨간 점선 화살표 ⑤가 인터럽트입니다. SPEAKING 중 다시 누르면 대각선으로, THINKING 중 다시 누르면 우측 곡선으로 — 두 경로 모두 곧장 LISTENING으로 들어갑니다. 진행 중이던 LLM·TTS는 즉시 중단되고, 사용자는 한 박자 안에 새 발화를 시작합니다. 이 한 가지 인터럽트가 음성 UX의 자연스러움을 좌우합니다.

    setState 한 함수가 모든 시각·청각 피드백을 통일한다

    네 상태를 단일 함수로 다루는 게 코드 관점에서 가장 단순합니다. 상태가 바뀌는 순간 화면 텍스트·버튼 색·CSS 클래스가 모두 함께 갱신되도록 한 곳에서 처리합니다.

    const STATE = {
      IDLE: 'idle',
      LISTENING: 'listening',
      THINKING: 'thinking',
      SPEAKING: 'speaking',
    };
    let appState = STATE.IDLE;
    
    function setState(s) {
      appState = s;
      // CSS 데이터 속성으로 색·애니메이션 일괄 변경
      stage.dataset.state = s;
      // 상태별 텍스트
      if (s === STATE.IDLE)         status.textContent = '눌러서 말해봐!';
      else if (s === STATE.LISTENING) status.textContent = '듣고 있어요…';
      else if (s === STATE.THINKING)  status.textContent = '생각 중…';
      else if (s === STATE.SPEAKING)  status.textContent = '말하는 중…';
    }
    

    코드 설명stage.dataset.state = s 한 줄이 핵심입니다. CSS 측에서 .stage[data-state="listening"] .mic-btn { ... } 식으로 데이터 속성에 따라 스타일을 바꿔두면, 자바스크립트에서 클래스 추가/제거를 일일이 안 해도 됩니다. 한 곳에서 데이터만 바꾸면 색·진동 애니메이션·테두리 등 모든 시각 요소가 자동 갱신됩니다. 이 패턴은 React/Vue 같은 프레임워크 없이도 작동하고, 작은 앱에선 오히려 코드가 깔끔합니다.

    핵심 — 클릭 핸들러의 단 하나의 분기

    마이크 버튼 클릭 핸들러가 모든 흐름의 진입점입니다. 현재 상태에 따라 무엇을 할지 결정합니다.

    micBtn.addEventListener('click', () => {
      if (appState === STATE.LISTENING) {
        // ② 녹음 중 클릭 → 녹음 종료, THINKING으로 자동 진입
        stopMic();
      } else if (appState === STATE.IDLE) {
        // ① IDLE 상태에서 클릭 → 녹음 시작
        startMic();
      } else {
        // ⑤ THINKING / SPEAKING 중 클릭 → 인터럽트
        //    진행 중이던 LLM 응답·TTS를 모두 중단하고 즉시 새 녹음
        stopGeneration();
        setState(STATE.IDLE);
        startMic();
      }
    });
    

    코드 설명 — 세 분기가 전부입니다. IDLE에서 누르면 마이크 시작, LISTENING 중 누르면 마이크 종료, 그 외(THINKING/SPEAKING) 누르면 인터럽트입니다. 인터럽트 분기에서 stopGeneration()은 진행 중인 SSE 스트림 abort·TTS 큐 비우기·재생 중인 오디오 정지를 모두 처리합니다. 그 후 setState(IDLE)로 한 번 거쳐서 즉시 startMic()를 부르는데, 이 한 박자가 사용자에게 "끊겼다 다시 시작"이라는 인상을 주는 데 도움이 됩니다(0.05초 정도 지연).

    인터럽트가 왜 그렇게 중요한가

    음성 인터페이스에서 가장 자주 일어나는 사용자 동작이 "지금 답 들으면서 다음 질문을 하고 싶어"입니다. 텍스트 챗봇은 답이 흐르는 동안 그냥 다음 입력칸에 타이핑하면 됩니다. 그러나 음성 챗봇에서 답 들으면서 동시에 말하면 마이크가 자기 자신의 음성까지 녹음해 버립니다. 그래서 "한 번에 한 발화" 원칙이 필요한데, 이게 답을 끝까지 들어야만 다음 질문을 할 수 있다는 강제가 되면 너무 답답합니다.

    인터럽트는 그 답답함을 푸는 방식입니다. 사용자가 마이크 버튼을 누르는 행동을 "지금 챗봇이 뭘 하든 멈추고 내가 말할 차례"라는 신호로 해석합니다. 답 듣다가 어색하면 끊고 새 질문을 할 수 있고, 지나치게 긴 답이 시작됐다면 멈춰서 더 짧게 다시 물을 수 있습니다.

    이 동작이 자연스럽게 작동하려면 진행 중인 모든 작업의 정리 코드가 필요합니다.

    let chatAbortController = null;
    let turnId = 0;        // 단조 증가하는 턴 식별자
    let ttsAudio = null;
    const ttsQueue = [];
    
    function stopGeneration() {
      turnId += 1;                      // 현재 진행 중 turn은 stale로 만들기
    
      // 1) SSE 스트림 abort
      if (chatAbortController) {
        try { chatAbortController.abort(); } catch {}
        chatAbortController = null;
      }
      // 2) TTS 큐 비우기
      ttsQueue.length = 0;
      // 3) 재생 중인 오디오 정지
      if (ttsAudio) {
        try { ttsAudio.pause(); ttsAudio.src = ''; } catch {}
        ttsAudio = null;
      }
    }
    

    코드 설명 — 세 종류의 진행 작업을 멈춥니다. 핵심 트릭은 turnId를 단조 증가시키는 것입니다. SSE chunk 핸들러나 TTS 합성 콜백은 자기가 시작될 때의 turnId를 기억해두고, 결과 처리 직전에 현재 turnId와 비교합니다. 다르면 (= 그 사이 인터럽트가 일어났음) 결과를 그냥 무시합니다. abort가 비동기 흐름의 모든 콜백을 즉시 막아주지는 않기 때문에, 이 turnId 비교가 race condition 방어선 역할을 합니다.

    화면을 안 봐도 흐름이 잡히도록 — 청각 피드백

    음성 챗봇 사용자는 화면을 자주 안 봅니다. 발화하는 순간엔 천장이나 옆을 보고 있고, 답을 듣는 동안엔 다른 일을 하고 있을 수도 있습니다. 그래서 상태 변화는 소리로도 전달돼야 합니다.

    각 상태 전이마다 짧은 비프 톤을 붙였습니다. 마이크 시작은 위로 올라가는 톤(800Hz, 80ms), 종료는 내려오는 톤(600Hz→400Hz). LLM 응답 도착은 살짝 부드러운 톤. 길이가 짧고 음량이 작아 부담스럽지 않으면서, 사용자는 화면을 안 봐도 "지금 시작했네", "지금 끝났네"를 청각으로 인지합니다.

    진동 애니메이션도 비슷한 역할입니다. LISTENING 상태의 마이크 버튼은 부드럽게 맥동하고, SPEAKING 상태는 동그라미 캐릭터의 입꼬리가 살짝 움직입니다. 이런 작은 동적 시각 요소가 "지금 챗봇이 일하고 있구나"를 비명시적으로 알립니다.

    마치며 — "줄이는 디자인"의 가능성

    일반 채팅 앱의 화면을 보면 입력창·전송 버튼·이전 메시지 목록·옵션 토글·이모지 피커 등 위젯이 많습니다. 그런 풍부함이 일반 사용자에겐 자연스럽지만, 어린 사용자에겐 인지부하가 됩니다. "어디를 봐야 하지?", "이 버튼은 뭐야?"가 발화 자체보다 더 어려운 과제가 됩니다.

    1버튼 + 4상태 디자인은 이 인지부하를 극단까지 줄인 결과입니다. 아이가 처음 쓰던 날 설명 한 마디 — "여기 동그라미 누르고 말하면 돼" — 만 했고, 그 뒤로 사용법을 다시 묻지 않았습니다. 상태 텍스트("듣고 있어요…", "생각 중…", "말하는 중…")만 보고 자연스럽게 흐름을 익혔습니다.

    줄이는 디자인이 항상 가능한 건 아닙니다. 일반 채팅 앱이 1버튼이 되면 다양한 사용자 시나리오를 못 담습니다. 그러나 사용자가 1명이고 시나리오가 좁다면, 줄이는 데서 오는 명료함이 추가 기능보다 훨씬 큰 가치를 만듭니다. 아들 1명을 위한 챗봇이라는 출발점이 이 디자인을 가능하게 했습니다.


    이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.

Designed by Tistory.