[PWA][Next.js] 푸시알림 구현하기

Joowon Jang·2025년 2월 15일

PWA

목록 보기
1/1

푸시알림의 동작 흐름

푸시알림은 아래와 같은 과정을 거쳐 일어난다.

푸시알림 구독 (클라이언 -> 서버)

  1. 클라이언트에서 푸시알림을 받을 수 있도록 권한을 요청 및 허용
  2. 브라우저에 service worker 등록 (push 이벤트에 대한 event listener 설정)
  3. 푸시 알림에 대한 구독 설정
  4. 설정한 구독 정보를 서버로 전송
  5. 서버에서 구독 정보를 저장(클라이언트마다 다르게 설정되어 있음)

이렇게 구독한 클라이언트들에게 필요할 때마다 푸시 알림을 보낸다.

푸시 알림 발송 (서버 -> 클라이언트)

  1. 서버에서 특정(혹은 모든) 클라이언트를 대상으로 push 이벤트를 발생
  2. 클라이언트에서 service worker에 설정해둔 push 이벤트에 대한 event listener 함수 실행
  3. showNotification 함수를 통해 service worker가 푸시알림을 띄움

푸시알림 구현하기

알림 허용 요청 (클라이언트)

보통 앱을 실행할 때, "알림을 허용하시겠습니까?"하는 창을 본 적이 많을 것이다.
앱을 처음 실행할 때 그런 창을 띄워주면 좋겠지만, 네이티브 앱이 아닌 PWA에서는 ios 환경에서 페이지를 방문할 때, 알림 허용 요청을 자동으로 실행할 수 없기 때문에 버튼을 클릭하는 등의 "사용자 상호작용이 있을 때에만" 알림 허용 요청을 할 수 있다.

아래의 코드는 특정 버튼을 클릭할 때 사용자에게 알림 허용을 선택하는 창을 띄우고, 알림이 허용된 상태라면 푸시 알림을 구독하는 함수이다.

// 버튼 클릭 시 알림 허용 요청
const handleBtnClick = async () => {
  const permission = await Notification.requestPermission();

  // 알림이 허용되어있지 않은 경우
  if (permission !== 'granted') {
    if (permission === 'denied') {
      alert('알림이 차단되어 있습니다. 설정에서 직접 변경해야 합니다.');
    }
    return;
  }
  
  // 푸시 알림을 구독 (아래쪽에 코드 있음)
  subscribePush(userId);
};

Service Worker 설정 (클라이언트)

service-worker.js에 push 이벤트에 대한 event listener를 설정해준다.

// service-worker.js
// push 이벤트가 발생할 때, 푸시알림을 보여줌
self.addEventListener('push', (event) => {
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icon512_maskable.png',
      data: { link: data.link }, // 클릭 이벤트에서 사용할 데이터 저장
    })
  );
});

// 푸시알림을 클릭하면 알림에 설정된 페이지로 이동
self.addEventListener('notificationclick', (event) => {
  const url = event.notification.data?.link;
  if (url) {
    clients.openWindow(url);
  }
  event.notification.close();
});

// 기존에 설정된 service worker가 있다면 그 service worker들이 제거될 때까지 기다려야 하기 때문에
// 그 과정을 생략하기 위한 코드임
self.addEventListener('install', () => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    (async () => {
      await clients.claim(); // 기존 열린 탭에서도 새로운 서비스 워커를 즉시 적용
      console.log('새로운 서비스 워커가 활성화되었습니다!');
    })()
  );
});

푸시알림 구독하기 (클라이언트)

openssl rand -base64 32 | tr '+/' '-_' | tr -d '='

우선, 위의 코드를 실행해서 Base64 URL Safe 형식의 문자열을 만들어 .env 파일에 NEXT_PUBLIC_VAPID_PUBLIC_KEY라는 이름으로 저장해준다.

// subscribePush.ts
export const subscribePush = async (userId: string) => {
  // 푸시알림을 지원하지 않는 브라우저일 경우 구독하지 않고 종료
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.warn('푸시 알림을 지원하지 않는 브라우저입니다.');
    alert('푸시 알림을 지원하지 않는 브라우저입니다.');
    return;
  }

  // 미리 설정해 둔 service worker를 브라우저에 등록
  const registerServiceWorker = async () => {
    return await navigator.serviceWorker.register('/service-worker.js');
  };

  const getPushSubscription = async () => {
    const serviceWorker = await registerServiceWorker();

    // 브라우저에 등록한 service worker를 통해 푸시알림에 대한 구독 정보를 설정
    const subscription = await serviceWorker.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
    });

    return subscription;
  };

  try {
    const subscription = await getPushSubscription();

    // 위에서 생성된 구독 정보를 서버로 전송
    const response = await fetch(`/api/user/subscription`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(subscription),
    });

    if (!response.ok) {
      throw new Error(`서버 오류: ${response.status}`);
    }
  } catch (err) {
    console.error('구독 정보 전송 중 오류 발생:', err);
  }
};

서버에서 구독 정보 저장하기 (서버)

간단하게 Supabase를 사용해서 구독 정보를 저장하자.

// supabseClient.ts
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_KEY!;
export const supabase = createClient(supabaseUrl, supabaseKey);

여기서는 Supabase를 사용해 구독 정보를 저장하고 있지만, prisma같은 DB ORM, firebase 등을 사용할 수 있다.

// /app/api/user/subscription/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { supabase } from '@/utils/supabase/supabaseClient';

export async function POST(req: NextRequest) {
  const subscription = await req.json();

  const { data, error } = await supabase
    .from('subscription')
    .insert(subscription);

  if (error) {
    return NextResponse.json({ message: '구독 실패', error: error.message }, { status: 400 });
  }

  return NextResponse.json({ message: '구독 완료' }, { status: 201 });
}

푸시알림 발송 (서버)

별도의 서버나 vercel cron기능 등을 사용해서 아래의 함수를 실행하면 알림을 허용한 모든 사용자에게 푸시알림이 발송된다.

// sendNotifications.ts
import webpush from 'web-push';
import { supabase } from './supabaseClient';

webpush.setVapidDetails(
  'mailto:메일주소', // 앱 관리자의 메일 주소
  process.env.VAPID_PUBLIC_KEY!, // 위에서 설정한 NEXT_PUBLIC_VAPID_PUBLIC_KEY와 동일
  // (클라이언트에서 보내는 VAPID_PUBLIC_KEY와 서버에 있는 VAPID_PUBLIC_KEY가 동일한지 확인)
  process.env.VAPID_PRIVATE_KEY! // VAPID_PUBLIC_KEY처럼 Base64 URL Safe 형태로 새로 생성
);

export async function sendNotifications() {
  const { data: subscriptions, error } = await supabase
    .from('subscription')
    .select('*');
  
  if (error) {
    console.error('Error fetching subscriptions:', error.message);
    return;
  }
  
  const notification = {
    title: '테스트 푸시 알림',
    body: '테스트 성공!',
    link: '/'
  };
  
  for (const subscription of subscriptions) {
  	webpush.sendNotification(subscription, JSON.stringify(notification));
  }
}

클라이언트에서 이 함수를 호출하거나 또는 별도의 서버, vercel cron기능 등에서 위의 함수를 실행하면 알림을 허용한 모든 사용자에게 푸시알림이 발송된다.

profile
깊이 공부하는 웹개발자

0개의 댓글