logo

DowanKim

6. 아직 말 다 안끝났는데 렌더링 시작해버리는 문제

2025년 9월 28일

꽃다발 방명록

침묵 감지 미작동 — 말하는 중간에 렌더링이 시작되는 문제

문제 : 말하는 중간에 화면이 바뀜

음성 인식 기능을 추가한 후, 사용자가 말하는 중간에 렌더링 애니메이션이 시작되는 문제가 발생했습니다.

증상

사용자가 다음과 같이 말했을 때:

"안녕하세요" [숨을 고름] "날씨가 좋네요"

실제 동작:

  1. "안녕하세요"가 final 결과로 나옴
  2. 즉시 화면에 "안녕하세요"가 렌더링되기 시작 (애니메이션 시작)
  3. 사용자가 아직 "날씨가 좋네요"를 말하고 있는데도 불구하고 화면이 바뀜
  4. 결과적으로 "안녕하세요"만 캔버스에 표시되고, "날씨가 좋네요"는 다음 세션에서 인식됨

예상 동작

"안녕하세요" [숨] "날씨가 좋네요" [침묵 1초]
→ 전체 문장이 완성된 후에만 렌더링 시작
→ "안녕하세요 날씨가 좋네요"가 한 번에 표시됨

실제 동작

"안녕하세요" [final 결과]
→  즉시 렌더링 시작 (사용자가 아직 말하고 있는 중)
→ 화면에 "안녕하세요" 표시
→ 사용자가 "날씨가 좋네요"를 말함
→ 다음 세션에서 "날씨가 좋네요"만 인식됨

원인 분석

1단계: 초기 구현 확인

초기에는 final 결과가 나오면 바로 처리했습니다.

// 초기 버전 recognition.onresult = (event) => { const lastResultIndex = event.results.length - 1; const result = event.results[lastResultIndex]; const transcript = result[0].transcript.trim(); if (result.isFinal && transcript) { // final 결과가 나오면 즉시 처리 collectedTranscriptsRef.current.push(transcript); // 바로 최종 처리 및 렌더링 시작 const fullTranscript = collectedTranscriptsRef.current.join(" "); setLastTranscript(fullTranscript); setStatus("done"); handleTranscript(fullTranscript); // 렌더링 시작 recognition.stop(); // 세션 종료 } };

문제점:

  • final 결과가 나오면 즉시 처리
  • 사용자가 말을 계속하고 있어도 렌더링이 시작됨
  • 문장이 중간에 끊김

2단계: 실제 사용 패턴 관찰

사용자가 말하는 패턴:

시간: 0초 ──────────────────────────────────→ 5초

사용자: "안녕하세요" [0.8초 침묵] "날씨가 좋네요" [1초 침묵]
        │                    │                │
        │                    │                └─ 말 끝
        │                    └─ 숨을 고름 (아직 말할 예정)
        └─ 첫 번째 final 결과

Web Speech API:
- 2초: "안녕하세요" (isFinal: true) ❌
- 4초: "날씨가 좋네요" (isFinal: true)

초기 구현에서는 첫 번째 final 결과에서 렌더링이 시작되어 문제가 발생했습니다.

3단계: 로그 분석

콘솔 로그를 확인한 결과:

"Recognition result final transcript: 안녕하세요"
"Stored final transcript segment 안녕하세요"
"Finalize recognition with transcript: 안녕하세요"  //  즉시 처리
"Handling transcript: 안녕하세요"
"Appending transcript to canvas 안녕하세요"
"Status: rendering"  //  렌더링 시작 (사용자가 아직 말하고 있는 중!)
"Drawing frame"  // 화면에 "안녕하세요" 표시 시작
...
"Recognition result final transcript: 날씨가 좋네요"  // 나중에 인식됨
"Stored final transcript segment 날씨가 좋네요"
"Finalize recognition with transcript: 날씨가 좋네요"  // 별도 세션으로 처리

문제: 첫 번째 final 결과에서 바로 처리되어 렌더링이 시작되었습니다.

