Express.js Web Push

1. Web Push 개요

Web Push는 브라우저가 서버로부터 비동기 알림을 수신하는 메커니즘이다.

특징은 다음과 같다.

  • 웹 페이지가 열려 있지 않은 상태에서도 알림 수신 가능
  • 데스크톱, 모바일 브라우저 공통 동작
  • 브라우저 벤더가 운영하는 Push Service를 경유
  • Service Worker, Push API, VAPID 기반 표준 기술

서버는 브라우저에 직접 메시지를 전달하지 않는다.

서버 → Push Service → 브라우저(Service Worker)

Push Service는 메시지 전달 신뢰성과 확장성을 담당한다.


2. 동작 흐름

  1. 브라우저에서 Service Worker 등록
  2. 브라우저가 Push Service에 구독 요청
  3. 구독 정보(endpoint, keys)를 서버로 전송
  4. 서버가 구독 정보 저장
  5. 서버가 Push Service로 메시지 전송
  6. Push Service가 Service Worker 실행
  7. Service Worker가 알림 표시

이 흐름을 기준으로 서버와 클라이언트 구현을 구성한다.


3. 서버 저장 스키마

Web Push 구독은 사용자 기준이 아니라 브라우저 및 디바이스 기준으로 관리된다. 하나의 사용자는 여러 개의 subscription을 가질 수 있다.

push_subscriptions

push_subscriptions

id              bigint PK
user_id         bigint
endpoint        text UNIQUE
p256dh          varchar(255)
auth            varchar(255)
user_agent      varchar(255)
is_active       boolean
created_at      timestamp
updated_at      timestamp

컬럼 설명

  • endpoint: Push Service URL, 구독 식별자
  • p256dh / auth: payload 암호화 키
  • user_id: 사용자 기준 발송을 위한 참조 값
  • is_active: 만료 또는 실패 구독 관리용 플래그

4. 서버 코드 예제 (Express.js)

const express = require('express');
const webPush = require('web-push');

const {
  saveSubscription,
  getActiveSubscriptionsByUser,
  deactivateSubscription
} = require('./subscriptionStore');

const app = express();
app.use(express.json());

webPush.setVapidDetails(
  'mailto:admin@company.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

app.post('/push/subscribe', (req, res) => {
  const { userId, subscription } = req.body;

  if (!userId || !subscription?.endpoint) {
    return res.status(400).end();
  }

  saveSubscription({
    userId,
    endpoint: subscription.endpoint,
    keys: subscription.keys,
    userAgent: req.headers['user-agent']
  });

  res.sendStatus(201);
});

app.post('/push/send', async (req, res) => {
  const { userId, title, body } = req.body;

  const payload = JSON.stringify({ title, body });
  const subs = await getActiveSubscriptionsByUser(userId);

  await Promise.all(
    subs.map(async (sub) => {
      try {
        await webPush.sendNotification(sub, payload);
      } catch (err) {
        if (err.statusCode === 404 || err.statusCode === 410) {
          deactivateSubscription(sub.endpoint);
        }
      }
    })
  );

  res.sendStatus(200);
});

app.listen(3000);

5. 클라이언트 코드

Service Worker

self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : {};

  event.waitUntil(
    self.registration.showNotification(data.title || 'Notification', {
      body: data.body || ''
    })
  );
});

브라우저 구독 처리

<script>
(async () => {
  if (!('serviceWorker' in navigator)) return;

  const reg = await navigator.serviceWorker.register('/sw.js');

  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;

  let sub = await reg.pushManager.getSubscription();

  if (!sub) {
    sub = await reg.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: urlBase64ToUint8Array('VAPID_PUBLIC_KEY')
    });
  }

  await fetch('/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId: 1, subscription: sub })
  });
})();

function urlBase64ToUint8Array(base64) {
  const raw = atob(base64.replace(/-/g, '+').replace(/_/g, '/'));
  return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
}
</script>

6. 구현 시 고려 사항

  • 서버는 브라우저로 직접 메시지를 전송하지 않는다
  • subscription은 브라우저 및 디바이스 단위로 관리된다
  • endpoint는 고유 값으로 저장해야 한다
  • Push 전송 실패(404, 410) 시 즉시 비활성화 처리한다

7. FCM과의 차이

Web Push

  • 웹 표준 API 기반
  • 브라우저 환경에 한정
  • 구독 관리, 암호화, 실패 처리를 서버에서 직접 구현

FCM

  • Google Firebase 제공 플랫폼
  • Web, Android, iOS 통합 지원
  • 토픽, 디바이스 그룹, 통계, 재시도 기능 제공
  • Web 환경에서는 내부적으로 Web Push 사용

비교

구분Web PushFCM
성격웹 표준플랫폼 서비스
의존성브라우저Firebase
제어 범위높음제한적
구현 난이도높음낮음
대상웹 + 모바일

참조