음성으로 블로그 쓰다가 "안녕하세요 안녕하세요 안녕하세요"가 나온 이유
카테고리: AI 기반 개발 / 트러블슈팅 태그: 음성인식, Web Speech API, 안드로이드, 크롬, 자바스크립트, 디버깅
시작은 아주 단순한 기능이었습니다
사장님이 가게에서 일하다가 문득 블로그에 올릴 거리가 생겼다고 칩시다. 손은 젖어 있고, 키보드는 저 안쪽에 있고, 손님은 계속 들어옵니다.
그래서 만들었습니다. "말로 입력" 버튼.
누르고 말하면 텍스트가 들어가고, AI가 그걸 블로그 글로 만들어줍니다. 별거 아닌 기능이죠. 브라우저에 내장된 Web Speech API만 쓰면 되니까 서버도, 외부 라이브러리도, 추가 비용도 없습니다.
PC에서 테스트했습니다. 완벽했습니다.
그리고 핸드폰으로 열어봤습니다.
"안녕하세요 안녕하세요 반갑습니다"
핸드폰에서 말을 하니 이런 게 나옵니다.
안녕하세요
안녕하세요 반갑습니다
같은 말이 두 번, 세 번씩 나옵니다. 게다가 말하다가 중간에 뚝 끊깁니다. 생각 좀 하려고 잠깐 멈추면 그대로 녹음이 종료돼 있습니다.
가장 미치는 부분은 이겁니다.
PC에서는 멀쩡합니다.
같은 코드, 같은 브라우저(크롬), 같은 웹사이트. 그런데 PC는 되고 폰은 안 됩니다.

범인은 안드로이드였습니다
한참을 파고들어 찾아낸 진실은 이겁니다.
안드로이드 크롬은 침묵이 몇 초 이어지면, 음성 인식 세션을 자기 마음대로 종료하고 다시 시작합니다.
PC 크롬은 그러지 않습니다.
이게 왜 문제냐면, 당시 코드가 이렇게 생겼기 때문입니다.
recognition.onstart = function() {
// 녹음이 시작되면, 텍스트박스에 이미 있는 내용을 '기준값'으로 저장
voiceBaseText = fieldSourceText.value.trim(); // ← 여기가 시한폭탄
voiceFinalText = '';
};
onstart는 "녹음이 시작될 때" 실행됩니다. 사용자가 버튼을 눌렀을 때만 실행될 거라고 믿었습니다.
그런데 안드로이드는 몰래 세션을 재시작합니다. 그러면 onstart가 또 실행됩니다.
무슨 일이 벌어지는지 순서대로 보시죠.
| 순서 | 벌어지는 일 | 텍스트박스 |
|---|---|---|
| 1 | 버튼 클릭 → onstart → base = "" (빈칸) |
`` |
| 2 | "안녕하세요" 말함 | 안녕하세요 |
| 3 | 3초 쉼 → 안드로이드가 몰래 세션 재시작 | 안녕하세요 |
| 4 | onstart 또 실행 → base = "안녕하세요" 😱 |
안녕하세요 |
| 5 | "반갑습니다" 말함 → base + 인식결과 | 안녕하세요 + 안녕하세요 반갑습니다 |
4번이 사고 지점입니다.
이미 입력된 "안녕하세요"를 다시 기준값으로 잡아버립니다. 그리고 그 위에 인식 결과를 또 얹으니, 당연히 중복됩니다.
말을 오래 할수록, 중간에 많이 쉴수록 더 많이 겹칩니다. 사장님이 신나서 3분 동안 말하면 같은 문장이 대여섯 번씩 나옵니다.

