본문 바로가기

React

React 앱에서 브라우저 Notification API로 알림 보내기

브라우저 Notification API는 사용자에게 네이티브 수준의 시스템 알림을 띄울 수 있게 해주는 표준 웹 API입니다. React 앱에서도 간단한 코드로 권한을 요청하고 알림을 보낼 수 있습니다. 이 글은 실무에서 바로 적용할 수 있는 최소 예제부터 서비스 워커를 통한 백그라운드 알림까지 단계별로 정리합니다.

1. 개요: 동작 원리와 필수 조건

Notification API는 두 가지 축으로 나뉩니다. 페이지 컨텍스트에서 new Notification(...)으로 즉시 알림을 띄우는 방법과, 서비스 워커(Service Worker) 컨텍스트에서 registration.showNotification(...)으로 백그라운드 알림을 띄우는 방법입니다. 전자는 페이지가 열려 있고 권한이 허용된 상태에서 사용자 제스처로 트리거하는 데 적합합니다. 후자는 PWA, 웹 푸시와 결합해 앱이 포그라운드가 아닐 때도 동작합니다.

필수 조건은 다음과 같습니다. HTTPS 또는 localhost에서만 동작합니다. 사용자가 명시적으로 권한을 허용해야 합니다. 브라우저 지원 범위는 크롬, 엣지, 파이어폭스, 사파리 최신 버전에서 가능합니다. iOS는 16.4+부터 설치형 웹앱(PWA) 중심으로 알림을 지원합니다.

2. 지원 여부와 권한 상태 점검

React에서 렌더링 시점에 브라우저 지원과 현재 권한 상태(default, granted, denied)를 점검합니다.

// utils/notifications.js
export function isNotificationSupported() {
  return typeof window !== 'undefined' && 'Notification' in window;
}

export function getPermission() {
  if (!isNotificationSupported()) return 'denied';
  return Notification.permission; // 'default' | 'granted' | 'denied'
}

export async function requestNotificationPermission() {
  if (!isNotificationSupported()) return 'denied';
  // 사용자 제스처(클릭 등) 안에서 호출해야 차단되지 않습니다.
  try {
    const result = await Notification.requestPermission();
    return result; // same union type
  } catch (e) {
    console.error('[Notification] permission request failed', e);
    return 'denied';
  }
}

3. 최소 구현: 버튼 클릭으로 권한 요청 후 알림 띄우기

사용자 클릭 이벤트 안에서 권한 요청과 알림 표시를 처리합니다. 아이콘, 태그, 재알림 등 기본 옵션을 함께 설정합니다.

import { useEffect, useState } from 'react';

function NotificationDemo() {
  const [supported, setSupported] = useState(false);
  const [permission, setPermission] = useState('default');

  useEffect(() => {
    const ok = 'Notification' in window;
    setSupported(ok);
    setPermission(ok ? Notification.permission : 'denied');
  }, []);

  const request = async () => {
    if (!supported) return;
    const result = await Notification.requestPermission();
    setPermission(result);
  };

  const notify = () => {
    if (!supported) return;
    if (permission !== 'granted') {
      alert('알림 권한이 필요합니다. 먼저 권한을 허용해주세요.');
      return;
    }
    const n = new Notification('새 소식이 도착했습니다', {
      body: '클릭해서 메시지함을 열어보세요',
      icon: '/icons/icon-192.png',
      badge: '/icons/badge-72.png',
      tag: 'inbox-update', // 같은 tag면 갱신됩니다(renotify=true일 때)
      renotify: true,
      requireInteraction: false, // true면 사용자가 닫을 때까지 유지
      timestamp: Date.now(),
      data: { url: '/inbox' },
      silent: false,
    });

    // 일부 브라우저에서 페이지 컨텍스트 클릭 핸들러가 제한적일 수 있습니다. 백그라운드 처리는 SW 권장.
    n.onclick = () => {
      window.focus();
      window.location.href = n.data?.url || '/';
      n.close();
    };
  };

  return (
    <div>
      <p>지원 여부: {String(supported)} / 권한: {permission}</p>
      <button onClick={request} disabled={!supported || permission === 'granted'}>
        알림 권한 요청
      </button>
      <button onClick={notify} disabled={!supported || permission !== 'granted'}>
        테스트 알림 보내기
      </button>
    </div>
  );
}

export default NotificationDemo;

4. 재사용 훅으로 추상화: useNotification

여러 컴포넌트에서 동일 로직을 쓰려면 훅으로 분리합니다.

// hooks/useNotification.js
import { useCallback, useEffect, useState } from 'react';

export function useNotification() {
  const [supported, setSupported] = useState(false);
  const [permission, setPermission] = useState('default');

  useEffect(() => {
    const ok = typeof window !== 'undefined' && 'Notification' in window;
    setSupported(ok);
    setPermission(ok ? Notification.permission : 'denied');
  }, []);

  const request = useCallback(async () => {
    if (!supported) return 'denied';
    try {
      const res = await Notification.requestPermission();
      setPermission(res);
      return res;
    } catch {
      setPermission('denied');
      return 'denied';
    }
  }, [supported]);

  const notify = useCallback((title, options = {}) => {
    if (!supported || permission !== 'granted') return null;
    try {
      const n = new Notification(title, options);
      return n;
    } catch (e) {
      console.error('[Notification] show failed', e);
      return null;
    }
  }, [supported, permission]);

  return { supported, permission, request, notify };
}

