태그 음성 입력한국어 파싱온디바이스 STT정규식제품
전체 글 보기

음성 입력

계획 세우기가 귀찮아서 음성 입력을 붙였습니다

할 일을 손으로 적는 게 귀찮아서 음성 입력을 붙였습니다. LLM 없이 정규식으로 끝냈고, 한국어를 쪼개는 규칙 하나 때문에 멀쩡한 말이 반토막 나는 함정을 만났습니다.

2026년 8월 15일 3분

제가 만든 앱인데도 할 일을 적는 게 귀찮았습니다.

버튼을 누르고, 제목을 치고, 시간을 고르고, 저장을 누릅니다. 할 일 하나에 네 번이니 세 개를 넣으려면 열두 번인데, 정작 머릿속에는 “내일 두시에 은행 전화, 저녁에 러닝” 이라는 한 문장이 이미 다 들어 있었습니다.

그래서 그 한 문장을 그대로 받는 기능을 붙였습니다.

사실 방향을 반대로 잡은 적도 있습니다. 같은 화면에서 계획을 적으라고 자동으로 올라오던 시트를 껐던 게 며칠 전인데, 그때는 재촉을 없앤 것이고 이번엔 적는 비용을 줄인 것입니다.

음성 입력에 모델을 부르지 않기로 했습니다

음성 입력이라고 하면 “내일 오후 2시에 은행 전화” 를 해석해야 하니 처음에는 모델을 떠올리기 쉽습니다. 그런데 실제로 사람들이 할 일을 말할 때 쓰는 패턴은 손에 꼽을 만큼 적습니다. 날짜는 오늘/내일/모레거나 며칠 또는 무슨 요일이고, 시각은 오전 오후에 몇 시 몇 분, 여기에 이름이 붙는 게 거의 전부입니다.

정규식으로 끝나는 일에 모델을 부르면 토큰과 네트워크 왕복이 붙고, 무엇보다 비행기 안에서는 안 됩니다. 그래서 파서를 직접 썼고, 이게 못 잡는 문장만 나중에 모델로 넘기면 된다고 정리했습니다.

인식 자체는 기기 안에서 도는 STT 를 씁니다. 말이 서버로 안 나가니 개인정보 이야기를 꺼낼 필요도 없어집니다.

한국어를 쪼갤 때 “~하고” 를 쓰면 안 됩니다

문장 하나에 할 일이 여러 개 들어오니 어딘가에서 쪼개야 했습니다. 쉼표와 “그리고” 는 당연했고, 자연스럽게 “~하고” 도 넣으려다가 멈췄습니다.

“청소하고 빨래” 를 생각해보면 이유가 바로 나옵니다. 여기서 “하고” 는 연결어가 아니라 동사의 일부라서, 쪼개는 순간 “청소” 라는 없는 할 일이 생기고 “빨래” 는 문맥을 잃습니다.

그래서 구분자를 좁게 잡았습니다. 줄바꿈, 쉼표, “그리고”, “그다음(에)”, “다음으로”, 그리고 앞뒤가 띄어쓰기로 분리된 “또” 만 봅니다.

방금 실제로 돌려본 결과입니다.

입력: 청소하고 빨래
  쪼갠 결과 1개: ["청소하고 빨래"]

입력: 내일 오후 2시에 은행 전화, 저녁 7시 러닝 그리고 책 20쪽
  쪼갠 결과 3개: ["내일 오후 2시에 은행 전화","저녁 7시 러닝","책 20쪽"]
  → "은행 전화"  2026. 8. 16. 오후 2:00:00  (explicit)
  → "러닝"       2026. 8. 15. 오후 7:00:00  (none)
  → "책 20쪽"    시간 미지정                 (none)

이 규칙은 공짜가 아닙니다

같은 실행에서 이것도 같이 나왔습니다.

입력: 운동하고 회의
  쪼갠 결과 1개: ["운동하고 회의"]

여기서 “하고” 는 진짜 연결어라서, 사용자 입장에서는 두 개를 말했는데 하나로 들어갑니다. 즉 이 규칙은 “청소하고” 를 지키는 대신 “운동하고 회의” 를 포기한 것입니다.

