푸시 알림 서버 빌드

이 Codelab에서는 푸시 알림 서버를 빌드합니다. 서버에서 푸시 구독 목록을 관리하고 알림을 전송합니다.

클라이언트 코드는 이미 완성되어 있습니다. 이 Codelab에서는 서버 측 기능을 다룹니다.

샘플 앱을 리믹스하고 새 탭에서 보기

삽입된 Glitch 앱의 알림이 자동으로 차단되므로 이 페이지에서 앱을 미리 볼 수 없습니다. 대신 다음 조치를 취하세요.

  1. 리믹스하여 수정을 클릭하여 프로젝트를 수정할 수 있도록 합니다.
  2. 사이트를 미리 보려면 View App을 누른 다음 Fullscreen 전체 화면을 누릅니다.

라이브 앱이 새 Chrome 탭에서 열립니다. 삽입된 Glitch에서 소스 보기를 클릭하여 코드를 다시 표시합니다.

이 Codelab을 진행하면서 이 페이지에 삽입된 Glitch의 코드를 변경합니다. 라이브 앱으로 새 탭을 새로고침하여 변경사항을 확인합니다.

시작 앱 및 코드 익히기

먼저 앱의 클라이언트 UI를 살펴보세요.

새 Chrome 탭의 경우:

  1. `Control+Shift+J` (Mac의 경우 `Command+Option+J`)를 눌러 DevTools를 엽니다. 콘솔 탭을 클릭합니다.

  2. UI에서 버튼을 클릭해 봅니다 (Chrome 개발자 콘솔에서 출력 확인).

    • 서비스 워커 등록은 Glitch 프로젝트 URL 범위에 대한 서비스 워커를 등록합니다. 서비스 워커 등록을 취소하면 서비스 워커가 삭제됩니다. 푸시 구독이 연결되어 있으면 푸시 구독도 비활성화됩니다.

    • 푸시 구독은 푸시 구독을 만듭니다. 이 메서드는 서비스 워커가 등록되었고 클라이언트 코드에 VAPID_PUBLIC_KEY 상수가 있을 때만 사용할 수 있으므로 (자세한 내용은 뒷부분에 설명되어 있음) 아직 클릭할 수 없습니다.

    • 활성 푸시 구독이 있으면 현재 구독 알림을 통해 서버에서 엔드포인트로 알림을 보내도록 요청합니다.

    • 모든 구독 알림은 서버에 데이터베이스의 모든 구독 엔드포인트로 알림을 보내도록 지시합니다.

      이러한 엔드포인트 중 일부는 비활성 상태일 수 있습니다. 서버에서 알림을 보내는 시점이 되면 언제든지 구독이 사라질 수 있습니다.

서버 측에서 무슨 일이 일어나는지 살펴보겠습니다. 서버 코드에서 메시지를 보려면 Glitch 인터페이스 내의 Node.js 로그를 확인하세요.

  • Glitch 앱에서 Tools -> Logs를 클릭합니다.

    Listening on port 3000와 같은 메시지가 표시될 수 있습니다.

    라이브 앱 UI에서 현재 정기 결제 알림 또는 모든 정기 결제 알림을 클릭하려고 하면 다음 메시지도 표시됩니다.

    TODO: Implement sendNotifications()
    Endpoints to send to:  []
    

이제 코드를 살펴보겠습니다.

  • public/index.js에는 완성된 클라이언트 코드가 있습니다. 또한 기능 감지를 수행하고, 서비스 워커를 등록 및 등록 취소하며, 푸시 알림에 대한 사용자의 구독을 제어합니다. 또한 신규 구독 및 삭제된 구독에 대한 정보도 서버로 전송합니다.

    서버 기능만 작업할 것이므로 이 파일은 VAPID_PUBLIC_KEY 상수를 채우는 것 외에는 수정하지 않습니다.

  • public/service-worker.js는 푸시 이벤트를 캡처하고 알림을 표시하는 간단한 서비스 워커입니다.

  • /views/index.html에는 앱 UI가 포함됩니다.

  • .env에는 Glitch가 시작될 때 앱 서버에 로드하는 환경 변수가 포함되어 있습니다. .env에 알림 전송을 위한 인증 세부정보가 채워집니다.

  • server.js은 이 Codelab에서 대부분의 작업을 할 파일입니다.

    시작 코드를 통해 간단한 Express 웹 서버가 생성됩니다. 네 가지 TODO 항목이 있으며 코드 주석에 TODO:로 표시되어 있습니다. 다음 작업을 수행해야 합니다.

    이 Codelab에서는 이러한 TODO 항목을 한 번에 하나씩 살펴봅니다.

