ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • '잘 못 들었어요' 한 줄의 UX — STT 거부 후의 회복 흐름
    IT 2026. 5. 13. 23:00
    '잘 못 들었어요' 한 줄의 UX — STT 거부 후의 회복 흐름

    음성 챗봇에서 "에러 처리"라는 말이 어울리지 않는 곳이 있습니다. STT가 사용자 발화를 못 알아들었거나, 환각으로 가짜 텍스트를 만들었거나, 발화가 너무 짧거나 길 때입니다. 이건 시스템 에러가 아니라 그냥 들어오는 입력의 한계입니다. 그러나 잘 다루지 않으면 어린이 사용자에겐 시스템 고장과 다를 게 없게 느껴집니다.

    실제로 운영하면서 가장 많이 다듬은 부분이 이 회복 흐름입니다. 큰 모듈이나 새 기능 없이, "잘 못 들었어요" 같은 작은 메시지 한 줄과 그것이 사라지는 타이밍·다음 동작까지의 전이를 다듬는 일이었습니다. 이런 디테일이 챗봇 자체에 대한 신뢰를 좌우합니다.

    거부가 일어나는 두 가지 경로

    음성 챗봇의 입력 단계에서 거부가 일어나는 경로는 두 가지입니다.

    첫째, 네트워크·서비스 에러입니다. STT 데몬이 죽었거나, 게이트웨이가 응답을 안 하거나, 네트워크가 끊긴 경우. 이건 진짜 시스템 에러로, 다시 시도해도 같은 결과가 나올 가능성이 높습니다.

    둘째, 입력 자체가 부적합한 경우입니다. STT 결과가 환각으로 보이거나(별도 글에서 다룬 clarity 필터에 걸림), 발화가 너무 짧거나, 무의미한 추임새만 있는 경우. 시스템은 정상 동작했고 결과가 신뢰할 수 없을 뿐입니다.

    두 경로의 회복 전략은 약간 다릅니다. 그러나 사용자(특히 아이) 입장에서는 이 둘을 구분할 필요가 없습니다. 둘 다 "내 말이 잘 전달되지 않았다"로 인지되고, 다음에 할 일은 똑같이 "다시 한번 해보기"입니다. 그래서 UI도 두 경우를 비슷하게 다룹니다.

    세 가지 회복 옵션

    거부가 일어났을 때 무엇을 보여줄지에 옵션 세 가지가 있습니다.

    STT 거부 시 회복 옵션 3가지 비교

    그림 설명 — A(침묵)는 깔끔하지만 사용자가 헤맵니다. B(에러 다이얼로그)는 명확하지만 위협적이고 흐름을 끊습니다. C(한 줄 + 즉시 IDLE)는 가운데 길로, 사용자에게 "잠깐 못 들었으니 다시 해보자"는 친근한 신호를 주면서도 다음 시도까지의 마찰을 0으로 줄입니다. 마이크 버튼이 IDLE 상태 그대로 있어서 사용자가 즉시 다시 누를 수 있습니다.

    코드 — onRecordingStopped 안의 한 분기

    STT 거부를 감지해 회복 메시지를 띄우는 코드는 매우 짧습니다.

    // 마이크 버튼을 떼는 순간(=녹음 종료) MediaRecorder가 호출하는 콜백
    async function onRecordingStopped() {
      // 녹음 도중 쌓인 오디오 청크들을 하나의 Blob으로 합침 (서버 전송 단위)
      const blob = new Blob(recordedChunks);
    
      // 1KB 미만이면 의미 있는 음성이 없다고 보고 조용히 IDLE로 복귀
      // (실수 클릭·즉시 떼기·무음 등을 빠르게 걸러내는 1차 필터)
      if (blob.size < 1000) {
        // 너무 짧은 녹음 — 사용자가 실수로 눌렀거나 무음
        setState(STATE.IDLE);
        return;
      }
    
      // "생각 중" UI 상태로 전환 — 마이크가 비활성화되고 로딩 애니메이션이 돈다
      setState(STATE.THINKING);
    
      // multipart/form-data로 오디오를 STT 엔드포인트에 보낼 준비
      const fd = new FormData();
      fd.append('file', blob, 'rec.webm');
    
      try {
        // 서버 STT 호출 — Whisper 전사 + clarity 필터가 함께 묶인 엔드포인트
        const r = await fetch('/api/transcribe', { method: 'POST', body: fd });
    
        // 4xx/5xx 응답이면 throw로 catch 블록에 합류시켜 회복 경로를 단일화
        if (!r.ok) throw new Error('transcribe ' + r.status);
    
        const data = await r.json();
    
        // 전사 텍스트 — 앞뒤 공백을 잘라 빈 문자열 판정을 단순하게
        const userText = (data.text || '').trim();
    
        // clarity 필터 판정. 서버가 필드를 안 주면 통과(true)로 간주(하위호환)
        const isClear = data.is_clear !== false;  // 기본은 통과
    
        // 전사 결과가 빈 문자열인 경우 — 메시지 없이 IDLE로 돌려 다음 시도를 유도
        if (!userText) { setState(STATE.IDLE); return; }
    
        // 정상 흐름 진행
        // clarity가 의심스러우면(suppressScore=true) 정답 보상 점수만 차단하고,
        // 답변 생성·재생은 그대로 진행해 대화 흐름은 끊지 않는다
        await streamChat(userText, { suppressScore: !isClear });
      } catch (e) {
        // 회복 처리 — 네트워크 오류·HTTP 에러를 한 분기로 모아 처리
        console.error('transcribe failed', e);
    
        // 다이얼로그·모달 없이 IDLE로만 돌려, 사용자가 마이크를 즉시 다시 누를 수 있게 한다
        setState(STATE.IDLE);
    
        // 짧고 친근한 한 줄을 status 영역에 표시.
        // 다음 LISTENING 전환 시 "듣고 있어요…"로 자연스레 덮어써지므로 별도 정리 코드 불필요
        status.textContent = '잘 못 들었어요. 다시 한번!';
      }
    }
    

    코드 설명 — 핵심은 catch 블록입니다. STT 호출이 실패하면 즉시 setState(STATE.IDLE)로 마이크 버튼을 다시 누를 수 있는 상태로 돌리고, 짧은 한 줄을 표시합니다. 다이얼로그·모달·확인 버튼 같은 무거운 위젯 없이 상태 텍스트(status 요소) 하나만 갱신합니다. 사용자는 그 텍스트를 보고 다시 마이크 버튼을 누르면 끝입니다 — 화면 위 다른 어떤 요소도 만질 필요가 없습니다.

    네트워크 에러뿐 아니라 빈 텍스트가 돌아온 경우(!userText)도 같은 회복 경로로 처리합니다. setState(STATE.IDLE)로 돌려두지만 별도 메시지는 안 띄웁니다 — 사용자가 그냥 마이크를 다시 누르면 새 시도가 시작됩니다.

    "잘 못 들었어요" 메시지가 사라지는 타이밍

    이 메시지를 언제 사라지게 할지가 또 하나의 디테일입니다. 옵션이 셋입니다.

    1. 몇 초 후 자동 사라짐(timeout) — 단순하지만 사용자가 그 사이 다른 일 하다 돌아오면 메시지가 사라져 있어서 다시 헤맵니다.
    2. 다음 마이크 클릭 때 사라짐 — 사용자가 다음 시도를 시작하는 자연스러운 시점에 메시지가 갱신됩니다.
    3. 안 사라짐 — 다음 마이크 클릭이 없으면 그대로 남아 있음. 마지막 상태가 화면에 박혀서 위협적으로 보일 수 있음.

    저는 ②를 골랐습니다. 다음 setState(STATE.LISTENING)이 호출되면 자동으로 텍스트가 "듣고 있어요…"로 갱신되니, 별도 정리 코드가 필요 없습니다. 메시지가 사라지는 시점이 사용자의 다음 동작과 자연스럽게 결합됩니다.

    이 패턴은 "실패 메시지의 수명을 사용자의 다음 동작에 묶는다"는 일반적인 디자인 발상입니다. 시간 기반 timeout보다 사용자 행동에 반응하는 게 더 자연스럽고, 코드도 단순해집니다.

    톤이 바뀌면 회복 비용이 달라진다

    같은 의미를 다르게 적었을 때의 차이를 시험해봤습니다.

    • "❌ 인식 실패. 다시 시도하세요." — 행정적, 위협적
    • "음성을 인식할 수 없습니다. 다시 말씀해주세요." — 정중하지만 사람 같지 않음
    • "잘 못 들었어요. 다시 한번!" — 사람이 옆에서 말하듯 친근

    아이가 가장 자연스럽게 받아들인 게 셋째였습니다. 의미상 차이는 거의 없는데, 사용자가 받는 인상이 크게 다릅니다. "잘 못 들었어요"는 챗봇이 자기 책임을 지는 표현(내가 못 들음)이라서 사용자가 자기 발화를 의심하지 않게 합니다. "다시 한번!"의 느낌표는 부담을 덜어주는 명령형이고요. 짧은 메시지일수록 이런 톤 선택의 비중이 큽니다.

    점수 차단의 부수 효과

    한 가지 더 — 거부된 발화에는 점수를 주지 않습니다. 별도 글에서 다룬 clarity 필터에서 is_clear=false로 마킹된 경우, 답변은 진행하되 그 turn의 "+5점" 보상은 차단합니다. suppressAnswerAward = true로 플래그를 세워두면 청취 완료 타이머가 발동해도 점수가 안 들어갑니다.

    이게 회복 흐름의 보조 장치 역할을 합니다. 환각 발화가 통과해서 챗봇이 어떤 답을 했더라도, 그 turn은 점수 시스템에 영향을 주지 않습니다. 게이미피케이션과 입력 신뢰도를 분리하는 작은 안전장치입니다.

    마치며 — 작은 메시지가 흐름을 좌우한다

    전체 챗봇 시스템의 코드량으로 보면 이 회복 처리는 catch 블록 안의 두 줄에 불과합니다. 그러나 사용자(아이)가 챗봇을 쓰면서 받는 인상은 이 두 줄의 디자인에 크게 좌우됩니다. 침묵·다이얼로그·짧은 메시지의 차이가, 챗봇이 "고장 나면 짜증나는 도구"인지 "가끔 못 들으면 그냥 다시 말하면 되는 친구"인지를 가릅니다.

    일반화하면 — 실패는 시스템의 정상 작동의 일부라는 발상입니다. ML 컴포넌트가 들어간 시스템에서 "100% 정확"은 불가능하고, 그래서 실패는 예외가 아닌 일상입니다. 이 일상을 어떻게 다루느냐가 사용자 신뢰의 80%를 결정합니다. 화려한 에러 다이얼로그보다, 작은 "잘 못 들었어요. 다시 한번!" 한 줄과 자연스러운 IDLE 복귀가 훨씬 더 강건한 회복 흐름을 만듭니다.


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

Designed by Tistory.