8. 전시장에서 장시간(거의 몇시간 정도) 작품을 운영 중 마이크가 갑자기 작동을 안하는 문제
2025년 11월 15일
마이크 스트림/AudioContext 중간 종료 — 헬스 체크와 자동 복구 메커니즘
문제 발견: 전시 중 마이크가 갑자기 작동하지 않음
전시 전날 확인용으로 작품을 계속해서 켜놓고 전시 동료들에게 지나갈때마다 한번씩 말해보고 지나가달라고 했는데, 전시 환경에서 장시간 운영 중 마이크가 갑자기 작동하지 않는 문제가 발생했습니다.
증상
- 초기에는 정상 작동
- 시간이 지나거나 절전 후 마이크가 반응하지 않음
- 볼륨이 항상 0으로 측정됨
- 음성 인식이 시작되지 않음
- 시스템이 또 "죽은" 상태로 유지됨
예상 동작
정상 작동 → 문제 감지 → 자동 복구 → 정상 작동 재개
실제 동작
정상 작동 → 문제 발생 → [멈춤] → 이후 반응 없음 ❌
원인 분석
1단계: MediaStreamTrack 상태 확인
마이크 스트림의 트랙 상태를 확인했습니다.
const stream = micStreamRef.current; const tracks = stream.getAudioTracks(); tracks.forEach(track => { console.log("Track state:", track.readyState); // "live" 또는 "ended" });
문제:
readyState === "ended"인 경우 볼륨 측정 불가- 스트림이 종료되어도 코드에서 감지하지 못함
2단계: AudioContext 상태 확인
AudioContext의 상태를 확인했습니다.
if (audioContextRef.current) { console.log("AudioContext state:", audioContextRef.current.state); // "running", "suspended", "closed" }
문제:
state === "suspended"인 경우 볼륨 측정 불가- macOS 절전 모드 등으로 자동 suspend됨
resume()없이는 복구되지 않음
3단계: 실제 동작 흐름 확인
로그를 확인한 결과:
"Volume check 0.1523" // 정상
"Volume check 0.1234" // 정상
...
"Volume check 0.0000" // ❌ 갑자기 0
"Volume check 0.0000" // 계속 0
"Volume check 0.0000" // 계속 0
[이후 계속 0만 반환]
문제: 볼륨이 0으로 고정되어도 원인을 파악하지 못했습니다.
4단계: 초기 구현의 문제
초기에는 스트림과 AudioContext 상태를 확인하지 않았습니다.
// 문제가 있던 초기 버전 const getCurrentVolume = () => { if (!analyserRef.current) { return 0; } const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount); analyserRef.current.getByteFrequencyData(dataArray); // ❌ 스트림 상태 확인 없음 // ❌ AudioContext 상태 확인 없음 // ❌ 문제 발생 시 복구 로직 없음 let sum = 0; for (let i = 0; i < dataArray.length; i++) { sum += dataArray[i]; } const average = sum / dataArray.length; return average / 255; };
문제점:
- 스트림 종료를 감지하지 않음
- AudioContext suspend를 감지하지 않음
- 문제 발생 시 복구 로직이 없음
해결 과정
시도 1: 스트림 재초기화 함수 분리
스트림과 AudioContext를 재초기화하는 함수를 분리했습니다.
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]);
핵심:
disposeAudioResources()로 기존 리소스 정리- 새 스트림과 AudioContext 생성
- 트랙
onended이벤트로 종료 감지 및 자동 복구
시도 2: 트랙 종료 이벤트 감시
트랙이 종료되면 자동으로 재초기화하도록 했습니다.
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); }; });
동작:
- 트랙이 종료되면
onended이벤트 발생 - 1초 대기 후 자동으로 재초기화
- 중복 복구 방지를 위한 플래그 체크
시도 3: 주기적 헬스 체크 구현
주기적으로 스트림과 AudioContext 상태를 확인하도록 했습니다.
useEffect(() => { const performHealthCheck = async () => { if (audioHealthCheckRunningRef.current) { return; } audioHealthCheckRunningRef.current = true; try { const stream = micStreamRef.current; const hasLiveTrack = !!stream && stream.getAudioTracks().some((track) => track.readyState === "live"); if (!hasLiveTrack) { warn("No live microphone tracks detected. Reinitializing audio."); await reinitializeAudioMonitoring(); return; } if ( audioContextRef.current && audioContextRef.current.state === "suspended" ) { try { await audioContextRef.current.resume(); log("AudioContext resumed after suspension"); } catch (resumeError) { warn( "Failed to resume AudioContext during health check. Rebuilding.", resumeError, ); await reinitializeAudioMonitoring(); } } } finally { audioHealthCheckRunningRef.current = false; } }; performHealthCheck(); audioHealthIntervalRef.current = setInterval(() => { performHealthCheck(); }, AUDIO_HEALTH_CHECK_INTERVAL_MS); return () => { if (audioHealthIntervalRef.current) { clearInterval(audioHealthIntervalRef.current); audioHealthIntervalRef.current = null; } }; }, [reinitializeAudioMonitoring]);
핵심:
- 15초마다 헬스 체크 실행
- 라이브 트랙이 없으면 재초기화
- AudioContext가 suspend 상태면
resume()시도 resume()실패 시 재초기화
시도 4: AudioContext suspend 감지 및 복구
AudioContext가 suspend 상태일 때 자동으로 resume하도록 했습니다.
const setupAudioMonitoring = useCallback(async () => { if (audioContextRef.current) { if (audioContextRef.current.state === "suspended") { try { await audioContextRef.current.resume(); } catch (resumeError) { warn("AudioContext resume failed, rebuilding resources.", resumeError); await reinitializeAudioMonitoring(); } } return; } await reinitializeAudioMonitoring(); }, [reinitializeAudioMonitoring]);
초기 설정 시 suspend 상태를 확인하고 resume합니다.
해결 후 동작
정상적인 로그 흐름
"Audio stream acquired"
"Volume check 0.1523"
"Volume check 0.1234"
...
[15초 후 헬스 체크]
[정상 상태 확인]
...
[트랙 종료 이벤트 발생]
"Microphone track ended unexpectedly. Scheduling recovery."
[1초 대기]
"Audio stream acquired" // ✅ 자동 복구!
"Volume check 0.1523" // ✅ 정상 작동 재개
AudioContext suspend 복구
"Volume check 0.1523"
"Volume check 0.0000" // ❌ suspend 상태
[15초 후 헬스 체크]
"AudioContext resumed after suspension" // ✅ 자동 resume!
"Volume check 0.1523" // ✅ 정상 작동 재개
동작 원리
타임라인: 0초 ──────────────────────────────────→ 30초
0.0초: 정상 작동
→ Volume check: 0.15
→ Volume check: 0.12
10.0초: 트랙 종료 (케이블 문제 등)
→ track.onended 이벤트 발생
→ "Microphone track ended unexpectedly"
→ 1초 대기 (MIC_RECOVERY_BACKOFF_MS)
11.0초: 자동 복구
→ reinitializeAudioMonitoring() 호출
→ 새 스트림 획득
→ 새 AudioContext 생성
→ "Audio stream acquired" ✅
15.0초: 헬스 체크
→ 라이브 트랙 확인 ✅
→ AudioContext 상태 확인 ✅
20.0초: macOS 절전 모드
→ AudioContext state: "suspended"
→ Volume check: 0.00
30.0초: 헬스 체크
→ AudioContext state: "suspended" 감지
→ audioContext.resume() 호출 ✅
→ "AudioContext resumed after suspension"
→ Volume check: 0.15 ✅
구현 세부사항
1. 리소스 정리 함수
const disposeAudioResources = useCallback(() => { if (micStreamRef.current) { micStreamRef.current.getTracks().forEach((track) => track.stop()); micStreamRef.current = null; } if (audioContextRef.current) { audioContextRef.current.close(); audioContextRef.current = null; } analyserRef.current = null; }, []);
재초기화 전에 기존 리소스를 정리합니다.
2. 트랙 종료 감지
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); }; });
트랙 종료 시 자동 복구를 예약합니다.
3. 헬스 체크: 라이브 트랙 확인
const stream = micStreamRef.current; const hasLiveTrack = !!stream && stream.getAudioTracks().some((track) => track.readyState === "live"); if (!hasLiveTrack) { warn("No live microphone tracks detected. Reinitializing audio."); await reinitializeAudioMonitoring(); return; }
라이브 트랙이 없으면 재초기화합니다.
4. 헬스 체크: AudioContext 상태 확인
if ( audioContextRef.current && audioContextRef.current.state === "suspended" ) { try { await audioContextRef.current.resume(); log("AudioContext resumed after suspension"); } catch (resumeError) { warn( "Failed to resume AudioContext during health check. Rebuilding.", resumeError, ); await reinitializeAudioMonitoring(); } }
suspend 상태면 resume을 시도하고, 실패 시 재초기화합니다.
5. 헬스 체크 주기 설정
export const AUDIO_HEALTH_CHECK_INTERVAL_MS = 15000;
15초마다 헬스 체크를 실행합니다.
문제 해결 전후 비교
해결 전
타임라인:
0.0초: 정상 작동
→ Volume check: 0.15
10.0초: 트랙 종료
→ ❌ 감지하지 못함
→ Volume check: 0.00
15.0초: Volume check: 0.00
→ ❌ 계속 0만 반환
→ ❌ 복구 로직 없음
결과: 마이크가 "죽은" 상태로 유지 ❌
해결 후
타임라인:
0.0초: 정상 작동
→ Volume check: 0.15
10.0초: 트랙 종료
→ track.onended 이벤트 발생 ✅
→ "Microphone track ended unexpectedly"
→ 1초 대기
11.0초: 자동 복구
→ reinitializeAudioMonitoring() 호출 ✅
→ 새 스트림 획득 ✅
→ Volume check: 0.15 ✅
20.0초: AudioContext suspend
→ Volume check: 0.00
30.0초: 헬스 체크
→ suspend 상태 감지 ✅
→ audioContext.resume() 호출 ✅
→ Volume check: 0.15 ✅
결과: 자동으로 복구되어 정상 작동 ✅
교훈
1. 리소스 상태 모니터링
장시간 운영 시 리소스 상태를 주기적으로 확인해야 합니다.
// ✅ 좋은 패턴: 주기적 헬스 체크 setInterval(() => { checkResourceHealth(); }, HEALTH_CHECK_INTERVAL);
2. 이벤트 기반 감지
이벤트로 문제를 즉시 감지하는 것이 효율적입니다.
// ✅ 좋은 패턴: 이벤트 기반 감지 track.onended = () => { // 즉시 복구 로직 실행 reinitializeAudioMonitoring(); };
3. 다층 복구 메커니즘
이벤트 기반 감지와 주기적 헬스 체크를 함께 사용합니다.
// ✅ 좋은 패턴: 다층 복구 // 1. 이벤트 기반 즉시 감지 track.onended = () => { /* 복구 */ }; // 2. 주기적 헬스 체크 setInterval(() => { /* 상태 확인 및 복구 */ }, 15000);
4. 리소스 정리 후 재생성
재초기화 전에 기존 리소스를 정리해야 합니다.
// ✅ 좋은 패턴: 정리 후 재생성 disposeAudioResources(); // 먼저 정리 // 그 다음 새로 생성 const stream = await getUserMedia();
5. 백오프 전략
복구 시도에 지연을 두어 과도한 재시도를 방지합니다.
// ✅ 좋은 패턴: 백오프 전략 setTimeout(() => { reinitializeAudioMonitoring(); }, MIC_RECOVERY_BACKOFF_MS); // 1초 대기
결론
마이크 스트림과 AudioContext 중간 종료 문제를 해결하기 위해 다음을 적용했습니다:
- 트랙
onended이벤트로 종료 감지 및 자동 복구 - 주기적 헬스 체크로 상태 모니터링
- AudioContext suspend 상태 자동 resume
- 문제 발생 시 자동 재초기화
이 개선으로:
- 전시 환경에서 장시간 안정적 운영
- 문제 발생 시 자동 복구
- 사용자 개입 없이 정상 작동 유지
이벤트 기반 감지와 주기적 헬스 체크를 결합하여, 호들갑이 심하다고 할 정도로 중복하여 오류를 방지하는.. 코드 수정이었습니다.
하지만 덕분에 전시기강 동안에는, 한번도 문제없이 지속해서 잘 운영할 수 있었습니다.