맥에서 말하면 노션에 바로 꽂힌다 — 온디바이스 받아쓰기 메뉴바 앱 직접 만들기
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
시리즈 · 직접 만든 macOS 도구 — 메뉴바에 사는 작은 앱들을 하나씩 만들어 온 기록입니다.
- Claude Code 사용량 메뉴바 모니터
- AI 에이전트 상태 모니터 (파이썬 rumps)
- 맥북 배터리 건강·메뉴바 유틸 3선
- 말하면 노션에 꽂히는 온디바이스 받아쓰기 앱 — 지금 보는 글
- 맥 절전·자리비움 방지 (마우스 지글러)
폰에 대고 말하면 노션에 저장되는 앱을 반년 넘게 만들어 써 왔습니다. 그런데 정작 일하는 시간의 대부분은 맥북 앞입니다. 코드를 짜다가, 문서를 읽다가 문득 떠오른 한 줄을 남기려고 폰을 집어 드는 그 몇 초 사이에 생각은 벌써 절반쯤 날아가 있었습니다.
맥에서 원한 건 단순했습니다. 화면을 벗어나지 않고, 손이 키보드를 떠나지 않고, 말 한마디가 그대로 노션에 남는 것. 마침 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를 누른 뒤 한 문장 말하면 끝이라, 흐름이 거의 끊기지 않습니다.
돌아보면 이 도구의 값어치는 인식 정확도나 화려한 기능이 아니라, 생각과 기록 사이의 거리를 최대한 좁힌 데 있었습니다. 외부 서비스를 거치지 않아 비용도 지연도 없고, 네트워크가 없어도 일단 남습니다. 남긴 문장을 나중에 노션에서 다듬는 건 그다음 일입니다.
구조 자체는 단순합니다. 전역 단축키, 온디바이스 인식, 노션 클라이언트, 오프라인 큐 — 이 네 조각을 붙였을 뿐입니다. 같은 불편을 느끼는 분이라면 단축키와 노션 데이터베이스 설정만 자기 것으로 바꾸면 그대로 쓸 수 있는 형태로 정리해 두었습니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기