Article - 깊게 탐구하기/시간 기록, 관리 서비스 Pinit

[Pinit] 푸쉬 알림 발송해보기 - 직접 Web Push 사용

조금씩 차근차근 2025. 12. 10. 03:06

웹 푸쉬 알림은 표준 Web Push 프로토콜을 따르며, VAPID 인증 방식을 사용하여 브라우저에 푸쉬 알림을 전송한다.

이때 VAPID(Voluntary Application Server Identification)는 애플리케이션 서버가 푸쉬 서비스에 자신을 식별할 수 있도록 도와주는 메커니즘이다.

VAPID는 그 자체로 푸쉬 서버에게 자신을 알리는 ID가 됨과 동시에, VAPID는 공개 키 암호화 방식을 사용하여 서버가 푸쉬 요청을 보낼 때 자신을 증명할 수 있게 한다.

Web Push 구현 목표

  1. 브라우저에서
    • Notification 권한 요청
    • pushManager.subscribe() 로 endpoint, keys.p256dh, keys.auth 획득
  2. 서버에서
    • VAPID 키 쌍 생성 및 관리
    • payload 암호화, HTTP 요청 직접 전송
  3. 서비스워커에서
    • 'push' 이벤트 수신 및 알림 표시
    • 'notificationclick' 이벤트 처리
    • cilents.openWindow() 로 특정 URL 열기

필수 의존성

    implementation 'nl.martijndwars:web-push:5.1.2'
    implementation 'org.apache.httpcomponents:httpcore:4.4.14'
    implementation 'org.bitbucket.b_c:jose4j:0.9.6'
    implementation 'org.bouncycastle:bcprov-jdk18on:1.83'

 

sequenceDiagram
    autonumber

    actor User as 사용자
    participant Browser as 브라우저(PWA)
    participant SW as 서비스워커(Service Worker)
    participant Backend as Spring 백엔드
    participant DB as DB
    participant PushLib as WebPush 라이브러리
    participant PushSrv as 푸시 서비스(브라우저 푸시 서버)

    Note over User,Browser: [1] 구독(Subscription) 과정

    User ->> Browser: PWA 접속
    Browser ->> Browser: Service Worker 파일 경로 확인
    Browser ->> SW: Service Worker 등록 (register)
    Browser ->> Browser: Notification 권한 요청 (Notification.requestPermission)
    Browser -->> User: 권한 팝업 표시
    User -->> Browser: 권한 허용

    Browser ->> SW: pushManager.subscribe(public VAPID key)
    SW ->> PushSrv: 구독 요청(Endpoint + 키 생성)
    PushSrv -->> SW: Subscription 데이터(Endpoint, p256dh, auth)
    SW -->> Browser: Subscription 객체 반환

    Browser ->> Backend: POST /api/push/subscription\n(Endpoint, p256dh, auth)
    Backend ->> DB: Subscription 저장
    DB -->> Backend: 저장 완료
    Backend -->> Browser: 200 OK

    Note over User,Backend: [2] 서버에서 푸시 트리거 (관리자 콘솔/비즈니스 로직 등)

    User ->> Backend: (예: 관리자 화면에서 푸시 발송 요청)
    Backend ->> DB: 대상 구독 리스트 조회
    DB -->> Backend: Subscription 목록 반환

    Backend ->> PushLib: sendNotificationToAll(제목, 내용, URL, subscriptions)
    loop 각 Subscription
        PushLib ->> PushSrv: Web Push 요청(Endpoint, VAPID, Payload)
        PushSrv -->> PushLib: 수신 완료/응답
    end
    PushLib -->> Backend: 발송 결과 반환

    Note over PushSrv,SW: [3] 브라우저 푸시 수신 및 표시

    PushSrv ->> SW: Push 메시지 전달
    SW ->> SW: 'push' 이벤트 핸들러 실행\n(event.data.json() 파싱)
    SW ->> SW: showNotification(title, options)

    Note over User,SW: [4] 알림 클릭 시 동작

    User ->> SW: 알림 클릭
    SW ->> SW: 'notificationclick' 이벤트 핸들러
    SW ->> Browser: clients.openWindow(URL) 또는 기존 탭 포커스

1. 구독의 구현

클라이언트 (PWA)

  1. 서비스 워커 등록을 위한 페이지 생성
  2. 서비스 워커 파일 경로 확인
  3. 서비스워커 등록
  4. Notification 권한 요청
  5. 권한 팝업 표시 및 사용자 응답 처리
  6. pushManager.subscribe(VAPID 공개 키) 호출로 구독 요청
  7. 서비스워커가 푸쉬 서버로 구독 요청
    • 푸쉬서버는 "이 endpoint는 이 VAPID public key와 연관된 구독”으로 저장
  8. 푸쉬 서버로부터 Subscription 데이터 수신(Endpoint, p256dh, auth)
    • 이때 p256dh는 클라이언트(브라우저) 공개 키, auth는 인증 토큰이다.
    • 이는 추후 서버에서 푸쉬 메시지를 만들 때 ECDH(타원곡선 디피-헬만, 키 공유 방식)에 사용된다.
  9. 스프링 백엔드로 Subscription 객체 전송 (POST /api/push/subscription)
  10. 백엔드로부터 200 OK 응답 수신

