초대는 보냈는데, 받은 사람 쪽에는 아무 흔적이 없었다.

식단서랍에는 식단 달력을 가족과 함께 쓰는 공유 기능이 있다. 초대를 보내면 상대 폰에 앱 푸시가 간다. 그런데 이 문장에는 조건이 셋이나 숨어 있었다. 상대가 Android 앱을 쓰고, 그 앱에서 알림을 켰고, 서버의 FCM 설정이 살아 있어야 한다. 하나라도 빠지면 초대는 어디에도 남지 않았다. PC 브라우저로 식단을 짜는 사람은 초대가 왔다는 것조차 알 수 없었다.

알림이 푸시라는 배달 수단과 한 몸이었던 게 문제였다.

푸시보다 먼저 남기는 곳

그래서 순서를 바꿨다. 알림은 먼저 사용자별로 저장하고, 푸시는 그다음에 갈 수 있으면 간다. user_notifications 테이블을 만들고, 화면 상단에 종 아이콘을 달았다. 안 읽은 수가 배지로 뜨고, 펼치면 목록과 '모두 읽음'이 있다. 푸시를 꺼 둔 사람도, 브라우저로만 쓰는 사람도 종에서 같은 알림을 본다.

알림을 보내는 DeliverAsync 는 알림함에 먼저 넣고, 푸시가 준비돼 있을 때만 기기로도 보낸다. 알림함 저장이 실패해도 푸시는 시도하고, 둘 다 실패해도 초대를 보낸 요청은 깨지지 않는다.

알림이 쌓이기만 하면 안 되니 한 건을 넣을 때마다 90일이 지난 것과 최근 100개 밖의 것을 같이 정리한다. 알림을 누르면 읽음으로 바꾸고 저장된 경로로 보내는데, 여기서 경로를 한 번 더 거른다. / 로 시작하고 // 나 /\ 로 시작하지 않는 앱 안 경로만 연다. 저장된 값을 그대로 믿고 이동시키면 종 아이콘이 다른 사이트로 가는 문, 흔히 말하는 열린 리다이렉트가 된다.

앱 사용자에게는 알림을 켤 기회를 한 번 더 줬다. 앱으로 들어왔는데 알림이 꺼져 있으면 화면 아래에 '알림을 켜 둘까요?'가 뜬다. 묻는 조건은 깐깐하게 잡았다. 앱을 열어 둔 동안 한 번, '나중에'를 누르면 14일 동안 조용히, 휴대폰 설정에서 아예 막아 둔 사람과 서버 쪽 푸시가 준비되지 않은 경우에는 묻지 않는다.

아침 여덟 시, 앱 풀이 자고 있으면

알림을 남길 자리가 생기자 보낼 거리가 늘었다. 첫째가 오늘의 메뉴다. 매일 아침 그날 식단이 있는 사람에게 "점심 김치볶음밥 · 저녁 제육볶음 외 3" 같은 한 줄을 보낸다.

정해진 시각에 보내는 일은 생각보다 까다로웠다. 서버는 IIS 위에서 돈다. 접속이 없으면 앱 풀이 잠들고, 잠든 프로세스의 타이머는 돌지 않는다. 그래서 BackgroundService 가 5분마다 깨어 한국 시간으로 설정한 시각(기본 8시)부터 3시간 안인지 본다. 8시 정각에 자고 있었더라도 9시 반에 누군가 접속해 프로세스가 깨어나면 그때 보낸다. 대신 같은 날 두 번 가면 안 된다.

그래서 보내기 전에 실행 기록부터 넣는다. notification_runs 의 (종류, 날짜)에는 유니크 인덱스가 걸려 있어서, 프로세스가 둘 떠 있거나 관리자가 '지금 보내기'를 동시에 눌러도 먼저 기록한 쪽만 보낸다. 늦은 쪽은 저장에서 DbUpdateException 으로 튕겨 아무것도 하지 않는다. 관리자 새 소식도 같은 원리다. 전체에게 보내기 전에 SentAt 을 먼저 찍어서, 버튼을 두 번 눌러도 한 번만 나간다.

보낼 거리가 셋이 되자 규칙을 한곳에 모았다. 여러 사람에게 보내는 일은 NotificationDispatcher 가 맡고, 알림 종류가 사용자 설정을 어떻게 따를지 정한다.

bool Wants(string userId, bool forPush) => kind switch
{
    NotificationKind.DailyMenu => !prefs.TryGetValue(userId, out var p) || p.DailyMenu,
    NotificationKind.News => !forPush || !prefs.TryGetValue(userId, out var p) || p.News,
    _ => true
};

오늘의 메뉴를 끈 사람에게는 푸시도 알림함도 안 간다. 매일 오는 알림이라 알림함에만 쌓여도 소음이다. 새 소식은 알림함에는 모두에게 남기고 푸시만 설정을 따른다. 버전이 바뀌었다는 사실은 한 번은 보여야 해서다. 관리자가 전체나 한 사람에게 직접 보내는 공지는 설정을 보지 않는다. 설정 행이 없는 사람은 모두 켜진 것으로 본다. 한 사람의 푸시가 실패해도 다음 사람으로 넘어가고,