VAPID 세부정보 생성 및 로드

첫 번째 TODO 항목은 VAPID 세부정보를 생성하여 Node.js 환경 변수에 추가한 후 클라이언트 및 서버 코드를 새 값으로 업데이트하는 것입니다.

배경

사용자는 알림을 구독할 때 앱 ID와 서버 정보를 신뢰해야 합니다. 또한 사용자는 알림을 받을 때 정기 결제를 설정한 동일한 앱에서 보낸 알림인지 확신할 수 있어야 합니다. 또한 아무도 알림 내용을 읽을 수 없다는 것을 신뢰해야 합니다.

푸시 알림을 안전하게 비공개로 만드는 프로토콜을 VAPID (Voluntary Application Server Identification for Web Push)라고 합니다. VAPID는 공개 키 암호화를 사용하여 앱, 서버, 구독 엔드포인트의 ID를 확인하고 알림 콘텐츠를 암호화합니다.

이 앱에서는 web-push npm 패키지를 사용하여 VAPID 키를 생성하고 알림을 암호화 및 전송합니다.

구현

이 단계에서는 앱의 VAPID 키 쌍을 생성하여 환경 변수에 추가합니다. 서버에서 환경 변수를 로드하고 클라이언트 코드에 공개 키를 상수로 추가합니다.

  1. web-push 라이브러리의 generateVAPIDKeys 함수를 사용하여 VAPID 키 쌍을 만듭니다.

    server.js에서 다음 코드 줄의 주석을 삭제합니다.

    server.js

    // Generate VAPID keys (only do this once).
    /*
     * const vapidKeys = webpush.generateVAPIDKeys();
     * console.log(vapidKeys);
     */
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    
  2. Glitch에서 앱을 다시 시작하면 생성된 키를 Chrome 콘솔이 아닌 Glitch 인터페이스 내의 Node.js 로그에 출력합니다. VAPID 키를 확인하려면 Glitch 인터페이스에서 Tools -> Logs를 선택합니다.

    동일한 키 쌍에서 공개 키와 비공개 키를 복사해야 합니다.

    코드를 수정할 때마다 Glitch가 앱을 다시 시작하므로 생성한 첫 번째 키 쌍이 더 많은 출력이 따라오면 스크롤되어 보이지 않게 될 수 있습니다.

  3. .env에서 VAPID 키를 복사하여 붙여넣습니다. 키를 큰따옴표로 묶습니다 ("...").

    VAPID_SUBJECT"mailto:test@test.test"를 입력할 수 있습니다.

    .env

    # process.env.SECRET
    VAPID_PUBLIC_KEY=
    VAPID_PRIVATE_KEY=
    VAPID_SUBJECT=
    VAPID_PUBLIC_KEY="BN3tWzHp3L3rBh03lGLlLlsq..."
    VAPID_PRIVATE_KEY="I_lM7JMIXRhOk6HN..."
    VAPID_SUBJECT="mailto:test@test.test"
    
  4. server.js에서는 VAPID 키를 한 번만 생성하면 되므로 두 줄의 코드를 다시 주석 처리합니다.

    server.js

    // Generate VAPID keys (only do this once).
    /*
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    */
    const vapidKeys = webpush.generateVAPIDKeys();
    console.log(vapidKeys);
    
  5. server.js의 환경 변수에서 VAPID 세부정보를 로드합니다.

    server.js

    const vapidDetails = {
      // TODO: Load VAPID details from environment variables.
      publicKey: process.env.VAPID_PUBLIC_KEY,
      privateKey: process.env.VAPID_PRIVATE_KEY,
      subject: process.env.VAPID_SUBJECT
    }
    
  6. 공개 키를 복사하여 클라이언트 코드에도 붙여넣습니다.

    public/index.js에서 .env 파일에 복사한 것과 동일한 VAPID_PUBLIC_KEY 값을 입력합니다.

    public/index.js

    // Copy from .env
    const VAPID_PUBLIC_KEY = '';
    const VAPID_PUBLIC_KEY = 'BN3tWzHp3L3rBh03lGLlLlsq...';
    ````
    