백엔드 (Spring)

  1. 구독 정보 수신용 API 엔드포인트 구현 (POST /api/push/subscription)
  2. 수신된 Subscription 정보를 데이터베이스에 저장
  3. 저장 완료 후 200 OK 응답 반환

2. 서버에서 푸시 트리거

백엔드 (Spring)

  1. 푸시 발송 요청 수신 (예: task 서버에서/관리자 페이지에서 요청)
  2. 백엔드에서 대상 구독 리스트를 데이터베이스에서 조회
  3. 조회된 Subscription 목록을 WebPush 라이브러리에 전달하여 푸시 발송 요청
    • Spring 서버(WebPush 라이브러리)가 private key로 VAPID JWT 생성
    • (ECDH를 이용한) 공유 비밀 키를 사용해 payload를 암호화
    • HTTP 요청 헤더에 VAPID 토큰 + public key 포함해서 푸시 서버로 전송
    • sendNotificationToAll(제목, 내용, URL, subscriptions)
    • 푸쉬 서버는 푸쉬 라이브러리에 수신 완료 응답 반환
  4. 백엔드는 푸쉬 발송 결과를 라이브러리로부터 받음

ECDH 키 교환 방식은 클라이언트와 서버가 서로의 공개 키를 교환하여 공유 비밀 키를 생성하는 방식이다.

ECDH 는 EC(엘립틱 커브) 기반의 디피-헬만 키 교환 알고리즘이다. (타원곡선 부분은 수학을 이용한 시간복잡도 최적화 영역이므로, 암호화 자체가 궁금하다면 디피-헬만 부분만 학습하면 된다.)

간단하게 여기서 사용된 디피-헬만 키 교환 방식을 설명하면, 다음 두 개의 비대칭 키 쌍을 이용해 암호를 교환한다.

  • 클라이언트(브라우저)가 구독 시 보내준 p256dh (클라이언트 공개 키) + auth
  • 서버가 메시지를 보낼 때 임의로 생성하는 서버 비공개 키 + 서버 공개 키(VAPID를 사용하는 것이 아니다. 매번 새로 생성한다.)

이렇게 매번 새로운 암호화된 대칭 키를 이용해 푸쉬 메시지 페이로드를 암호화한다.

3. 브라우저 푸시 수신 및 표시

  1. 푸쉬 서버로부터 서비스워커로 Push 메시지 전달
  2. 서비스워커에서 'push' 이벤트 핸들러 실행
  3. 이벤트 데이터(event.data.json()) 파싱
  4. showNotification(title, options) 호출로 알림 표시

4. 알림 클릭 시 동작

  1. 사용자가 알림 클릭
  2. 서비스워커에서 'notificationclick' 이벤트 핸들러 실행
  3. clients.openWindow(URL) 또는 기존 탭 포커스 처리

실제로 1번부터 차근차근 해보자.

푸쉬 서버에게서 받은 구독 정보는 다음과 같았다.

이제 백엔드에서 이 구독정보를 가지고 푸쉬 알림을 보내보자.

라이브러리는 위에서 언급한 nl.martijndwars:web-push 를 사용한다.

https://github.com/web-push-libs/webpush-java

 

GitHub - web-push-libs/webpush-java: Web Push library for Java

Web Push library for Java. Contribute to web-push-libs/webpush-java development by creating an account on GitHub.

github.com

 

해당 라이브러리의 README를 참고하며 API를 확인했다.

그래서 대강 이렇게 PushService를 정의해주고

다음과 같이 send 메소드를 우겨넣었다.

그런데 크롬에서 정상 응답을 받을 수 없었다.

로그를 찍어보니 403 오류를 받고 있었고, crypto-key가 올바른 포맷으로 도착하지 않았다는 메시지가 있었다.

내 코드에 아무리 봐도 문제가 없어 보였고, codex에게 코드 리뷰를 부택해도 문제가 없어, 결국 라이브러리 이슈를 찾아보게 되었다.

역시나, 관련된 이슈를 찾을 수 있었다.

해당 이슈에 따르면, AESGCM 방식은 표준이 아니고, 이에 따라 크롬이 해당 대칭 키 암호화 방식을 "조용히 제거"했고,

따라서 인코딩(암호화) 방식을 AES128GCM으로 설정해야 한다고 한다.

그래서 코드를 다음과 같이 수정했다.

그 결과 성공 응답을 받았고, 정상적으로 알림을 받을 수 있었다!

 

 

이렇게 대강 Web Push 알림의 구조와 발송 과정을 이해할 수 있었다.

 

이제 이를 이해한 바를 바탕으로 FCM을 이용해 알림을 발송할 수 있다.

 

BE: https://github.com/GoGradually/web-push-sample

FE: https://github.com/GoGradually/wep-push-sample-front