해결 과정

시도 1: final 결과를 모으기만 하기

final 결과가 나와도 바로 처리하지 않고 모으기만 했습니다.

// 개선 시도 1 recognition.onresult = (event) => { const lastResultIndex = event.results.length - 1; const result = event.results[lastResultIndex]; const transcript = result[0].transcript.trim(); if (result.isFinal && transcript) { collectedTranscriptsRef.current.push(transcript); // 바로 처리하지 않고 모으기만 함 } };

하지만 언제 최종 처리할지 결정하는 로직이 필요했습니다.

시도 2: 침묵 타이머 도입

일정 시간 침묵이 지속될 때만 최종 처리하도록 침묵 타이머를 도입했습니다.

2-1. 상수 정의

export const SILENCE_TIMEOUT_MS = 1000;

1초 동안 침묵이 지속되면 사용자가 말을 마친 것으로 간주합니다.

2-2. 침묵 타이머 리셋 함수 구현

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. 새 타이머 시작 (1초 후 finalizeRecognition() 호출)
  3. 음성이 감지되면 타이머가 리셋되어 세션이 계속됨

2-3. 음성 감지 시 타이머 리셋

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(); } };

핵심:

  • isFinal: true일 때: 최종 결과를 저장하고 타이머 리셋 (렌더링 시작하지 않음)
  • isFinal: false일 때: 중간 결과가 와도 타이머 리셋

이유:

  • final 결과가 나와도 바로 처리하지 않고 타이머만 리셋
  • 사용자가 말을 계속하면 타이머가 계속 리셋되어 세션 유지
  • 1초 동안 침묵이 지속되면 그때 finalizeRecognition() 호출

2-4. 음성 시작 시 타이머 시작

recognition.onspeechstart = () => { log("Speech detected - switching to listening state"); setStatus("listening"); resetSilenceTimer(); };

음성이 감지되면 타이머를 시작합니다.

2-5. 최종 처리 함수

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. 그때 렌더링 시작

해결 후 동작

정상적인 로그 흐름

"Speech detected - switching to listening state"
"Starting silence timer 1000 ms"
"Recognition result interim transcript: 안녕하세요"
"Resetting existing silence timer"
"Starting silence timer 1000 ms"  // 타이머 리셋
"Recognition result final transcript: 안녕하세요"
"Stored final transcript segment 안녕하세요"
"Resetting existing silence timer"
"Starting silence timer 1000 ms"  //  타이머 리셋 (렌더링 시작하지 않음!)
[0.8초 침묵 - 사용자가 숨을 고름]
"Recognition result interim transcript: 날씨가 좋네요"
"Resetting existing silence timer"  //  타이머 리셋! 세션 계속됨
"Starting silence timer 1000 ms"
"Recognition result final transcript: 날씨가 좋네요"
"Stored final transcript segment 날씨가 좋네요"
"Resetting existing silence timer"
"Starting silence timer 1000 ms"
[1.2초 침묵 - 사용자가 말을 마침]
"Finalize recognition with transcript: 안녕하세요 날씨가 좋네요"  //  이제 렌더링 시작!
"Handling transcript: 안녕하세요 날씨가 좋네요"
"Appending transcript to canvas 안녕하세요 날씨가 좋네요"
"Status: rendering"  //  전체 문장이 완성된 후에만 렌더링!

동작 원리

시간축: 0초 ──────────────────────────────────→ 5초

사용자: "안녕하세요" [숨] "날씨가 좋네요" [침묵]
        │          │    │            │    │
        │          │    │            │    └─ 1초 침묵 → finalize
        │          │    │            └─ 타이머 리셋
        │          │    └─ 타이머 리셋 (렌더링 시작 안 함!)
        │          └─ 0.8초 침묵 (타이머 리셋 전에 음성 재개)
        └─ 타이머 시작

Web Speech API:
- "안녕하세요" (final) → 저장 + 타이머 리셋 (렌더링 X)
- "날씨가 좋네요" (final) → 저장 + 타이머 리셋 (렌더링 X)
- 1초 침묵 → finalizeRecognition() 호출
- "안녕하세요 날씨가 좋네요" 렌더링 시작 ✅

