음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

너무 느려서 내가 안 썼다 — 음성 앱 레이턴시를 온디바이스 STT로 잡은 방법


온디바이스 STT는 음성 앱의 속도 문제와 프라이버시 문제를 동시에 해결할 수 있는 카드입니다. 그런데 이 글에서 하려는 이야기는 온디바이스 STT가 얼마나 뛰어난 기술인가가 아닙니다. 이 시리즈에서 가장 부끄러운 발견, 바로 내가 만든 앱을 내가 쓰지 않고 있었다는 사실을 먼저 꺼내야 합니다.

6편을 올리고 나서 일주일쯤 지났을 때였습니다. 앱스토어 심사를 기다리는 동안 실제로 어떻게 메모를 남기고 있는지 무심코 들여다봤습니다. 사파리에 홈 화면 단축키로 추가해둔 간단한 받아쓰기 페이지가 화면에 떠 있었습니다. JustBlurt가 아니었습니다. 거기서 말하고, 텍스트를 복사해서, Notion에 붙여넣고 있었습니다. 3단계짜리 수작업으로요. 그게 훨씬 빠르다고 몸이 판단하고 있었던 것입니다.

이유는 단순했습니다. 제 앱이 너무 느렸습니다. 만든 사람조차 못 참을 정도로요. 이 글은 그 레이턴시를 어떻게 진단하고, 온디바이스 STT로 어떻게 피벗하기로 했는지의 기술 회고입니다(이 시리즈의 다른 글처럼, 실제 기록이되 서비스명과 세부 정보는 바꿔 씁니다).


느렸던 구조

당시 파이프라인의 전체 흐름을 먼저 짚겠습니다. 사용자가 녹음 버튼을 눌렀다 뗍니다. 그 순간부터 Notion에 내용이 저장되기까지의 경로가 중요합니다.

  1. 사용자가 마이크 버튼을 누르고 말을 합니다.
  2. 녹음이 끝난 음성 파일을 통째로 서버에 업로드합니다.
  3. 서버에서 Gemini가 오디오를 멀티모달로 처리 — 받아쓰기, 분류, 구조화를 한 번의 API 호출로 처리합니다.
  4. 처리된 결과를 Notion API로 씁니다.
  5. 이 전 과정이 끝날 때까지 사용자는 로딩 화면 앞에 동기적으로 묶여 있습니다.

설계 의도 자체는 나쁘지 않았습니다. Gemini의 오디오 멀티모달 처리 능력은 실제로 인상적입니다. 음성을 텍스트로 변환하는 것에서 그치지 않고, 내용을 분류하고, 관련 Notion 페이지를 추천하고, 구조화된 포맷으로 정리해주는 모든 작업을 단 하나의 API 호출로 처리합니다. 서버가 할 수 있는 일의 범위가 클라이언트보다 월등히 넓으니, 최대한 서버에 위임하겠다는 판단이었습니다.

문제는 그 "좋은 결과"를 기다리는 시간이었습니다. 말을 마친 뒤 첫 반응이 돌아오기까지 화면은 멈춰 있었습니다. 짧은 메모 하나를 남기려고 15초를 기다리는 경험은, 아무리 결과물이 깔끔해도 일상적으로 반복하기 어렵습니다. 빠른 메모를 위해 만든 앱인데, 정작 가장 느린 경험을 주고 있었던 것입니다.

더 솔직하게 말하면, 저는 심사 대기 기간 동안 앱을 거의 켜지 않았습니다. 테스트용으로 필요할 때만 실행했고, 실제 메모가 필요한 순간에는 자연스럽게 다른 도구를 집어 들었습니다. 이것이 앱이 실패하고 있다는 신호입니다. 출시도 되지 않은 앱이 이미 만든 사람에게 외면받고 있었습니다.


병목을 코드로 확인하다

체감만으로 결론을 내리지 않기로 했습니다. 구조적으로 어디서 시간이 가장 많이 소요되는지 코드를 따라가며 지점을 특정했습니다. 기록을 남기려면 수치가 있어야 한다고 생각했고, 파이프라인의 각 단계 앞뒤로 타임스탬프를 찍었습니다.

