글

7월, 2026의 게시물 표시

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

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

음성봇에게 "말이 끝났다"를 어떻게 알려줄까 — VAD 엔드포인터 설계

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 — 지금 보는 글 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) 음성봇이 "사람이 말을 끝냈다"는 걸 대체 어떻게 알아챌까요? 사람은 상대의 말투, 침묵의 길이, 문맥을 종합해 직관적으로 판단하지만, 마이크에서 들어오는 건 그냥 숫자로 된 오디오 샘플뿐입니다. 이 판단을 놓치면 문제는 양쪽으로 다 생깁니다. 너무 성급하게 "끝났다"고 판단하면 사람이 숨 고르며 잠깐 멈춘 걸 끝난 것으로 착각해 말을 자르게 되고, 너무 오래 기다리면 봇이 굼떠 보입니다. 이 판단을 담당하는 조각을 엔드포인터(endpointer)라고 부르는데, 이번 편은 이걸 직접 설계한 기록입니다. 1. 왜 에너지 임계값이 "일단은" 정답인가 발화가 끝났는지 판단하려면 먼저 프레임 하나하나가 "말소리인지 아닌지"부터 판정할 수 있어야 합니다. 이 판정을 제대로 하려면 원래는 신경망 기반 음성 감지 모델을 써야 하지만, 처음부터 그걸 붙이면 판정 로직 자체가 맞게 동작하는지 검증하기가 오히려 어려워집니다. 그래서 첫 버전은 훨씬 단순한 방법을 택했습니다. 오디오 프레임의 RMS(제곱평균제곱근) 에너지를 구해서, 일정 임계값을 넘으면 말소리로 판정하는 방식입니다. DEFAULT_ENERGY_THRESHOLD: float = 500.0 def _is_speech(self, frame: bytes) -> bool: ret...

반려 한 건을 더 넘고서야 승인이 났다 — 개인 앱, 마지막 구간의 기록

제출했고, 또 한 번 걸렸고, 그리고 이번엔 넘었습니다. 지난 편 끝에서 남겨뒀던 두 가지 가능성 — 바로 승인이거나 다시 걸리거나 — 중 결국 후자로 흘러갔고, 거기서 한 걸음 더 나아가 승인까지 받아냈습니다. 반려 사유가 한 건으로 줄었다 사업자등록과 리브랜딩, 구독 가격 결정을 끝내고 빌드를 올렸습니다. 이번 심사에서는 이전에 걸렸던 항목들 — 아이콘, 구독 설정, 샌드박스 에러, 계정 삭제 기능 — 이 전부 통과했습니다. 반려는 딱 한 건이었습니다. 결제 화면 표기 문제였습니다. 연간 구독 카드에 "월 환산가"를 함께 보여주고 있었는데, 이 월 환산가가 실제 청구되는 연간 금액보다 화면에서 더 크게 강조돼 있었습니다. 사용자가 얼마를 실제로 결제하는지 헷갈릴 수 있다는 게 반려 사유였습니다. 기능이나 정책 위반이 아니라 화면 하나의 시각적 위계 문제였습니다. 숫자 하나를 고치는 데도 이유가 필요했다 고치는 자체는 어렵지 않았습니다. 연간 결제 카드에서 실제 청구액을 크게, 월 환산가는 작게 보조 표기로 내렸습니다. 버튼 문구도 "연 단위 청구 금액"이 먼저 보이도록 바꿨습니다. 다만 이게 왜 반려 사유가 되는지 이해하고 고치는 것과, 그냥 화면만 바꿔서 다시 올리는 것은 달랐습니다. 심사 가이드라인은 "사용자가 실제로 얼마를 내는지 오해하게 만들면 안 된다"는 원칙이고, 월 환산가 자체를 보여주는 건 문제가 아니었습니다. 어떤 숫자가 시각적으로 더 크게, 더 먼저 보이느냐가 문제였습니다. 이 구분을 명확히 하고 나서야 다른 화면(월간 구독 카드 등)에 같은 패턴이 남아있지 않은지도 같이 점검할 수 있었습니다. 재제출하고 이틀을 기다렸다 수정한 빌드를 다시 올렸습니다. 이번엔 이전 반려 이력에 대한 대응 메모도 같이 남겼습니다. 무엇을 어떻게 고쳤는지, 심사관이 다시 확인할 때 바로 알아볼 수 있게 정리한 것입니다. 첫 반려가 있었던 날로부터 이번 재제출까지, 그 사이에 사업자...