// 사용 예시
// const { supported, permission, request, notify } = useNotification();
// await request();
// notify('제목', { body: '내용', icon: '/icons/icon-192.png' });

5. 서비스 워커로 백그라운드 알림(PWA, 웹 푸시)

앱이 비활성화되어도 알림을 안정적으로 처리하려면 서비스 워커를 사용합니다. 페이지에서 등록하고, registration.showNotification으로 표시하거나 Push 이벤트에서 처리합니다.

// index.js (또는 main.jsx)
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker.register('/sw.js').then((reg) => {
      console.log('SW registered', reg.scope);
    }).catch(console.error);
  });
}

// 페이지에서 SW를 통해 알림 표시
export async function notifyViaSW(title, options) {
  if (!('serviceWorker' in navigator)) return;
  const reg = await navigator.serviceWorker.getRegistration();
  if (!reg) return;
  await reg.showNotification(title, options);
}

// 사용 예시
// await notifyViaSW('백그라운드 알림', {
//   body: '서비스 워커에서 표시',
//   icon: '/icons/icon-192.png',
//   tag: 'bg-1',
//   data: { url: '/messages/123' },
//   actions: [
//     { action: 'open', title: '열기' },
//     { action: 'dismiss', title: '닫기' }
//   ]
// });
// public/sw.js
self.addEventListener('notificationclick', (event) => {
  const action = event.action; // 'open' 등
  const url = event.notification.data?.url || '/';
  event.notification.close();

  event.waitUntil((async () => {
    if (action === 'dismiss') return; // 아무 동작도 하지 않음

    const allClients = await clients.matchAll({ type: 'window', includeUncontrolled: true });
    let client = allClients.find((c) => c.url.includes(url));

    if (client) {
      await client.focus();
      try { await client.navigate(url); } catch (_) {}
    } else {
      await clients.openWindow(url);
    }
  })());
});

// 웹 푸시 수신 시 표시
self.addEventListener('push', (event) => {
  const data = (() => {
    try { return event.data ? event.data.json() : {}; } catch { return {}; }
  })();
  const title = data.title || '새 알림';
  const options = {
    body: data.body || '',
    icon: '/icons/icon-192.png',
    data: { url: data.url || '/' },
  };
  event.waitUntil(self.registration.showNotification(title, options));
});

6. UX 베스트 프랙티스

권한은 최초 방문 즉시 묻지 말고, 가치가 분명한 순간(예: 주문 완료, 채팅 활성화) 사용자 제스처 안에서 요청합니다.

권한이 denied인 경우 대체 경로를 제공합니다. 예: 이메일 구독, 앱 내 배지, 소리 알림 등.

알림은 꼭 필요한 이벤트에만 보냅니다. 과도한 빈도는 차단 또는 이탈을 유발합니다.

아이콘은 192x192 이상 정사각형, 배지는 단색 마스크 친화적 이미지를 사용합니다.

클릭 시 이동 경로를 예측 가능하게 설계하고, 동일 태그(tag)와 renotify 옵션으로 중복 알림을 합칩니다.

7. 흔한 오류와 대응

NotAllowedError나 TypeError가 난다면 HTTP 환경이거나 사용자 제스처 없이 권한을 요청한 경우가 많습니다. HTTPS와 클릭 이벤트 내부 호출을 확인합니다.

iOS에서 알림이 보이지 않는다면 PWA로 설치되어 있는지와 권한이 앱 설정에서 허용되어 있는지 확인합니다.

페이지 컨텍스트의 Notification.onclick이 일부 환경에서 제한적인 경우가 있습니다. 중요한 클릭 처리 로직은 서비스 워커 notificationclick에서 구현합니다.

8. 보안, 프라이버시, 정책

명시적 동의를 얻고, 알림 목적과 빈도를 안내합니다. 개인정보나 민감한 내용은 기본 화면에 노출하지 않도록 제목과 본문을 설계합니다.

기업 환경에서는 브라우저 정책(MDM 등)으로 알림이 차단될 수 있습니다. 기능 플래그와 로깅으로 상태를 추적하고 대안을 제공합니다.

9. 실무 체크리스트

HTTPS와 서비스 워커 등록이 정상 동작하는지 확인합니다.

권한 상태별 UI 분기와 복귀 동선을 구현합니다.

아이콘, 배지, 로컬라이제이션(다국어), 시간대 고려(야간 방해 금지)를 반영합니다.

알림 테스트는 데스크톱과 모바일(안드로이드, iOS 16.4+ PWA)에서 모두 검증합니다.

10. 마무리

React에서 Notification API를 쓰는 일은 간단하지만, 권한 UX와 서비스 워커 연계를 설계해야 실제 서비스 품질이 올라갑니다. 위 예제를 바탕으로 첫 구현을 완료한 뒤, 트래킹과 빈도 제어, 다국어, 웹 푸시 연동을 단계적으로 확장해보시기 바랍니다.