PWA로 admin 페이지에 모바일 푸시 알림 붙이기
제가 직접 운영하는 admin(내부 관리자) 페이지가 하나 있습니다. 거창한 제품이라기보다는 데스크톱에서 가볍게 쓰는 로컬 도구에 가깝습니다. 이 admin 뒤편에서는 몇 가지 자동화 작업이 조용히 돌고 있고, 그 결과는 그동안 Slack으로 받아왔습니다.
그런데 책상을 비웠을 때가 문제였습니다. "방금 무언가 끝났다", "새로운 게 올라왔다" 하는 소식을 책상 앞이 아니라 휴대폰으로 바로 알고 싶다는 생각이 들었습니다. 알림 하나 더 받고 싶을 뿐인데, 그걸 위해 무엇을 해야 하나 고민하다 보니 자연스럽게 모바일 앱이 떠올랐습니다. 그리고 거의 동시에 그건 명백한 오버킬이라는 결론에 이르렀고, 결국 PWA로 이 admin에 모바일 푸시 알림만 얹는 길을 택하게 됐습니다.
알림 하나 때문에 앱을 만들 수는 없었습니다
네이티브 모바일 앱을 만든다는 건 생각보다 큰 결정입니다. 플랫폼별 코드베이스를 따로 들고 가거나 React Native, Flutter 같은 프레임워크를 새로 익혀야 하고, 앱스토어 심사와 배포, 그 이후의 업데이트 사이클까지 떠안아야 합니다. 솔직히 "알림만 받으면 되는데"라는 목적에 비하면 투자 대비 효용이 너무 낮았습니다.
곰곰이 생각해보니 제게 필요한 건 새로운 앱이 아니었습니다. 이미 있는 웹앱이 알림을 보낼 수 있으면 그걸로 충분했습니다. 문제를 이렇게 다시 정의하고 나니, 해법의 방향도 자연스럽게 좁혀졌습니다.
그래서 PWA를 골랐습니다
PWA(Progressive Web App)는 웹 기술로 만들어졌지만 설치되거나 알림을 받는 등 "앱처럼" 동작할 수 있는 웹앱을 말합니다. 제 상황에 이 방식이 잘 맞았던 이유는 단순했습니다. 이미 admin이라는 웹앱이 있으니 거기에 몇 가지 요소만 얹으면 됐고, 앱스토어도 별도의 배포 파이프라인도 필요 없이 기존 웹 배포를 그대로 쓸 수 있었습니다. 무엇보다 코드베이스 하나로 데스크톱과 모바일을 모두 커버한다는 점이 마음에 들었습니다.
| 항목 | 네이티브 앱 | PWA |
|---|---|---|
| 개발 비용 | 플랫폼별 코드 또는 새 프레임워크 학습 | 기존 웹앱에 일부만 추가 |
| 배포 | 앱스토어 심사·배포 사이클 | 기존 웹 배포 그대로 |
| 코드베이스 | 모바일 별도 | 데스크톱·모바일 공용 |
| OS 통합·백그라운드 | 깊음 | 제한적 |
물론 트레이드오프는 분명히 있습니다. PWA는 네이티브만큼 OS와 깊이 통합되지 못하고, 백그라운드 권한도 제한적입니다. 다만 제 목표는 "알림 수신"이라는 좁은 한 가지였고, 그 좁은 목표에는 이 정도면 충분하다는 판단이 섰습니다. 모든 걸 얻으려 하지 않고 필요한 것만 분명히 했더니 선택이 오히려 쉬워졌습니다.
PWA가 무엇인지 짧게
웹앱을 "설치되고 알림이 오는 앱"으로 만드는 데에는 몇 가지 핵심 조각이 필요합니다. 깊이 들어가기보다 큰 그림만 짚고 넘어가겠습니다.
- manifest: 앱 이름, 아이콘, 시작 URL, 표시 모드 같은 메타데이터를 적어 둔 파일입니다. 브라우저는 이걸 보고 "홈 화면에 추가" 설치를 제안합니다.
- service worker: 웹페이지와는 별개로 백그라운드에서 도는 스크립트입니다. 페이지가 닫혀 있어도 살아서 푸시 메시지를 받아 알림을 띄우는 역할을 합니다.
- web push와 알림 권한: 서버가 브라우저로 푸시를 보내는 표준 메커니즘입니다. 사용자가 알림 권한을 허용하면 브라우저가 구독 정보를 만들고, 서버는 그 정보로 알림을 발송합니다. 발신자를 식별하는 데에는 VAPID라는 표준을 쓰지만, 키 관리 같은 보안 세부는 이 글의 범위를 벗어납니다.
한 가지 전제 조건이 있습니다. service worker와 푸시는 보안 컨텍스트에서만 동작하기 때문에 HTTPS가 필수입니다. 이 점만 기억하고 다음으로 넘어가겠습니다.
실제로 해보고 알게 된 것들
개념만 보면 간단해 보이지만, 막상 직접 얹어 보니 튜토리얼에는 잘 적혀 있지 않은 함정과 판단의 순간들이 있었습니다. 결국 이 글에서 가장 나누고 싶은 건 이 부분입니다.
iOS는 홈 화면에 설치해야만 푸시가 옵니다
가장 크게 데인 함정부터 이야기하겠습니다. 안드로이드나 데스크톱 크롬에서는 브라우저 탭을 열어 둔 상태에서도 웹 푸시를 받을 수 있습니다. 그래서 저는 별생각 없이 아이폰에서도 같으리라 짐작했습니다. 그런데 iOS는 Safari 탭에 열어 둔 상태로는 푸시를 받지 못합니다.
반드시 "공유 → 홈 화면에 추가"를 거쳐 설치형 PWA로 띄운 뒤에야 알림 권한을 요청할 수 있고, 그제야 푸시가 도착합니다. iOS 16.4부터 웹 푸시가 열리긴 했지만, 이 "설치형 한정"이라는 제약이 함께 따라붙습니다. 처음에는 이 사실을 모른 채 "왜 다른 기기는 다 되는데 내 아이폰만 알림이 안 오지" 하며 한참을 헤맸습니다.
그래서 admin의 알림 설정 화면에 작은 안내 UI를 하나 두었습니다. 아이폰 사용자에게는 "먼저 홈 화면에 추가하세요"를 명시적으로 보여 주는 식입니다. 돌이켜 생각해보면, 이건 단순한 버그가 아니라 처음부터 UX에 녹여야 하는 제약이었습니다. PWA 푸시를 계획한다면 iOS의 이 한계를 설계 단계에서 미리 반영하는 편이, 나중에 원인을 못 찾아 시간을 허비하는 것보다 훨씬 낫다는 생각이 들었습니다.
풀 PWA가 아니라 푸시 전용 최소 service worker로 충분했습니다
두 번째로 알게 된 것은 조금 더 결이 다른 이야기입니다. 대부분의 PWA 튜토리얼은 service worker로 앱 전체를 미리 캐싱(precache)해서 오프라인에서도 열리게 하라고 권합니다. 처음에는 저도 으레 그렇게 해야 하는 줄 알았습니다.
하지만 제 목적은 오직 푸시 알림 하나였습니다. 게다가 admin은 SPA라서 자바스크립트 번들이 수 MB짜리 단일 덩어리에 가까운데, 이걸 통째로 캐싱하면 골치 아픈 문제가 생깁니다. 바로 사용자가 옛 버전에 갇히는(stale) 문제입니다. 캐시를 비우기 전까지는 새로 배포한 변경이 반영되지 않습니다. 알림 하나 받자고 도입한 기능이 오히려 정상적인 업데이트를 가로막는 셈입니다.
그래서 캐싱 로직을 아예 덜어 내고, 푸시를 받아 알림을 띄우고 알림 클릭을 처리하는 일만 하는 최소한의 service worker만 등록했습니다. 실제로 필요한 부분만 추리면 골격은 이 정도로 단출합니다.
// 푸시 수신: 받은 데이터로 알림만 띄운다
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
data: { url: data.url }, // 클릭 시 이동할 목적지
})
);
});
// 알림 클릭: 함께 실어 보낸 목적지로 이동
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
여기서 얻은 교훈은 분명했습니다. PWA는 전부 아니면 전무가 아닙니다. 흔히 PWA라고 하면 오프라인 캐싱, 설치, 푸시를 한 묶음으로 떠올리지만, 목적에 필요한 조각만 골라 쓰는 편이 오히려 안전했습니다. 알림만 원한다면 캐싱은 도움이 아니라 짐이 될 수 있다는 것을, 직접 겪고 나서야 제대로 이해하게 됐습니다.
캐시가 업데이트를 박제하지 않게
캐싱을 덜어 냈다고 해서 캐시 문제에서 완전히 자유로운 건 아니었습니다. service worker 파일과 manifest 파일 그 자체는 항상 최신으로 다시 받아오도록(no-cache) 설정해 두어야 합니다.
이 파일들을 일반 정적 파일처럼 오래 캐싱해 버리면, 정작 service worker 로직을 고쳐도 사용자 기기에는 옛날 service worker가 박제되어 바뀌지 않습니다. 정작 핵심이 되는 파일이 갱신되지 않는 역설이 생기는 것입니다. 등록 역시 자동 업데이트 모드로 두어, 새 버전을 배포하면 백그라운드에서 조용히 갱신되도록 했습니다.
Slack을 교체가 아니라 곁들였습니다
원래 알림은 Slack으로만 갔습니다. 모바일 푸시를 도입하면서 저는 이걸 Slack을 대체하는 것이 아니라 함께 받는 것으로 설계했습니다. 그래서 알림 발송부를 여러 채널로 동시에 뿌리는 fan-out 구조로 바꿨습니다. 하나의 알림이 발생하면 Slack과 웹 푸시로 동시에 나가는 식입니다.
중요한 건 한 채널이 실패해도 다른 채널을 막지 않게 한 점이었습니다. 각 채널로 독립적으로 발송한 뒤 결과만 모으기 때문에, Slack이 잠시 죽어도 푸시는 가고 그 반대도 성립합니다. 푸시 설정이 안 된 환경에서는 자동으로 비활성화되어 기존 Slack 단독 동작이 그대로 보존됩니다.
덕분에 이 변경은 점진적으로, 거의 위험 없이 들어갈 수 있었습니다. 새 알림 채널은 기존 것을 끄고 갈아 끼우기보다 조용히 곁들이는 방향이 안전하다는 것을 다시 한번 느꼈습니다. 무언가 잘못돼도 되돌릴 일이 거의 없으니까요.
알림을 누르면 그 화면으로
마지막은 작지만 실사용성을 크게 좌우한 디테일입니다. 푸시 알림은 띄우는 것으로 끝이 아니라, 눌렀을 때 곧장 그 작업 화면으로 이동해야 비로소 쓸모가 생깁니다. 알림만 울리고 정작 어디서 무슨 일이 일어났는지 직접 찾아 들어가야 한다면, 그건 반쪽짜리 알림입니다.
그래서 각 자동화가 알림을 보낼 때 "이 알림을 누르면 갈 목적지"를 함께 실어 보내도록 했습니다. 앞서 본 service worker의 알림 클릭 핸들러가 그 목적지를 받아 admin의 해당 페이지로 곧장 열어 줍니다. 사소해 보이지만, 이 한 가지로 알림이 단순한 신호에서 실제로 일을 시작하는 입구로 바뀌었습니다.
가볍게 얻은 것, 그리고 한계
결과적으로 저는 앱을 하나도 새로 만들지 않고, 기존 웹앱에 manifest와 최소한의 service worker, 그리고 푸시 구독만 얹어서 휴대폰으로 자동화 알림을 받게 됐습니다. 데스크톱과 모바일을 코드 하나로 함께 다루면서요. 들인 노력에 비하면 꽤 만족스러운 결과였습니다.
다만 한계도 솔직히 적어 두는 편이 맞겠습니다. 네이티브 수준의 OS 통합이나 백그라운드 권한은 얻지 못했고, 앞서 이야기한 iOS의 설치형 제약도 그대로 안고 갑니다. 웹 푸시는 본질적으로 브라우저와 OS 정책에 기대는 best effort 방식이라, 모든 알림이 항상 즉시 도착한다고 장담하기는 어렵습니다.
그럼에도 "내 도구의 알림만 모바일로 받고 싶다" 정도의 가벼운 니즈라면, PWA는 가성비가 매우 좋은 선택이라는 생각이 듭니다. 본격적인 모바일 제품을 만든다면 네이티브를 진지하게 고민할 일이지만, 그 전 단계에서 가볍게 한 발 내딛는 해법으로는 충분하고도 남았습니다. 결국 중요한 건 도구의 크기가 아니라, 내가 풀려는 문제의 크기에 해법을 맞추는 일이 아닐까 하는 생각이 들었습니다.
관련 글
Tauri로 30분 만에 3.8MB 데스크톱 앱 — SaaS를 접고 알게 된 것
SaaS를 검토하다 1인 사용 도메인임을 인정하고 데스크톱으로 선회했습니다. Python의 mac 함정과 Tauri vs Electron 비교 끝에 Tauri를 골랐고, 30분 만에 3.8MB .dmg가 나왔습니다. rusqlite 시그니처 함정도 함께 정리했습니다.
Github Actions 크론이 정시에 오지 않아서 — Cloudflare Workers로 옮긴 이야기
전편에서 약속한 'Github Actions 설정 시 고려할 부분' 이야기. 막상 해보니 메시지가 정시에 오지 않았습니다. 무료 크론이 5~30분 늦는 이유와, 정시에 작동하면서 비밀번호 같은 정보를 안전하게 다룰 수 있는 무료 대안 Cloudflare Workers로 옮긴 과정을 비개발자 관점에서 정리했습니다.
Admin 패널에 Git 브랜치 뷰어를 만든 이유
GitHub에서는 볼 수 없는 로컬 브랜치와 worktree 상태를 웹 브라우저에서 한눈에 확인하고 싶었습니다. NestJS 백엔드에서 git CLI를 실행하고 결과를 파싱하는 방식으로 Admin 패널에 Git 대시보드를 구현한 과정을 정리합니다.