꺼 둔 토큰이 남아 있었다

알림을 다시 들여다보다가 개인정보 처리방침의 한 줄과 코드가 맞지 않는 걸 발견했다. 처리방침에는 알림을 끄면 기기 토큰을 지운다고 적어 두었다. 코드는 지우지 않고 IsActive 를 false 로 바꾸기만 하고 있었다.

알림 끄기는 이제 행을 지운다. 다시 켜면 새로 등록하면 된다. 남은 건 이미 쌓인 비활성 행이었다. 그런데 비활성이 된 이유가 둘이었다. 사용자가 끈 것, 그리고 전송이 계속 실패해서 서버가 무효로 돌린 것. 뒤쪽은 관리자가 "왜 이 사람한테 안 가지"를 볼 때 필요한 진단 자료다.

둘을 가르는 표시가 이미 테이블에 있었다. 연속 실패 횟수는 전송에 성공할 때마다 0 으로 돌아간다. 사용자가 끄거나 토큰이 바뀌어 비활성이 된 행은 0 이고, 실패로 무효가 된 행은 1 이상이다.

DELETE FROM `push_subscriptions` WHERE `IsActive` = 0 AND `ConsecutiveFailures` = 0;

데이터만 고치는 마이그레이션 하나로 넣었다. 스키마는 그대로다. 되돌리는 Down 은 비워 두었다. 지운 토큰은 살리지 않는다. 기기에서 알림을 다시 켜면 새 토큰이 들어온다.

소리 없는 알림이 필요했다

오늘의 메뉴가 나가기 시작하자 앱 쪽 문제가 보였다. 앱에는 알림 채널이 하나뿐이었고, 중요도는 높음이었다. 달력 공유 초대에 맞춘 설정이다. 그대로 두면 매일 아침 오늘의 메뉴가 초대와 똑같이 소리와 진동을 내며 울린다. 초대는 놓치면 안 되지만, 아침 메뉴는 조용히 쌓여 있다가 볼 때 보면 된다.

Android 8 부터 알림의 소리·진동·중요도는 채널 단위로 정해지고, 한번 만든 채널의 중요도는 앱이 나중에 바꾸지 못한다. 사용자 설정이 이긴다. 그러니 기존 채널을 고칠 게 아니라 채널을 나눠야 했다. 오늘의 메뉴(daily_menu)는 소리와 진동을 끈 기본 중요도, 새 소식(news)은 기본, 공유·활동(activity)은 높음이다.

어느 알림이 어느 채널로 갈지는 서버가 고르게 했다. 앱이 제목을 보고 짐작하면 문구 하나 바꿀 때마다 앱을 새로 내야 한다. 서버는 FCM data 에 channel 값을 하나 더 실어 보낸다. 공유 초대와 새 레시피는 메시지를 만들 때부터 activity 를 달고, 나머지는 알림 종류에서 정한다.

static String resolveChannel(String channel) {
    if (CHANNEL_DAILY_MENU.equals(channel) || CHANNEL_NEWS.equals(channel) || CHANNEL_ACTIVITY.equals(channel)) {
        return channel;
    }
    return CHANNEL_ID;
}

모르는 값이나 빈 값은 원래 쓰던 recipe_push 채널로 보낸다. 이 한 줄 덕분에 배포 순서를 신경 쓰지 않아도 됐다. 웹을 먼저 올렸다. 옛 앱은 모르는 channel 값을 그냥 무시하고, 새 앱이 옛 서버를 만나면 원래 채널로 받는다. 어느 쪽이 먼저 나가도 알림은 빠지지 않는다.

채널이 생기니 사용자가 휴대폰 설정에서 알림을 통째로 막아 두는 경우도 다시 봤다. 이때 웹이 할 수 있는 말은 "휴대폰 설정에서 꺼져 있어요"뿐이라, 브리지에 openNotificationSettings() 를 하나 얹어 이 앱의 알림 설정 화면을 바로 열게 했다. Android 8 이상은 채널 목록이 보이는 알림 설정, 그 아래나 실패하면 앱 정보 화면이다. 설정에서 돌아오면 onResume 이 바뀐 권한을 웹에 다시 알린다. 웹은 그 함수가 있을 때만 버튼을 보여 주니 옛 앱에서는 버튼이 없을 뿐이다. 브리지와 출처 제한 채널 구조는 「TWA에서 WebView로」 연재에서 다룬 그대로고, 여기서는 함수 하나를 더한 것뿐이다.

앱이 켜져 있을 때 온 푸시도 손봤다. 알림 표시줄에는 새 알림이 떴는데 열린 화면의 종 숫자는 그대로였다. 이제 앱은 알림을 그린 뒤 WebView 가 살아 있고 앱 도메인 페이지면 웹의 _onPushReceived 를 부른다. 웹은 push-received 이벤트를 받아 종 숫자와 목록을 다시 불러온다.

