웹 기반 지식만으로는 막막하게 느껴지는'앱 알림'을 현재 진행 중인 프로젝트(VETOOL)의 실제 코드를 바탕으로 웹 개발자의 시선에서 흐름대로 정리해 보았다.
1. 왜 Web Push API 대신 'Capacitor 앱'일까?
웹 브라우저에도 Web Push API가 엄연히 존재하는데, 왜 굳이 Capacitor로 껍데기를 씌운 하이브리드 앱을 만들고 복잡한 네이티브 푸시를 구현해야 할까?
- iOS 브라우저 환경의 한계: Safari를 비롯한 iOS 환경에서는 웹 푸시(Web Push) 알림을 받기 위해 사용자가 반드시 해당 사이트를 '홈 화면에 추가(PWA)'해야만 기능이 활성화된다. 일반적인 모바일 웹 브라우저 상태에서는 알림을 보낼 방법이 없다. 물론 PWA를 설정해 두었으나 기술 고관심군이 아니고서야 PWA의 개념을 아는 일반인들은 극소수이다.
- OS 차원의 엄격한 통제: 모바일 OS(iOS, Android)는 배터리와 성능 최적화를 위해 브라우저 백그라운드 프로세스를 가차 없이 종료한다. 반면, 네이티브 앱은 OS가 제공하는 전용 푸시 서버를 통해 앱이 완전히 꺼져 있는 상태에서도 안정적으로 알림을 수신할 수 있다.
- 스토어 배포와 접근성: 유저들에게 '주소창에 URL을 치고 들어와 홈 화면에 추가해 달라'고 설득하는 것보다, 앱스토어와 플레이스토어에서 앱을 다운받게 하는 것이 사용자 경험 측면에서 압도적으로 유리하다.
즉, VETOOL 유저들에게 OS 제약 없이 가장 확실하고 안정적인 알림 경험을 제공하기 위해, 웹 서비스를 Capacitor로 감싸 네이티브 생태계로 진입시킨 것이다.
모바일 앱 환경에서는 각 OS가 푸시 알림을 아래와 같이 자체적으로 통제한다.
- 애플(iOS) 은 APNs (Apple Push Notification service)라는 서버를 통해서만 알림을 보낼 수 있고,
- 구글(Android) 은 FCM (Firebase Cloud Messaging) 서버를 통해서 알림을 보낸다.
즉, 우리 백엔드 서버가 스마트폰에 직접 알림을 쏘는 게 아니라, "애플/구글 서버야, 이 핸드폰으로 이 메시지 좀 전해줘!" 하고 부탁하는 방식이다. 우리는 Capacitor가 제공하는 네이티브 모듈을 사용해 이 시스템과 통신하게 된다.
2. 전체적인 흐름
- 토큰 발급 및 저장 (Client → DB)
- 앱을 켜면 스마트폰(OS)으로부터 기기 고유의 '푸시 주소(Device Token)' 를 발급받는다.
- 이 주소를 우리 DB(Supabase)에 저장한다. "A라는 유저의 핸드폰 푸시 주소는 B야!"
- 알림 전송 (Server → Firebase → 스마트폰)
- 백엔드에서 알림을 보낼 일이 생기면, DB에서 해당 유저의 푸시 주소(Token)를 찾는다.
- 이 주소를 들고 Firebase(FCM) 에 찾아가 알림 전송을 부탁한다.
- FCM가 유저의 스마트폰으로 알림을 배달한다.
- 알림 수신 및 화면 표시 (스마트폰 → Client)
- 핸드폰이 알림을 받아 화면에 띄워준다.
3. 실제 코드
단계 1: "제 폰으로 알림 보내도 돼요" - 토큰 발급 및 저장
// hooks/use-push-notifications.tsx
import { createBrowserClient } from '@/lib/supabase/client'
import { useEffect } from 'react'
export function usePushNotifications() {
useEffect(() => {
registerPushToken()
}, [])
}
async function registerPushToken() {
// 1. SSR 환경이 아니고, window에 'Capacitor' 객체가 주입된 실제 앱 환경인지 먼저 검증한다.
if (typeof window === 'undefined' || !('Capacitor' in window)) return
// 2. 일반 모바일 웹 브라우저가 아닌 '네이티브 플랫폼(iOS/Android 앱)'일 때만 후속 로직을 실행한다.
const { Capacitor } = window as typeof window & {
Capacitor: { isNativePlatform: () => boolean; getPlatform: () => string }
}
if (!Capacitor.isNativePlatform()) return
try {
// 3. PushNotifications Capacitor 플러그인 동적 import
const { PushNotifications } = await import('@capacitor/push-notifications')
// 4. 유저에게 "알림을 보내도 될까요?" 권한 창을 띄운다.
const permResult = await PushNotifications.requestPermissions()
if (permResult.receive !== 'granted') return
// 5. 권한을 허락받았다면, 운영체제(OS)에 푸시 알림 기기를 등록한다.
await PushNotifications.register()
// ... (포그라운드 알림 처리는 하단 '단계 3'에서 후술)
// 6. OS가 성공적으로 '푸시 주소(Token)'를 발급해주면 이 콜백이 실행된다!
PushNotifications.addListener('registration', async ({ value: token }) => {
const supabase = createBrowserClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return
// 7. DB(Supabase) 유저 데이터의 꼬임을 막기 위해, 동일 플랫폼의 기존 토큰을 먼저 지우고 새 토큰을 insert 한다.
await supabase
.from('device_push_tokens')
.delete()
.eq('user_id', user.id)
.eq('platform', Capacitor.getPlatform())
await supabase.from('device_push_tokens').insert({
user_id: user.id,
token,
platform: Capacitor.getPlatform(), // ios 인지 android 인지
updated_at: new Date().toISOString(),
})
})
} catch (e) {
console.error('[Push] setup failed:', e)
}
}
웹 개발자 입장에서 비유하자면, 이 과정은 유저의 이메일 주소를 수집해서 DB에 저장하는 과정과 완벽히 비슷하다. 단지 그 주소가 이메일이 아니라 핸드폰 고유의 아주 긴 문자열(Device Token)일 뿐이다.
단계 2: "알림 좀 배달해 주세요" - 알림 전송
누군가 병원에 새로운 공지사항을 등록했다고 가정해 보자. 해당 병원 직원들에게 알림을 쏴야 한다.
// lib/services/push-notification.ts
import { messaging } from '@/lib/firebase/admin'
export const sendNoticeAlarm = async (hosId: string, noticeText: string) => {
// ... 생략 (알림을 받을 타겟 유저 목록을 조회)
// 1. DB에서 알림을 받을 유저들의 '푸시 주소(Token)'들을 싹 가져온다.
const { data: tokenRows, error: tokensError } = await supabase
.from('device_push_tokens')
.select('token')
.in('user_id', userIds)
if (tokensError) throw new Error(tokensError.message)
if (!tokenRows?.length) return { sent: 0 }
const tokens = tokenRows.map((r) => r.token)
// 2. 알림 메세지 정리
const body = noticeText.length > 100 ? noticeText.slice(0, 100) + '...' : noticeText
const messages = tokens.map((token) => ({
token,
notification: { title: '전달사항', body },
}))
// 3. Firebase Admin을 이용해서 가져온 주소들(tokens)로 메시지를 발송한다.
// 이 코드가 실행되면 Firebase가 알아서 구글/애플 서버를 거쳐 핸드폰으로 알림을 쏜다.
const response = await messaging.sendEach(messages)
response.responses.forEach((r, i) => {
if (!r.success) console.error(`[FCM] token[${i}] failed:`, r.error)
})
return { sent: response.successCount }
}
웹 개발자라면 메일건(Mailgun) 이나 리센드(Resend) 와 같은 이메일 발송 서비스 코드를 작성해봤을 건데, 완전히 똑같은 원리다. Firebase Admin SDK가 이메일 발송 서비스 역할을 대신해 주는 것입니다.
단계 3: 포그라운드(Foreground) 알림 처리
앱이 백그라운드에 꺼져 있을 때는 OS가 알아서 알림 바를 띄워준다.
하지만 유저가 앱을 켜놓고 있을 때(포그라운드)는 알림이 발생하지 않는다.
그래서 유저가 앱을 사용 중일 때 알림이 도착하면, 앱이 이를 가로채서 강제로 팝업(로컬 알림)을 띄워주도록 설계해야한다.
카카오톡 사용 중 다른 카톡이 왔을 때 강제로 알림을 띄워줘야 알람이 온 것을 인지할 수 있다.
// hooks/use-push-notifications.tsx 중간 3단계 주석 부분
// 앱을 켜놓고 있을 때 알림이 도착하면 이 리스너가 작동한다.
const { LocalNotifications } = await import('@capacitor/local-notifications')
PushNotifications.addListener('pushNotificationReceived', async (notification) => {
await LocalNotifications.schedule({
notifications: [{
id: Date.now(),
title: notification.title ?? '',
body: notification.body ?? '',
extra: notification.data, // 알림을 눌렀을 때 이동할 딥링크 같은 데이터
}],
})
})
마무리 요약
- 앱 켤 때 기기 식별자(Token) 받아서 DB에 저장한다. (Capacitor 사용)
- 알림 보낼 때 DB에서 Token 꺼내서 Firebase에 API 요청을 쏜다.
- 만약 앱이 켜진 상태라면 도착한 알림을 캐치해서 예쁘게 띄워준다.
Capacitor를 통해 네이티브 생태계로 경계를 확장하는 과정은 웹 프론트엔드 개발자에게 분명 도전적인 과제다. 하지만 복잡해 보이는 모바일 푸시 알림도 결국 토큰을 관리하고 API를 호출하는 엔지니어링의 기본 원칙을 충실히 따르고 있다. 플랫폼의 껍데기만 바뀔 뿐, 아키텍처의 본질은 변하지 않는다.