앱을 열지 않고 막힌 카드에 답하기: Plan Wall에 앱 배지, 공유 시트, 알림 「승인」 버튼을 붙인 기록
Plan Wall은 에이전트 카드가 막히면 푸시를 보내지만, 답하려면 앱을 열어야 했습니다. 매니페스트의 shortcuts·share_target, 서비스 워커의 setAppBadge, 알림 action으로 그 단계를 줄이고, 조용히 끊기던 푸시 구독까지 고친 과정입니다.
목차
Plan Wall은 제가 매일 쓰는 주간·월간 할 일 보드입니다. Claude Code나 Codex 같은 에이전트도 API 토큰과 MCP로 이 보드에 카드를 만들고 상태를 바꿉니다.
에이전트는 제 판단이 필요할 때만 카드를 막힘(blocked)으로 바꿉니다. 그러면 서버가 <Line> 막힘: <제목> 형식의 제목에 카드 메모 마지막 줄 앞 100자를 붙여 푸시를 보냅니다. 여기까지는 전에 만들어 둔 구조입니다.
Plan Wall을 만든 이유는 첫 글에 적었습니다. 이번에는 홈 화면에 설치한 앱을 다룹니다.
알림은 왔는데, 답하려면 앱을 열어야 했습니다#
막힘 알림을 누르면 앱이 열리고 그 카드의 상세 패널이 떴습니다. 승인은 거기서 버튼을 한 번 더 눌러야 했습니다. 결정 하나에 앱 실행이 끼어 있었습니다.
설치한 앱에서는 막힌 카드 수도 보이지 않았습니다. 브라우저 탭에서는 제목 앞에 「(N 막힘)」이 붙는데, 홈 화면에 설치한 앱에는 탭 제목이 없습니다.
반대 방향도 번거로웠습니다. 휴대폰에서 읽던 링크 하나를 보드에 넣으려면 앱을 열고, Inbox를 찾고, 붙여 넣어야 했습니다. 설계 문서는 이걸 "세 단계"라고 적어 두었습니다.
목표는 막힘에서 제 답까지 걸리는 시간을 줄이는 것이었습니다. Plan Wall 회고 화면은 이 시간의 중앙값을 따로 셉니다.
2026년 10월 17일에 이 방향으로 기능 네 개가 들어갔습니다. 기능마다 먼저 넣은 커밋이 있고, 같은 날 리뷰를 반영해 코드를 줄인 커밋이 뒤따릅니다.
이 글의 코드는 리뷰를 반영한 뒤(ad138f2, 4163881, 5cb65da, dd8acc2)의 모습입니다.
매니페스트 shortcuts와 share_target#
설치한 앱 아이콘을 길게 누르면 나오는 메뉴는 매니페스트의 shortcuts가 정합니다. Plan Wall은 두 개를 넣었습니다.
- 「새 할일」(
/app?new=inbox): Inbox 트레이를 열고 빠른 추가 입력칸에 포커스를 둡니다 - 「내 결정 대기」(
/app?decisions=1): 막힌 카드를 모은 「내 결정」 트레이를 엽니다. 막힌 카드가 없으면 「기다리는 결정이 없어요」 토스트만 뜹니다
다른 앱의 공유 시트에 Plan Wall이 나오게 하는 건 share_target입니다. public/manifest.webmanifest의 실제 값은 이렇습니다.
"share_target": {
"action": "/app",
"method": "GET",
"params": { "title": "share_title", "text": "share_text", "url": "share_url" }
}공유하면 브라우저가 /app?share_title=…&share_text=…&share_url=…을 엽니다. 보드는 첫 로드에서 이 쿼리를 한 번만 읽고 곧바로 history.replaceState로 주소창에서 지웁니다. 새로고침해도 같은 공유가 다시 실행되지 않습니다.
세 값은 순수 함수 sharedNote가 카드 하나로 합칩니다. 첫 줄은 공유 제목이고, 제목이 없으면 글의 첫 줄을 100자까지 씁니다. 그 아래에 나머지 글이 오고, URL은 글에 없을 때만 마지막에 붙입니다.
빈 줄은 지우고 전체를 1,000자에서 자릅니다. 확인 창이 저장될 내용 전체를 보여 주므로, 빈 줄 아래에 숨은 글이 몰래 들어가지 않게 하려는 처리입니다.
그리고 바로 저장하지 않습니다. 「Inbox에 넣을까요?」 확인 창을 띄우고, 「넣기」를 눌러야 Inbox 카드 하나를 만듭니다.
void dialog.confirm(note, { title: 'Inbox에 넣을까요?', confirmLabel: '넣기' }).then((ok) => {
const id = ok ? createItem({ title: note, kind: 'task', date: '' }) : null;
if (id) toast.show({ text: 'Inbox에 넣었어요', tone: 'success', action: { label: '열기', run: () => setDetailId(id) } });
});이유는 주소에 있습니다. /app?share_text=…는 공유 시트가 아니어도 다른 사이트나 메신저의 링크로 열 수 있습니다. 그런데 에이전트가 이 보드를 읽습니다. 제가 모르는 사이 심긴 카드는 에이전트에게 지시문처럼 읽힐 수 있습니다.
설계 문서에는 Sec-Fetch-Site 헤더로도 메신저에서 연 링크와 공유 시트를 가를 수 없다고 적어 두었습니다. 그래서 한 번 누르는 비용으로 그 경로를 막았습니다.
MDN의 share_target 문서는 공유가 앱의 데이터를 바꾸는 경우(예: 북마크 생성) POST를 쓰라고 권합니다. Plan Wall은 GET이지만, 주소를 여는 것만으로는 아무것도 저장되지 않습니다.
데이터가 바뀌는 건 「넣기」를 누른 뒤입니다. new와 decisions는 화면만 열기 때문에 확인 창 없이 바로 동작합니다.
setAppBadge와 서비스 워커#
아이콘 배지 숫자는 두 곳에서 바뀝니다. 보드가 열려 있으면 앱이 바꾸고, 닫혀 있으면 서비스 워커가 바꿉니다.
보드가 열려 있을 때는 탭 제목의 「(N 막힘)」과 같은 수를 아이콘에도 씁니다.
if ('setAppBadge' in navigator) navigator.setAppBadge(agent.blocked).catch(() => {});처음 커밋은 0이면 clearAppBadge()를 따로 불렀습니다. 리뷰를 반영한 ad138f2가 이걸 setAppBadge(n) 한 줄로 줄였습니다. MDN의 Badging API 설명대로 0을 넘기면 배지가 지워지기 때문입니다.
이 기능의 본론은 앱이 닫혀 있을 때입니다. 서버의 createBlockedNotifier는 막힘 푸시를 보낼 때 그 사용자의 끝나지 않은 막힌 카드 수를 badge로 같이 싣습니다.
return repo.getTasks(pool, userId, { open: true, agentState: 'blocked' })
.then((blocked) => sendToUser(pool, push, userId, { ...payload, badge: blocked.length }, log))처음에는 이 수를 세는 SQL 함수 countBlocked가 따로 있었습니다. 이미 있던 getTasks 필터로 셀 수 있어서 ad138f2에서 지웠습니다.
서비스 워커는 푸시를 받으면 알림을 띄우면서 배지도 바꿉니다. MDN은 Badging API를 Web Worker에서도 쓸 수 있다고 적고 있습니다.
const badge = Number.isInteger(data.badge) && self.navigator?.setAppBadge ? self.navigator.setAppBadge(data.badge).catch(() => {}) : null;이 promise는 알림 표시와 함께 event.waitUntil로 넘어갑니다. badge가 정수일 때만 건드리므로, 숫자를 싣지 않는 테스트 알림은 배지를 바꾸지 않습니다.
setAppBadge가 없는 브라우저에서는 배지만 건너뛰고 알림은 빠짐없이 뜹니다. test/sw.test.js가 이 두 경우를 확인합니다.
알림의 「승인」 버튼#
막힘 푸시에는 카드 id(taskId)와 푸시를 보낸 시점의 updatedAt이 같이 들어갑니다. 서비스 워커는 taskId가 있는 알림에만 「승인」·「열기」 버튼을 답니다.
...(data.taskId && { actions: [{ action: 'approve', title: '승인' }, { action: 'open', title: '열기' }] }),「승인」을 누르면 서비스 워커가 앱을 열지 않고 답을 보냅니다. 같은 출처 요청이라 세션 쿠키가 같이 실립니다.
const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/reply`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ decision: 'approve', updated_at: updatedAt }),
// Opening a window is only allowed for a few seconds after the click: give up early enough to fall back.
signal: AbortSignal.timeout(4000),
});
if (!res.ok) throw new Error(`reply ${res.status}`);서버는 보드에서 승인 버튼을 누른 것과 똑같이 처리합니다. 활동 기록에 승인을 남기고 카드 상태를 막힘에서 대기로 바꿉니다. 서비스 워커는 같은 tag로 「승인했어요: 카드 제목」 알림을 띄웠다가 4초 뒤 닫습니다.
여기서 updated_at이 하는 일이 있습니다. 이 엔드포인트도 지난 글에서 만든 버전 검사를 똑같이 거칩니다.
푸시를 받은 뒤 에이전트가 카드를 또 고쳤다면 버전이 달라져 409가 옵니다. 그사이 바뀐 카드를 내용도 보지 않고 승인하면 안 됩니다.
그래서 응답이 실패면 이유와 상관없이 catch로 빠져 카드를 엽니다. 401(로그아웃), 409(그사이 바뀐 카드), 오프라인, 4초 초과가 모두 같은 길로 갑니다.
카드를 여는 openBoard는 열린 보드 탭이 있으면 그 탭에 plan-wall:open 메시지를 보내고 앞으로 가져옵니다. 보드는 메시지를 받아 그 카드의 상세 패널을 엽니다. 열린 탭이 없으면 /app?task=<id>로 새 창을 엽니다.
메시지는 포커스보다 먼저 보냅니다. 사용자 동작 없이 부른 포커스는 거절될 수 있는데, 그래도 카드는 열려야 하기 때문입니다.
4초 제한은 위 코드 주석이 설명합니다. 클릭 직후 몇 초 동안만 창을 열 수 있으니, 그 안에 실패를 알아야 카드를 여는 대안으로 넘어갈 수 있습니다.
「열기」 버튼과 알림 본문 클릭은 처음부터 openBoard로 갑니다. iOS처럼 알림 버튼이 없는 기기에서는 본문 클릭만 있으니 예전처럼 카드가 열립니다.
5cb65da는 이 클릭 분기를 waitUntil 한 번으로 줄이고, 아이콘 옵션을 ICONS 상수 하나로 모은 리뷰 반영 커밋입니다.
조용히 끊기던 푸시 구독#
푸시가 오지 않으면 위 기능은 하나도 동작하지 않습니다. 그런데 이전 코드에는 알림이 끊겨도 화면으로는 알 수 없는 경로가 있었습니다.
이전 앱은 로드할 때 브라우저에 구독이 있는지만 봤습니다.
if (k && 'PushManager' in window) setEnabled(!!(await subscription()));서버는 다른 이유로 구독 행을 지웁니다. 푸시 서비스가 404나 410을 돌려주면 지우고, 「내 데이터 삭제」를 눌러도 지웁니다. 그래도 브라우저에는 구독이 남아 있어서 더보기 메뉴는 계속 「알림 끄기」를 보여 줬습니다.
알림은 오지 않는데 켜져 있는 것처럼 보이는 상태입니다. 경로는 둘 더 있었습니다.
- 브라우저가 만료나 키 갱신으로 구독을 바꿔도, 새 구독을 서버에 알리는 코드가 없었습니다
- 서버의 VAPID 키를 바꾸면 옛 키로 만든 구독은 다시는 푸시를 받을 수 없습니다
고친 방법은 두 곳에서 구독을 다시 저장하는 것입니다. 첫째로 앱은 로드할 때마다 브라우저 구독을 서버에 다시 저장합니다. 서버는 endpoint 기준 upsert라 여러 번 저장해도 행이 늘지 않고, 지워졌던 행은 되살아납니다.
저장하기 전에 이 구독을 지금 서버 키로 만들었는지 확인합니다.
function sameKey(sub: PushSubscription, key: Uint8Array): boolean {
const current = sub.options.applicationServerKey;
return !current || String(new Uint8Array(current)) === String(key);
}키가 다르면 서버와 브라우저에서 구독을 지우고 「알림 설정이 바뀌었어요. 더보기에서 알림을 다시 켜 주세요」 토스트를 띄웁니다. 처음 버전은 길이를 비교하고 바이트를 하나씩 대조했는데, dd8acc2가 문자열 비교 한 줄로 줄였습니다.
둘째는 서비스 워커의 pushsubscriptionchange입니다. 브라우저가 새 구독을 넘겨주면 그 구독을 쓰고, 없으면 옛 구독의 옵션으로 다시 구독합니다. 옛 구독마저 없으면 /api/push/key에서 서버 키를 받아 구독합니다.
const options = old?.options ?? { userVisibleOnly: true, applicationServerKey: (await (await fetch('/api/push/key')).json()).key };
const sub = event.newSubscription ?? (await self.registration.pushManager.subscribe(options));새 구독은 POST /api/push/subscription으로 저장하고, endpoint가 바뀌었으면 옛 endpoint를 DELETE로 지웁니다. 로그아웃 상태라 저장이 실패하면 다음에 앱을 열 때 저장된다고 코드 주석에 적어 두었습니다.
옛 구독이 없는 경우를 따로 챙긴 것은 Firefox 때문입니다. MDN 호환표를 보면 Firefox는 이 이벤트에 oldSubscription·newSubscription 속성을 넣지 않습니다.
같은 표에서 Chrome은 이 이벤트를 138부터 지원합니다. 이벤트가 오지 않는 브라우저에서는 앱을 열 때 다시 저장하는 첫째 장치만 남습니다.
남은 것과 미결#
기기마다 되는 기능이 다릅니다. 2026년 10월 18일 MDN 호환표 기준입니다.
| 기능 | 지원 | 지원 안 함 |
|---|---|---|
setAppBadge |
Chrome·Edge 81(Windows·macOS), macOS Safari 17(설치한 앱), iOS Safari 16.4(홈 화면 앱) | Android Chrome, Firefox |
share_target |
Android Chrome 76, Chrome·Edge 89, Samsung Internet 12 | Safari, iOS Safari, Firefox |
shortcuts |
Android Chrome 84, Chrome·Edge 96, macOS Safari 17.4 | iOS Safari, Firefox |
알림 actions |
Chrome 48, Edge 18, Firefox 152 | Safari, iOS Safari |
표대로라면 iPhone에서는 배지가 붙지만 알림 버튼과 공유 시트가 없습니다. Android Chrome에서는 버튼과 공유 시트가 되지만 배지 API가 없습니다. 세 기능이 모두 되는 휴대폰 브라우저는 이 표에 없습니다.
배지 숫자가 줄어드는 순간에는 푸시가 없습니다. 배지 수를 싣는 푸시는 막힘 알림과 매일 아침 「내 결정 N건이 기다려요」 알림, 두 가지뿐입니다.
그래서 알림에서 승인하거나 다른 기기에서 답해도, 아이콘 숫자는 다음 푸시가 오거나 앱을 열 때까지 그대로입니다. 알림 「승인」 뒤의 이 지연은 설계 문서에도 적어 둔 한계입니다.
테스트가 확인하지 못하는 것도 있습니다. 오늘 node --test test/sw.test.js test/share-target.test.js를 돌렸을 때 9개가 모두 통과했습니다. 다만 sw.test.js는 public/sw.js를 node:vm 안의 가짜 워커 스코프에서 돌립니다.
실제 브라우저가 클릭 뒤 몇 초까지 창 열기를 허용하는지, 4초가 그 안에 드는지는 이 테스트로 알 수 없습니다. 기기에서 직접 재 본 기록도 커밋에는 없습니다.
4초에서 요청을 끊어도 서버는 이미 승인을 처리했을 수 있습니다. 그러면 열린 카드에는 이미 대기로 바뀐 상태가 보일 것으로 예상하지만, 재현해 보지는 않았습니다.
마지막으로, 이 기능들이 막힘에서 답까지의 시간을 실제로 줄였는지는 아직 모릅니다. 넣은 지 하루라 회고 화면에 비교할 기록이 쌓이지 않았습니다. 이건 미결입니다.