티스토리 뷰





반응형

웹 어플리케이션에서 사용자에게 푸시 알림을 보내는 기능을 구현하는 방법을 정리해보겠습니다.

개요

Firebase Cloud Messaging(FCM)은 웹, IOS, Android 앱에서 푸시 알림을 보낼 수 있을 크로스 플랫폼 메시징 솔루션 입니다.

Firebase 프로젝트 설정

  1. Firebase Console 에 접속
  2. 새 프로젝트 생성 또는 기존 프로젝트 선택
  3. 앱 웹 추가 (</> 아이콘 클릭)
  4. SDK 설정 정보 및 VAPID KEY 발급

저는 간단하게 memos 라는 프로젝트에 push라는 이름으로 </> 라는 플랫폼으로 생성했습니다.

 

좌측상단에 톱니바퀴 > 프로젝트 설정

그러면 가이드대로 npm으로 firebase 설치해주고 firebaseConfig는 저장해줍니다.

 

그리고 위로 올려서 "클라우드 메시징" 탭에서 VAPID KEY를 발급해줍시다.

프론트엔드 구성

  • Firebase 앱 초기화
  • Firebase Messaging 모듈 구현
  • Hook 만들기
  • Service Worker 작성
  • 테스트 페이지 구성

Firebase 앱 초기화

// src/lib/firebase.ts
import { initializeApp } from 'firebase/app'

const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APPID,
}

export const firebaseApp = initializeApp(firebaseConfig)

Firebase Messaging 모듈 구현

더보기
// src/lib/firebase-messaging.ts
'use client'

import { getMessaging, getToken, onMessage, type Messaging } from 'firebase/messaging'
import { firebaseApp } from './firebase'

let messaging: Messaging | null = null
let swRegistrationPromise: Promise<ServiceWorkerRegistration | null> | null = null

// Firebase Messaging 초기화
export const initMessaging = (): Messaging | null => {
  if (typeof window === 'undefined') {
    return null
  }

  if (messaging) {
    return messaging
  }

  try {
    messaging = getMessaging(firebaseApp)
    return messaging
  } catch (error) {
    console.error('Firebase Messaging 초기화 실패:', error)
    return null
  }
}

// Service Worker 등록 (페이지 로드 완료 후)
const waitForWindowLoad = async () => {
  if (typeof window === 'undefined') return
  if (document.readyState === 'complete') return
  
  await new Promise<void>((resolve) => {
    window.addEventListener('load', () => resolve(), { once: true })
  })
}

export const registerServiceWorker = async (): Promise<ServiceWorkerRegistration | null> => {
  if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
    return null
  }

  if (!swRegistrationPromise) {
    swRegistrationPromise = (async () => {
      await waitForWindowLoad()
      try {
        const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js')
        return registration
      } catch (error) {
        console.error('Service Worker 등록 실패:', error)
        const existing = await navigator.serviceWorker.getRegistration('/firebase-messaging-sw.js')
        return existing ?? null
      }
    })()
  }

  return swRegistrationPromise
}

// 알림 권한 요청
export const requestNotificationPermission = async (): Promise<NotificationPermission> => {
  if (typeof window === 'undefined' || !('Notification' in window)) {
    return 'denied'
  }

  if (Notification.permission === 'granted') {
    return 'granted'
  }

  if (Notification.permission === 'denied') {
    return 'denied'
  }

  // 사용자 제스처(버튼 클릭 등) 안에서 호출되어야 함
  const permission = await Notification.requestPermission()
  return permission
}

// FCM 토큰 발급
export const requestForToken = async (): Promise<string | null> => {
  if (typeof window === 'undefined') {
    return null
  }

  const vapidKey = process.env.NEXT_PUBLIC_FIREBASE_VAPID_KEY ?? ''
  if (!vapidKey) {
    console.error('VAPID 키가 설정되지 않았습니다.')
    return null
  }

  // 알림 권한 확인
  const permission = await requestNotificationPermission()
  if (permission !== 'granted') {
    console.error('알림 권한이 허용되지 않았습니다.')
    return null
  }

  const messagingInstance = initMessaging()
  if (!messagingInstance) {
    return null
  }

  const registration = await registerServiceWorker()
  if (!registration) {
    console.error('Service Worker를 등록할 수 없습니다.')
    return null
  }

  try {
    const token = await getToken(messagingInstance, {
      vapidKey,
      serviceWorkerRegistration: registration,
    })

    if (token) {
      console.log('FCM 토큰 발급 성공:', token)
      return token
    }

    return null
  } catch (error) {
    console.error('FCM 토큰 발급 실패:', error)
    return null
  }
}

