[Web] Push Notification 구현

2020. 4. 27. 23:22WEB

W3C에서 제정한 Service Worker 표준을 통해 웹사이트에서도 Push Notification을 지원 할 수 있습니다. 웹사이트에 접속하지 않아도 브라우저가 실행 중이라면 Push Notification이 사용자에게 노출됩니다. Push Notification은 웹표준으로 IE를 제외한 Chrome, Firefox, Edge, Safari 등 최신 브라우저와 모바일 브라우저에서 지원하는 기술입니다. 여기에서는 ASP.NET MVC 5 프로젝트와 Google Firebase Messaging을 사용하여 Push Notification API 구현에 대해 정리하였습니다.

사전 준비

Push Notification은 2015년 출시된 크롬 42버전부터 지원하므로 그 이상의 버전이 필요합니다. 이 포스트는 크롬 81버전을 사용하였습니다. 크롬이 아니더라도 최신 브라우저에서는 모두 동일하게 동작합니다.

Push Notification은 공격자가 개입하지 못하도록 HTTPS 통신만 지원합니다. 따라서 도메인이 연결된 웹서버가 필요하며 HTTPS 설정까지 마무리 되어야 합니다. TLS인증서 발급 및 HTTPS 적용에 관해서는 '무료 TLS 인증서 발급 및 HTTPS 설치'를 참고하시기 바랍니다.

Server-side 구현은 ASP.NET MVC 5 플렛폼을 사용하여 구현하였습니다.
이 포스트는 모바일 푸시알림, JavaScript, REST, ASP.NET MVC 숙련자를 대상으로 작성되었습니다.

Push Notification 구현

Push Notification은 크게 3단계의 작업으로 진행됩니다.

  1. Client-side 구현: JavaScript를 사용하여 Client-side에서 Push Notification 수신 이벤트를 작성합니다.
  2. Firebase 설정: Google Firebase Cloud Messaging (FCM)에 프로젝트를 추가하고 초기화를 진행합니다.
  3. Server-side 구현: ASP.NET MVC 프로젝트를 사용하여 Client Token을 서버에 저장하고, FCM 서비스와 연계하는 작업을 진행합니다.

1단계: Client-side 구현

먼저 작업을 진행하기 위해 먼저 ASP.NET MVC 프로젝트를 생성합니다. REST 통신을 위해 Web API 지원을 추가하고 HTTPS를 활성화해주세요.
ASP.NET MVC 프로젝트 생성

JavaScript로 Push Message 수신 이벤트를 구현하려면, HTML5 표준인 Service Worker 기술을 사용해야 합니다. Service Worker에 대한 자세한 정보를 원하시면 Service Worker 기초를 참고해주세요.

Service Worker는 사용자가 웹사이트를 떠나더라도 브라우저의 Service에 등록되어 실행되는 스크립트를 말합니다. 웹사이트와 별개로 동작하기 때문에 별도의 JavaScript 파일을 생성해야 합니다. 여기서는 service-worker.js 파일을 생성했습니다.
service-worker.js 파일추가
(참고: 루트에 service-worker.js를 추가 해주세요. 다른 경로에 추가하시면 오류가 발생합니다. 꼭 다른 위치에 두어야 한다면 HTTP Header에 Service-Worker-Allowed: /를 추가해 주셔야 오류가 발생하지 않습니다.)

생성된 service-worker.js 파일을 서비스로 등록하기 위해, 아래 JavaScript를 HTML 파일에 추가합시다.

var isServiceWorkerSupported = 'serviceWorker' in navigator;
if (isServiceWorkerSupported)
{
  //브라우저에 Service Worker를 등록
  navigator.serviceWorker.register('service-worker.js', { scope: '/'})
    .then(function(registration)
    {
       console.log('[ServiceWorker] 등록 성공: ', registration.scope);
    })
    .catch(function(err) 
    {
       console.log('[ServiceWorker] 등록 실패: ', err);
    });
}

위 코드가 포함된 HTML 파일을 크롬 브라우저로 실행해봅시다. 그리고 F12 키를 눌러 디버거를 실행한 후 [Application] 탭의 [Service Workers] 메뉴를 클릭하시면, 서비스로 등록된 service-worker.js를 확인할 수 있습니다.
브라우저러 실행한 화면
(참고: 편하게 디버깅 하시려면 Update on reload 체크박스를 체크해주세요.)

다음으로 사용자에게 Notification 권한을 요청하는 코드를 추가해 봅시다.

var isNotificationSupported = 'Notification' in window;
if (isNotificationSupported)
{
    Notification.requestPermission().then(function (result)
    {
        if (result === 'granted')
        {
            console.log('[Notification] 허용: ', result);
        }
        else
        {
            console.log('[Notification] 차단: ', result);
        }
    });
}

HTML 파일을 실행하면 권한 요청 창이 나타납니다. 참고로 사용자가 한 번이라도 거부하거나, 브라우저 설정에서 알림 기능을 비활성화하면 권한 요청 창이 나타나지 않습니다. 이 경우 사용자가 직접 브라우저 설정에서 알림을 허용하지 않는 이상 Notification 기능이 동작하지 않습니다.
Push 권한 요청 팝업