결과는 세 곳으로 집약됐습니다.

  • 오디오 파일 업로드: 30초짜리 음성 파일은 생각보다 무겁습니다. 모바일 네트워크 환경에서 업로드 자체에만 2~4초가 소요됐습니다. Wi-Fi에서는 괜찮았지만, LTE나 5G 환경에서 건물 안에 있을 때는 더 길어졌습니다. 메모를 남기고 싶은 순간이 항상 빠른 네트워크 환경에 있지는 않습니다.
  • Gemini 멀티모달 오디오 처리: 텍스트만 보내는 Gemini 호출과 비교했을 때 오디오 첨부 호출은 눈에 띄게 느렸습니다. 오디오 데이터를 서버가 읽고, 인코딩하고, 처리하는 시간이 추가됩니다. 짧은 메모조차 이 단계에서 4~8초가 소요됐습니다.
  • 전 과정 동기 대기: 위 두 단계가 직렬로 완료될 때까지 사용자는 로딩 인디케이터 외에 아무것도 볼 수 없었습니다. 결과가 나오기 전까지는 다음 메모를 남기는 것도 불가능했습니다. 앱을 닫거나 다른 곳을 터치하면 진행 중인 작업이 중단될 수 있었기 때문에 사용자는 화면 앞에 묶여야 했습니다.

세 지점이 직렬로 쌓이니 합산 지연은 최악의 경우 10초를 넘겼습니다. 단순히 "한 군데가 느린" 게 아니라, 느린 단계들이 줄줄이 직렬로 이어진 구조였습니다. 어느 한 단계만 빠르게 만들어서는 체감이 바뀌지 않습니다. 구조 자체를 바꿔야 했습니다.

측정하면서 한 가지를 더 확인했습니다. 업로드에서 Gemini 처리, Notion 쓰기까지 각 단계의 실패율이 0이 아니었습니다. 네트워크가 불안정하면 업로드가 실패합니다. Gemini API가 타임아웃을 낼 때도 있었습니다. Notion API가 간헐적으로 느려지는 시간대도 있었습니다. 이 세 단계가 모두 성공해야만 메모 하나가 저장되는 구조는 실패 확률이 직렬로 곱해집니다. 빠른 메모 앱에서 메모가 조용히 사라지는 경험은 치명적입니다.

수치를 보고 나자 판단이 명확해졌습니다. 오디오를 서버에 보내는 구조 자체가 근본 원인이었습니다. 속도를 잡으려면 아키텍처를 바꿔야 했습니다.


피벗 — 온디바이스 STT + 낙관적 UI

새 구조의 핵심 아이디어는 단순합니다. 받아쓰기는 기기에서 끝내고, 서버에는 텍스트만 보냅니다. iOS의 Speech framework는 온디바이스 음성 인식을 제공합니다. 말을 하는 동안 실시간으로 텍스트가 화면에 나타납니다. 오디오 파일을 만들고 업로드할 필요가 없습니다.

이 방향으로 피벗하면 파이프라인이 어떻게 바뀌는지 단계별로 보겠습니다.

  • 기존: 녹음 종료 → 파일 생성 → 서버 업로드 → Gemini 오디오 처리 → Notion 쓰기 → UI 갱신
  • 변경 후: 말하는 동안 실시간 STT → 말 끝내는 순간 텍스트 확정 → 서버에 텍스트 전송 → Gemini 텍스트 처리 → Notion 쓰기

두 번째 결정이 첫 번째만큼 중요합니다. UI를 낙관적으로 만드는 것입니다. 사용자가 말을 마치는 순간, 확정된 텍스트를 화면에 즉시 표시하고 "저장됨" 상태를 먼저 보여줍니다. Gemini 처리와 Notion 쓰기는 백그라운드에서 진행됩니다. 분류 결과나 구조화된 포맷이 준비되면 조용히 화면을 갱신합니다. 사용자가 결과를 기다리며 멈춰 있어야 하는 구간이 사라집니다.

