코스 알림이 한 통도 나가지 않고 있었다
Utakoto 에는 곡 코스가 있습니다. 노래 한 곡을 하루 한 줄씩, 한 줄·단어 3개·퀴즈 3문항으로 3분 안에 끝내는 회차로 나눠 배우는 흐름입니다. 이 코스를 이어 주던 장치가 아침 알림이었습니다. "〈곡명〉 N번째 줄이 준비됐어요" 하고 알려 주고, 탭하면 그 회차가 바로 열립니다.
10월 15일, 홈·코스·메뉴 개편 뒤 지표를 보는 판정 날에 이 알림이 10월 7일부터 한 통도 예약되지 않았다는 걸 알았습니다. 알림을 예약할 때 남기는 이벤트(notification_scheduled)를 분류별로 보면, 10월 6일까지는 코스 알림이 찍히다가 7일부터는 전부 단어 카드였습니다. 코스를 이어 가는 사람의 비율도 목표에 한참 못 미쳤습니다.
누가 지운 게 아니었습니다. 제가 열흘 전에 내린 결정의 결과였습니다.
단어 카드가 코스를 대신할 거라 봤다
10월 4일에 아침 알림을 "오늘의 단어" 카드로 바꾸는 결정을 했습니다. intent 에 적은 문제는 이랬습니다. 그때까지 아침 알림은 "N번째 줄이 준비됐어요 / 복습 시간이에요" 같은 할 일 통보였고, 잠금화면에서 배울 것도 탭할 궁금증도 없었습니다. 또 오늘·내일 2일치만 예약해서, 사흘 넘게 앱을 안 연 사람에게는 아침 알림이 아예 가지 않았습니다.
그래서 아침 슬롯을 7일 모두 단어 카드로 바꿨습니다. 뜻을 가린 질문형 문구로, 그 단어가 내가 담은 노래 몇 번째 줄에 나오는지를 붙여서요. 코스를 버린 건 아니었습니다. 서버가 단어를 고를 때 "코스 다음 줄"을 첫 출처로 두었습니다. 코스를 하는 사람에게는 다음 줄의 단어가 카드로 가고, 카드에서 "이 줄 이어서 배우기 →"로 코스에 들어오면 된다고 봤습니다.
10월 5일 커밋(c434238)에서 예약 함수는 이렇게 됐습니다. 단어 카드를 한 통이라도 예약하면 바로 끝나고, 예전 리마인더(코스 알림이 첫 순위인)는 단어를 못 받았을 때만 도는 폴백이 됐습니다.
if (await scheduleWordCards(prefs)) return;
// 단어를 못 받았다(오프라인·서버 오류·빈 서재 등) — 예전 리마인더 2일치로 폴백.
const { anchorAllSlots, anchorAgeDays, ...state } = await gatherState();
서버가 단어를 잘 내려 주는 한, 폴백은 거의 돌지 않습니다. 코스 알림이 0통이 된 건 코드가 설계대로 움직인 결과였습니다. 빗나간 건 "단어 카드가 코스로 이어 줄 것"이라는 가정이었습니다. 알림을 탭하면 단어 카드가 열리고, 코스 회차는 거기서 한 단계 더 들어가야 나옵니다. 판정 날 기록을 보면 단어 카드를 거쳐 코스로 들어온 경우는 2주 동안 손에 꼽을 만큼이었습니다.
바꾼 것: 코스가 있으면 코스 먼저
고친 커밋은 b716b9e 입니다. 순서에 한 줄을 끼웠습니다.
// 진행 중인 코스가 있으면 아침 슬롯은 코스 알림이다 — 단어 카드보다 먼저(.claude/intent/course-reminder-priority).
if (await scheduleCourseReminders(prefs)) return;
if (await scheduleWordCards(prefs)) return;
이제 아침 슬롯의 순서는 진행 중인 코스 → 단어 카드 → 예전 리마인더입니다. "진행 중인 코스"는 기기에 저장된 진도 중 아직 완주하지 않았고 가장 최근에 회차를 한 곡입니다. 코스 알림을 만드는 부분은 예전 폴백 안에 박혀 있던 것을 함수로 꺼냈고, 분류를 review 에서 course 로 바꿨습니다.
export function courseReminder(anchor: NonNullable<ReminderState['courseAnchor']>): Reminder | null {
const { songId, title, nextLine, total } = anchor;
if (!(nextLine >= 1) || (total != null && nextLine > total)) return null;
const progress = total != null && total > 0 ? ` · ${nextLine - 1}/${total}` : '';
return {
category: 'course',
title: `〈${title}〉 ${nextLine}번째 줄이 준비됐어요`,
body: `한 줄 · 단어 3개 · 퀴즈 3문항, 3분이면 끝나요${progress}`,
url: `/course/${songId}?source=notification`,
};
}
줄 번호가 범위를 벗어나면(완주했거나 저장된 진도가 깨졌거나) null 을 돌려주고, 그러면 단어 카드로 넘어갑니다. 설정 화면의 스위치 이름도 "아침 단어 카드"에서 "아침 알림"으로 바꾸고, 설명을 "코스 중인 곡의 다음 줄을, 없으면 내 노래 단어 하나를 아침에 보내드려요"로 고쳤습니다. 받는 알림이 사람마다 달라졌으니 이름이 한쪽만 말하면 안 됐습니다.
같은 커밋에 예약 순서 테스트 파일(schedule.test.ts)을 새로 붙였습니다. 코스가 있으면 7통 모두 코스 알림이고 단어 API 를 부르지 않는다, 코스가 없으면 단어 카드 7통이다, 줄 번호가 범위 밖이면 단어 카드로 넘어간다, 세 가지입니다. 파일 첫 주석에 "10/7~10/15 코스 알림이 0통이었던 회귀를 막는다"고 적어 두었습니다.
지킨 것과 되돌린 것
intent 의 제약에 이번 변경의 경계가 적혀 있습니다.
먼저 되돌린 범위는 코스 진행자뿐입니다. 10월 4일의 "아침 슬롯 = 단어 카드 7일" 결정을 전부 무르지 않았습니다. 코스가 없는 사람은 지금처럼 단어 카드를 받고, 단어 카드가 효과가 있는지는 이제 그 사람들만 놓고 봅니다. 문제는 단어 카드 자체가 아니라, 코스가 있는 사람에게 단어 카드가 코스로 가는 길을 한 단계 늘렸다는 데 있었기 때문입니다.
하루 한 통 예산도 그대로입니다. 알림은 하루 두 통, 아침 로컬 알림 1통과 오후 서버 추천 1통으로 9월 4일에 정해 두었고, 단어 카드 때도 "오전 슬롯을 늘리지 않는다"를 제약으로 적었습니다. 이번에도 코스 알림은 한 통을 더 얹는 게 아니라 같은 아침 슬롯, 같은 시각, 같은 스위치를 나눠 씁니다. 사람마다 그 한 통의 내용이 달라질 뿐입니다.
코스 알림도 단어 카드처럼 7일치를 미리 예약합니다. 예전 리마인더가 2일치만 잡았던 건 문구에 복습 수 같은 상태 숫자가 들어가 며칠 뒤엔 틀려지기 때문이었습니다. 코스 알림의 줄 번호도 상태 숫자처럼 보이지만, 회차를 마칠 때마다 다시 예약하니 그 사이에는 낡지 않습니다. 사흘 넘게 오지 않은 사람에게도 "그 곡의 그 줄"이 갑니다.
분류를 course 로 떼어 낸 건 다음 판정을 위해서입니다. 예전에는 코스 알림이 복습 알림과 같은 review 로 찍혀 둘을 가를 수 없었습니다. 판정 지표는 세 가지로 적어 두었습니다. 코스 알림 예약 수가 다시 0보다 커지는지, 알림에서 시작한 코스 회차가 늘어나는지, 그리고 처음 정한 목표치까지 코스를 이어 가는 비율이 OTA 뒤 2주 안에 올라오는지입니다.
이번 일에서 남는 건 이겁니다. 폴백은 거의 돌지 않습니다. 중요한 알림을 폴백 자리에 두면, 앞 단계가 잘 될수록 그 알림은 조용히 사라집니다. 10월 5일에 코스 알림을 폴백으로 옮길 때 저는 그걸 "단어 카드가 대신한다"로 읽었고, 대신하는지는 열흘 뒤 숫자로 처음 봤습니다.
출시와 남은 것
네이티브 변경이 없어 OTA 로만 나갔습니다. intent 목록의 기록으로는 10월 15일에 runtime 1.10.0 과 1.11.0 두 갈래에 iOS·Android 모두 발행했습니다. 이 기록에 실기기에서 아침 알림이 실제로 도착하는 것을 확인했다는 줄은 없습니다. 예약 수와 회차 시작은 이벤트로 남으니, 판정은 OTA 뒤 2주 지표로 보기로 했습니다. intent 의 Open questions 는 "없음"입니다.
Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.