음성봇 "끼어들기"는 어떻게 처리할까 — 재생 중인 TTS를 실시간으로 끊는 법

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 — 지금 보는 글 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) 사람과 대화할 때 상대가 말하는 도중에 끼어드는 건 자연스러운 일입니다. 그런데 음성봇을 직접 만들어 보면, 이 당연한 동작이 꽤 까다로운 동시성 문제라는 걸 알게 됩니다. 봇이 한창 답을 음성으로 재생하고 있는데 사람이 다시 말을 걸면, 재생 중인 오디오를 그 자리에서 멈추고 새로 들어온 말을 받아야 합니다. 이 글은 직접 만든 음성봇에서 이 "끼어들기(barge-in)"를 어떻게 처리했는지 정리한 기록입니다. 1. 왜 그냥 멈추면 안 되나 가장 단순하게 생각하면 "새 발화가 들리면 재생을 멈춘다"로 끝날 것 같지만, 실제로는 몇 가지가 동시에 맞물립니다. 봇의 답변 음성은 텍스트를 다 만든 뒤 한 번에 재생하는 게 아니라, 합성되는 대로 조각조각(청크) 흘려보내며 재생합니다. 그래야 답이 길어도 첫 소리가 빨리 나옵니다. 그런데 이 스트리밍 재생이 백그라운드에서 계속 돌고 있는 동안, 마이크로 들어오는 사람 목소리도 동시에 감시해야 합니다. 두 흐름이 동시에 진행되는 상황에서 한쪽이 다른 쪽을 멈추게 만드는 것이 끼어들기의 핵심입니다. 여기다 재생을 멈출 때 리소스를 깔끔하게 정리하지 않으면, 음성 합성기가 물고 있던 내부 버퍼나 태스크가 계속 살아남아 다음 턴에서 이상 동작을 일으킵니다. 멈추는 것 자체보다 "제대로 멈추고 뒷정리까지 끝내는 것...

Vercel 서버리스로 API 키 숨기고 배포하기 — 브라우저 프로토타입을 백엔드로 옮기기

시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) 6. 브라우저 프로토타입 → Vercel 서버리스 — 지금 보는 글 프로토타입이 브라우저 단독이었다는 것의 함정 처음 만든 것은 HTML 파일 하나였습니다. 음성 인식(STT)도 브라우저가 하고, 인식된 문자열을 실제 주소로 보정하는 로직도 브라우저 안에서 돌았습니다. 데모를 보여주기에는 이만한 게 없습니다. 파일 하나 열면 바로 동작하니까요. 문제는 그 보정 로직이 외부 API 두 개에 의존한다는 데 있었습니다. 하나는 국토교통부 도로명주소 API의 승인키, 다른 하나는 LLM 폴백에 쓰는 Anthropic API 키입니다. 브라우저 안에서 이 API들을 호출한다는 것은, 그 키들이 클라이언트 코드 안에 그대로 박혀 있다는 뜻입니다. 개발자 도구만 열면 누구나 볼 수 있습니다. 키를 남에게 그대로 넘겨주는 코드를, 데모라는 이유로 계속 들고 갈 수는 없었습니다. 이게 서버로 옮긴 첫 번째 이유입니다. CORS 때문에 JSONP로 우회하던 흔적 두 번째 이유는 조금 더 기술적입니다. 국토부 도로명주소 API는 정식으로는 JSON을 돌려주는 엔드포인트를 제공합니다. 그런데 브라우저에서 이 JSON 엔드포인트를 직접 부르면 CORS(교차 출처 리소스 공유) 정책에 막힙니다. 다른 출처의 서버가 "브라우저에서 직접 불러도 된다"고 허용해주지 않으면, 브라우저는 응답을 읽지 못하게 막아버립니다. 그래서 프로토타입은 JSONP라는 우회로를 썼습니다. JSONP는 <script> 태그로 외부 스크...

규칙 기반 처리에 LLM 폴백 붙이기 — Claude API vs CLI 선택 (환각 없이 AI 쓰는 법)

