2024. 12. 15. 23:00ㆍFramework/Laravel
이번 포스팅에서는 Web Push 기술을 통해 웹 알림 발송 기능을 구현하는 과정에 대해 학습한 내용과 그 내용을 공유 하기 위해 글을 작성해보려고 합니다. 현재 사내에서는 진행중인 [결제 개편 프로젝트] 에서는 크게 두 가지 이유로 웹 알림이 필요했습니다.
- 주문 수집 제한 기준에 따른 [경고] 알림
- 결제 완료 및 버전 변경에 따른 [안내] 알림
Web Push Notification(웹 푸시 알림) 이란?
웹 푸시 알림이란 짜증나는(?) 크롬의 광고를 경험해보셨으면 이미 다들 알고 있는 기술입니다. 화면의 오른쪽 하단에 광고서 알림을 본적이 있으시다면 그게 웹 푸시 알림이라고 할 수 있습니다. 슬랙을 써보신 분이 계시다면 pc에서 슬랙을 사용하고 있을 때 크롬이나 기타 브라우저를 통해서 웹 알림을 받은 경험이 있으실 겁니다. 이게 가능한 이유는 웹 푸시 알림은 브라우저가 띄워져 있지 않더라고 [백그라운드]로 등록된 [Serivce Worker]가 알림을 보내기 위해 일을 하고 있기 때문에 가능합니다.
Web Push Notification 동작 방식 - A to Z
1. 웹 푸시 알림 구성요소
웹 푸시 알림은 크게 3가지 구성요소의 프로토콜을 이해하면 그 동작방식을 이해할 수 있습니다.
- 클라이언트(브라우저)
- 서버
- 푸시 서비스(Push Service)
먼저, 클라이언트는 푸시 메시지를 수신 받는 대상이며 [User Agent]를 의미합니다. 클라이언트는 브라우저로 부터 웹 푸시 알림을 수신 [허용] 요청을 받습니다. 이 요청에 대해서 수락하게 되면 클라이언트는 웹 푸시 알림을 송신하는 특정 서버로 부터 푸시 알림을 수신받을 수 있습니다. 이것을 [구독] 이라고 합니다.
다음으로 서버는 푸시 메시지를 발행하는 주체입니다. 푸시 알림을 만들고 클라이언트로 푸시 알림을 보내는 역할을 담당합니다. (해당 블로그에서는 해당 서버의 기능 구현을 Laravel Framework로 구현했습니다.)
마지막으로 푸시 서비스는 서버로 부터 푸시 메시지를 전달받고 클라이언트로 해당 메시지를 전달하는 역할을 담당합니다.
2. 클라이언트의 푸시 알림 구독 방법 및 동작 방식
클라이언트가 푸시 알림을 수신 받기 위해서는 푸시 서비스로 서버에 대한 푸시 서버 알림 [구독] 요청을 보내야 합니다. 일반적으로 푸시 서비스는 클라이언트의 구독 요청에 대한 결과로 브라우저의 [구독 정보]를 전달하고 클라이언트는 응답으로 받은 구독 정보를 서버로 [등록] 요청을 보냅니다.
// 구독정보
{
"endpoint": "https://fcm.googleapis.com/fcm/send/eM_5INpg...",
"keys": {
"p256dh": "BG8J42gZ5ILUZ8XM1p-dHxJhJI4yg2Wlut...",
"auth": "9qoctardEyiiwN..."
}
}
한편, 브라우저에는 푸시 알림을 수신 받았을 때 최종적으로 클라이언트에게 전달하기 위해서 Service Worker를 등록합니다.
Service Worker는 일반적으로 푸시 메시지를 수신받은 이벤트가 발생했을 때 실행되는 콜백 함수로 구현가능합니다.
클라이언트 푸시 알림 구독 및 서비스 워커 등록을 위해 실제 기능 구현에 사용한 코드 예시는 다음과 같습니다.
// Register the service worker
navigator.serviceWorker.register('./service-worker.js')
.then(registration => {
// Request notification permission from the user
return Notification.requestPermission().then(permission => {
if (permission !== 'granted') {
alert("Notifications permission denied.");
return null;
}
// Subscribe to push notifications
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array([VAPID]),
});
});
})
// Subscription Info send to Server
.then(subscription => {
if (!subscription) return;
// Send the subscription object to the server
return fetch('[server url], {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription),
});
})
// service worker
self.addEventListener('push', function(event) {
const data = event.data.json();
self.registration.showNotification(data.title, {
body: data.body,
});
});
전체적인 프로세스를 보자면 다음과 같습니다.
- Service Worker를 Navigator 객체를 통해 등록합니다. (Navigator는 User Agent를 식별할 수 있는 인터페이스용 객체를 의미합니다.)
- Notification 객체를 통해 푸시 알림 수신 허용을 요청하고 (허용시 granted) 허용 시 PushManager를 통해 푸시 알림 구독을 요청합니다.
- PushManager가 전달한 구독 정보를 갖고 서버로 푸시 알림 구독 등록 요청을 보냅니다.
PushManger는 Push API의 일부로 웹 어플리케이션이 푸시 알림을 등록하고 관리할 수 있도록 지원하는 인터페이스 입니다. 푸시 알림은 서버에서 전송되며, 브라우저의 푸시 서비스를 통해 클라이언트에게 전달됩니다. 브라우저의 service worker는 푸시 메시지를 수신하고 이를 처리합니다.
stack overflow에 따르면 PushManager.subscribe()를 통해 구독 요청을 전송했을 때 반환되는 객체의 프로퍼티 중 endpoint를 확인해보면 각 브라우저마다 고정된 호스트 값들이 있는데 이는 각 브라우저 벤더사가 고정적으로 사용하는 자체 푸시 서버가 있음을 알 수 있습니다.
Vendor | Browser | Push Serveer Endpoint |
Chrome | https://fcm.googleapis.com/fcm/send/ | |
Microsoft | Edge | https://wns2-pn1p.notify.windows.com/w |
Mozila | Fileforx | https://updates.push.services.mozilla.com/push |
브라우저 호환성 체크
현재 고객사가 웹 서비스에 접근하는 경로를 파악한 결과 80%이상의 고객이 크롬을 통해서 접근하는 것을 확인했다. 크롬의 경우 백그라운드에서 지속적으로 버전을 업데이트하고 있기도 하고 대부분의 유저의 경우 100이상의 버전을 사용하는 걸로 보아 알림을 수신받지 못하는 비운의 유저는 거의 없다고 판단했다.
다만, 브라우저간 정책에 의해 생기는 하나의 특이사항은 푸시 알림을 허용하겠냐? 라는 프롬프트가 띄워지는 조건이 크롬에 비해 엣지, 파이어폭스가 조금 더 엄격하다는 점이다. 크롬은 페이지가 렌더링이 완료된 시점에 자동으로 푸시 알림 허용 프롬프트를 띄우는게 가능하지만, 엣지, 파이어폭스는 사용자의 액션, 예를 들면 버튼의 클릭 같은 직접적인 푸시 알림 허용에 대한 의도(?)를 엄격하게 체크하는것 같다.
Web Push Notification 구현 전략
푸시 알림을 생성하는 서버를 구현하기 위해서는 Push Message를 생성하고 Push Service로 알림 전송 요청을 보내는 기능 구현이 필요합니다.
먼저 사내 기술 스택을 나열하자면 다음과 같습니다.
- Laravel Framework (php v8.2.x)
- Docker Compose
GitHub - web-push-libs/web-push-php: Web Push library for PHP
Web Push library for PHP. Contribute to web-push-libs/web-push-php development by creating an account on GitHub.
github.com
Php를 사용한 Web Push Notification Server 구축을 위한 아래 라이브러리를 사용하기로 결정했습니다. MIT Licese 라 상업적으로 사용하는데 문제가 없다고 판단했습니다. Github Readme에도 명시되어 있지만, php v8.1 이상에서는 gmp php extension을 추가로 install 해야합니다.
Dockerfile에는 gmp php extension을 추가합니다.
FROM php:8.2.7...
RUN apt-get update && \
...
apt-get install -y --no-install-recommends ... libgmp-dev && \
apt-get update && \
...
docker-php-ext-configure gmp && \
docker-php-ext-install ... gmp && \
gmp(GNU Multifple Precision Arithmetic)는 정수, 유리수, 부동소수점 수에 대해 매우 큰 정밀도 계산을 수행하는것을 돕는 php 라이브러리로 Push Message를 생성할 때 VAPID(Voluntary Application Server Identification) 키 생성 시 수학 연산 시 필요하기 때문에 추가되어야 하는 라이브러리 입니다.
VAPID에 대해 간단히 설명하자면 웹 푸시 프로토콜에서 서버의 신원을 증명하고 인증하는데 사용되는 공개 키 기반의 암호화 방식입니다.
- VAPID 키 생성 과정
- 키 생성: 서버는 공개키와 비공개키로 이루어진 키 쌍을 생성합니다. 해당 키들은 푸시 서비스와의 통신에서 사용됩니다. 생성된 공개키는 클라이언트 측에서 푸시 알림 구독 시 사용됩니다.
- 서명 생성: 새푸시 메시지를 전송할 때 서버는 비공개키를 사용해서 특정 정보를 서명합니다.
- 검증: 서버는 비공개키를 사용해서 JWT를 생성하고 서명합니다. 이 서명된 토큰은 푸시 메시지와 함께 푸시 서비스에 전송되며 푸시 서비스는 공개키를 사용해서 서명의 유효성을 검증합니다.
해당 라이브러리를 사용하면 VAPID를 간단하게 생성할 수 있습니다.
<?php
use Illuminate\Console\Command;
use Minishlink\WebPush\VAPID;
class GenerateVAPIDKeys extends Command
{
protected $signature = 'vapid:generate';
protected $description = 'Generate VAPID public and private keys for Web Push Notifications';
public function handle()
{
// Generate VAPID keys
$keys = VAPID::createVapidKeys();
// Output the keys to the console
$this->info('VAPID Public Key: ' . $keys['publicKey']);
$this->info('VAPID Private Key: ' . $keys['privateKey']);
return Command::SUCCESS;
}
}
command 를 통해 생성한 VAPID를 갖고 이제 Push 알림을 생성하고 푸시서비스에 전송하기 위한 코드를 작성합니다. (참고 레퍼런스)
<?php
use App\Dto\Tenant\{
PushSubscriptionDto,
PushNotificationDto,
};
use App\Models\Tenant\WebPush\{
PushSubscriptions,
PushNotificationLog,
};
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
use Carbon\Carbon;
class PushSubscriptionService
{
public function subscribe(PushSubscriptionDto $pushSubscription): void
{
$keys = $pushSubscription->getKeys();
PushSubscriptions::firstOrCreate(
['endpoint' => $pushSubscription->getEndpoint()],
[
'public_key' => $keys['p256dh'],
'auth_token' => $keys['auth'],
]
);
}
public function notify(PushNotificationDto $pushNotification): void
{
// Initialize WebPush with VAPID authentication
$webPush = new WebPush([
'VAPID' => [
'subject' => 'dev@sellmate.co.kr',
'publicKey' => env('VAPID_PUBLIC_KEY'),
'privateKey' => env('VAPID_PRIVATE_KEY'),
],
]);
// Set the batch size(The default size is 1000)
$batchSize = 800;
// Retrieve active subscriptions in chunks
PushSubscriptions::with('notificationLogs')
...
->chunk($batchSize, function ($subscriptions) use ($webPush, $batchSize, $pushNotification) {
$failedEndpoints = [];
foreach ($subscriptions as $subscription) {
$webPush->queueNotification(
Subscription::create([
'endpoint' => $subscription->endpoint,
'publicKey' => $subscription->public_key,
'authToken' => $subscription->auth_token,
]),
json_encode([
'title' => $pushNotification->getTitle(),
'body' => $pushNotification->getMessage(),
])
);
}
// Flush notifications in batches
foreach ($webPush->flush($batchSize) as $report) {
$endpoint = $report->getEndpoint();
$status = $report->isSuccess() ? 'success' : 'fail';
$response = $report->isSuccess() ? null : $report->getReason();
PushNotificationLog::create([
'type' => $pushNotification->getType(),
'push_subscription_id' => PushSubscriptions::where('endpoint', $endpoint)->pluck('id')->first(),
'endpoint' => $endpoint,
'status' => $status,
'response' => $response,
]);
if (!$report->isSuccess()) {
$failedEndpoints[] = $endpoint;
}
}
if (!empty($failedEndpoints)) {
PushSubscriptions::whereIn('endpoint', $failedEndpoints)
->update(['expired_at' => Carbon::now()]);
}
});
}
}
기본적인 사용법은 참고 레퍼런스를 통해 확인할 수 있기 때문에 여기서는 해당 라이브러리 사용시 몇가지 주의사항에 대해 언급해보려고 합니다.
- 알림 오버플로우 이슈
만약 한 번에 수만개의 알림을 전송하게 되면 Guzzle에서 엔드포인트를 호출하는 방식 때문에 메모리 오버플로우가 발생할 수 있습니다. 이를 해결하기 위해서 해당 라이브러리는 알림을 배치(Batch)로 나눠서 전송합니다. 기본 batchSize는 1000개로 지정되어 있습니다. 구현에 있어서 batchSize를 명시적으로 지정할 수 도 있습니다. 저는 해당 구현에 있어서 좀 더 안전한 구현을 위해 알림의 대상이 되는 클라이언트를 조회시 chunking하도록 구현했습니다.
- Endpoint 만료
최종적으로 알림을 수신받는 클라이언트를 알 수 있는 Endpoint는 여러가지 이유에 따라서 만료(?)될 수 있습니다. 엔드포인트가 변경되는 조건을 gpt에 물어본 결과 아래와 같은 이벤트가 발생할 때 해당 endpoint가 변경될 수 있습니다. 서버측에서 전송하려는 endpoint가 유효하지 않다는 것을 인지할 수 있는 시점은 Push Service로 메시지를 전송할 때 ( $webpush->flush() )입니다. 그래서 위 구현에서 알림이 성공적으로 전송됬는지 유무를 확인하고 실패 시에 해당 endpoint를 만료처리하는 로직을 추가했습니다.
마치며
1년동안 담당했던 단순 REST API 구현에서 조금 벗어나 새로운 기술을 단기간에 학습하고 적용하는 과정에서 많은 레퍼런스를 읽고 이해해야만 했습니다. 개발자는 새로운 기술을 빠르고 정확하게 이해한 후 이해한 내용을 적절한 상황에 적용하여 문제를 해결할 수 있는 사람이어야 한다는 것을 이번 업무를 통해 다시 깨닫게 되었습니다.