알림 전송 기능 구현

배경

이 앱에서는 web-push npm 패키지를 사용하여 알림을 보냅니다.

이 패키지는 webpush.sendNotification()가 호출될 때 알림을 자동으로 암호화하므로 걱정하지 않아도 됩니다.

web-push는 여러 가지 알림 옵션을 허용합니다. 예를 들어 메일에 헤더를 첨부하고 콘텐츠 인코딩을 지정할 수 있습니다.

이 Codelab에서는 다음 코드 줄로 정의된 두 가지 옵션만 사용합니다.

let options = {
  TTL: 10000; // Time-to-live. Notifications expire after this.
  vapidDetails: vapidDetails; // VAPID keys from .env
};

TTL (TTL) 옵션은 알림에 만료 시간 제한을 설정합니다. 이렇게 하면 서버에서 더 이상 관련이 없는 사용자에게 알림을 전송하지 않을 수 있습니다.

vapidDetails 옵션에는 환경 변수에서 로드한 VAPID 키가 포함됩니다.

구현

server.js에서 sendNotifications 함수를 다음과 같이 수정합니다.

server.js

function sendNotifications(database, endpoints) {
  // TODO: Implement functionality to send notifications.
  console.log('TODO: Implement sendNotifications()');
  console.log('Endpoints to send to: ', endpoints);
  let notification = JSON.stringify(createNotification());
  let options = {
    TTL: 10000, // Time-to-live. Notifications expire after this.
    vapidDetails: vapidDetails // VAPID keys from .env
  };
  endpoints.map(endpoint => {
    let subscription = database[endpoint];
    webpush.sendNotification(subscription, notification, options);
  });
}

webpush.sendNotification()가 프로미스를 반환하므로 오류 처리를 쉽게 추가할 수 있습니다.

server.js에서 sendNotifications 함수를 다시 수정합니다.

server.js

function sendNotifications(database, endpoints) {
  let notification = JSON.stringify(createNotification());
  let options = {
    TTL: 10000; // Time-to-live. Notifications expire after this.
    vapidDetails: vapidDetails; // VAPID keys from .env
  };
  endpoints.map(endpoint => {
    let subscription = database[endpoint];
    webpush.sendNotification(subscription, notification, options);
    let id = endpoint.substr((endpoint.length - 8), endpoint.length);
    webpush.sendNotification(subscription, notification, options)
    .then(result => {
      console.log(`Endpoint ID: ${id}`);
      console.log(`Result: ${result.statusCode} `);
    })
    .catch(error => {
      console.log(`Endpoint ID: ${id}`);
      console.log(`Error: ${error.body} `);
    });
  });
}

새 정기 결제 처리

배경

사용자가 푸시 알림을 구독하면 다음과 같은 상황이 발생합니다.

  1. 사용자가 푸시 구독을 클릭합니다.

  2. 클라이언트는 VAPID_PUBLIC_KEY 상수 (서버의 공개 VAPID 키)를 사용하여 고유한 서버별 subscription 객체를 생성합니다. subscription 객체는 다음과 같습니다.

       {
         "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9...",
         "expirationTime": null,
         "keys":
         {
           "p256dh": "BNYDjQL9d5PSoeBurHy2e4d4GY0sGJXBN...",
           "auth": "0IyyvUGNJ9RxJc83poo3bA"
         }
       }
    
  3. 클라이언트는 본문에 문자열화된 JSON으로 구독을 포함하여 POST 요청을 /add-subscription URL에 전송합니다.

  4. 서버가 POST 요청의 본문에서 문자열화된 subscription를 가져와 다시 JSON으로 파싱하여 구독 데이터베이스에 추가합니다.

    데이터베이스는 자체 엔드포인트를 키로 사용하여 구독을 저장합니다.

    {
      "https://fcm...1234": {
        endpoint: "https://fcm...1234",
        expirationTime: ...,
        keys: { ... }
      },
      "https://fcm...abcd": {
        endpoint: "https://fcm...abcd",
        expirationTime: ...,
        keys: { ... }
      },
      "https://fcm...zxcv": {
        endpoint: "https://fcm...zxcv",
        expirationTime: ...,
        keys: { ... }
      },
    }