이 파이프라인에서 LLM은 첫 번째 도구가 아니라 마지막 도구입니다. STT가 뱉은 문자열은 먼저 토큰화·숫자 변환·자모 유사도·국토부 실존 조회를 차례로 거칩니다. 이 결정론 경로에서 답이 나오면 LLM은 아예 부르지 않습니다. 빠르고, 결과가 항상 같고, 비용이 0이기 때문입니다. LLM을 부르는 건 그 경로가 전부 실패했을 때뿐입니다. 발음이 심하게 깨진 건물명, 어떤 조합으로 검색해도 국토부가 빈손으로 답하는 경우처럼, 규칙으로는 손댈 수 없는 애매함이 남았을 때입니다. 폴백의 위치를 이렇게 못 박아 두면, LLM이 잘못 부풀려 답해도 시스템 전체가 흔들리지 않습니다. 이번 편은 그 폴백의 자리와, 그것을 부르는 두 개의 경로를 다룹니다. 시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) — 지금 보는 글 6. 브라우저 프로토타입 → Vercel 서버리스 1. 폴백 세 종류, 그런데 호출구는 하나 LLM 폴백은 세 가지입니다. 첫째, 주소 정규화 — 룰 토크나이저가 못 풀었을 때 STT 원문을 표준 주소 문자열로 정리합니다. 둘째, 건물명 매칭 — 자모 유사도로도 답이 안 나올 만큼 심하게 깨진 건물명을, 국토부가 반환한 실제 후보 목록 중 하나로 골라 줍니다. 셋째, 검색어 제안 — 고정 조합으로 검색을 다 해봐도 실패했을 때 새 검색어 조합을 최대 4개까지 제안합니다. 세 용도는 성격이 다르지만, 실제로 모델을 부르는 자리는 callClaude 함수 한 곳입니다. 프롬프트를 만들고, 응답에서 JSON을 뽑고, 안전장치를 거는 흐름을 세 폴백이 공유합니...

한글로 읽은 숫자를 아라비아 숫자로 변환하기 — '이백팔십'을 280으로 (STT 후처리)