낙관적 UI는 "아직 저장이 안 됐는데 저장됐다고 보여주는" 기법입니다. 실패 시 롤백 처리가 필요하므로 구현이 더 복잡합니다. 그러나 음성 메모 앱의 맥락에서 이 트레이드오프는 명확합니다. 대부분의 경우 Gemini 호출과 Notion 쓰기는 성공합니다. 실패하는 소수의 케이스를 위해 모든 사용자가 매번 기다려야 하는 구조는 잘못된 선택입니다.

아키텍처 변경이 가져오는 부수 효과도 컸습니다.

  • 속도: 오디오 업로드가 사라집니다. Gemini 호출이 오디오 처리에서 텍스트 처리로 바뀌어 처리 시간이 짧아집니다. 낙관적 UI로 사용자가 기다리는 체감 시간은 거의 0에 가까워집니다.
  • 프라이버시: 음성이 기기를 떠나지 않습니다. 6편에서 앱스토어 심사 반려 사유 중 하나가 오디오 수집에 대한 프라이버시 정책 명시 문제였습니다. 온디바이스 STT는 그 우려를 구조적으로 해소합니다. 서버에 보내는 것은 텍스트뿐이고, 텍스트는 오디오와 달리 음성 정보가 없습니다.
  • 비용: Gemini의 오디오 멀티모달 처리 비용이 통째로 사라집니다. 텍스트 처리는 오디오 처리에 비해 단가가 낮습니다. 유료 플랜 설계 시 여유가 생깁니다.
  • 오프라인 내성: STT 단계가 기기 안에서 완결되므로 네트워크가 없는 순간에도 텍스트 확정까지는 진행됩니다. 이후 Notion 동기화만 지연되거나 큐에 쌓이는 방식으로 처리하면 오프라인 상황을 자연스럽게 처리할 수 있습니다.

5편에서 분류·정제·구조화 로직을 독립적인 모듈로 잘라두었던 것이 여기서 보상을 줬습니다. 입력 경로만 바꾸면 됐습니다. 오디오가 들어오던 자리에 텍스트가 들어오도록 연결하는 작업이었습니다. 그 뒤의 AI 파이프라인은 그대로 재사용했습니다. 모듈 단위로 설계해두지 않았다면 이 변경이 훨씬 더 큰 작업이 됐을 것입니다.


실전 함정 — 의존성 버전이 빌드를 깼다

설계를 마치고 구현에 들어갔을 때, 가장 시간을 많이 잡아먹은 것은 아키텍처 변경이 아니었습니다. 의존성 버전이었습니다. 구체적으로는 expo-speech-recognition 라이브러리를 설치하는 과정에서 발생한 네이티브 빌드 실패였습니다.

패키지 매니저는 기본적으로 최신 버전을 가져옵니다. 설치 명령을 실행했을 때 받아진 버전은 더 높은 Expo SDK를 전제로 작성된 것이었습니다. 제 프로젝트가 사용하고 있는 Expo SDK 버전과 맞지 않았고, 결과는 네이티브 자동 링크 실패였습니다. 자바스크립트 레이어에서는 모듈이 등록된 것처럼 보였지만, 네이티브 모듈이 실제로 연결되지 않아 런타임에서 undefined를 돌려줬습니다. 개발 빌드에서는 크래시도 없이 그냥 작동하지 않는 상태였습니다.

"설치는 됐는데 작동하지 않는" 상태는 "설치 실패"보다 디버깅하기 어렵습니다. 에러 메시지가 없기 때문입니다. 처음에는 권한 설정 문제인지, 코드 문제인지, 라이브러리 API 변경인지 하나씩 의심했습니다. 스택 오버플로우에서 비슷한 증상을 찾는 데 시간이 걸렸고, 결국 원인이 SDK 버전 불일치라는 것을 확인하기까지 두 시간 넘게 소요됐습니다.

