VORA는 매 60초마다 진행 중인 회의 요약을 생성합니다. 그것이 설계입니다. 실제로는 요약이 예측 불가능한 간격으로 도착했습니다: 때로는 30초, 때로는 5분, 때로는 안 온 것처럼 보입니다. 사용자가 알아챘습니다. 우리는 그것이 깨진 것을 알았고 즉시 설명할 수 없었습니다. 이것은 버그를 야기한 JavaScript 타이머 메커니즘과 우리가 정착한 솔루션으로의 깊은 탐구입니다.
원래 구현과 숨겨진 결함
원래 startSummaryTimer() 메서드는 합리적으로 보였습니다:
startSummaryTimer() {
this.stopSummaryTimer();
// 데이터 누적 기간 후 첫 요약 (20초)
setTimeout(() => {
if (this.fullMeetingTranscript.length > 0) {
this.generateMeetingSummary();
}
}, 20000);
// 그러면 60초마다
this.summaryTimer = setInterval(
() => this.generateMeetingSummary(),
this.summaryInterval // 60000ms
);
}
즉각적인 문제: setTimeout(20s)과 setInterval(60s)은 동시에 등록됩니다. 따라서 트리거 이벤트의 시퀀스는:
- T+0초: 녹음 시작, 타이머 등록.
- T+20초: setTimeout 발동, 요약 트리거(데이터 사용 가능한 경우).
- T+60초: setInterval 첫 틱 발동, 요약 트리거.
- T+80초: setInterval 두 번째 틱(첫 틱 후 60초).
첫 번째 두 요약은 40초 떨어져 있습니다(T+20 및 T+60), 60초가 아닙니다. 그러면 T+60부터는 60초 떨어져 있습니다. 불규칙한 시작, 그 후 정규적. 성가신 것이지만 주요 버그는 아닙니다.
실제 문제: setInterval은 작업 지속 시간을 고려하지 않음
setInterval(fn, 60000)은 60초마다 콜백을 발생시키며, 이전 틱이 예약된 때부터 측정됩니다. 이전 실행이 완료된 때가 아닙니다. 콜백이 5초 실행하는 데 걸리면 다음 틱은 완료 후 55초 발동합니다. 스케줄링 관점에서 유효 간격은 여전히 60초입니다.
하지만 VORA의 generateMeetingSummary()는 단지 5초가 아닙니다. Gemini API 우선순위 큐에 작업을 큐에 넣습니다. 큐가 바쁠 경우(많은 CORRECTION 및 QA 작업), SUMMARY 작업은 처리되기 전에 20~30초를 기다릴 수 있습니다. 실제 API 호출은 2~5초가 걸립니다. 총 실행 시간: 트리거에서 완료까지 30초 이상일 가능성이 있습니다.
여기서 더 나빠집니다: setInterval은 이전 실행 완료를 신경 쓰지 않습니다. T+120초에 관계없이 T+60초, T+180초에 발동합니다. T+60초 요약이 여전히 T+120초에 큐에서 기다리고 있으면, 둘 다 이제 큐에 있습니다. 둘 다 결국 실행될 때, 사용자는 연속으로 생성된 두 요약을 봅니다. 그러면 간격 없음. 그 후 조용한 기간. 이것은 사용자가 보고한 "때로는 30초, 때로는 5분" 패턴을 설명합니다. 실제 불규칙 스케줄링이 아닙니다. 연속 실행을 보고 있는 것입니다.
2차 문제: 큐 넘침
확장된 회의(90분 이상)에서 setInterval 접근 방식은 큐 넘침을 생성합니다. SUMMARY 작업이 60초마다 발동되고 작업이 30초 이상(큐 대기 + API 시간) 실행할 가능성이 있으므로, 큐가 여러 보류 중인 SUMMARY 작업을 누적할 수 있습니다. 결국 각각 실행될 때, 최근 전사 항목 40개의 요약을 Gemini에 요청합니다. 90분 회의에서는 동시에 큐에 있는 3~4개의 SUMMARY 작업이 있을 수 있습니다. 각각이 회의의 다른 창을 요청합니다. 결과 요약은 일관성이 없을 것입니다. 다른 창, 순서 비지정 처리, 겹침.
해결책: 재귀적 setTimeout
수정책은 setInterval을 재귀적 setTimeout 패턴으로 바꾸는 것입니다. 핵심 차이: 다음 타이머는 현재 트리거가 발동한 후에만 예약됩니다(완료 후가 아닙니다. 우리는 여전히 간격이 트리거에서 트리거까지이기를 원합니다. 완료에서 트리거가 아닙니다). 하지만 마지막 트리거가 언제 일어났는지 추적하고, 어떤 이유로 일찍 호출된 경우 나머지 시간을 기다립니다.
startSummaryTimer() {
this.stopSummaryTimer();
this._lastSummaryTriggerTime = Date.now();
this._summaryTimerRunning = true;
const scheduleNext = () => {
if (!this._summaryTimerRunning) return;
const now = Date.now();
const elapsed = now - this._lastSummaryTriggerTime;
const waitMs = Math.max(0, this.summaryInterval - elapsed);
this.summaryTimer = setTimeout(() => {
if (!this._summaryTimerRunning) return;
this._lastSummaryTriggerTime = Date.now();
if (this.fullMeetingTranscript.length > 0) {
this.generateMeetingSummary();
}
scheduleNext(); // 트리거 후 다음 스케줄
}, waitMs);
};
scheduleNext(); // summaryInterval 후 첫 트리거 (60초)
}
stopSummaryTimer() {
this._summaryTimerRunning = false;
if (this.summaryTimer) {
clearTimeout(this.summaryTimer);
this.summaryTimer = null;
}
}
재귀적 setTimeout 패턴은 다음을 보장합니다:
- 트리거는 정확히 간격당 한 번 발동합니다. 첫 번째 실행 중에 두 번째 타이머가 예약되지 않습니다.
- 간격은 트리거 시간에서 트리거 시간까지 측정되며, 큐 API 호출이 얼마나 오래 걸리는지 관계없이.
- 타이머는
_summaryTimerRunning = false를 설정하여 깔끔하게 취소할 수 있습니다. - 초기 20초 지연이 없습니다. 첫 번째 요약은 녹음 시작 후 정확히 한 전체 60초 간격에 발동합니다.
왜 단순히 동시 요약을 방지하지 않을까?
대체 솔루션은 플래그를 추가하는 것입니다: if (this._summaryInProgress) return; generateMeetingSummary()의 시작 부분에. 이것은 동시 요약 실행을 방지합니다. 그들이 큐에 쌓이는 것을 방지하지 않습니다 — SUMMARY 작업은 여전히 큐에 쌓이고, 큐 용량을 소비하고, 결국 플래그가 설정되어 있을 때 디큐되고 버려집니다.
더 중요한 것은, 동시 가드가 불규칙 간격 문제를 수정하지 않습니다. setInterval + 동시 가드로 다음과 같이 보일 것입니다: T+60초에 트리거(실행), T+120초에 트리거(진행 중이므로 건너뜀), T+180초에 트리거(실행), T+240초에(건너뛸 수 있음)... 요약은 일반적으로 120초마다 발생합니다. 더 낫지만 의도된 동작이 아닙니다.
재귀적 setTimeout은 올바른 솔루션입니다. 왜냐하면 우리가 원하는 것의 실제 의미론과 스케줄링 메커니즘을 정렬시키기 때문입니다: "이전 트리거 후 60초마다 요약을 생성하되, 안정적으로, 한 번에 한 사이클씩."
알아야 할 JavaScript 스케줄링 패턴
이 버그 클래스 — 변수 시간을 취할 수 있는 비동기 작업과 함께 사용되는 setInterval — 일반적으로 재귀 setTimeout 패턴을 표준 도구로 가지는 것이 가치 있을 정도로 충분합니다. 일반 형식:
// 신뢰할 수 있는 비동기 주기 작업에 대한 일반 패턴
function startPeriodicTask(fn, intervalMs) {
let running = true;
let lastTrigger = Date.now();
let timer;
const schedule = () => {
if (!running) return;
const elapsed = Date.now() - lastTrigger;
const wait = Math.max(0, intervalMs - elapsed);
timer = setTimeout(async () => {
if (!running) return;
lastTrigger = Date.now();
try { await fn(); } catch(e) { console.error(e); }
schedule(); // 완료 후가 아닌 트리거 후 다시 스케줄
}, wait);
};
schedule();
return () => {
running = false;
clearTimeout(timer);
};
}
schedule() → 실행 → schedule() 체인은 최대 한 개의 보류 중인 타이머를 보장합니다. lastTrigger 추적은 타이머가 늦게 발동될 경우(탭이 백그라운드에 있거나 시스템이 로드될 때), 다음 스케줄이 덜 기다림으로써 보상합니다. 결과는 구성된 간격 측정 전체에 정확한 타이머이며, 개별 사이클이 오래 실행되더라도.
이 수정은 다른 수정과 동일한 배치로 나갔습니다. 기능 개발에 깊숙이 있을 때 무시하기 쉬운 버그 클래스입니다. "타이머는 기본적으로 작동하고 회의 요약이 때때로 발동됩니다". 하지만 불규칙 요약의 사용자 가시 증상은 보고된 더 일관된 불만 중 하나였습니다. 수정은 40줄의 코드입니다. 진단은 수정보다 오래 걸렸습니다. 항상 그렇습니다.