이 변환은 LLM에게 맡기지 않기로 처음부터 정했습니다. "이백팔십"이 280이라는 건 규칙이 정확히 알려진 문제입니다. 답이 하나로 딱 떨어지고, 같은 입력에 항상 같은 결과가 나와야 하며, 굳이 모델을 부를 이유가 없습니다. LLM을 쓰면 느리고, 비용이 들고, 아주 가끔이라도 엉뚱한 숫자를 내놓을 여지가 생깁니다. 반대로 코드로 짠 변환은 빠르고 공짜이고 언제나 같습니다. 그래서 이 편은 규칙으로 푼 결정론적 변환기 이야기입니다. 시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 — 지금 보는 글 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) 6. 브라우저 프로토타입 → Vercel 서버리스 1. 단위가 있으면 정석 계산: 이백팔십 → 280 한글 숫자에는 자리를 나타내는 단위가 있습니다. 십·백·천·만입니다. "이백팔십"에는 백과 십이 들어 있으니, 사람이 계산하듯 자리마다 값을 더해 주면 됩니다. 이(2)×백(100) + 팔(8)×십(10) = 280. 코드도 딱 그 순서로 읽습니다. 숫자 글자를 만나면 그 값을 기억해 두고, 단위 글자를 만나면 앞의 숫자에 단위를 곱해 누적합니다. 만(10000)은 자리 단위가 크니 그때까지 쌓인 값을 한 덩어리로 묶고 다시 시작합니다. let result = 0, section = 0, num = 0; for (const ch of str) { if (DIGITS[ch] !== undefined) { num = DIGITS[ch]; // 이, 팔 ... 숫자 글자 } else if (UNITS[c...

한글 자모 분해와 레벤슈타인으로 유사 단어 매칭하기 — 오타·발음 오류 잡는 법

음성으로 받은 주소에서 "역산동"이라는 문자열이 나왔다고 해 보겠습니다. 실제로는 "역삼동"인데 STT가 받침을 살짝 바꿔 들은 것입니다. 이제 실존 동네 목록에서 "역산동"과 가장 가까운 걸 찾아야 하는데, 여기서 흔한 방법인 문자열 비교가 이상하게 무력해집니다. 컴퓨터 입장에서 "역삼동"과 "역산동"은 가운데 한 글자가 통째로 다른 단어입니다. 삼 과 산 은 서로 다른 문자 코드를 가진, 아무 관계 없는 두 글자니까요. 사람 눈에는 받침 하나 차이지만, 글자 단위로 비교하면 "역삼동"과 "역삼구"만큼이나 멀게 나옵니다. 발음이 가까운 정도를 글자 비교로는 잴 수 없다는 뜻입니다. 시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 — 지금 보는 글 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) 6. 브라우저 프로토타입 → Vercel 서버리스 1. 왜 음절이 아니라 자모 단위인가 문제의 뿌리는 한글 한 글자가 여러 소리를 뭉쳐 하나의 코드로 저장된다는 데 있습니다. 삼 은 ㅅ+ㅏ+ㅁ이 합쳐진 한 음절이고, 산 은 ㅅ+ㅏ+ㄴ입니다. 둘은 초성 ㅅ과 중성 ㅏ가 같고 종성만 ㅁ↔ㄴ으로 다릅니다. 세 소리 중 두 개가 같은데도, 음절 단위로 비교하면 이 겹침이 전혀 보이지 않습니다. 완성된 글자끼리는 "같다/다르다" 두 값만 나오기 때문입니다. 그래서 비교 단위를 음절에서 자모(초성·중성·종성) 로 내려야 합니다. 삼 을 [ㅅ, ㅏ, ㅁ]으로, 산 을 [ㅅ, ㅏ, ㄴ]으로 펼쳐...

국토교통부 도로명주소 API 사용법 — 서버에서 주소 검색 호출하기 (CORS·JSONP 해결)

처음 이 기능을 프로토타이핑할 때는 브라우저에서 바로 국토부 API를 불렀습니다. 화면에 입력창 하나 놓고, 자바스크립트로 주소를 검색해 결과를 띄우는 식이었습니다. 그런데 브라우저에서 fetch 로 국토부 주소 검색 API를 그냥 부르면 CORS에 막힙니다. 국토부 서버가 다른 도메인에서 온 요청에 응답 헤더를 열어 주지 않기 때문입니다. 그래서 브라우저 버전에서는 JSONP로 우회했습니다. 국토부는 addrLinkApiJsonp.do 라는 별도 엔드포인트를 제공해서, <script> 태그로 불러오는 방식이라면 CORS 제약을 피할 수 있습니다. 동작은 하지만, JSONP는 콜백을 등록하고 응답을 기다리는 구조라 에러 처리와 타임아웃이 지저분해집니다. 보정 로직을 서버로 옮기면서 이 문제가 통째로 사라졌습니다. 서버 대 서버 호출에는 CORS가 없으니까요. 브라우저에서 쓰던 JSONP 우회를 걷어내고, 국토부가 원래 제공하는 정식 JSON 엔드포인트를 직접 부를 수 있게 됐습니다. 이번 편은 그 서버 쪽 호출 코드를 조각별로 뜯어봅니다. 시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) — 지금 보는 글 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) 6. 브라우저 프로토타입 → Vercel 서버리스 1. 정식 엔드포인트는 addrLinkApi.do 국토부 도로명주소 검색 API의 정식 엔드포인트는 addrLinkApi.do 입니다. 브라우저에서 쓰던 addrLinkApiJsonp.do 와 이름이 한 글자 차이지만 성격이 다릅니다. 앞의 것은 순수 JSON을 돌려주고, 뒤의 것은 JSONP 콜백으로 감...

음성인식 주소 오류, 국토부 API로 자동 보정하기 — STT가 틀린 한국 주소 고치는 법

음성으로 "테헤란로 이백팔십"이라고 말하면, STT는 그 소리를 들은 그대로 텍스트로 옮깁니다. 사람에게는 "280번지"라는 게 뻔하지만, STT에게는 그냥 "이백팔십"이라는 글자입니다. 여기에 도로명이 살짝 뭉개지거나 비슷한 발음의 다른 동네 이름이 섞이면, 결과 문자열은 실제로 존재하지 않는 주소가 됩니다. 주소를 텍스트로 입력받을 때는 안 겪던 문제입니다. 키보드로 치면 자동완성이 실존 주소를 골라 주니까요. 그런데 음성으로 받는 순간, STT가 뱉은 문자열이 진짜 주소인지 아닌지를 확인하고 고쳐 주는 단계가 통째로 비어 있습니다. 이 글은 그 빈 자리를 왜, 어떻게 메우는지에 대한 시리즈의 시작입니다. 시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요 — 지금 보는 글 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버) 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) 6. 브라우저 프로토타입 → Vercel 서버리스 1. 주소는 일반 문장과 다르다 STT 오류 보정이라고 하면 보통 맞춤법 교정기를 떠올립니다. 그런데 주소에는 일반 문장에 없는 제약이 하나 있습니다. 실제로 존재해야 한다 는 것입니다. 일반 문장은 조금 어색해도 뜻이 통하면 됩니다. 하지만 주소는 "그럴듯한" 걸로는 부족합니다. "서울 강남구 테헤란로 280"은 실존하고, "서울 강남구 테헤란로 281"은 없을 수 있습니다. 한 글자, 숫자 하나가 틀리면 배송이 안 가고 좌표가 안 찍힙니다. 그래서 주소 보정은 문장을 다듬는 문제가 아니라, 후보를 실존 주소 목록과 대조해 맞는 걸 찾는 문...