이제 service-worker.js 파일을 열고 Push 수신 이벤트 코드를 추가합시다.

//Push Message 수신 이벤트
self.addEventListener('push', function (event)
{
    console.log('[ServiceWorker] 푸시알림 수신: ', event);

    //Push 정보 조회
    var title = event.data.title || '알림';
    var body = event.data.body;
    var icon = event.data.icon || '/Images/icon.png'; //512x512
    var badge = event.data.badge || '/Images/badge.png'; //128x128
    var options = {
        body: body,
        icon: icon,
        badge: badge
    };

    //Notification 출력
    event.waitUntil(self.registration.showNotification(title, options));
});

//사용자가 Notification을 클릭했을 때
self.addEventListener('notificationclick', function (event)
{
    console.log('[ServiceWorker] 푸시알림 클릭: ', event);

    event.notification.close();
    event.waitUntil(
        clients.matchAll({ type: "window" })
            .then(function (clientList) 
            {
                //실행된 브라우저가 있으면 Focus
                for (var i = 0; i < clientList.length; i++) 
                {
                    var client = clientList[i];
                    if (client.url == '/' && 'focus' in client)
                        return client.focus();
                }
                //실행된 브라우저가 없으면 Open
                if (clients.openWindow)
                    return clients.openWindow('https://localhost:44337/');
            })
    );
});

console.log('[ServiceWorker] 시작');

위 코드까지 추가하면 Push Notification 수신준비가 완료된 것 입니다.

2단계: Google Firebase Messaging

Google Firebase Messaging에 접속합시다. 시작하기를 클릭하여 새 프로젝트를 추가합시다.
Firebase 프로젝트 생성

프로젝트가 생성되면 웹 앱을 추가해주세요.
웹 앱 추가

Firebase SDK 추가화면에서 제공하는 Script를 HTML 페이지에 추가해주세요. 그리고 웹페이지가 정상동작하는지 확인해주세요.
Firebase SDK 추가 화면

위에서 추가했던 Firebase SDK Script를 아래와 같이 initFirebase() 함수로 감싸주세요.

<script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/7.14.2/firebase-analytics.js"></script>
<script>
  function initFirebase(serviceWorkRegistration)
  {
    //Firebase SDK 초기화
    var firebaseConfig = {
        apiKey: "DIzaSyDFi4k5JEtUtoNT3oaQ39TijtsTfOJiyV8",
        authDomain: "webpushtest-56fd5.firebaseapp.com",
        databaseURL: "https://webpushtest-56fd5.firebaseio.com",
        projectId: "webpushtest-56fd5",
        storageBucket: "webpushtest-56fd5.appspot.com",
        messagingSenderId: "135041163347",
        appId: "1:135041163347:web:efcab5b3f535112e3a66a5",
        measurementId: "G-VLC8S6ZDFA"
    };
    firebase.initializeApp(firebaseConfig);
    firebase.analytics();

    //Messaging 서비스 활성화
    var messaging = firebase.messaging();
    messaging.useServiceWorker(serviceWorkRegistration);
    messaging.usePublicVapidKey("<Firebase Public Key>");
  }
</script>

마지막에 추가된 var messaging = firebase.messaging(); 코드를 통해 Firebase Messaging 서비스가 활성화됩니다. <Firebase Public Key>부분은 Firebase 설정에서 제공하는 Public Key로 변경해주세요. [프로젝트 설정]→[Settings]→[클라우드 메시징]→[웹 푸시 인증서] 부분에서 조회하실 수 있습니다. 아래와 같이 [키 쌍 생성] 버튼이 나타난다면 버튼을 클릭하여 Public Key를 생성할 수 있습니다.
Public Key 생성

1단계 Client-side 작업에서 추가했던 Service Worker 등록 코드로 이동하여, initFirebase() 함수 호출을 추가해주세요.

