제가 만든 앱인데도 할 일을 적는 게 귀찮았습니다.
버튼을 누르고, 제목을 치고, 시간을 고르고, 저장을 누릅니다. 할 일 하나에 네 번이니 세 개를 넣으려면 열두 번인데, 정작 머릿속에는 “내일 두시에 은행 전화, 저녁에 러닝” 이라는 한 문장이 이미 다 들어 있었습니다.
그래서 그 한 문장을 그대로 받는 기능을 붙였습니다.
사실 방향을 반대로 잡은 적도 있습니다. 같은 화면에서 계획을 적으라고 자동으로 올라오던 시트를 껐던 게 며칠 전인데, 그때는 재촉을 없앤 것이고 이번엔 적는 비용을 줄인 것입니다.
음성 입력에 모델을 부르지 않기로 했습니다
음성 입력이라고 하면 “내일 오후 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 가 사투리나 시끄러운 곳에서 얼마나 버티는지는 제 기기 몇 번의 경험뿐이라 숫자로 말할 수 없습니다. 파서가 못 잡는 문장을 모델로 넘기는 경로도 아직 안 만들었습니다.