// 포그라운드 메시지 수신 리스너
export const onMessageListener = () =>
  new Promise((resolve) => {
    const messagingInstance = initMessaging()
    if (!messagingInstance) {
      resolve(null)
      return
    }

    onMessage(messagingInstance, (payload) => {
      console.log('포그라운드 메시지 수신:', payload)
      resolve(payload)
    })
  })

React Hook 구현

// src/hooks/common/use-web-push.ts
'use client'

import { useCallback, useEffect, useState } from 'react'
import { onMessageListener, requestForToken, requestNotificationPermission } from '@/lib/firebase-messaging'

export const useWebPush = () => {
  const [token, setToken] = useState<string | null>(null)
  const [permission, setPermission] = useState<NotificationPermission>('default')
  const [loading, setLoading] = useState(false)

  const requestToken = useCallback(async () => {
    setLoading(true)

    const permissionResult = await requestNotificationPermission()
    setPermission(permissionResult)

    if (permissionResult !== 'granted') {
      console.warn('알림 권한이 허용되지 않았습니다.')
      setLoading(false)
      return null
    }

    const issuedToken = await requestForToken()
    setToken(issuedToken)
    setLoading(false)
    return issuedToken
  }, [])

  useEffect(() => {
    if (token) {
      onMessageListener().then((payload) => {
        if (payload) {
          console.log('포그라운드 메시지 수신:', payload)
        }
      })
    }
  }, [token])

  return {
    token,
    permission,
    loading,
    refreshToken: requestToken,
  }
}

 

Service Worker 설정

Service Worker 파일은 반드시 'public' 폴더에 위치해야 하며

루트 경로(/firebase-mnessaging-sw.js)에서 접근이 가능해야 합니다.

/* eslint-disable no-undef */
// Firebase Cloud Messaging Service Worker
importScripts('https://www.gstatic.com/firebasejs/10.14.0/firebase-app-compat.js')
importScripts('https://www.gstatic.com/firebasejs/10.14.0/firebase-messaging-compat.js')

const firebaseConfig = {
  apiKey: 'YOUR_API_KEY',
  authDomain: 'YOUR_AUTH_DOMAIN',
  projectId: 'YOUR_PROJECT_ID',
  storageBucket: 'YOUR_STORAGE_BUCKET',
  messagingSenderId: 'YOUR_MESSAGING_SENDER_ID',
  appId: 'YOUR_APP_ID',
}

const firebaseApp = firebase.initializeApp(firebaseConfig)
const messaging = firebase.messaging()

// 백그라운드 메시지 수신 처리
messaging.onBackgroundMessage(function (payload) {
  const notification = payload.notification

  console.log('[firebase-messaging-sw.js] Received background message ', notification)

  const notificationTitle = notification?.title || payload.data?.title || '알림'
  const notificationOptions = {
    body: notification?.body || payload.data?.body || payload.data?.message || '',
    icon: '/icons/favicon-96x96.png',
    badge: '/icons/favicon-32x32.png',
    data: {
      url: payload.data?.link || payload.fcmOptions?.link || '/',
      ...payload.data,
    },
  }

  self.registration.showNotification(notificationTitle, notificationOptions)
})

// 알림 클릭 이벤트 처리
self.addEventListener('notificationclick', (event) => {
  event.notification.close()
  const urlToOpen = event.notification.data?.url || '/'

  event.waitUntil(
    clients
      .matchAll({ type: 'window', includeUncontrolled: true })
      .then((clientList) => {
        for (const client of clientList) {
          if (client.url === urlToOpen && 'focus' in client) {
            return client.focus()
          }
        }
        if (clients.openWindow) {
          return clients.openWindow(urlToOpen)
        }
        return null
      })
  )
})
서비스 워커는 FCM 웹 푸시에서 필수 요구사항이며, 백그라운드로 메시지 수신을 하여 브라우저가 
꺼져있어도 독립적으로 실행되어 메시지를 수신하고 알림을 표시하기 위해서 입니다.