다른 앱에서 건너온 레시피

알림과 같은 짝으로 앱이 받아 주는 것을 하나 더 늘렸다. 블로그나 메모 앱에서 레시피 글을 보다가 '공유하기'를 누르면 목록에 식단서랍이 뜬다. 매니페스트에 ACTION_SEND 와 text/plain 을 받는 intent-filter 를 넣었다. 받은 글은 레시피 등록 화면의 AI 레시피 정리 칸으로 간다. 붙여 넣은 글을 재료와 단계로 나눠 주는, 이 연재 앞쪽에서 만든 그 칸이다.

받는 순간과 넘기는 순간이 다르다는 게 일이었다. 앱이 공유 인텐트를 받으면 글을 6000자에서 잘라 보관하고 /Recipe/Create?from=share 를 연다. 그런데 로그인이 풀려 있으면 로그인 화면이 먼저 뜬다. 거기서 글을 넘기면 받을 함수가 없다. 그래서 페이지 로드가 끝날 때마다 지금 주소가 정확히 그 공유 페이지인지 보고, 맞을 때만 넘긴다.

String js = "(function(){var s=window.RecipeDrawerShare;"
        + "if(!s||typeof s.receive!=='function'){return false;}"
        + "s.receive(" + JSONObject.quote(text) + "," + JSONObject.quote(subject) + ");return true;})();";
webView.evaluateJavascript(js, result -> {
    if (!"true".equals(result) && pendingShareText == null) {
        pendingShareText = text;
        pendingShareSubject = subject;
    }
});

로드 완료가 겹쳐 와도 두 번 들어가지 않게 보관한 글은 먼저 비우고, 웹이 받았다고 true 를 돌려주지 않으면 다시 보관한다. 로그인을 마치고 공유 페이지로 돌아왔을 때 그제야 넘어간다. 처리한 공유 인텐트는 평범한 실행 인텐트로 바꿔 둔다. 화면이 다시 만들어질 때 같은 글이 또 들어오지 않게 하려고서다.

웹은 받은 글을 칸에 채우고 그 칸을 펼쳐 두기만 한다. 'AI 로 정리하기'는 사람이 누른다. 공유 한 번에 언어 모델 호출이 자동으로 나가면, 잘못 고른 글 하나가 쓸 수 있는 횟수를 깎는다. AI 칸이 없는 상황이면 레시피 설명 칸에 넣는다. 에뮬레이터에서 앱을 완전히 끈 상태로 공유했을 때 로그인 화면을 거쳐도 돌아갈 주소가 유지되는 것까지 확인했다.

세로로 묶어 둔 화면을 풀었다

마지막 둘은 Play 쪽에서 왔다. 하나는 업데이트다. 새 버전을 올려도 자동 업데이트를 꺼 둔 사람은 옛 앱에 머문다. Play 인앱 업데이트의 유연(FLEXIBLE) 방식을 붙였다. 새 버전이 있으면 Play 안내를 띄우고, 받기로 하면 앱을 쓰는 동안 뒤에서 내려받는다. 다 받으면 '다시 시작'을 물어 본다. 안내는 프로세스당 한 번, 거절하면 사흘 동안 묻지 않는다.

다른 하나는 화면 방향이다. 앱은 매니페스트에서 세로로 고정돼 있었고, 전체 화면 영상을 볼 때만 방향을 풀었다가 끝나면 다시 묶었다. Play 는 태블릿과 폴더블 같은 큰 화면에서 방향 고정을 하지 말라고 권하고, 대상 SDK 36 의 Android 16 큰 화면에서는 고정이 아예 무시된다. 고정을 지우고, 영상 전체 화면에서 방향을 바꾸던 두 줄도 걷어냈다. 회전은 기기 설정을 따른다. 회전 때 액티비티가 다시 만들어지지 않도록 configChanges 는 원래 받고 있었으니, 돌려도 보던 화면과 영상은 그대로다.

초대가 사라졌던 이유를 돌아보면, 알림을 "폰에 뜨는 것"으로만 생각했기 때문이었다. 알림을 먼저 기록으로 남기고 나니 푸시는 그 기록을 전하는 여러 길 중 하나가 됐다. 그러자 어떤 소리로 울릴지, 꺼 둔 사람의 흔적을 어떻게 지울지, 앱이 켜져 있을 땐 무엇을 다시 그릴지가 차례로 따라왔다.

「식단서랍」 그 후 연재는 여기서 마친다. 언어 모델을 서비스 기능으로 붙인 일에서 시작해 알림과 앱으로 끝났다. 처음 만든 식단서랍이 식단표와 장보기를 채우는 도구였다면, 그 뒤의 일은 사람이 먼저 찾아오지 않아도 서비스가 말을 거는 방법을 만드는 일이었다. 말을 거는 쪽은 언제 조용해야 하는지도 알아야 한다는 걸, 소리 없는 채널 하나를 만들면서 배웠다.