36.1 앱이 꺼져 있어도 알림이 오는 이유가 뭘까요?
카카오톡이나 인스타그램은 앱을 안 켜놔도 "새 메시지 도착" 알림이 오죠. 웹도 이걸 할 수 있어요. 바로 푸시 알림(Push Notification, 밀어 넣는 알림)이에요. 브라우저가 닫혀 있어도, 심지어 사용자가 우리 사이트를 잊고 있어도 알림을 띄울 수 있어요.
비밀은 서비스워커(service worker, 브라우저 뒤에서 도는 작은 스크립트)와 푸시 서비스(push service, 브라우저 회사가 운영하는 알림 중계소)의 협업이에요. 우리 서버가 푸시 서비스에 "이 사용자에게 이 내용 좀 전해줘"라고 부탁하면, 푸시 서비스가 사용자 기기의 서비스워커를 깨워서 알림을 띄워요. 오늘은 이 과정을 권한 요청, 구독, 알림 표시 순으로 차근차근 볼게요.
36.2 먼저 무엇부터 물어봐야 하나요?
알림은 사용자를 방해할 수 있으니, 브라우저는 허락 없이는 못 띄우게 막아놨어요. 그래서 첫 단계는 권한 요청(permission)이에요. Notification.requestPermission으로 물어봐요.
const perm = await Notification.requestPermission();
if (perm === 'granted') {
// 허락받음, 다음 단계로
} else {
// 'denied' 또는 'default', 여기서 멈춰요
}
결과는 세 가지예요. granted(허락), denied(거부), default(아직 안 정함)죠. 한 가지 매너가 있어요. 사이트 들어오자마자 팝업을 띄우면 사용자는 반사적으로 거부를 눌러요. 한 번 denied가 되면 다시 묻기가 아주 어려워지고요. 그러니 "알림 받기" 버튼을 사용자가 직접 누른 순간에 물어보는 게 정석이에요.
36.3 "구독"한다는 게 무슨 뜻인가요?
허락을 받았다면 이제 구독(subscribe)을 해요. 구독이란 "이 기기로 알림을 보낼 수 있는 주소를 하나 발급받는" 일이에요. 이 주소를 PushSubscription(푸시 구독 정보)이라고 불러요.
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY
});
옵션 두 개만 알면 돼요. userVisibleOnly: true는 "받은 푸시는 반드시 사용자에게 보이는 알림으로 띄우겠다"는 약속이에요. 몰래 뒤에서 뭔가 하는 걸 막으려는 규칙이죠. applicationServerKey는 우리 서버의 공개 키(VAPID 키, 서버 신원을 증명하는 열쇠)예요. "이 알림은 진짜 그 사이트가 보낸 거다"를 증명하는 데 써요.
36.4 발급받은 구독 정보는 어디에 쓰나요?
subscribe가 돌려준 sub 객체 안에는 이 기기로 알림을 쏠 엔드포인트(endpoint, 알림을 넣을 주소)와 암호 키가 들어 있어요. 이걸 우리 서버로 보내 저장해둬야 나중에 알림을 보낼 수 있어요.
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub)
});
흐름을 정리하면 이래요. 브라우저가 발급한 구독 주소를 서버가 사용자별로 보관해요. 그러다 "새 댓글이 달렸다" 같은 사건이 생기면, 서버가 그 사용자의 저장된 주소로 푸시를 보내는 거죠. 서버는 이때 VAPID의 비공개 키로 서명해서, 앞서 등록한 공개 키와 짝을 맞춰요. 그래야 푸시 서비스가 "믿을 만한 발신자"로 인정하고 알림을 전달해줘요.
36.5 알림은 어디서 화면에 뜨나요?
서버가 보낸 푸시가 기기에 도착하면, 브라우저가 서비스워커를 깨워 push 이벤트를 발생시켜요. 여기서 실제 알림을 그려요.
self.addEventListener('push', (e) => {
const data = e.data.json();
e.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
badge: '/badge.png',
data: { url: data.url }
})
);
});
showNotification이 실제로 알림을 띄우는 함수예요. 첫 번째 인자는 제목, 두 번째 객체엔 본문(body), 아이콘(icon), 상태표시줄에 뜨는 작은 badge, 그리고 알림에 딸려 보낼 data를 담아요. 여기서도 waitUntil이 필수예요. 알림을 다 그리기 전에 서비스워커가 잠들면 알림이 안 뜨거든요. 참고로 e.data.json()은 서버가 실어 보낸 내용을 꺼내는 부분이에요. userVisibleOnly 약속을 했으니, push 이벤트에서는 반드시 showNotification을 한 번은 불러야 해요. 안 그러면 브라우저가 "약속을 어겼다"며 경고를 띄우고, 심하면 구독을 끊어버리기도 해요.
36.6 알림을 누르면 무슨 일이 일어나야 하죠?
알림은 띄우는 것보다 누른 다음이 더 중요해요. 사용자가 알림을 탭했는데 아무 반응이 없으면 실망하죠. 이건 notificationclick 이벤트로 처리해요.
self.addEventListener('notificationclick', (e) => {
e.notification.close();
e.waitUntil(
clients.openWindow(e.notification.data.url)
);
});
e.notification.close()로 알림을 닫고, 아까 심어둔 data.url을 꺼내 clients.openWindow로 그 페이지를 열어요. 결과적으로 "새 댓글" 알림을 누르면 바로 그 댓글 화면이 뜨는 거죠. 이미 앱 탭이 열려 있다면 새 창을 여는 대신 그 탭으로 초점을 옮기는 방식으로 다듬기도 해요. clients.matchAll로 열린 창 목록을 훑어서, 같은 주소의 창이 있으면 focus로 앞으로 끌어오고 없을 때만 새로 여는 식이죠. 사용자가 원하는 곳에 정확히 데려다주는 게 핵심이에요.
36.7 푸시 알림, 전체 그림을 정리하면요?
순서를 한 줄로 꿰볼게요. 사용자가 버튼을 누르면 권한을 요청하고, 허락받으면 pushManager로 구독해요. 발급받은 구독 주소를 서버에 저장하고요. 나중에 사건이 생기면 서버가 VAPID 키로 서명해 푸시를 발송하고, 기기의 서비스워커가 push 이벤트에서 알림을 띄우고, notificationclick에서 알맞은 페이지로 데려가요.
기억할 매너도 되짚을게요. 권한은 사용자 행동 뒤에 물어보고, userVisibleOnly 약속을 지켜 반드시 보이는 알림만 띄우고, 알림 내용은 사용자에게 정말 쓸모 있는 것만 보내요. 남발하면 사용자가 알림을 통째로 꺼버리니까요.
확인은 개발자 도구 Application 탭의 Service Workers에서 해요. Push 칸에 가짜 메시지를 넣고 버튼을 누르면, 서버 없이도 push 이벤트를 흉내 낼 수 있어요. 알림이 뜨고, 눌렀을 때 원하는 페이지로 가는지 여기서 먼저 실험해보세요. 감이 잡히면 그다음에 서버 발송을 붙이면 돼요.