결과: "안녕하세요 날씨가 좋네요" ✅

문제 해결 전후 비교

해결 전

사용자: "안녕하세요" [숨] "날씨가 좋네요"

타임라인:
0초: "안녕하세요" (final) → ❌ 즉시 렌더링 시작
1초: 화면에 "안녕하세요" 표시 시작 (애니메이션)
2초: 사용자가 "날씨가 좋네요"를 말하는 중인데도 화면이 바뀜
3초: "날씨가 좋네요" (final) → 다음 세션으로 처리

최종 결과:
- 캔버스: "안녕하세요"
- 다음 세션: "날씨가 좋네요"

해결 후

사용자: "안녕하세요" [숨] "날씨가 좋네요"

타임라인:
0초: "안녕하세요" (final) → 저장만 함, 타이머 리셋 (렌더링 X)
1초: 사용자가 숨을 고름 (타이머 진행 중)
2초: "날씨가 좋네요" (final) → 저장만 함, 타이머 리셋 (렌더링 X)
3초: 1초 침묵 지속 → finalizeRecognition() 호출
4초: "안녕하세요 날씨가 좋네요" 렌더링 시작 ✅

최종 결과:
- 캔버스: "안녕하세요 날씨가 좋네요" ✅

구현 세부사항

final 결과를 모으기만 하기

if (result.isFinal && transcript) { collectedTranscriptsRef.current.push(transcript); log("Stored final transcript segment", transcript); resetSilenceTimer();

핵심:

  • final 결과가 나와도 collectedTranscriptsRef.current에만 저장
  • resetSilenceTimer()로 타이머만 리셋
  • finalizeRecognition()은 호출하지 않음

침묵 타이머가 만료될 때만 최종 처리

silenceTimerRef.current = setTimeout(() => { finalizeRecognition(); }, SILENCE_TIMEOUT_MS);

1초 동안 침묵이 지속되면 finalizeRecognition()이 호출되어 렌더링이 시작됩니다.

interim 결과도 타이머 리셋

} else if (!result.isFinal && transcript) { log("Interim transcript segment", transcript); resetSilenceTimer(); }

중간 결과도 음성 활동으로 간주해 타이머를 리셋합니다.

교훈

1. final 결과 ≠ 말하기 완료

final 결과가 나와도 사용자가 말을 계속할 수 있습니다. final 결과를 받았다고 바로 처리하지 말고, 침묵이 지속될 때만 최종 처리해야 합니다.

// ❌ 나쁜 예: final 결과가 나오면 즉시 처리 if (result.isFinal) { handleTranscript(transcript); // 너무 이름 } // ✅ 좋은 예: 침묵이 지속될 때만 처리 if (result.isFinal) { collectedTranscriptsRef.current.push(transcript); resetSilenceTimer(); // 타이머 리셋만 } // 타이머가 만료되면 finalizeRecognition() 호출

2. 침묵 타이머 패턴

음성 활동이 감지될 때마다 타이머를 리셋해, 사용자가 말하는 중에는 최종 처리가 되지 않도록 합니다.

3. 사용자 경험 우선

기술적 동작보다 사용자가 말을 마칠 때까지 기다리는 것이 중요합니다.

4. interim 결과 활용

중간 결과도 음성 활동으로 간주해 타이머를 리셋하면 더 자연스러운 경험을 제공합니다.

결론

침묵 타이머 도입으로 말하는 중간에 렌더링이 시작되는 문제를 해결했습니다. 핵심은 final 결과가 나와도 바로 처리하지 않고, 침묵이 지속될 때만 최종 처리하는 것입니다.

이 개선으로:

  • 말하는 중간에 화면이 바뀌지 않음
  • 전체 문장이 완성된 후에만 렌더링 시작
  • 자연스러운 사용자 경험 제공

이를 통해 사용성을 개선할 수 있었습니다.