일부러 이쪽을 골랐습니다. 멀쩡한 할 일 하나가 두 동강 나서 이상한 이름이 생기는 것보다, 두 개가 한 줄로 들어와 사용자가 나중에 나누는 편이 덜 나쁘다고 봤습니다. 앞의 실수는 무엇이 잘못됐는지 알기 어렵지만, 뒤의 실수는 화면을 보면 바로 보입니다.

못 알아들으면 되묻지 않습니다

시각을 못 찾으면 어떻게 할지도 정해야 했습니다.

되묻는 방법이 있고 그게 정확하긴 한데, 되묻는 순간 음성의 유일한 장점인 빠름이 사라집니다. 그래서 시각을 못 찾으면 이름만 남기고 시간 미지정으로 그냥 만듭니다. 위 실행에서 “책 20쪽” 이 그렇게 들어간 경우입니다.

이게 가능한 건 앱에 시간 미지정이 원래 1급 상태로 있기 때문입니다. 받아줄 자리가 없었다면 이 결정은 못 했을 겁니다.

날짜에는 규칙을 하나 더 뒀습니다. “14일” 이나 “금요일” 처럼 지나간 날을 말하면 가장 가까운 미래로 굴리는데, 오늘이 8월 15일이라 “14일” 은 9월 14일이 됐습니다. 다만 “오늘” 이라고 명시하면 굴리지 않습니다. 오후 3시에 “오늘 2시” 라고 말하는 사람은 지나간 걸 알면서 기록하려는 것이라서요.

반복문으로 저장했더니 순서가 전부 같았습니다

여러 건을 한 번에 만드는 부분에서 진짜 버그가 났습니다.

처음에는 기존의 한 건 추가 함수를 반복문으로 여러 번 불렀습니다. 그랬더니 N 개가 전부 같은 순서 값을 받아서 화면에서 뒤죽박죽이 됐습니다.

원인은 그 함수가 순서를 정할 때 현재 목록의 최댓값을 보기 때문이었습니다. 목록은 서버 리스너가 갱신해주는데, 반복문은 그 응답을 기다리지 않고 다음 회차로 넘어갑니다. 그러니 두 번째도 세 번째도 여전히 갱신 전의 같은 목록을 보고 같은 값을 계산합니다.

고친 방법은 단순합니다. 목록을 한 번만 읽어 기준값을 잡고, 거기에 인덱스를 더한 뒤 배치로 한 번에 씁니다.

const baseOrder = oneOff.reduce((m, t) => Math.max(m, t.order ?? 0), 0);
const batch = writeBatch(todosRef.firestore);
items.forEach((it, i) => {
  batch.set(doc(todosRef), {...todo, order: baseOrder + 1 + i});
});
await batch.commit();

알림은 커밋이 끝난 뒤에 겁니다. 저장이 실패했는데 알림만 남으면 없는 할 일이 울리기 때문입니다.

교훈은 이렇게 정리했습니다. 비동기로 갱신되는 상태를 읽어서 값을 만드는 함수는, 반복문으로 여러 번 부르면 안 됩니다. 여러 건을 만들 함수를 따로 두는 게 맞습니다.

결국 얻은 것

지금은 마이크를 누르고 한 문장 말하면 끝입니다. “내일 두시에 은행 전화, 저녁 7시 러닝 그리고 책 20쪽” 이 세 줄로 들어오고, 손으로 하면 열두 번이던 것이 두 번이 됐습니다.

작은 기능이고 화면에 버튼 하나 늘어난 게 전부인데, 제가 실제로 매일 씁니다. 계획을 세우게 만들려고 기능을 늘리는 대신 계획을 적는 비용을 줄인 셈입니다.

어디까지 확인한 것인가

2026년 8월 15일 기준으로 Expo SDK 56 기반 앱에서 확인했고, 위 실행 결과는 이 글을 쓰면서 그 자리에서 파서를 돌려 받은 것입니다.

인식 정확도는 재지 않았습니다. STT 가 사투리나 시끄러운 곳에서 얼마나 버티는지는 제 기기 몇 번의 경험뿐이라 숫자로 말할 수 없습니다. 파서가 못 잡는 문장을 모델로 넘기는 경로도 아직 안 만들었습니다.

이전 글 스터디플래너를 검색하면 서로 다른 세 가지가 나옵니다