해결은 명시적 버전 고정이었습니다. expo-speech-recognition의 릴리스 노트와 이슈 트래커를 뒤져서 제가 사용하는 Expo SDK 버전을 지원하는 마지막 릴리스를 찾았습니다. package.json에 해당 버전을 직접 명시하고, 클린 인스톨을 진행했습니다. 그 뒤 클린 빌드로 네이티브 모듈이 올바르게 링크되는지, 권한 설명이 Info.plist에 자동 주입되는지, 모듈 자동 등록이 AppDelegate에 반영되는지를 확인했습니다.

빌드가 통과되고 시뮬레이터에서 처음으로 온디바이스 STT가 동작했을 때, 말하는 동안 텍스트가 실시간으로 화면에 나타났습니다. 오래 걸리지 않았습니다. 마이크 입력이 시작되고 첫 단어가 화면에 반영되기까지 체감상 100밀리초 안팎이었습니다. 서버에 파일을 올리고 결과를 기다리던 구조와는 완전히 다른 반응 속도였습니다.

이 경험에서 배운 것은 하나입니다. 네이티브 모듈은 "최신"이 아니라 "내 SDK와 짝이 맞는" 버전을 써야 합니다. AI 에이전트가 코드를 작성할 때는 자연스럽게 최신 버전을 제안합니다. 순수 자바스크립트 패키지라면 큰 문제가 없지만, 네이티브 브리지를 포함한 React Native 패키지는 Expo SDK 또는 React Native 버전과의 호환성을 별도로 확인해야 합니다. 에이전트가 설치 명령을 알아서 작성해줄 때 버전을 한 번 더 확인하는 습관이 필요합니다.


또 한 번의 제품 중독이 되지 않으려면

레이턴시 문제를 해결하고 나서 잠시 멈췄습니다. 온디바이스 STT가 동작하고, 낙관적 UI가 붙었고, 앱이 빨라졌습니다. 그리고 바로 이 시점이 위험합니다. 앱이 빨라졌으니 이제 UX를 더 다듬고 싶어집니다. 피드백 애니메이션이 아직 투박합니다. 실시간 텍스트 전환 타이밍을 조금 더 자연스럽게 만들 수 있습니다. Notion에 쓰여진 결과를 앱 안에서도 미리 보여주는 기능을 넣으면 좋겠습니다.

3편에서 기록했던 "준비 중독"의 패턴과 정확히 같습니다. 당시에는 제출 전에 완성도를 높이려다 출시 자체를 무기한 미뤘습니다. 그 사이 심리적 에너지가 소진됐고, 결국 전면 재작성으로 도망쳤습니다. 지금 다시 그 입구에 서 있는 것입니다. 레이턴시를 고쳤다는 성취감이 다음 개선을 정당화하는 동기가 됩니다. 그리고 그 다음 개선도, 그다음 개선도.

이번에 스스로에게 박아둔 기준은 이것입니다.

레이턴시를 고쳤으면, 그건 곧바로 출시·배포·사용자 확보로 이어져야 한다. 더 다듬고 싶은 충동이 들면 그게 또 한 번의 제품 중독이다.

온디바이스 STT 피벗은 실사용을 막는 진짜 병목을 제거하는 작업이었습니다. 그것은 정당한 제품 개선입니다. 그러나 병목이 사라진 뒤에도 계속 내부를 다듬는 것은 다른 이유에서 하는 일입니다. 사용자가 없는 상태에서의 완성도는 만드는 사람을 위한 것이지, 사용자를 위한 것이 아닙니다.

JustBlurt의 진짜 병목은 이제 기술이 아닙니다. 사용자입니다. 아직 아무도 쓰지 않습니다. 실제로 쓰는 사람이 한 명이라도 생기면 피드백이 생깁니다. 피드백이 생기면 무엇을 고쳐야 하는지 알 수 있습니다. 그것이 기준이 되어야 합니다. 사용자가 없는 상태에서 내가 느끼는 불편함만으로 개선 우선순위를 정하는 것은 결국 내 취향으로 앱을 만드는 것입니다.

마지막 편에서, 매출 $0에서 다시 세운 우선순위를 적습니다. 이번엔 제품이 아니라 사람부터입니다.



댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기