-
끝까지 들으면 점수를 더 주는 챗봇 — 청취 완료 타이머의 디자인IT 2026. 5. 14. 21:00
아이가 챗봇한테 질문을 던지고 답이 음성으로 흘러나오기 시작하면, 한두 마디 듣고 마이크 버튼을 다시 눌러 새 질문을 하기 일쑤였습니다. 챗봇이 답을 다 못 마쳤는데 다음 질문이 들어오니 자연스럽게 답이 잘리고, 학습 효과 측면에서도 아쉬웠습니다. "지금 답을 끝까지 들어봐"라고 옆에서 말해줘야 했죠.
게이미피케이션의 한 갈래로 "답변을 끝까지 들으면 추가 점수"라는 보상을 추가했습니다. 처음 1점, 들으면 +5점이라는 단순한 구조입니다. 그런데 "끝까지 들었다"를 어떻게 판정할 것인가가 의외로 까다로운 디자인 문제였습니다. 10초 타이머와 큐 배출, 두 신호의 OR 게이트로 푼 이야기를 정리합니다.
"끝까지 들음"의 정의가 모호하다
판정의 가장 단순한 기준은 "TTS 음성 재생이 끝났을 때"입니다. 그러나 이건 두 가지 문제가 있습니다.
첫째, 답변 길이가 들쭉날쭉합니다. LLM 답이 한 문장(2초)인 경우와 일곱 문장(15초)인 경우가 있는데, 둘 다 "끝까지 듣기"로 인정하면 짧은 답에선 너무 쉽고 긴 답에선 너무 가혹해집니다. 짧은 답을 끝까지 듣는 게 학습 측면에서 큰 의미가 없는데도 보상이 같다면 보상 자체가 약해집니다.
둘째, 답이 매우 긴 경우 사용자가 못 기다리고 인터럽트할 때 보상을 줘야 하는지 모호합니다. 30초짜리 답을 25초까지 듣고 다음 질문으로 넘어간 경우, "끝까지"는 아니지만 "충분히 들었다"고 볼 수도 있습니다.
그래서 두 가지 신호의 OR 게이트로 정의했습니다.
- (A) 첫 TTS 오디오가 재생을 시작한 뒤 10초가 지났다 — 시간 기반 임계값.
- (B) TTS 큐가 완전히 배출됐다(= 답 전체 재생 완료) — 자연 종료.
둘 중 먼저 일어난 것을 트리거로 +5점을 부여합니다. 짧은 답은 (B)가 빨리 일어나서 즉시 보상, 긴 답은 (A)가 먼저 일어나서 10초만 들으면 보상. 인터럽트가 그 전에 일어나면 보상 없음.
그림 설명 — 시나리오 1(짧은 답): 큐가 3초만에 다 배출되니 (B) 신호가 먼저 발화해 +5점이 부여됩니다. 시나리오 2(긴 답): 10초가 먼저 지나가니 (A) 신호가 발화하고, 그 후 큐 배출(20초 시점)은 이미 보상이 주어진 뒤라 무시됩니다. 시나리오 3(인터럽트): 5초만 듣고 마이크를 다시 누르면 두 신호 모두 취소되고 보상은 없습니다. 학습 동기를 만드는 핵심 장치이면서도, 짧은 답에서 너무 쉽게 보상이 풀리지 않도록 균형을 잡는 디자인입니다.
코드 — 두 핸들러와 OR 게이트
// 답변 청취 보상 — +5점은 (A) 10초 OR (B) 큐 완전 배출 중 먼저 일어난 쪽에서. // 사용자가 5초 안에 인터럽트하면 보상 없음. const ANSWER_LISTEN_THRESHOLD_MS = 10000; let answerListenTimer = null; let answerAwarded = false; let suppressAnswerAward = false; // 인사·unclear 발화엔 보상 차단 function resetAnswerTracking() { if (answerListenTimer) { clearTimeout(answerListenTimer); answerListenTimer = null; } answerAwarded = false; suppressAnswerAward = false; }코드 설명 — 매 turn 시작 시
resetAnswerTracking으로 상태 초기화합니다.answerAwarded는 이번 turn에서 이미 보상이 줬는지 추적하는 idempotency 플래그 — 두 신호가 동시에 도착해도 한 번만 +5점이 들어가게 보장합니다.suppressAnswerAward는 인사("안녕!")나 STT 환각 같은 케이스에서 답변 자체는 진행하되 점수 보상은 차단할 때 켜는 별도 게이트입니다(다른 글에서 다룬 unclear 처리와 연결).// (A) 10초 타이머 무장 — 첫 TTS 오디오 재생이 시작될 때 호출 function armAnswerListenTimer(myTurn) { if (answerAwarded || suppressAnswerAward || answerListenTimer) return; answerListenTimer = setTimeout(() => { answerListenTimer = null; if (myTurn === turnId) awardAnswerListened(); }, ANSWER_LISTEN_THRESHOLD_MS); } // (B) 큐 완전 배출 — playNextTts에서 큐가 비었을 때 호출 function onConversationDone(myTurn) { if (myTurn === turnId) awardAnswerListened(); } // 보상 부여 — 두 경로에서 모두 호출되며 idempotent function awardAnswerListened() { if (answerAwarded || suppressAnswerAward) return; answerAwarded = true; if (answerListenTimer) { clearTimeout(answerListenTimer); answerListenTimer = null; } postEvent('answer_listened'); // 서버에 +5점 이벤트 전송 }코드 설명 — 세 함수의 협력입니다.
armAnswerListenTimer는 첫 TTS 오디오가 재생을 시작할 때 호출돼 10초 타이머를 단 한 번만 무장합니다(이미 무장됐거나 보상이 풀렸으면 무시).onConversationDone은 TTS 큐가 비고 마지막 오디오까지 재생이 끝났을 때 호출됩니다. 둘 다awardAnswerListened를 호출하는데, 그 함수는answerAwarded플래그를 체크해 한 번만 보상을 부여합니다. 두 신호가 거의 동시에 도착하는 경계 케이스(타이머 직전에 큐 배출)도 이 idempotency로 안전하게 처리됩니다.turnId비교는 인터럽트 방어선입니다. 사용자가 마이크를 다시 누르면turnId가 증가하는데, 그 사이에 발동된 setTimeout 콜백이나 비동기 onended 콜백은 자기가 시작될 때의myTurn을 기억해두고, 콜백 시점에turnId와 비교해 다르면 결과를 무시합니다. 이게 인터럽트 시 보상 취소의 메커니즘입니다.왜 10초인가
10초라는 숫자는 임의로 정한 게 아니라 사용성 관찰을 거쳐 결정됐습니다.
5초로 시작했더니 너무 쉬웠습니다. 챗봇이 "음, 그건 말이지…" 정도만 말해도 5초가 흘러서 보상이 풀렸고, 학습 동기로 작동하기엔 약했습니다. 15초로 올렸더니 너무 가혹했습니다. 평균적인 답변(약 8~12초)이 끝나기 전에 보상이 풀리지 않으니, 자연 종료(B)에만 의존하게 됐고 인터럽트 한 번이면 보상이 사라져 답답했습니다.
10초는 "내용이 들어가는 답변의 도입부 + 한 호흡" 정도의 길이입니다. 이 정도 들으면 답의 핵심은 거의 잡히고, 그 시점에 다음 질문을 해도 학습 흐름이 깨지지 않습니다. 자연 종료(B)와 인공 임계값(A) 사이의 균형을 가장 잘 잡는 값이었습니다.
보상이 학습에 미친 효과
이 한 가지 보상을 적용한 뒤 아이의 사용 패턴이 두 가지 측면에서 바뀌었습니다.
첫째, 답을 끝까지 듣는 빈도가 늘었습니다. 이전에는 한두 마디 듣고 다음 질문으로 넘어가는 패턴이 잦았는데, 보상 도입 후 끝까지 듣고 그 답을 곱씹는 시간이 자연스럽게 생겼습니다. "+5"가 화면에 떠오르는 시각적 보상이 작은 도파민으로 작동하는 것 같습니다.
둘째, 긴 답에 대한 인내심이 늘었습니다. 이전엔 답이 길면 중간에 끊고 짧게 다시 묻는 경향이 있었는데, 10초 임계값을 넘기면 보상이 들어오니 그 시간 동안은 일단 들어보는 패턴이 생겼습니다. 그 10초 안에 답의 핵심이 들어 있으면 인터럽트할 필요가 없어집니다.
이 두 변화가 합쳐져서 대화 한 turn당 학습 밀도가 높아졌습니다. 같은 질문 수에서 답을 더 깊이 받아들이는 방향으로 행동이 바뀐 거죠. 게이미피케이션의 흔한 비판인 "외재적 동기가 내재적 동기를 약화시킨다"가 걱정됐지만, 짧은 답엔 즉시 보상·긴 답엔 시간 임계값이라는 비대칭 디자인이 학습 행동을 촉진하는 방향으로 잘 작동했습니다.
마치며 — 두 신호의 OR 게이트는 단순한데 효과는 미묘하다
이 디자인은 게이미피케이션의 한 작은 부분일 뿐입니다. 그러나 두 신호(시간·자연 종료)의 OR 게이트라는 단순한 구조가 의외로 미묘한 효과를 냅니다. 답 길이에 무관하게 적당히 보상이 풀리고, 인터럽트가 자연스럽게 보상을 차단하고, 짧은 답에선 자연 종료가 빨리 일어나 즉시 만족감을 줍니다.
일반화하면 — "수행했다"의 정의는 보통 단일 신호로 잡기 어렵고, 두 개 이상의 신호의 결합으로 정의하는 게 강건하다는 발상입니다. 시간 임계값만 두면 짧은 답에서 보상이 너무 쉽고, 자연 종료만 두면 긴 답에서 보상이 너무 가혹합니다. 둘을 OR로 연결하면 양 끝의 케이스를 모두 자연스럽게 처리합니다. 이런 작은 조합이 게이미피케이션을 "유치한 점수 시스템"에서 "학습을 미세하게 유도하는 장치"로 만듭니다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
자율 스크립트의 부팅과 셧다운 — 외부 자원을 잡았다면 정확히 한 번만 놓아라 (Ralph Loop 시리즈 2편) (0) 2026.05.16 AI 한테 코드를 자동으로 시킬 때 — 컨텍스트를 3축으로 쪼개라 (Ralph Loop 시리즈 1편) (0) 2026.05.16 시각 피드백의 시간차 — ripple·fly-up·confetti의 650/900/1100ms (0) 2026.05.15 SQLite로 streak를 영리하게 — substr DATE와 cursor 역순 (0) 2026.05.14 5축 25배지로 학습 동기를 설계하기 — 단기 도파민과 장기 약속 (1) 2026.05.14 '잘 못 들었어요' 한 줄의 UX — STT 거부 후의 회복 흐름 (0) 2026.05.13 버튼 하나로 끝나는 UI — 음성 챗봇의 4-state 상태머신 (0) 2026.05.13 마이크 떼자마자 STT를 깨우는 법 — Warmup POST 패턴 (0) 2026.05.13 LLM 토큰을 듣자마자 TTS에 넣기 — 문장 경계 큐잉으로 첫 음성 지연 압축 (0) 2026.05.12 자체 인증서 없이 모바일에서 마이크 권한 받기 — Tailscale serve의 한 줄 (0) 2026.05.12