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

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

맥에서 말하면 노션에 바로 꽂힌다 — 온디바이스 받아쓰기 메뉴바 앱 직접 만들기

시리즈 · 직접 만든 macOS 도구 — 메뉴바에 사는 작은 앱들을 하나씩 만들어 온 기록입니다.

  1. Claude Code 사용량 메뉴바 모니터
  2. AI 에이전트 상태 모니터 (파이썬 rumps)
  3. 맥북 배터리 건강·메뉴바 유틸 3선
  4. 말하면 노션에 꽂히는 온디바이스 받아쓰기 앱 — 지금 보는 글
  5. 맥 절전·자리비움 방지 (마우스 지글러)

폰에 대고 말하면 노션에 저장되는 앱을 반년 넘게 만들어 써 왔습니다. 그런데 정작 일하는 시간의 대부분은 맥북 앞입니다. 코드를 짜다가, 문서를 읽다가 문득 떠오른 한 줄을 남기려고 폰을 집어 드는 그 몇 초 사이에 생각은 벌써 절반쯤 날아가 있었습니다.

맥에서 원한 건 단순했습니다. 화면을 벗어나지 않고, 손이 키보드를 떠나지 않고, 말 한마디가 그대로 노션에 남는 것. 마침 macOS에는 이미 쓸 만한 음성 인식 엔진이 깔려 있었습니다. 그걸 노션에 연결하는 작은 메뉴바 앱을 Swift로 만들었습니다. 어떻게 돌아가는지, 어디서 막혔는지를 코드와 함께 풀어 보겠습니다.

1. 아이폰 앱이 있는데 맥에서 또 만든 이유

같은 "음성 → 노션"인데 왜 또 만들었느냐고 하면, 입력하는 자리가 다르기 때문입니다. 폰 앱은 걷다가, 지하철에서, 자리를 뜬 상태에서 쓰기 좋습니다. 반대로 맥에서 일할 때 폰을 드는 동작은 그 자체가 흐름을 끊습니다. 브라우저와 에디터를 오가던 손이 책상 위 폰으로 향하는 순간, 방금 붙잡으려던 생각은 이미 옅어져 있습니다.

그래서 맥용은 조건이 하나 더 붙었습니다. 어떤 앱을 쓰고 있든 그 위에서 바로 녹음이 시작돼야 한다는 것. 창을 새로 띄우거나 특정 앱으로 전환하는 순간 이미 진 겁니다. 전역 단축키 하나로 지금 보고 있는 화면 그대로 마이크가 열리는 게 이 앱의 출발점이었습니다.

2. 클라우드 STT를 안 쓰기로 한 결정

폰 앱에서는 정확도 때문에 결국 클라우드 STT로 넘어갔습니다. 그런데 맥용을 짜면서는 처음부터 반대로 갔습니다. macOS의 Speech 프레임워크가 제공하는 온디바이스 인식을 쓰기로 한 겁니다. 시스템 설정의 "받아쓰기"와 같은 엔진이라, 언어 데이터만 한 번 내려받으면 인터넷 없이도 돕니다.

이유는 세 가지였는데 무게가 서로 달랐습니다. 우선 짧은 메모 한 줄을 남길 때마다 외부 API 호출 비용과 왕복 지연을 감당할 이유가 없었습니다. 그리고 떠오른 생각을 말로 뱉는 도구가 매번 서버를 거친다는 게 마음에 걸렸습니다 — 회의 중 흘린 한마디가 어딘가로 전송된다는 사실 자체가요. 마지막으로, 비행기나 지하 회의실처럼 네트워크가 없는 자리에서도 녹음은 되어야 했습니다.

코드에서 온디바이스를 강제하는 부분은 이 한 줄입니다.

let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
if recognizer.supportsOnDeviceRecognition {
    request.requiresOnDeviceRecognition = true   // 서버로 오디오를 보내지 않음
}

requiresOnDeviceRecognition = true를 걸면 오디오 버퍼가 기기 밖으로 나가지 않습니다. 대신 인식 품질은 시스템에 내려받은 언어 모델에 의존하게 되는데, 짧은 메모 용도로는 충분했습니다. 뭉개진 부분은 뒤에서 한 번 더 다듬는 장치를 따로 두었습니다(4장).

3. ⌘⇧D 한 번에 벌어지는 일

앱은 Dock에 뜨지 않고 메뉴바에만 상주합니다. 아이콘 하나가 상태를 알려줍니다 — 대기, 녹음 중, 전송 중, 설정 필요. 내부는 작은 상태 기계로, idle → recording → refining → sending 순서를 오갑니다.

3.1 어디서든 잡히는 전역 단축키

전역 단축키는 Carbon의 RegisterEventHotKey로 등록합니다. 오래된 API지만 다른 앱이 포커스를 쥐고 있어도 확실히 잡힌다는 점에서 여전히 쓸 만합니다.

func register(keyCode: UInt32 = UInt32(kVK_ANSI_D),
              modifiers: UInt32 = UInt32(cmdKey | shiftKey)) {
    RegisterEventHotKey(keyCode, modifiers, hotKeyID,
                        GetApplicationEventTarget(), 0, &hotKeyRef)
}

⌘⇧D를 한 번 누르면 녹음이 시작되고, 다시 누르면 인식을 마치고 전송으로 넘어갑니다. 같은 키로 시작과 종료를 토글하는 방식이라 따로 외울 게 없습니다.

3.2 인터넷 없이 도는 받아쓰기

녹음이 시작되면 AVAudioEngine의 입력 노드에 탭을 걸어 오디오 버퍼를 인식 요청에 계속 흘려보냅니다. 부분 결과(partial)를 받아 HUD에 실시간으로 보여 주고, 종료 시점의 최종 문장을 다음 단계로 넘깁니다. 이 과정 전체가 기기 안에서 끝납니다.