포그라운드: 페이지가 열려있을 때 onMessage() 리스너로 메시지 처리
백그라운드: 페이지가 닫혀있을 때 onBackgroundMessage() 리스너로 메시지 처리

테스트 페이지 작성

더보기
'use client'

import { useCallback, useEffect, useMemo, useState } from 'react'

import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Paper from '@mui/material/Paper'
import Stack from '@mui/material/Stack'
import Typography from '@mui/material/Typography'
import Alert from '@mui/material/Alert'
import TextField from '@mui/material/TextField'

import { useWebPush } from '@/hooks/common/use-web-push'

const truncateToken = (token?: string | null) => {
  if (!token) {
    return '토큰이 아직 발급되지 않았습니다.'
  }
  return `${token.slice(0, 30)}...${token.slice(-6)}`
}

const copyToken = async (token: string | null) => {
  if (!token) {
    return
  }
  try {
    await navigator.clipboard.writeText(token)
    console.log('📋 토큰이 클립보드에 복사되었습니다.')
  } catch (error) {
    console.error('토큰 복사에 실패했습니다:', error)
  }
}

export default function WebPushTestPage() {
  const { token, permission, loading, refreshToken } = useWebPush()
  const [pushTitle, setPushTitle] = useState('웹 푸시 테스트')
  const [pushBody, setPushBody] = useState('이것은 테스트 메시지입니다.')
  const [pushLink, setPushLink] = useState('')
  const [targetToken, setTargetToken] = useState('')
  const [sending, setSending] = useState(false)
  const [sendResult, setSendResult] = useState<{ success: boolean; message: string } | null>(null)

  const [endpoint, setEndpoint] = useState(() => {
    const base = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '')
    return base ? `${base}/interface/fcm` : '/interface/fcm'
  })

  useEffect(() => {
    console.log('현재 알림 권한:', permission)
  }, [permission])

  useEffect(() => {
    if (token) {
      console.log('현재 발급된 토큰:', token)
      setTargetToken(token)
    }
  }, [token])

  useEffect(() => {
    if (typeof window !== 'undefined') {
      setPushLink(window.location.origin)
    }
  }, [])

  const sendTestPush = useCallback(async () => {
    if (!targetToken) {
      alert('전송할 대상 토큰이 없습니다.')
      return
    }

    setSending(true)
    setSendResult(null)

    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          title: pushTitle,
          body: pushBody,
          link: pushLink,
          tokens: [{ token: targetToken }],
        }),
      })

      const data = await response.json().catch(() => null)

      if (!response.ok) {
        throw new Error(data?.message || 'FCM 전송 요청이 실패했습니다.')
      }

      const successCount = data?.payload?.successCount ?? 0
      const failureCount = data?.payload?.failureCount ?? 0
      setSendResult({
        success: true,
        message: `전송 완료 - 성공 ${successCount}건 / 실패 ${failureCount}건`,
      })
    } catch (error) {
      console.error('FCM 전송 중 오류:', error)
      setSendResult({
        success: false,
        message: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
      })
    } finally {
      setSending(false)
    }
  }, [targetToken, pushTitle, pushBody, pushLink, endpoint])

  return (
    <Box sx={{ p: 4, maxWidth: 960, mx: 'auto', display: 'flex', flexDirection: 'column', gap: 3 }}>
      <Typography variant="h4" fontWeight={700}>
        웹 푸시 테스트
      </Typography>

      <Paper sx={{ p: 3 }} elevation={3}>
        <Stack spacing={2}>
          <Typography variant="subtitle1">1. 권한 상태</Typography>
          <Typography variant="body2">알림 권한: {permission}</Typography>
          <Typography variant="body2">로딩 상태: {loading ? '토큰 발급 중...' : '대기 중'}</Typography>
        </Stack>
      </Paper>

      <Paper sx={{ p: 3 }} elevation={3}>
        <Stack spacing={2}>
          <Typography variant="subtitle1">2. 토큰 정보</Typography>
          <Typography variant="body2">{truncateToken(token)}</Typography>
          <Stack direction="row" spacing={2}>
            <Button variant="contained" onClick={refreshToken} disabled={loading}>
              {loading ? '토큰 요청 중...' : '토큰 다시 요청'}
            </Button>
            <Button variant="outlined" onClick={() => copyToken(token)} disabled={!token}>
              토큰 복사
            </Button>
          </Stack>
        </Stack>
      </Paper>

      <Paper sx={{ p: 3 }} elevation={3}>
        <Stack spacing={2}>
          <Typography variant="subtitle1">3. 실행 로그</Typography>
          <Typography variant="body2" color="text.secondary">
            이 페이지는 접근 즉시 알림 권한을 요청하고 FCM 토큰을 발급합니다. 결과는 브라우저 콘솔에서
            확인할 수 있습니다.
          </Typography>
        </Stack>
      </Paper>

      <Paper sx={{ p: 3 }} elevation={3}>
        <Stack spacing={2}>
          <Typography variant="subtitle1">4. FCM 전송 테스트</Typography>
          <TextField
            label="제목"
            value={pushTitle}
            onChange={(event) => setPushTitle(event.target.value)}
            fullWidth
          />
          <TextField
            label="본문"
            value={pushBody}
            multiline
            minRows={2}
            onChange={(event) => setPushBody(event.target.value)}
            fullWidth
          />
          <TextField
            label="링크"
            value={pushLink}
            onChange={(event) => setPushLink(event.target.value)}
            fullWidth
          />
          <TextField
            label="대상 토큰"
            value={targetToken}
            onChange={(event) => setTargetToken(event.target.value)}
            multiline
            minRows={3}
            fullWidth
          />
          {sendResult && (
            <Alert severity={sendResult.success ? 'success' : 'error'}>{sendResult.message}</Alert>
          )}
          <Stack direction="row" spacing={2}>
            <TextField
              label="호출 엔드포인트"
              value={endpoint}
              onChange={(event) => setEndpoint(event.target.value)}
              fullWidth
            />
            <Button variant="contained" onClick={sendTestPush} disabled={sending}>
              {sending ? '전송 중...' : 'FCM 메시지 전송'}
            </Button>
          </Stack>
        </Stack>
      </Paper>
    </Box>
  )
}