여기서 사장님의 역발상
버그를 잡기 전에, 사장님이 이런 말씀을 하셨습니다.
"실시간으로 글자가 나올 필요가 있나? 말 다 하고 버튼 누르면 그때 한 번에 들어가면 되잖아."
솔직히 이 말이 이 글의 핵심입니다.
기존 방식은 말하는 족족 텍스트박스에 실시간으로 글자를 뿌리는 구조였습니다. 멋있어 보이니까요. 유튜브 자막처럼 글자가 촤르륵 나오는 게 있어 보이잖아요.
그런데 그게 모든 문제의 근원이었습니다.
- 실시간으로 뿌리려니 → 중간 결과(
interimResults)를 받아야 하고 - 중간 결과를 받으려니 → 텍스트박스를 계속 다시 그려야 하고
- 계속 다시 그리려니 → "지금까지 뭐가 들어있었지?"를 매번 확인해야 하고
- 그게 바로 base를 다시 읽는 그 코드였던 겁니다
"실시간으로 안 보여줘도 된다" 는 한마디가 이 사슬을 통째로 끊었습니다.
기능을 더 넣어서 고친 게 아니라, 기능을 빼서 고쳤습니다.
그런데 여기 함정이 하나 더 있었습니다
"말 다 하고 버튼 누르면 확정" 방식으로 바꾸려고 다른 AI(Gemini)에게 코드를 물어봤습니다. 이런 답이 왔습니다.
recognition.continuous = false; // 중간에 끊기지 않게 함
recognition.interimResults = false;
...
const transcript = event.results[0][0].transcript;
이 코드를 그대로 넣었으면 지금보다 더 나빠졌습니다. 세 군데가 틀렸습니다.
❌ 오류 1: continuous = false
주석에 "중간에 끊기지 않게 함" 이라고 적혀 있는데, 정반대입니다.
continuous = false는 "첫 문장이 끝나면 즉시 종료" 라는 뜻입니다. 끊김을 막는 게 아니라 끊김을 강제합니다.
"말 다 하고 버튼 누르기"를 하고 싶은데, 첫 문장 끝나자마자 저절로 꺼져버려서 누를 기회조차 없습니다.
❌ 오류 2: results[0][0]
첫 번째 결과만 가져옵니다. 여러 문장을 말하면 첫 문장만 남고 나머지는 다 버려집니다.
❌ 오류 3: "onend에서 AI 생성을 호출하세요"
onend는 에러가 나도, 침묵이 길어도, 안드로이드가 멋대로 꺼도 매번 발생합니다.
여기에 AI 호출을 걸면 가만히 있어도 API가 계속 호출됩니다. 돈이 나갑니다.
교훈: AI가 준 코드는 주석까지 의심하세요. 자신 있게 적힌 주석이 정반대인 경우가 실제로 있습니다.
그래서 실제로 이렇게 고쳤습니다
핵심은 딱 세 가지입니다. 하나라도 빼면 버그가 다시 살아납니다.
① 누적 버퍼는 "수동 시작" 때만 비운다
var voiceChunks = []; // 확정된 문장 조각들
var manualStop = false; // 사용자가 [중지]를 눌렀는가?
fieldVoiceBtn.addEventListener('click', function() {
if (listening) {
manualStop = true; // 사용자가 진짜로 멈춘 것
recognition.stop();
} else {
manualStop = false;
voiceChunks = []; // ★ 수동 시작 때'만' 초기화
recognition.start();
}
});
안드로이드가 몰래 재시작해도 voiceChunks는 그대로 살아있습니다. 앞서 말한 내용이 안 날아갑니다.
② 몰래 꺼지면, 몰래 다시 켠다
recognition.onend = function() {
// 사용자가 중지를 안 눌렀는데 끝났다? = 안드로이드가 멋대로 끊은 것
if (!manualStop) {
setTimeout(function() { recognition.start(); }, 250); // ★ 다시 켠다
return; // UI는 '녹음 중' 그대로 유지 (사용자는 눈치 못 챔)
}
// 여기까지 왔다 = 사용자가 진짜로 [중지]를 누른 것
listening = false;
setRecUI(false);
commitVoiceText(); // ★ 이때 한 번에 확정
};
manualStop 플래그 하나로 "사용자가 끈 것" 과 "안드로이드가 끈 것" 을 구별합니다.
안드로이드가 껐으면 0.25초 뒤에 조용히 다시 켭니다. 사용자는 계속 말하기만 하면 됩니다. 끊긴 걸 눈치채지도 못합니다.
③ 결과는 resultIndex부터만 읽는다
recognition.onresult = function(e) {
// ★ e.resultIndex 부터만 읽는다. 0부터 재순회하면 중복 누적된다.
for (var i = e.resultIndex; i < e.results.length; i++) {
var r = e.results[i];
if (r && r.isFinal && r[0] && r[0].transcript) {
voiceChunks.push(r[0].transcript);
}
}
};
e.results는 지금까지의 모든 결과가 쌓인 배열입니다.
이걸 i = 0부터 다시 훑으면, 이미 처리한 문장을 또 집어넣게 됩니다. e.resultIndex는 "이번에 새로 추가된 것이 몇 번부터인지" 를 알려줍니다. 거기서부터만 읽으면 중복이 원천 차단됩니다.
그리고 설정 두 줄
recognition.continuous = true; // 끊김 방지 (false로 하면 첫 문장 후 종료!)
recognition.interimResults = false; // 중간 결과 안 받음 → 글자 튐/중복 방지

