# 음성 입력(STT) 기술 가이드 > **문서 버전**: 1.1 > **작성일**: 2026-02-10 > **적용 페이지**: 공사현장 사진대지, 영업 전략 시나리오, 매니저 상담 프로세스 > **대상 프로젝트**: MNG (React 18 + Alpine.js) --- ## 1. 개요 ### 1.1 목적 텍스트 입력 필드(input, textarea)에 **마이크 버튼**을 배치하여, 사용자가 음성으로 텍스트를 입력할 수 있게 하는 브라우저 내장 STT(Speech-to-Text) 기능. ### 1.2 기술 선택 | 방식 | 비용 | 정확도 | 지연 | 채택 | |------|------|--------|------|------| | Web Speech API (브라우저 내장) | **무료** | 높음 (Google STT 엔진) | 실시간 | **채택** | | Google Cloud STT API | 유료 ($0.006/15초) | 매우 높음 | 서버 왕복 | 미채택 | | Whisper (OpenAI) | 유료 ($0.006/분) | 매우 높음 | 서버 왕복 | 미채택 | **선택 이유**: 브라우저 내장 Web Speech API는 Chrome 기반에서 Google STT 엔진을 무료로 사용하며, 실시간 스트리밍으로 interim/final 결과를 즉시 받을 수 있다. 비용 없이 충분한 한국어 인식률을 제공한다. ### 1.3 브라우저 지원 | 브라우저 | 지원 | 비고 | |----------|------|------| | Chrome (Desktop/Android) | ✅ | 최적 지원, Google STT 엔진 사용 | | Edge | ✅ | Chromium 기반 | | Safari (iOS/macOS) | ✅ | `webkitSpeechRecognition` | | Firefox | ❌ | 미지원 (버튼 자동 숨김) | --- ## 2. 핵심 개념: Interim vs Final Web Speech API의 핵심은 **미확정(interim)** 텍스트와 **확정(final)** 텍스트의 구분이다. ### 2.1 텍스트 상태 흐름 ``` [음성 입력 시작] │ ├─ interim: "안녕하" ← 인식 진행 중 (수정될 수 있음) ├─ interim: "안녕하세" ← 교정 발생 (이전 interim 덮어씀) ├─ interim: "안녕하세요" ← 교정 발생 │ ├─ ★ FINAL: "안녕하세요" ← 확정! (절대 삭제 불가) │ ├─ interim: "반갑습" ← 새로운 인식 시작 ├─ interim: "반갑습니다" │ ├─ ★ FINAL: "반갑습니다" ← 확정! │ [음성 입력 종료] ``` ### 2.2 렌더링 규칙 (필수 준수) | 상태 | 스타일 | 동작 | 삭제 가능 | |------|--------|------|-----------| | **interim** (미확정) | `italic` + `text-gray-400` | 실시간 교정됨. 이전 interim을 덮어씀 | 교정만 허용 | | **final** (확정) | `font-normal` + `text-white` | `finalizedSegments[]` 배열에 영구 추가 | **절대 불가** | ### 2.3 input 반영 규칙 - **final 이벤트 발생 시에만** `onResult(transcript)` 호출하여 input에 텍스트 추가 - interim 텍스트는 **프리뷰 패널에만** 표시하고, input에는 반영하지 않음 - input에 추가된 텍스트는 사용자가 직접 수정 가능 (일반 텍스트) --- ## 3. 컴포넌트 아키텍처 ### 3.1 VoiceInputButton 컴포넌트 ``` ┌─────────────────────────────────┐ │ VoiceInputButton │ │ │ │ Props: │ │ onResult: (text) => void │ ← final 텍스트만 전달 │ disabled: boolean │ ← 비활성화 (읽기 모드 등) │ │ │ State: │ │ recording: boolean │ ← 녹음 중 여부 │ finalizedSegments: string[] │ ← 확정 텍스트 누적 (프리뷰용) │ interimText: string │ ← 현재 미확정 텍스트 │ │ │ Refs: │ │ recognitionRef │ ← SpeechRecognition 인스턴스 │ startTimeRef │ ← 녹음 시작 시각 (사용량 추적) │ dismissTimerRef │ ← 프리뷰 닫기 타이머 │ previewRef │ ← 프리뷰 DOM (자동 스크롤) │ │ │ Output: │ │ [마이크 버튼] + [프리뷰 패널] │ └─────────────────────────────────┘ ``` ### 3.2 전체 코드 ```jsx function VoiceInputButton({ onResult, disabled }) { const [recording, setRecording] = useState(false); const [finalizedSegments, setFinalizedSegments] = useState([]); const [interimText, setInterimText] = useState(''); const recognitionRef = useRef(null); const startTimeRef = useRef(null); const dismissTimerRef = useRef(null); const previewRef = useRef(null); // 브라우저 지원 확인 const isSupported = typeof window !== 'undefined' && (window.SpeechRecognition || window.webkitSpeechRecognition); // STT 사용량 로깅 (AI 토큰 사용량 추적) const logUsage = useCallback((startTime) => { const duration = Math.max(1, Math.round((Date.now() - startTime) / 1000)); apiFetch(API.logSttUsage, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration_seconds: duration }), }).catch(() => {}); }, []); // 프리뷰 패널 자동 스크롤 useEffect(() => { if (previewRef.current) { previewRef.current.scrollTop = previewRef.current.scrollHeight; } }, [finalizedSegments, interimText]); // 녹음 중지 const stopRecording = useCallback(() => { recognitionRef.current?.stop(); recognitionRef.current = null; if (startTimeRef.current) { logUsage(startTimeRef.current); startTimeRef.current = null; } setRecording(false); setInterimText(''); // 녹음 종료 후 2초 뒤 프리뷰 닫기 dismissTimerRef.current = setTimeout(() => { setFinalizedSegments([]); }, 2000); }, [logUsage]); // 녹음 시작 const startRecording = useCallback(() => { // 이전 타이머 정리 if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); dismissTimerRef.current = null; } const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const recognition = new SR(); recognition.lang = 'ko-KR'; // 한국어 recognition.continuous = true; // 연속 인식 (자동 종료 안 함) recognition.interimResults = true; // interim 결과 수신 recognition.maxAlternatives = 1; // 후보 1개만 recognition.onresult = (event) => { // dismiss 타이머 취소 (아직 인식 중) if (dismissTimerRef.current) { clearTimeout(dismissTimerRef.current); dismissTimerRef.current = null; } let currentInterim = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { // ★ 확정: input에 반영 + 프리뷰에 영구 저장 onResult(transcript); setFinalizedSegments(prev => [...prev, transcript]); currentInterim = ''; } else { // 미확정: 교정은 허용하되 이전 확정분은 보존 currentInterim = transcript; } } setInterimText(currentInterim); }; recognition.onerror = () => stopRecording(); recognition.onend = () => { // 브라우저가 자동 종료한 경우 처리 if (startTimeRef.current) { logUsage(startTimeRef.current); startTimeRef.current = null; } setRecording(false); setInterimText(''); recognitionRef.current = null; dismissTimerRef.current = setTimeout(() => { setFinalizedSegments([]); }, 2000); }; recognitionRef.current = recognition; startTimeRef.current = Date.now(); setFinalizedSegments([]); setInterimText(''); recognition.start(); setRecording(true); }, [onResult, stopRecording, logUsage]); // 토글 (시작/중지) const toggle = useCallback((e) => { e.preventDefault(); e.stopPropagation(); if (disabled || !isSupported) return; recording ? stopRecording() : startRecording(); }, [disabled, isSupported, recording, stopRecording, startRecording]); // 컴포넌트 언마운트 시 정리 useEffect(() => { return () => { recognitionRef.current?.stop(); if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current); }; }, []); // 미지원 브라우저에서는 렌더링하지 않음 if (!isSupported) return null; const hasContent = finalizedSegments.length > 0 || interimText; return (
{/* 마이크 버튼 */} {/* 스트리밍 프리뷰 패널 */} {(recording || hasContent) && (
{/* 확정 텍스트: 일반체 + 흰색 */} {finalizedSegments.map((seg, i) => ( {seg} ))} {/* 미확정 텍스트: 이탤릭 + 연한 회색 */} {interimText && ( {interimText} )} {/* 녹음 중 + 텍스트 없음: 대기 표시 */} {recording && !hasContent && ( 말씀하세요... )} {/* 녹음 종료 후 확정 텍스트 완료 표시 */} {!recording && finalizedSegments.length > 0 && !interimText && ( )}
)}
); } ``` --- ## 4. 사용 패턴 ### 4.1 기본 사용법 (input 옆에 배치) ```jsx function MyForm() { const [value, setValue] = useState(''); return (
setValue(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm" placeholder="입력하세요" /> setValue(prev => prev ? prev + ' ' + text : text )} />
); } ``` ### 4.2 textarea와 함께 사용 ```jsx
{/* items-start: 상단 정렬 */}