3.3 노션에 새 페이지로 꽂기

인식된 문장은 노션 REST API의 POST /v1/pages로 데이터베이스에 새 페이지가 됩니다. 제목 속성에는 나중에 찾기 쉽도록 음성 메모: 말머리를 붙이고, 같은 내용을 본문 문단 블록으로도 넣습니다.

req.setValue("Bearer \(settings.notionToken)", forHTTPHeaderField: "Authorization")
req.setValue(settings.notionVersion,           forHTTPHeaderField: "Notion-Version")
// body: { parent: { database_id }, properties: { <title> }, children: [ paragraph ] }

노션 API에서 처음 헤매기 쉬운 지점은 인증이 아니라 연결입니다. 통합(Integration)을 만들어 토큰을 받아도, 그 통합을 대상 데이터베이스에 Connections로 연결하지 않으면 404가 납니다. 토큰은 멀쩡한데 객체를 못 찾는다고 나와서 한참 엉뚱한 곳을 팠습니다.

3.4 오프라인이면 잠시 넣어둔다

네트워크가 없거나 노션이 5xx·429로 응답하면, 텍스트를 pending.json에 넣어 두고 알림만 띄웁니다. 연결이 돌아오면 큐를 비우며 자동으로 다시 보냅니다.

public func enqueue(_ text: String) {
    var items = load()
    items.append(Item(text: text, createdAt: Date()))
    save(items)          // ~/Library/Application Support/.../pending.json
}

한 가지 구분을 뒀습니다. 토큰 오타 같은 4xx 오류는 몇 번을 다시 보내도 똑같이 실패하므로 재시도 대상에서 빼고 원인을 바로 알립니다. 큐가 죽은 요청으로 막히지 않게 하려는 겁니다. 재시도는 일시적인 실패(오프라인, 서버 5xx, 429)에만 의미가 있으니까요.

4. 말한 뒤 한 번 더 다듬고 싶을 때

온디바이스 인식은 빠르고 공짜지만, 말할 때 섞여 나오는 "어", "음", "그" 같은 필러나 구어체 어미가 그대로 남습니다. 그래서 전송 직전에 로컬 LLM으로 한 번 정제하는 단계를 옵션으로 넣었습니다. 로컬에 띄운 Ollama에 문장을 넘기고, 다듬어진 결과를 받아 노션에 보냅니다.

let body: [String: Any] = [
    "model": settings.ollamaModel,
    "stream": false,
    "messages": [
        ["role": "system", "content": "받아쓰기 오류 교정, 필러 제거, 구어체를 문어체로. 의미는 절대 바꾸지 말 것."],
        ["role": "user", "content": text]
    ]
]

여기서 중요한 건 실패했을 때의 태도입니다. Ollama가 안 떠 있거나 응답이 비면, 정제를 포기하고 원본 텍스트를 그대로 노션에 보냅니다. 다듬기는 어디까지나 있으면 좋은 단계고, 그것 때문에 메모 자체를 잃는 건 본말이 전도된 겁니다. 온디바이스 인식과 마찬가지로 이 정제도 전부 로컬에서 끝나니, 문장이 밖으로 나가는 일은 없습니다.

5. 만들면서 막혔던 자리

가장 오래 붙든 건 앞서 말한 노션 404였습니다. 통합을 데이터베이스에 연결하는 단계를 문서에서 놓치고, 토큰만 열 번쯤 다시 발급했습니다.

두 번째는 받아쓰기 언어입니다. 온디바이스 인식은 해당 언어가 기기에 준비돼 있어야 하는데, 한국어는 시스템 설정의 받아쓰기에서 한 번 내려받아야 활성화됩니다. 이걸 안 하면 인식기가 조용히 사용 불가 상태로 떨어져서, 마이크는 켜지는데 아무 글자도 안 나오는 상황이 됩니다.

한 가지 곁다리 사정도 있었습니다. 이 프로젝트는 Windows 쪽에서 코드를 짰지만 빌드와 실행은 맥에서만 됩니다. Speech 프레임워크도, 코드 사인도 macOS에서만 돌기 때문입니다. 그래서 폴더를 통째로 맥으로 옮겨 build.sh로 .app 번들을 만들고 ad-hoc 사인을 붙이는 흐름으로 정리했습니다. 파이프라인·노션 클라이언트·오프라인 큐 같은 핵심 로직은 UI와 분리한 코어 모듈로 빼서, 실기 없이도 테스트로 검증할 수 있게 두었습니다.

마치며

이 앱을 쓰기 시작한 뒤로 달라진 건 "나중에 적어야지" 하고 미루는 순간이 사라진 것입니다. 예전에는 작업 중 떠오른 할 일을 어딘가 적으려다 창을 찾는 사이에 잊었습니다. 지금은 화면을 안 벗어나고 ⌘⇧D를 누른 뒤 한 문장 말하면 끝이라, 흐름이 거의 끊기지 않습니다.

돌아보면 이 도구의 값어치는 인식 정확도나 화려한 기능이 아니라, 생각과 기록 사이의 거리를 최대한 좁힌 데 있었습니다. 외부 서비스를 거치지 않아 비용도 지연도 없고, 네트워크가 없어도 일단 남습니다. 남긴 문장을 나중에 노션에서 다듬는 건 그다음 일입니다.

구조 자체는 단순합니다. 전역 단축키, 온디바이스 인식, 노션 클라이언트, 오프라인 큐 — 이 네 조각을 붙였을 뿐입니다. 같은 불편을 느끼는 분이라면 단축키와 노션 데이터베이스 설정만 자기 것으로 바꾸면 그대로 쓸 수 있는 형태로 정리해 두었습니다.

댓글

이 블로그의 인기 게시물

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

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

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