사업자등록만 하면 되는 줄 알았다 — 개인 앱 출시 직전에 만난 행정의 여러 겹

지난 편에서는 코드를 다 짜고 나면 바로 제출 버튼을 누를 수 있을 줄 알았다가, 최소 1-2주 걸리는 사업자등록부터 다시 시작해야 한다는 사실을 확인하는 데서 끝났습니다. 그때는 사업자등록만 통과하면 제출까지 갈 수 있을 거라고 믿었습니다. 실제로는 아니었습니다. 사업자등록은 벽이 아니라 첫 번째 겹이었고, 그 뒤로 몇 겹이 더 있었습니다. 이 글은 그 겹들을 지나며 함께 처리한 리브랜딩과 구독 가격 결정까지, 제출 버튼 직전까지의 기록입니다. 사업자등록은 끝이 아니라 자물쇠 하나였다 유료 앱, 특히 구독이 들어간 앱을 App Store에 올리려면 App Store Connect에서 Paid Apps Agreement 가 활성화(Active)되어야 합니다. 사업자등록은 이걸 켜기 위한 조건 중 하나일 뿐이었습니다. Agreement가 Active로 바뀌려면 그 아래 항목들이 전부 초록불이어야 했습니다. 사업체 정보, 대금을 받을 은행 계좌, 그리고 세금 서류입니다. 저는 해외(미국) 매출에 대한 원천징수 관련 양식(W-8BEN)과 한국 쪽 세금 정보를 각각 넣어야 했고, 이것들이 하나씩 검토를 거쳐 Active가 되는 걸 기다려야 했습니다. 하나라도 "보류" 상태면 Agreement 전체가 안 켜지고, Agreement가 안 켜지면 구독 상품을 "제출 준비 완료"로 만들 수가 없습니다. 코드에서 의존성 하나가 안 풀리면 그 위의 것들이 전부 빌드가 안 되는 것과 비슷했습니다. 다른 점은, 여기서는 내가 재시도 버튼을 누르는 게 아니라 심사 쪽의 처리를 기다려야 한다는 거였습니다. 손을 쓸 수 없는 대기 시간이 있다는 게 코드와 가장 다른 부분이었습니다. 기다리는 동안 이름을 바꿨다 승인을 기다리는 시간이 아깝기도 했고, 출시를 코앞에 두니 이름이 다시 눈에 들어왔습니다. 그래서 앱 이름을 JustBlurt에서 Talktion으로 바꿨습니다. 서브타이틀은 앱이 하는 일을 그대로 드러내는 Talk2Not...

I Got Tired of Alt-Tabbing to Notion Mid-Code. So I Built This.

There's a particular kind of friction that builds up when you're deep in a coding session and Notion is two clicks away. Not a big deal each time — maybe twenty seconds to switch windows, add the task, switch back. But those twenty seconds are enough to lose the thread you were holding. By the time you're back in the editor, something has already drifted. I tried a few workarounds. A scratch notes file as a buffer. A second monitor with Notion always visible. Keyboard shortcuts to flip between apps faster. None of it fixed the core thing: I still had to leave what I was doing, even briefly, and that interruption added up over a day. What I actually wanted was: press one key, say it, press the same key again, done. No new window. No typing. Eyes stay on the code. So I built a small macOS menu bar app called MacDictationNotion . It uses the built-in on-device speech recognition — same engine as macOS Dictation in System Settings — and sends the result straight to a No...

맥이 자꾸 잠들 때 — caffeinate로 안 되면 커서를 아주 조금 움직인다 (메뉴바 앱)