결과

테스트 페이지로 접근 시 권한 관련한 얼럿이 뜨게된다.

토큰정보는 device token으로 타켓팅을 하기위한 key 입니다.

 

다시 콘솔로 돌아가서 해당 토큰으로 메시지를 보내봅시다.

 

첫 번째 캠페인 만들기를 클릭해서

아까 저장한 토큰을 가지고 테스트 메시지 전송 gogo

 

 

하지만 이슈가 하나 있는데 알림을 차단/허용을 한 번 선택하면 같은 창을 다시 띄우기가 어렵다.

웹이나 안드에서는 권한 재설정이라는 기능으로 on/off가 가능한데 "유저가 그렇게 사용할까?" 라는 관점에서는 미지수다.

error fix

Service Worker 파일이 HTML로 반환되는 경우

Next.js에서 rewrites 설정이 모든 경로를 가로채서 Service Worker 파일도 HTML로 반환되는 문제가 발생할 수 있음

더보기
### 해결 방법

`next.config.mjs`에서 다음과 같이 설정합니다:

const nextConfig = {
  // ... 기타 설정

  async headers() {
    return [
      {
        source: '/firebase-messaging-sw.js',
        headers: [
          {
            key: 'Content-Type',
            value: 'application/javascript; charset=utf-8',
          },
          {
            key: 'Service-Worker-Allowed',
            value: '/',
          },
        ],
      },
    ]
  },

  async rewrites() {
    return [
      // ... 기타 rewrites
      {
        source: '/:module*/:path*',
        destination: `/page/:module*/:path*`,
        has: [
          {
            type: 'header',
            key: 'accept',
            value: 'text/html',
          },
        ],
      },
    ]
  },
}

 

반응형

'front-end > React' 카테고리의 다른 글

리액트 서버 사이드 렌더링의 이해  (0) 2019.12.31
리액트 Context API  (0) 2019.12.15
리액트 라우터 적용해보기  (0) 2019.12.13
리액트 SPA 요점  (0) 2019.12.11
리액트 Hook #2  (0) 2019.12.08
댓글
반응형
최근에 달린 댓글
글 보관함
Total
Today
Yesterday
최근에 올라온 글
«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28