logo

DowanKim

5. 음성 인식 1회 후 idle로 돌아가지 않는 버그

2025년 9월 9일

꽃다발 방명록

음성 인식 1회 후 중단 버그

문제 발견: 첫 번째 문장 후 시스템이 멈춤

상태 머신 구현 후 첫 번째 문장은 정상 동작했지만, 이후 음성 인식이 재시작되지 않았습니다.

증상

  1. 첫 번째 음성 인식 → 정상 작동
  2. 텍스트가 캔버스에 렌더링됨
  3. 이후 아무 소리에도 반응하지 않음
  4. 볼륨 모니터링이 재시작되지 않음

예상 동작

음성 인식 → 렌더링 → idle 복귀 → 볼륨 모니터링 재시작 → 다음 음성 대기

실제 동작

음성 인식 → 렌더링 → [멈춤] → idle로 복귀하지 않음

원인 분석

1단계: 상태 확인

콘솔 로그로 상태 전환을 추적했습니다.

"Status=idle: starting volume monitor interval" // 초기 "Volume threshold exceeded" // 음성 감지 "Starting new recognition session" // 인식 시작 "Speech detected - switching to listening state" // listening 상태 "Finalize recognition with transcript: ..." // 인식 완료 "Handling transcript: ..." // 처리 시작 "Appending transcript to canvas" // 렌더링 시작 "Drawing frame" // 캔버스 그리기 "Scheduling idle transition after rendering" // idle 전환 예약

"Idle transition timer fired" // idle 전환 실행 "Status=idle: starting volume monitor interval" // 볼륨 모니터링 재시작

두줄이 출력되지 않았습니다.

2단계: 렌더링 로직 확인

렌더링 완료 후 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); } }

조건:

  • !stillAnimating: 애니메이션이 완료되어야 함
  • !animationFrameRef.current: 애니메이션 프레임이 없어야 함

3단계: 애니메이션 완료 처리 확인

애니메이션 완료 시 처리 로직을 확인했습니다.

if (animationState.active && progress >= 1) { animationState.active = false; animationState.startTime = null; previousCharCountRef.current = characters.length; }

문제:

  • animationFrameRef.currentnull로 설정되지 않아 조건 !animationFrameRef.current가 항상 false가 됨
  • 결과적으로 idle 타이머가 설정되지 않음

4단계: 문제 발생 이유

애니메이션 루프 구조:

const drawFrame = (resources) => { // ... 캔버스 그리기 ... if (animationState.active && progress < 1) { // 아직 애니메이션 중이면 다음 프레임 요청 animationFrameRef.current = requestAnimationFrame(() => drawFrame(resources)); return; } // 애니메이션 완료 처리 if (animationState.active && progress >= 1) { animationState.active = false; // 여기서 animationFrameRef.current를 정리하지 않음 } // idle 타이머 설정 if (status === "rendering") { const stillAnimating = newTextAnimationRef.current.active; if (!stillAnimating && !animationFrameRef.current) { // animationFrameRef.current가 여전히 값이 있어서 여기로 들어오지 않음 idleTimer = setTimeout(() => { setStatus("idle"); }, RENDER_IDLE_DELAY_MS); } } };

문제의 흐름:

  1. 마지막 requestAnimationFrameanimationFrameRef.current에 저장됨
  2. 애니메이션이 완료되어도 animationFrameRef.current가 그대로 남음
  3. !animationFrameRef.currentfalse가 되어 idle 타이머가 설정되지 않음
  4. 상태가 rendering에 고정됨
  5. 볼륨 모니터링이 재시작되지 않음

해결 과정

시도 1: 조건 확인

조건이 항상 false인 이유를 확인했습니다.

// 디버깅 코드 추가 if (status === "rendering") { const stillAnimating = newTextAnimationRef.current.active; console.log("stillAnimating:", stillAnimating); console.log("animationFrameRef.current:", animationFrameRef.current); console.log("조건 만족:", !stillAnimating && !animationFrameRef.current); if (!stillAnimating && !animationFrameRef.current) { // 이 블록에 절대 들어오지 않음 } }

출력:

stillAnimating: false
animationFrameRef.current: 12345  // 숫자 값이 남아있음
조건 만족: false

시도 2: 애니메이션 완료 시 정리

애니메이션 완료 시 animationFrameRef.currentnull로 설정했습니다.

if (animationState.active && progress >= 1) { animationState.active = false; animationState.startTime = null; previousCharCountRef.current = characters.length; // 애니메이션 프레임 참조 정리 if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; // 명시적으로 null 설정 } }

최종 해결 코드

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

해결 후 동작 확인

정상 로그 흐름

"Drawing frame" { status: "rendering", fadePhase: "idle", textLength: 1 }
"Drawing frame" { status: "rendering", fadePhase: "idle", textLength: 1 }
... (애니메이션 진행 중)
"Drawing frame" { status: "rendering", fadePhase: "idle", textLength: 1 }
// 애니메이션 완료
"Scheduling idle transition after rendering"
"Idle transition timer fired"
"Status=idle: starting volume monitor interval"  // 볼륨 모니터링 재시작!
"Volume check 0.0000"
"Volume check 0.0000"
...
"Volume threshold exceeded 0.1523"  // 다음 음성 감지!

상태 전환 확인

idle → listening → done → rendering → [애니메이션 완료] → idle ✅

교훈

1. Ref 정리 시점 명확히

requestAnimationFrame으로 설정한 ref는 완료 시 명시적으로 정리해야 합니다.

// ❌ 정리하지 않음 animationFrameRef.current = requestAnimationFrame(() => { // ... // animationFrameRef.current가 그대로 남음 }); // ✅ 명시적으로 정리 animationFrameRef.current = requestAnimationFrame(() => { // ... if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; // 명시적으로 null } });

2. 조건부 로직의 참조 상태 확인

조건에 사용하는 ref가 예상 상태인지 확인해야 합니다.

// 조건에 사용하는 ref의 상태를 항상 확인 if (!stillAnimating && !animationFrameRef.current) { // animationFrameRef.current가 정말 null인지 확인 필요 }

3. 디버깅 시 실제 값 확인

디버깅 시 콘솔 로그로 조건을 체크합니다.

// 디버깅 코드 console.log("조건 체크:", { stillAnimating: newTextAnimationRef.current.active, animationFrameRef: animationFrameRef.current, 조건만족: !newTextAnimationRef.current.active && !animationFrameRef.current });

4. 상태 머신의 모든 경로 검증

각 상태 전환이 확실히 이루어지는지 확인해야 합니다.

// 각 상태 전환에 로그 추가 useEffect(() => { console.log("상태 전환:", status); // 상태가 특정 상태에 머물러 있는지 확인 }, [status]);

추가 개선: 워치독 타이머

이 문제를 계기로 상태 고착을 방지하는 워치독을 추가했습니다. 워치돜이 있으면 idle상태로 돌아가지 않는 다양한 상황을 일단은 해결할 수 있습니다.

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로 전환합니다.

결론

이 버그는 requestAnimationFrame 참조를 정리하지 않아 발생했습니다. 애니메이션 완료 시 ref를 명시적으로 null로 설정해 해결했습니다.

교훈:

  • 비동기 작업의 참조는 완료 시 명시적으로 정리
  • 조건부 로직에서 사용하는 ref의 실제 상태 확인
  • 상태 머신의 모든 전환 경로 검증
  • 예외 상황 대비 워치독 추가