시리즈 · 직접 만든 macOS 도구 — 메뉴바에 사는 작은 앱들을 하나씩 만들어 온 기록입니다. Claude Code 사용량 메뉴바 모니터 AI 에이전트 상태 모니터 (파이썬 rumps) 맥북 배터리 건강·메뉴바 유틸 3선 말하면 노션에 꽂히는 온디바이스 받아쓰기 앱 맥 절전·자리비움 방지 (마우스 지글러) — 지금 보는 글 1. caffeinate로 안 되는 순간 맥이 잠들지 않게 하는 방법은 이미 있습니다. 터미널에서 caffeinate 를 실행하면 시스템은 화면도 안 끄고 잠도 안 잡니다. 오래 걸리는 빌드나 다운로드를 걸어 두고 자리를 뜰 때 이만한 게 없습니다. 그런데 여기에 안 걸리는 게 하나 있었습니다. 협업 툴에 뜨는 자리비움(Away) 표시 입니다. caffeinate 가 막는 건 시스템의 유휴 상태지, 사용자의 유휴 상태가 아닙니다. 상태 표시를 켜는 쪽 프로그램들은 시스템이 자느냐가 아니라 마지막 입력이 언제였는지 를 봅니다. 그래서 화면은 멀쩡히 켜져 있어도, 몇 분간 키보드·마우스를 안 건드리면 아이콘이 노랗게 바뀝니다. 잠깐 화면을 지켜보기만 하는 시간에도 "자리 비움"으로 찍히는 게 거슬렸습니다. 시스템을 깨우는 걸로는 부족했고, 실제 입력이 있었던 것처럼 보여야 했습니다. 방법은 단순합니다. 아주 가끔, 커서를 몇 픽셀 움직이면 됩니다. 2. 딱 필요한 만큼만 움직이는 워커 핵심은 백그라운드에서 도는 작은 루프 하나입니다. 지금 커서 위치를 읽어, 몇 픽셀 옮겼다가 바로 제자리로 되돌립니다. def _worker(self): while self.is_active: try: x, y = pyautogui.position() offset = random.choice([-3, -2, -1, 1, 2, 3]) pyautogui.moveTo(x + offset, y + offset, duratio...

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

시리즈 · 직접 만든 macOS 도구 — 메뉴바에 사는 작은 앱들을 하나씩 만들어 온 기록입니다. Claude Code 사용량 메뉴바 모니터 AI 에이전트 상태 모니터 (파이썬 rumps) 맥북 배터리 건강·메뉴바 유틸 3선 말하면 노션에 꽂히는 온디바이스 받아쓰기 앱 — 지금 보는 글 맥 절전·자리비움 방지 (마우스 지글러) 폰에 대고 말하면 노션에 저장되는 앱을 반년 넘게 만들어 써 왔습니다. 그런데 정작 일하는 시간의 대부분은 맥북 앞입니다. 코드를 짜다가, 문서를 읽다가 문득 떠오른 한 줄을 남기려고 폰을 집어 드는 그 몇 초 사이에 생각은 벌써 절반쯤 날아가 있었습니다. 맥에서 원한 건 단순했습니다. 화면을 벗어나지 않고, 손이 키보드를 떠나지 않고, 말 한마디가 그대로 노션에 남는 것. 마침 macOS에는 이미 쓸 만한 음성 인식 엔진이 깔려 있었습니다. 그걸 노션에 연결하는 작은 메뉴바 앱을 Swift로 만들었습니다. 어떻게 돌아가는지, 어디서 막혔는지를 코드와 함께 풀어 보겠습니다. 1. 아이폰 앱이 있는데 맥에서 또 만든 이유 같은 "음성 → 노션"인데 왜 또 만들었느냐고 하면, 입력하는 자리가 다르기 때문입니다. 폰 앱은 걷다가, 지하철에서, 자리를 뜬 상태에서 쓰기 좋습니다. 반대로 맥에서 일할 때 폰을 드는 동작은 그 자체가 흐름을 끊습니다. 브라우저와 에디터를 오가던 손이 책상 위 폰으로 향하는 순간, 방금 붙잡으려던 생각은 이미 옅어져 있습니다. 그래서 맥용은 조건이 하나 더 붙었습니다. 어떤 앱을 쓰고 있든 그 위에서 바로 녹음이 시작돼야 한다는 것. 창을 새로 띄우거나 특정 앱으로 전환하는 순간 이미 진 겁니다. 전역 단축키 하나로 지금 보고 있는 화면 그대로 마이크가 열리는 게 이 앱의 출발점이었습니다. 2. 클라우드 STT를 안 쓰기로 한 결정 폰 앱에서는 정확도 때문에 결국 클라우드 STT로 넘어갔습니다. 그런데 맥용을 짜면서는 처음부터 반대로 갔습니다. macOS의 ...