이제 서버에서 알림을 보내는 데 새 구독을 사용할 수 있습니다.

구현

새 구독 요청은 POST URL인 /add-subscription 경로로 전송됩니다. server.js에 스텁 경로 핸들러가 표시됩니다.

server.js

app.post('/add-subscription', (request, response) => {
  // TODO: implement handler for /add-subscription
  console.log('TODO: Implement handler for /add-subscription');
  console.log('Request body: ', request.body);
  response.sendStatus(200);
});

구현에서 이 핸들러는 다음을 수행해야 합니다.

  • 요청 본문에서 새 구독을 검색합니다.
  • 활성 정기 결제 데이터베이스에 액세스합니다.
  • 활성 구독 목록에 새 구독을 추가합니다.

신규 정기 결제 처리 방법:

  • server.js에서 /add-subscription의 경로 핸들러를 다음과 같이 수정합니다.

    server.js

    app.post('/add-subscription', (request, response) => {
      // TODO: implement handler for /add-subscription
      console.log('TODO: Implement handler for /add-subscription');
      console.log('Request body: ', request.body);
      let subscriptions = Object.assign({}, request.session.subscriptions);
      subscriptions[request.body.endpoint] = request.body;
      request.session.subscriptions = subscriptions;
      response.sendStatus(200);
    });

정기 결제 취소 처리

배경

서버가 가입이 비활성화되는 시점을 파악하지 못하는 경우도 있습니다. 예를 들어 브라우저가 서비스 워커를 종료할 때 가입이 완전히 삭제될 수 있습니다.

하지만 서버는 앱 UI를 통해 취소된 정기 결제 정보를 파악할 수 있습니다. 이 단계에서는 데이터베이스에서 구독을 삭제하는 기능을 구현합니다.

이렇게 하면 서버가 존재하지 않는 엔드포인트에 많은 알림을 전송하지 않습니다. 간단한 테스트 앱에서는 이러한 문제가 분명히 중요하지 않지만, 규모가 커지면 중요해집니다.

구현

구독 취소 요청은 /remove-subscription POST URL로 전송됩니다.

server.js의 스텁 경로 핸들러는 다음과 같습니다.

server.js

app.post('/remove-subscription', (request, response) => {
  // TODO: implement handler for /remove-subscription
  console.log('TODO: Implement handler for /remove-subscription');
  console.log('Request body: ', request.body);
  response.sendStatus(200);
});

구현에서 이 핸들러는 다음을 수행해야 합니다.

  • 요청 본문에서 취소된 구독의 엔드포인트를 검색합니다.
  • 활성 정기 결제 데이터베이스에 액세스합니다.
  • 활성 상태인 정기 결제 목록에서 취소된 정기 결제를 삭제합니다.

클라이언트의 POST 요청 본문에는 제거해야 하는 엔드포인트가 포함되어 있습니다.

{
  "endpoint": "https://fcm.googleapis.com/fcm/send/cpqAgzGzkzQ:APA9..."
}

정기 결제 취소를 처리하려면 다음 단계를 따르세요.

  • server.js에서 /remove-subscription의 경로 핸들러를 다음과 같이 수정합니다.

    server.js

  app.post('/remove-subscription', (request, response) => {
    // TODO: implement handler for /remove-subscription
    console.log('TODO: Implement handler for /remove-subscription');
    console.log('Request body: ', request.body);
    let subscriptions = Object.assign({}, request.session.subscriptions);
    delete subscriptions[request.body.endpoint];
    request.session.subscriptions = subscriptions;
    response.sendStatus(200);
  });