진짜 고쳐졌는지 어떻게 확인했나
여기가 중요합니다. "고친 것 같은데?" 로 끝내면 안 됩니다.
핸드폰을 붙잡고 될 때까지 말해보는 대신, 안드로이드의 그 얄미운 행동을 코드로 흉내 내는 시뮬레이터를 만들었습니다.
════ 시나리오 ════
"안녕하세요" 말함 → 3초 침묵(안드로이드 자동재시작) → "반갑습니다" 말함 → 중지 클릭
구버전: "안녕하세요 / 안녕하세요 반갑습니다" 중복: ❌ 있음
신규: "안녕하세요 반갑습니다" 중복: ✅ 없음
버그가 그대로 재현됐고, 수정본에서 사라졌습니다.
그리고 엣지 케이스를 하나씩 다 뚫어봤습니다.
| 상황 | 결과 |
|---|---|
| 기존에 쓴 메모가 있는 상태에서 음성 추가 | ✅ 기존 내용 보존 + 줄바꿈 추가 |
| 안드로이드가 5번 연속 세션 끊음 (긴 발화) | ✅ 중복 없이 전부 누적 |
| 아무 말 안 하고 중지 | ✅ 빈 텍스트로 덮어쓰지 않음 |
no-speech 에러 (침묵 중 흔함) |
✅ 무시하고 계속 녹음 |
| 마이크 권한 거부 | ✅ 지금까지 인식된 건 살려줌 |
| 녹음 → 중지 → 다시 녹음 | ✅ 각각 줄바꿈으로 추가 |
9개 케이스 전부 통과. 그다음에 핸드폰으로 열었고, 잘 됐습니다.
사용법 (완성된 모습)
1. [말로 입력] 버튼 클릭
↓
2. 편하게 말합니다
- 중간에 생각하느라 쉬어도 안 끊깁니다
- "녹음 중입니다 (3문장 인식됨)" 으로 진행 상황이 보입니다
- 말하는 동안 텍스트박스는 조용합니다 (글자가 안 튑니다)
↓
3. [말하기 중지] 클릭
↓
4. 그때 한 번에 텍스트가 들어갑니다
↓
5. [AI 콘텐츠 생성] 클릭 → 블로그 글 완성
필요한 것: 크롬 브라우저 + HTTPS + 마이크 권한. 그게 전부입니다. 서버도, 외부 API도, 추가 비용도 없습니다.
남는 이야기
이 버그에서 배운 게 세 가지 있습니다.
첫째, "PC에서는 되는데요"는 아무 의미가 없습니다. 같은 브라우저 이름을 달고 있어도 데스크톱과 모바일은 다른 물건입니다. 실제 기기에서 확인하기 전까지는 아무것도 확인된 게 아닙니다.
둘째, 기능을 빼서 고칠 수도 있습니다. "실시간으로 글자를 보여주자"는 멋있어 보이는 욕심이 모든 문제의 뿌리였습니다. "안 보여줘도 된다" 는 한마디가 코드 절반을 지웠고, 버그도 같이 지워졌습니다.
가장 좋은 코드는 없는 코드입니다.
셋째, AI가 준 코드는 주석까지 의심하세요.
continuous = false; // 중간에 끊기지 않게 함 — 아주 자신 있게 적혀 있었지만 정확히 반대였습니다. 그대로 믿고 넣었으면 "고쳤는데 더 나빠졌다"가 됐을 겁니다.
AI는 훌륭한 조수지만, 왜 그렇게 되는지 이해하지 못한 채 붙여넣는 순간 조수가 아니라 상사가 됩니다.
이 글은 실제 운영 중인 시스템에서 발생한 버그를 수정한 기록입니다.
수정 파일: admin/write.php · 검증: 시뮬레이션 재현 + 엣지 케이스 9종 + 실기기 테스트



댓글 0