logo

DowanKim

4. 사용자의 모든 케이스를 잡기 위한 한단갈의 상태관리 구현 여행기

2025년 8월 20일

꽃다발 방명록

이제 버튼 클릭 후 수동 음성인식 시작이 아닌, 전시장 사용성을 위해서 사용자가 마이크에 대고 말을 시작하면 자동으로 인터랙션이 시작되게 해야 합니다.

이는 복잡한 상태관리가 필요해 집니다.


대기-인식-결과표시-렌더링-복귀-무한반복

필요한 기능:

  1. 자동 음성 감지 시작
  2. 연속적인 음성 인식
  3. 결과 확인 시간 제공
  4. 부드러운 렌더링
  5. 자동으로 대기 상태로 복귀

상태 관리 설계

상태 정의

/** * 컴포넌트 상태 * @type {"idle" | "listening" | "done" | "rendering" | "clearing"} */ const [status, setStatus] = useState("idle");

상태 전환 흐름:

idle → listening → done → rendering → idle
                      ↓
                  clearing → rendering → idle

각 상태의 역할:

  • idle: 대기 상태, 볼륨 모니터링 중
  • listening: 음성 인식 중
  • done: 인식 완료, 결과 표시 중
  • rendering: 캔버스에 텍스트 렌더링 중
  • clearing: 캔버스가 가득 차서 초기화 중

1단계: idle 상태 - 볼륨 모니터링으로 자동 시작

문제: 언제 음성 인식을 시작할까?

사용자가 말하기 시작하는 순간을 자동으로 감지해야 합니다.

해결: Web Audio API로 볼륨 모니터링

const reinitializeAudioMonitoring = useCallback(async () => { disposeAudioResources(); try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); log("Audio stream acquired"); micStreamRef.current = stream; stream.getTracks().forEach((track) => { track.onended = () => { warn("Microphone track ended unexpectedly. Scheduling recovery."); if (audioRecoveryTimeoutRef.current) { return; } audioRecoveryTimeoutRef.current = setTimeout(() => { audioRecoveryTimeoutRef.current = null; reinitializeAudioMonitoring(); }, MIC_RECOVERY_BACKOFF_MS); }; }); const AudioContext = window.AudioContext || window.webkitAudioContext; const audioContext = new AudioContext(); audioContextRef.current = audioContext; const analyser = audioContext.createAnalyser(); analyser.fftSize = 512; analyser.smoothingTimeConstant = 0.8; analyserRef.current = analyser; const microphone = audioContext.createMediaStreamSource(stream); microphone.connect(analyser); } catch (error) { errorLog("Failed to reinitialize audio monitoring:", error); } }, [disposeAudioResources]);

볼륨 측정 함수:

const getCurrentVolume = useCallback(() => { if (!analyserRef.current) { return 0; } const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount); analyserRef.current.getByteFrequencyData(dataArray); let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } const average = sum / dataArray.length; return average / 255; }, []);

idle 상태에서 주기적으로 볼륨 체크:

useEffect(() => { if (status === "idle") { log("Status=idle: starting volume monitor interval"); if (volumeCheckIntervalRef.current) { clearInterval(volumeCheckIntervalRef.current); } volumeCheckIntervalRef.current = setInterval(() => { const volume = getCurrentVolume(); log("Volume check", volume.toFixed(4)); if (volume >= VOLUME_THRESHOLD) { log("Volume threshold exceeded", volume); clearInterval(volumeCheckIntervalRef.current); volumeCheckIntervalRef.current = null; startListening(); } }, VOLUME_CHECK_INTERVAL_MS); return () => { if (volumeCheckIntervalRef.current) { log("Clearing volume monitor interval"); clearInterval(volumeCheckIntervalRef.current); volumeCheckIntervalRef.current = null; } }; } }, [getCurrentVolume, startListening, status]);

동작 방식:

  1. status === "idle"일 때만 볼륨 체크 시작
  2. 50ms마다 볼륨 측정
  3. VOLUME_THRESHOLD(0.15) 이상이면 startListening() 호출
  4. 다른 상태로 전환되면 인터벌 정리