...
navigator.serviceWorker.register('service-worker.js')
    .then(function (registration)
    {
        console.log('[ServiceWorker] 등록 성공: ', registration.scope);
		
        //Firebase 초기화
        initFirebase(registration); 
        ...

Firebase Messaging 서비스가 시작되면 브라우저 마다 고유한 Instance ID Token이 생성됩니다. initFirebase() 함수 끝부분에 Instance ID Token을 조회하여 서버로 전송하는 코드를 추가해줍시다.

function initFirebase(serviceWorkRegistration)
{
    ...

    //Instance ID Token 발급 요청
    messaging.getToken()
        .then((currentToken) =>
        {
            if (currentToken)
            {
                console.log('[InstanceID Token] 발행완료: ', currentToken);
                sendTokenToServer(currentToken); //Token을 서버로 전송
            }
            else
            {
                console.log('[InstanceID Token] 발행실패');
                sendTokenToServer(null);
            }
        })
        .catch((err) => 
        {
            console.log('[InstanceID Token] 발행오류: ', err);
            sendTokenToServer(null);
        });

    //Instance ID Token 변경 시 호출되는 이벤트
    messaging.onTokenRefresh(() =>
    {
        messaging.getToken().then((refreshedToken) =>
        {
            console.log('[InstanceID Token] 갱신완료', refreshedToken);
            sendTokenToServer(refreshedToken); //Token을 서버로 전송
        })
        .catch((err) =>
        {
            console.log('[InstanceID Token] 갱신실패', err);
            sendTokenToServer(null);
        });
    });

    messaging.onMessage((payload) =>
    {
        //Push Message 수신 시 호출되는 이벤트
        console.log('[PushMessage] 수신: ', payload);
    });
}


//발급된 Instance ID Token을 서버에 전송
function sendTokenToServer(token)
{
    return fetch('/api/save-token/', 
        {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: token
        })
        .then(function (response)
        {
            if (!response.ok)
                console.log('[InstanceID Token] 서버전송 실패: ', response);
            else
                console.log('[InstanceID Token] 서버전송 완료: ', response);

            return response.json();
        })
        .then(function (responseData)
        {
            if (!(responseData && responseData.Success))
                console.log('[InstanceID Token] 서버전송 응답(실패): ', responseData);
            else
                console.log('[InstanceID Token] 서버전송 응답(성공): ', responseData);
        });
}

마지막으로 프로젝트의 루트에 manifest.json 파일을 추가하고 아래와 같이 내용을 추가해주세요.

{
    "gcm_sender_id": "<발신자ID>"
}

<발신자ID>부분은 Firebase Settings에서 조회할 수 있는 발신자ID로 변경해주세요.
발신자 ID 조회

HTML 파일을 열고 manifest.json 파일에 대한 참조를 추가합시다.

<!DOCTYPE html>

<html lang="ko">
<head>
    <meta charset="utf-8" />
    <title>pushTest</title>

    <!-- manifest 참조추가 -->
    <link rel="manifest" href="manifest.json">

    ...

이제 Firebase 설정이 완료되었습니다. 마지막으로 Server-side 작업을 마무리 하도록 합시다.

3단계: Server-side 구성

먼저 브라우저에서 전달한 InstanceID Token를 수신하여 서버에 저장하는 작업을 합시다. 다음과 같이 WebAPI 코드를 추가해주세요. ASP.NET WebAPI 구성에 대한 자세한 설명은 링크를 참조해주세요.

public class WebApiController : ApiController
{
    //브라우저에서 전달받은 Instnace ID Token 저장
    [Route("~/api/save-token"), HttpPost]
    public HttpResponseMessage SaveSubscription()
    {
        try
        {
            //1. Instnace ID Token 조회
            string clientId = null;
            using (var req = HttpContext.Current.Request.InputStream)
            {
                req.Seek(0, System.IO.SeekOrigin.Begin);
                using (var sr = new StreamReader(req))
                    clientId = sr.ReadToEnd();
            }

            //2. 서버에 저장 (TEST 목적이므로 파일로 저장)
            if (string.IsNullOrWhiteSpace(clientId) == false)
            {
                string filePath = "~/InsatnceIdToken.txt";
                string filePathAbsolute = HttpContext.Current.Server.MapPath(filePath);
                System.IO.File.WriteAllText(filePathAbsolute, clientId);
            }

            HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.OK, new { Success = true });
            return response;
        }
        catch (Exception ex)
        {
            string error = ex.ToString();
            HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.BadRequest, new { Success = false, Error = error });
            return response;
        }
    }
}

이제 Google FCM 서버에 Push Message 발송을 요청하는 코드를 작성해봅시다.

//Google FCM 서버에 Push Message 발송요청
public async Task sendMessage(string title, string msg, string instanceIdToken)
{
    //1. 전송정보 생성
    string serverKey = "key=<Firebase 서버키>";
    string url = "https://fcm.googleapis.com/fcm/send";
    string postJson = (@"{
                            'content_available': false,
                            'data':
                            {
                                'title': '" + HttpUtility.UrlEncode(title) + @"',
                                'body': '" + HttpUtility.UrlEncode(msg) + @"',
                                'icon': 'Content/Images/icon192x192.png',
                            },
                            'to': '" + instanceIdToken + @"'
                        }")
                        .Replace("'", "\"")
                        .Trim();

    //2. Firebase 서버에 REST 전송
    using (HttpClient client = new HttpClient() { BaseAddress = new Uri(url) })
    {
        //1) 요청
        client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", serverKey);
        StringContent postEncoded = new StringContent(postJson.Replace("\t", "").Replace("\r\n", ""), Encoding.UTF8, "application/json");
        HttpResponseMessage httpResponse = await client.PostAsync("", postEncoded);

        //2) 응답분석
        string responseText = await httpResponse.Content.ReadAsStringAsync();
        if (httpResponse.IsSuccessStatusCode)
            Debug.WriteLine("전송성공", responseText);
        else
            Debug.WriteLine("전송실패:", responseText, httpResponse.StatusCode);
    }
}

위 코드에서 <Firebase 서버키> 부분은 Firebase의 Setting 화면에서 제공하는 서버키로 변경해주세요.
서버키 조회

이제 위에서 작성한 sendMessage() 메서드를 호출하여 Push Message를 발송하시면 아래와 같이 Push Notification이 나타나게 됩니다.
Push Messaging 테스트 결과

References