[Spring] Web Push API(Push Notification) 구현 방법
모든 소스 코드는 깃헙에 있습니다.
Web Push API란?
서버에서 클라이언트로 비연결 기반으로 단방향 통신을 가능하게 하는 기술입니다.
비연결이라는 것은 WebSocket, SSE처럼 연결한 상태로 사용하는 기술이 아니라는 뜻 입니다.
기본 개념을 설명하자면 클라이언트는 VAPID Key를 이용해 서버에 구독 정보를 저장하고 서버는 후에 구독한 클라이언트에게 메시지를 전송하는 과정으로 동작합니다.
VAPID의 뜻을 풀어보면 "자발적 애플리케이션 서버 식별(Voluntary Application Server Identification)"
즉, 서버가 브라우저에게 자발적으로 자신을 인증하는 방법을 말합니다.
Web Push API는 푸쉬 알림(Push Notification)의 일종이고 가장 많이 사용하는 경우는 앱에 Push을 보내는 기능입니다.
보통 이런 경우는 FCM(Firebase Cloud Messaging)을 이용해서 알림을 구현하는 경우가 많습니다.
제가 소개하고자 하는 Web Push API는 말 그대로 Web에서만 작동하는 기능이며 특정 최신 브라우저에만 종속된다는 특징이 있습니다.
그렇기 때문에 특정 요구 사항에 따라 Web Push API와 FCM 중 선택할 수 있으며, 모바일 앱과 웹 모두를 지원해야 한다면 FCM을 사용하는 것이 일반적입니다.
Push Notification은 앱에 Push를 하는 기능 뿐만 아니라 실시간 데이터가 필요하지 않은 결제 시스템(결제 서버에 요청을 하고 나중에 결제가 완료되면 알림을 보내는 Paypal과 같은 시스템) 등에서도 많이 쓰이는 기능입니다.
비연결 지향이기 때문에 보통의 Spring은 클라이언트가 서버에 요청하면 데이터에 대한 Return을 항상 기대하지만 이 경우엔 다른 서버가 Return할 수 있는 구조의 설계도 가능합니다.
준비
테스트 환경
푸쉬 알림 테스트는 https 서버에서만 가능하고 예외적으로 localhost에서도 테스트가 가능합니다.
VAPID Key 생성
sudo npx web-push generate-vapid-keys
Public Key:
BCz1BNLNccwKDd9XwGJUnNNcKoluFigzD_5xRlehWtGinDRoESwgR63bHrhHvEcZydUj4qPWDk7YcDhmvisNmrM
Private Key:
3p2xHXA51O4LJVW7Og3svbiJDGEg5orhfM9vX8LYUX0
위와 같이 vapid 표준에 맞는 key를 생성해줘야 합니다.
직접 생성하기 귀찮다면 테스트 용도로 위의 key를 사용해도 무방합니다.
Spring Boot에 의존성 추가
// Web Push API
implementation group: 'nl.martijndwars', name: 'web-push', version: '5.1.1'
// Web Push API가 사용하는 VAPID Key를 암호화 라이브러리
implementation group: 'org.bouncycastle', name: 'bcprov-jdk15on', version: '1.70'
구현 방법
프론트엔드
일단 임의로 Thymeleaf를 사용해서 브라우저에서 service-worker.js, index.html을 만듭니다.
self.addEventListener('push', function(event) { // 푸시 알림이 도착할 때 발생하는 push 이벤트를 리스닝하고, 해당 알림을 처리합니다.
const data = event.data.json();
const options = {
body: data.body,
icon: 'icon.png'
};
event.waitUntil( // 푸시 알림을 정상적으로 브라우저에 표시할 때까지 작업이 중단되지 않도록 보장하는 역할을 합니다.
self.registration.showNotification(data.title, options) // Service Worker의 registration 객체에서 showNotification() 메서드를 호출하여 푸시 알림을 실제로 표시합니다.
);
});
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>Push Notification Test</title>
</head>
<body>
<h1>Push Notification Test</h1>
<script>
/**
* VAPID 공개키 설정
*/
const publicVapidKey = 'BCz1BNLNccwKDd9XwGJUnNNcKoluFigzD_5xRlehWtGinDRoESwgR63bHrhHvEcZydUj4qPWDk7YcDhmvisNmrM';
/**
* urlBase64ToUint8Array: 이 함수는 Base64 URL-safe 문자열을 Uint8Array로 변환하는 함수입니다.
* 브라우저의 Push API는 applicationServerKey를 Uint8Array 형식으로 받아야 하기 때문에, 공개 키(Base64)를 이 형식으로 변환하는 역할을 합니다.
*/
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
if ('serviceWorker' in navigator) { // 현재 브라우저가 Service Worker 기능을 지원하는지 확인하는 조건문(Service Worker는 백그라운드에서 푸시 알림을 처리하는 데 사용되며, 푸시 알림을 구현하려면 필수입니다.)
Notification.requestPermission().then(permission => { // 브라우저가 사용자에게 알림 권한을 요청합니다. 사용자가 권한을 허용하거나 거부할 수 있습니다.
if (permission === 'granted') {
navigator.serviceWorker.register('/service-worker.js') // 실제로 푸쉬 알림을 처리하는 service-worker.js파일을 등록합니다.
.then(function(registration) {
console.log('Service Worker 등록 성공:', registration);
return navigator.serviceWorker.ready; // Service Worker가 준비될 때까지 기다립니다.
})
.then(function(registration) { // service-worker.js파일이 성공적으로 등록되었을 때 반환하는 Service Worker 등록 정보
return registration.pushManager.subscribe({ // 아래의 옵션으로 Service Worker 구독
userVisibleOnly: true, // 이 옵션은 사용자에게 보이지 않는 백그라운드 알림이 아닌, 반드시 사용자가 볼 수 있는 알림만 받도록 설정합니다.
applicationServerKey: urlBase64ToUint8Array(publicVapidKey) // publicVapidKey를 변환한 값으로, VAPID 공개 키를 사용하여 서버가 인증된 푸시 알림을 보낼 수 있도록 합니다.
});
})
.then(function(subscription) {
console.log('푸시 알림 구독 성공:', subscription);
fetch('/subscribe', {
method: 'POST',
body: JSON.stringify(subscription), // 구독된 정보를 서버에 전송
headers: {
'Content-Type': 'application/json'
}
}).then(function(response) {
console.log('서버 응답:', response);
});
})
.catch(function(error) {
console.error('푸시 알림 구독 실패:', error);
});
} else {
console.error('알림 권한이 거부되었습니다.');
}
});
}
</script>
</body>
</html>
각 클라이언트(브라우저)가 서버에 구독 정보를 생성할 때 브라우저는 해당 클라이언트에 고유한 endpoint, keys (예: p256dh, auth)를 포함한 구독 정보를 자동으로 생성합니다. 그러므로 여러 사용자의 알림 시스템도 구현 가능합니다.
백엔드
Producer Server
Producer는 구독 정보를 발행하는 서버라고 볼 수 있습니다.
해당 구독 정보는 DB에 저장되는 것이 옳지만 지금은 임의로 Consumer 서버에 전역 변수로 할당하겠습니다.
@GetMapping("/index")
public String home(Model model) {
return "index";
}
일단 백엔드 주소를 통해 index.html 페이지로 이동하도록 구현합니다.
이동하자마자 위의 프론트엔드 로직이 실행되는데 올바르게 작동하지 않습니다.
이유는 브라우저에서 알림 허용을 해줘야합니다.
주소창 좌측에 아이콘을 눌러서 아래와 같이 알림 허용을 해주세요.
위와 같은 이유로 보통 웹에서는 자주 쓰이는 기능은 아닙니다.
이유는 사용자가 직접 알림을 허용해줘야하기 때문입니다.
특히 크롬과 같은 브라우저는 사용자가 여러 사이트에서 반복적으로 알림을 차단해왔다면 자동으로 차단하는 기능도 내재하고 있기 때문에 굉장히 기능이 한정적이라는 것을 알 수 있습니다.
@PostMapping("/subscribe")
public ResponseEntity<String> subscribe(@RequestBody Subscription subscription) {
webPushService.sendToConsumerServer(subscription);
return ResponseEntity.ok("Subscription saved.");
}
프론트엔드 코드에서 /subscribe를 통해 서버에 구독 정보를 저장할 수 있도록 API를 구현해줍니다.
프론트엔드에서 localhost:9090으로 요청한 정보는 다시 localhost:9091로 전달하여 9091 서버에서 구독 정보를 저장하도록 합니다.
Consumer Server
public class PushNotificationService {
private final PushService pushService;
static {
// BouncyCastle 암호화 방법 등록
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
}
}
public PushNotificationService() throws Exception {
pushService = new PushService();
// VAPID 키 등록
pushService.setPublicKey("BCz1BNLNccwKDd9XwGJUnNNcKoluFigzD_5xRlehWtGinDRoESwgR63bHrhHvEcZydUj4qPWDk7YcDhmvisNmrM");
pushService.setPrivateKey("3p2xHXA51O4LJVW7Og3svbiJDGEg5orhfM9vX8LYUX0");
}
public void sendPushNotification(Subscription subscription, String title, String body) throws Exception {
String payload = "{\"title\": \"" + title + "\", \"body\": \"" + body + "\"}";
Notification notification = new Notification(subscription, payload);
HttpResponse response = pushService.send(notification);
System.out.println("Push notification sent with status: " + response.getStatusLine());
}
}
Subscription(구독 정보) 객체를 꺼내서 클라이언트에 역으로 알림을 보내는 구현입니다.