2단계: listening 상태 - 연속 음성 인식

문제: 긴 문장과 침묵 처리

사용자가 말을 멈췄을 때와 계속 말할 때를 구분해야 합니다.

해결: 연속 모드 + 침묵 타이머

음성 인식 설정:

const startListening = useCallback(() => { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { errorLog("SpeechRecognition API not available"); alert("이 브라우저는 음성 인식을 지원하지 않습니다."); return; } if (recognitionActiveRef.current) { warn("Recognition already active. Deferring new start request"); pendingRecognitionStartRef.current = true; return; } if (silenceTimerRef.current) { log("Clearing pending silence timer before starting new session"); clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null; } collectedTranscriptsRef.current = []; log("Starting new recognition session"); if (!recognitionRef.current) { log("Creating new SpeechRecognition instance"); const recognition = new SpeechRecognition(); recognition.lang = "ko-KR"; recognition.interimResults = true; recognition.continuous = true; recognition.maxAlternatives = 1; recognition.onspeechstart = () => { log("Speech detected - switching to listening state"); setStatus("listening"); resetSilenceTimer(); }; recognition.onresult = (event) => { const lastResultIndex = event.results.length - 1; const result = event.results[lastResultIndex]; const transcript = result[0].transcript.trim(); log( "Recognition result", result.isFinal ? "final" : "interim", "transcript:", transcript, ); if (result.isFinal && transcript) { collectedTranscriptsRef.current.push(transcript); log("Stored final transcript segment", transcript); resetSilenceTimer(); } else if (!result.isFinal && transcript) { log("Interim transcript segment", transcript); resetSilenceTimer(); } }; recognition.onerror = (event) => { if (event.error === "aborted") { log("Recognition aborted by stop()"); recognitionActiveRef.current = false; setStatus((current) => (current === "listening" ? "idle" : current)); return; } recognitionActiveRef.current = false; const fatalErrors = new Set([ "network", "not-allowed", "service-not-allowed", "audio-capture", ]); if (event.error === "no-speech") { warn("No speech detected during recognition session"); pendingRecognitionStartRef.current = true; } else { errorLog("Speech recognition error", event.error); } if (fatalErrors.has(event.error)) { warn("Fatal recognition error detected. Clearing instance."); recognitionRef.current = null; } finalizeRecognition(); requestRecognitionRestart(event.error); }; recognition.onend = () => { recognitionActiveRef.current = false; if (collectedTranscriptsRef.current.length > 0) { log("Recognition ended with pending transcripts. Finalizing now."); finalizeRecognition(); return; } setStatus((current) => { log("Recognition ended - previous status", current); return current === "listening" ? "idle" : current; }); if (pendingRecognitionStartRef.current && statusRef.current === "idle") { log("Processing deferred recognition start"); pendingRecognitionStartRef.current = false; setTimeout(() => { startListening(); }, 0); } }; recognitionRef.current = recognition; } try { recognitionRef.current.start(); recognitionActiveRef.current = true; pendingRecognitionStartRef.current = false; recognitionRetryCountRef.current = 0; log("SpeechRecognition.start() invoked"); } catch (error) { recognitionActiveRef.current = false; if (error.name !== "InvalidStateError") { errorLog("Speech recognition could not be started", error); setStatus("idle"); requestRecognitionRestart(error.name); } else { warn("SpeechRecognition.start() ignored: already running"); } } }, [resetSilenceTimer, finalizeRecognition, requestRecognitionRestart]);

핵심 설정:

  • continuous: true: 연속 인식 모드
  • interimResults: true: 중간 결과도 수신
  • collectedTranscriptsRef: 여러 세그먼트를 수집

침묵 타이머:

const resetSilenceTimer = useCallback(() => { if (silenceTimerRef.current) { log("Resetting existing silence timer"); clearTimeout(silenceTimerRef.current); } log("Starting silence timer", SILENCE_TIMEOUT_MS, "ms"); silenceTimerRef.current = setTimeout(() => { finalizeRecognition(); }, SILENCE_TIMEOUT_MS); }, [finalizeRecognition]);

동작:

  • 음성 감지 시 타이머 리셋
  • 1초간 침묵이면 finalizeRecognition() 호출

3단계: done 상태 - 결과 확인 시간 제공

문제: 인식 결과를 사용자가 확인할 시간 필요

해결: 결과 표시 후 지연 렌더링

const finalizeRecognition = useCallback(() => { if (silenceTimerRef.current) { log("Clearing silence timer in finalizeRecognition"); clearTimeout(silenceTimerRef.current); silenceTimerRef.current = null; } const fullTranscript = collectedTranscriptsRef.current.join(" ").trim(); log("Finalize recognition with transcript:", fullTranscript); collectedTranscriptsRef.current = []; if (fullTranscript) { setLastTranscript(fullTranscript); setStatus("done"); clearResultTimer(); resultTimerRef.current = setTimeout(() => { log("Scheduling transcript render after delay"); handleTranscript(fullTranscript); }, RESULT_DISPLAY_DELAY_MS); } else { log("No transcript collected. Returning to idle"); setStatus("idle"); } if (recognitionRef.current && recognitionActiveRef.current) { try { log("Stopping recognition session from finalizeRecognition"); recognitionRef.current.stop(); } catch { warn("Failed to stop recognition (already stopped)"); } } else { log("Recognition already stopped when finalizeRecognition executed"); } }, [clearResultTimer, handleTranscript]);

동작:

  1. 수집된 세그먼트를 합쳐 최종 텍스트 생성
  2. setStatus("done")으로 상태 전환
  3. setLastTranscript()로 UI에 표시
  4. 1.5초 후 handleTranscript() 호출

UI 표시:

const showOverlay = status === "listening" || status === "done"; const canvasOpacity = fadePhase === "out" ? 0 : 1; return ( <div style={{ width: "100vw", height: "100vh", display: "flex", justifyContent: "center", alignItems: "center", position: "relative", backgroundColor: "#FFFFFF", }} > <div style={{ transition: "filter 0.5s ease-in-out", filter: showOverlay ? "blur(0.74vmin)" : "none", }} > <canvas ref={canvasRef} onTransitionEnd={handleCanvasTransitionEnd} style={{ width: "67.6vmin", height: "auto", display: "block", opacity: canvasOpacity, transition: `opacity ${CANVAS_FADE_DURATION_MS / 1000}s ease`, }} /> {/* 730px -> 67.6vmin */} </div> {showOverlay && ( <div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", textAlign: "center", }} > {status === "listening" ? ( <> <img src="/mic.png" alt="mic" style={{ width: "4.44vmin", marginBottom: "1.85vmin" }} /> <p style={{ fontSize: "3.33vmin", color: "#333", fontFamily: "Eulyoo1945" }}> 방명록을 남겨주세요. </p> </> ) : ( <> <p style={{ fontSize: "4.44vmin", marginBottom: "2.78vmin", maxWidth: "55.56vmin", lineHeight: 1.5, color: "#333", padding: "0 1.85vmin", fontFamily: "Eulyoo1945", }} > {lastTranscript} </p> <img src="/mic.png" alt="mic" style={{ width: "3.33vmin" }} /> </> )} </div> )} </div> );

4단계: rendering 상태 - 텍스트를 캔버스에 렌더링

문제: 오버플로우 처리와 애니메이션

캔버스가 가득 찬 경우와 새 텍스트 추가 시 애니메이션이 필요합니다.

해결: 오버플로우 체크와 페이드 애니메이션

트랜스크립트 처리:

const handleTranscript = useCallback( async (transcript) => { clearResultTimer(); if (!transcript) { log("Received empty transcript, returning to idle"); setStatus("idle"); return; } log("Handling transcript:", transcript); try { const candidate = [...textRef.current, transcript]; const overflow = await willTextOverflow(candidate); if (overflow) { log("Canvas overflow detected. Pending transcript stored."); pendingTranscriptRef.current = transcript; setFadePhase("out"); setStatus("clearing"); } else { log("Appending transcript to canvas", transcript); pendingTranscriptRef.current = null; const previousFullText = textRef.current.join(" "); previousCharCountRef.current = previousFullText.length; newTextAnimationRef.current = { active: true, startTime: null, duration: NEW_TEXT_FADE_DURATION_MS, }; setText(() => { textRef.current = candidate; return candidate; }); setFadePhase("idle"); setStatus("rendering"); } } catch (error) { errorLog("Failed to process transcript", error); setStatus("idle"); } }, [clearResultTimer], );

동작:

  1. 오버플로우 체크
  2. 가득 차면 clearing 상태로 전환
  3. 여유 있으면 rendering 상태로 전환하고 페이드인 시작

렌더링 로직:

useEffect(() => { if (status !== "idle" && status !== "rendering") { return; } log("Render effect running with status", status, "text length", text.length); if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } let cancelled = false; let idleTimer = null; /** * Ease-out 애니메이션 함수 * @param {number} t - 0.0 ~ 1.0 범위의 시간 진행도 * @returns {number} 애니메이션 진행률 */ const easeOut = (t) => 1 - Math.pow(1 - t, 3); /** * 캔버스 프레임 그리기 * @param {Object} resources - 꽃 리소스 객체 */ const drawFrame = (resources) => { if (cancelled) return; log("Drawing frame", { status, fadePhase, textLength: text.length }); const { image, expandedPath, originalPath, flowerWidth, flowerHeight } = resources; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; canvas.width = flowerWidth; canvas.height = flowerHeight; ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.globalAlpha = 0.08; ctx.drawImage(image, 0, 0, flowerWidth, flowerHeight); ctx.restore(); if (text.length > 0) { ctx.save(); ctx.clip(originalPath); ctx.font = `${FONT_SIZE_PX}px Eulyoo1945`; ctx.fillStyle = "#1a1a1a"; const fullText = text.join(" "); const characters = fullText.split(""); const previousCount = Math.min(previousCharCountRef.current, characters.length); const animationState = newTextAnimationRef.current; let progress = 1; let alphaForNewChars = 1; if (animationState.active) { if (animationState.startTime == null) { animationState.startTime = performance.now(); } const now = performance.now(); progress = Math.min(1, (now - animationState.startTime) / animationState.duration); alphaForNewChars = easeOut(progress); } let charIndex = 0; for (let y = canvas.height - LINE_HEIGHT_PX; y >= 0 && charIndex < characters.length; y -= LINE_HEIGHT_PX) { let x = 0; let inPath = false; let startX = 0; const ranges = []; while (x < canvas.width) { const inside = ctx.isPointInPath(expandedPath, x, y); if (inside && !inPath) { startX = x; inPath = true; } else if (!inside && inPath) { ranges.push([startX, x]); inPath = false; } x += 1; } if (inPath) { ranges.push([startX, canvas.width]); } for (const [xStart, xEnd] of ranges) { let currX = xStart; while (charIndex < characters.length && currX < xEnd) { const ch = characters[charIndex]; const width = ctx.measureText(ch).width; if (currX + width > xEnd) { break; } const isNewChar = charIndex >= previousCount; ctx.globalAlpha = isNewChar && animationState.active ? alphaForNewChars : 1; ctx.fillText(ch, currX, y); currX += width; charIndex += 1; } if (charIndex >= characters.length) { break; } } } ctx.restore(); ctx.globalAlpha = 1; if (animationState.active && progress < 1) { animationFrameRef.current = requestAnimationFrame(() => drawFrame(resources)); return; } if (animationState.active && progress >= 1) { animationState.active = false; animationState.startTime = null; previousCharCountRef.current = characters.length; if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } } } if (status === "rendering") { const stillAnimating = newTextAnimationRef.current.active; if (!stillAnimating && !animationFrameRef.current) { log("Scheduling idle transition after rendering"); idleTimer = setTimeout(() => { log("Idle transition timer fired"); setStatus("idle"); }, RENDER_IDLE_DELAY_MS); } } }; loadFlowerResources() .then((resources) => { if (!cancelled) { drawFrame(resources); } }) .catch((error) => { console.error("Failed to render flower canvas", error); if (status !== "idle") { setStatus("idle"); } }); return () => { cancelled = true; if (animationFrameRef.current) { log("Cancelling pending animation frame in cleanup"); cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } if (idleTimer) { log("Clearing idle transition timer in cleanup"); clearTimeout(idleTimer); } }; }, [status, text, fadePhase]);

핵심 포인트:

  1. 페이드인 애니메이션: 새 글자만 alphaForNewChars 적용
  2. requestAnimationFrame으로 부드러운 애니메이션
  3. 애니메이션 완료 후 0.5초 대기 후 idle로 전환

5단계: clearing 상태 - 캔버스 초기화

문제: 캔버스가 가득 찬 경우 처리

해결: 페이드아웃 후 초기화

페이드 트랜지션 처리:

const handleCanvasTransitionEnd = useCallback(() => { log("Canvas transition end", fadePhase); if (fadePhase === "out") { const pending = pendingTranscriptRef.current; pendingTranscriptRef.current = null; previousCharCountRef.current = 0; newTextAnimationRef.current = { active: true, startTime: null, duration: NEW_TEXT_FADE_DURATION_MS, }; const nextText = pending ? [pending] : []; setText(() => { textRef.current = nextText; return nextText; }); setFadePhase("in"); setStatus("rendering"); } else if (fadePhase === "in") { setFadePhase("idle"); } }, [fadePhase]);

동작:

  1. fadePhase === "out"일 때 텍스트 초기화
  2. 대기 중인 새 텍스트만 남김
  3. fadePhase = "in"으로 페이드인 시작
  4. status = "rendering"으로 전환

캔버스 opacity 제어:

const showOverlay = status === "listening" || status === "done"; const canvasOpacity = fadePhase === "out" ? 0 : 1;

6단계: idle로 복귀 - 사이클 완성

렌더링 완료 후 자동 복귀

if (status === "rendering") { const stillAnimating = newTextAnimationRef.current.active; if (!stillAnimating && !animationFrameRef.current) { log("Scheduling idle transition after rendering"); idleTimer = setTimeout(() => { log("Idle transition timer fired"); setStatus("idle"); }, RENDER_IDLE_DELAY_MS); } }

동작:

  1. 애니메이션 완료 확인
  2. 0.5초 대기
  3. setStatus("idle")로 전환
  4. 볼륨 모니터링 재시작

상태 전환 다이어그램

[볼륨 체크] → [임계값 초과]
     ↓
  [idle] ──────────────────────────────┐
     ↓                                  │
[startListening]                        │
     ↓                                  │
[listening] ──[음성 감지]──→ [침묵 타이머 리셋]
     ↓                                  │
[finalizeRecognition]                   │
     ↓                                  │
[done] ──[1.5초 대기]──→ [handleTranscript]
     ↓                                  │
[오버플로우 체크]                           │
     ↓                                  │
  ┌─────────────────┐                  │
  │                 │                  │
[clearing]      [rendering]            │
  │                 │                  │
  │ [페이드아웃]       │ [페이드인]         │
  │                 │                  │
  └────→ [rendering] ──[애니메이션 완료]─┘
                    │
              [0.5초 대기]
                    │
                    └──→ [idle]

상태 관리의 핵심 설계 원칙

복잡한 상태관리와 엣지 케이스에 대해, 나름의 원칙을 가지고 설계 및 구현을 진행했습니다.

1. 상태와 부수 효과 분리

  • status: 현재 상태만 저장
  • useEffect: 상태 변화에 따른 부수 효과 처리

2. Ref를 활용한 비동기 상태 관리

const recognitionRef = useRef(null); const resultTimerRef = useRef(null); const textRef = useRef(text); const pendingTranscriptRef = useRef(null); const previousCharCountRef = useRef(0); const animationFrameRef = useRef(null); const newTextAnimationRef = useRef({ active: false, startTime: null, duration: NEW_TEXT_FADE_DURATION_MS, }); const recognitionActiveRef = useRef(false); const pendingRecognitionStartRef = useRef(false); const statusRef = useRef("idle"); const silenceTimerRef = useRef(null); const collectedTranscriptsRef = useRef([]); const audioContextRef = useRef(null); const analyserRef = useRef(null); const micStreamRef = useRef(null); const volumeCheckIntervalRef = useRef(null); const recognitionRetryCountRef = useRef(0); const recognitionRestartTimeoutRef = useRef(null); const startListeningRef = useRef(null); const statusWatchdogRef = useRef(null); const audioHealthIntervalRef = useRef(null); const audioRecoveryTimeoutRef = useRef(null); const audioHealthCheckRunningRef = useRef(false);

이유:

  • 비동기 콜백에서 최신 상태 접근
  • 리렌더링 없이 값 업데이트
  • 타이머/인터벌 참조 관리

3. 상태 동기화

useEffect(() => { statusRef.current = status; }, [status]);

비동기 콜백에서 최신 상태를 참조하기 위해 statusRef를 유지합니다.

4. 리소스 정리

useEffect( () => () => { clearResultTimer(); log("Component unmount: clearing timers and audio resources"); if (silenceTimerRef.current) { clearTimeout(silenceTimerRef.current); } if (volumeCheckIntervalRef.current) { clearInterval(volumeCheckIntervalRef.current); } if (statusWatchdogRef.current) { clearTimeout(statusWatchdogRef.current); } if (recognitionRestartTimeoutRef.current) { clearTimeout(recognitionRestartTimeoutRef.current); } if (audioHealthIntervalRef.current) { clearInterval(audioHealthIntervalRef.current); } if (audioRecoveryTimeoutRef.current) { clearTimeout(audioRecoveryTimeoutRef.current); } disposeAudioResources(); }, [clearResultTimer, disposeAudioResources], );

모든 타이머와 리소스를 정리해 메모리 누수를 방지합니다.

구현 과정에서의 문제 해결

문제 1: 상태가 특정 단계에서 멈춤

해결: 워치독 타이머 추가

useEffect(() => { if (statusWatchdogRef.current) { clearTimeout(statusWatchdogRef.current); statusWatchdogRef.current = null; } if (status === "idle") { return; } statusWatchdogRef.current = setTimeout(() => { warn("Status watchdog forcing idle transition", statusRef.current); setStatus("idle"); }, RENDER_IDLE_WATCHDOG_MS); return () => { if (statusWatchdogRef.current) { clearTimeout(statusWatchdogRef.current); statusWatchdogRef.current = null; } }; }, [status]);

5초 이상 비-idle 상태가 지속되면 강제로 idle로 전환합니다.

문제 2: 음성 인식 세션 중첩

해결: recognitionActiveRef로 중복 시작 방지

if (recognitionActiveRef.current) { warn("Recognition already active. Deferring new start request"); pendingRecognitionStartRef.current = true; return; }

문제 3: 침묵 중 인식 종료

해결: 침묵 타이머로 자연스러운 종료

const resetSilenceTimer = useCallback(() => { if (silenceTimerRef.current) { log("Resetting existing silence timer"); clearTimeout(silenceTimerRef.current); } log("Starting silence timer", SILENCE_TIMEOUT_MS, "ms"); silenceTimerRef.current = setTimeout(() => { finalizeRecognition(); }, SILENCE_TIMEOUT_MS); }, [finalizeRecognition]);

이번 구현 기능 요약

  1. 자동화: 사용자 개입 없이 동작
  2. 연속성: 끊김 없는 사이클
  3. 안정성: 예외 상황 처리
  4. 사용자 경험: 자연스러운 전환과 피드백

상태를 명확히 정의하고, 각 전환을 useEffect로 처리하며, Ref로 비동기 상태를 관리하였습니다. 이 구조로 전시 환경에서 안정적으로 동작하는 인터랙티브 시스템을 구현하고자 했습니다.