생생한 활동 업데이트 3부: 푸시 알림 및 Windows Azure Mobile Services
이 시리즈의 1부에서는 "생생함"이라는 것이 사용자에게 주는 의미, 그리고 생생한 경험을 구축하는 데 있어서 앱이 어떤 역할을 하는지에 대해 살펴보았습니다. 2부에서는 웹 서비스를 작성하고 디버깅하여 라이브 타일의 주기적 업데이트를 지원하는 방법을 살펴보았습니다. 이 시리즈의 완결편인 이번 3부에서는 WNS(Windows 푸시 알림 서비스)를 통해 요청이 있을 때 특정 클라이언트 장치에 타일 업데이트, 알림 및 원시 알림을 제공하는 방법과 Windows Azure Mobile Services에서 전체 프로세스를 간소화하는 방식에 대해 알아보겠습니다.
푸시 알림
2부에서 살펴본 것처럼 주기적 업데이트는 클라이언트 쪽에서 실행되며타일이나 배지를 업데이트하는 폴링 또는 "pull" 메서드를 지원합니다. 서비스에서 업데이트를 장치로 직접 보내면 "푸시" 알림이 발생하며, 이 경우 해당 업데이트가 특정 사용자, 앱 및 보조 타일에만 적용될 수 있습니다.
푸시 알림은 폴링과는 달리 언제든지 발생할 수 있으며 발생 빈도 또한 매우 높습니다. 하지만 장치가 배터리를 사용 중이고 연결된 대기 상태이거나 알림 트래픽이 과도하게 발생할 경우 Windows가 장치의 푸시 알림 트래픽의 양을 제한합니다. 즉, 모든 알림이 반드시 전달되는 것은 아니며 특히 장치가 꺼져 있으면 알림이 전달되지 않을 확률이 더 높아집니다.
따라서 푸시 알림을 사용하여 빈도 또는 시간 확인 기능이 비슷한 시계 타일 또는 기타 타일 가젯을 구현하려 하면 안 됩니다. 그보다는 푸시 알림을 사용하여 사용자에게 중요한 의미가 있거나 사용자가 관심을 보일 만한 백엔드 서비스에 타일 및 알림을 연결하는 방법을 생각해 보시기 바랍니다. 이렇게 하면 사용자가 여러분의 앱을 자주 방문하도록 유도할 수 있습니다.
자세한 내용으로 넘어가기 전에, 푸시 알림을 크게 두 종류로 나누어 살펴보겠습니다.
- 타일이나 배지 업데이트가 포함된 XML 업데이트 또는 알림 메시지 페이로드: Windows는 이러한 푸시 알림을 처리하고, 앱을 대신하여 업데이트 또는 알림을 실행할 수 있습니다. 원한다면 앱에서 이러한 알림을 직접 처리할 수도 있습니다.
- 서비스에서 보낼 데이터가 들어 있는 이진 또는 '원시' 알림: 앱별 코드로 처리하지 않으면 Windows가 데이터로 어떤 작업을 해야 할지 알 수 없기 때문에 이러한 알림은 '반드시' 앱별 코드로 처리해야 합니다. 페이로드 5KB 크기 제한, Base64 인코딩 등에 대한 자세한 내용은 원시 알림에 대한 지침 및 검사 목록을 참조하십시오.
두 가지 중 어떤 경우에 해당하든, 실행 중인(포그라운드) 앱이 PushNotificationChannel 클래스 및 이 클래스의 PushNotificationReceived 이벤트를 통해 푸시 알림을 직접 처리할 수 있습니다. XML 페이로드의 경우 앱에서 콘텐츠를 수정하고, 태그를 변경하는 등의 작업을 수행한 후 로컬에서 실행하거나 무시할 수 있습니다. 원시 알림의 경우 앱에서 콘텐츠를 처리한 후 알림이 있으면 어떤 알림을 실행할지 결정합니다.
앱이 일시 중지 상태이거나 실행되지 않을 때도 같은 목적으로 백그라운드 작업을 제공할 수 있습니다. 반드시 앱의 요청이 있고 앱이 잠금 화면에 액세스할 수 있어야 합니다. 그래야만 알림을 받을 때 앱별 코드가 실행됩니다.
알림이 도착하면 일반적으로 백그라운드 작업에서 다음과 같은 한두 가지 동작이 수행됩니다. 첫째, 알림의 관련 정보를 로컬 앱 데이터로 저장합니다. 다음에 앱이 활성화되거나 재개될 때 앱에서 이 정보를 검색할 수 있습니다. 둘째, 백그라운드 작업에서 로컬 타일과 배지 업데이트 및/또는 알림 메시지를 실행할 수 있습니다.
일반적인 이메일 앱을 떠올려 보면 원시 알림을 보다 쉽게 이해할 수 있습니다. 백엔드 서비스는 사용자에게 전송되는 새 메시지를 탐지하면 여러 이메일 메시지 헤더가 들어 있는 원시 푸시 알림 또는 WNS를 보냅니다. 이때 서비스에서는 해당 사용자의 특정 장치에 설치된 특정 앱과 연결되어 있는 '채널 URI'를 사용하여 이 작업을 수행합니다.
그런 다음 WNS가 해당 알림을 클라이언트로 푸시합니다. 작업이 성공하면 Windows가 해당 알림을 받아서 이 채널 URI와 연결된 앱을 검색합니다. 해당하는 앱을 찾을 수 없으면 알림이 무시됩니다. 어떤 앱이 있고 그 앱이 실행 중이라면 Windows는 'PushNotificationReceived' 이벤트를 실행하고 그렇지 않으면 해당 앱에 대해 실행할 수 있는 백그라운드 작업을 찾아서 호출합니다.
둘 중 어디에 해당하든 원시 알림은 결국 앱 코드에 의해 실행되어 그 후 데이터를 처리하고, 앱 타일에 배지 업데이트를 실행하여 새 메시지 개수를 표시하고, 메시지 헤더가 있는 순환 타일 업데이트를 최대 5개까지 실행합니다. 또한 앱에서는 새 메시지가 도착할 때마다 알림 메시지를 표시할 수도 있고 새 메일이 있다는 사실을 알리는 알림 메시지를 하나 이상 표시할 수도 있습니다.
결과적으로 알림은 사용자에게 새 메일이 도착했다는 사실을 알려 주고, 시작 화면의 앱 타일은 새로운 이메일 소식을 신속하게 확인할 수 있는 정보를 제공합니다.
이러한 클라이언트 쪽 이벤트 처리기 및 백그라운드 작업에 대한 자세한 내용은 원시 알림 샘플, 이 블로그의 오프스크린 상태에서 앱의 효율성 높이기 - 백그라운드 작업, 그리고 백그라운드 네트워킹 백서를 참조하십시오. 지금부터는 이 글의 본래 목적인 서비스 쪽 이야기로 넘어가겠습니다.
WNS(Windows 푸시 알림 서비스) 작업
Windows, 앱, 서비스 및 WNS가 협력적으로 작동하기 때문에 특정 사용자의 특정 장치에 있는 특정 앱 타일 또는 알림이나 원시 알림 처리기에 사용자별 데이터를 제공할 수 있습니다. 아래는 이러한 관계를 그림으로 나타낸 것입니다.
이 모든 작업이 원활하게 수행되려면 다음과 같은 배선 작업이 필요합니다.
- 개발자가 Windows 스토어에 앱을 등록하여 푸시 알림을 사용합니다. 그러면 서비스가 WNS로 인증할 때 사용하는 SID 및 클라이언트 암호가 제공됩니다. 보안을 위해 이러한 정보를 클라이언트 장치에 저장하면 절대 안 됩니다.
- 런타임에서 앱은 각 라이브 타일(기본 및 보조)에 사용하거나 원시 알림에 사용할 Windows의 WNS 채널 URI를 요청합니다. 앱에서는 다른 백그라운드 작업에 사용할 수 있도록 30일마다 이러한 채널 URI를 새로 고쳐야 합니다.
- 앱 서비스에서는 날씨 업데이트 위치나 특정 사용자 계정 및 활동 같은 사용법을 설명하는 데이터와 함께 앱이 채널 URI를 업로드할 수 있는 URI를 제공합니다. 서비스는 나중에 사용할 수 있도록 이러한 채널 URI 및 연결된 데이터를 URI를 받는 즉시 저장합니다.
- 서비스는 백엔드를 모니터링하여 각 특정 사용자/장치/앱/타일 조합에 적용되는 변경 사항을 확인합니다. 특정 채널의 알림을 트리거하는 조건이 감지되면 서비스는 해당 XML 또는 원시 알림 콘텐츠를 작성하고, SID 및 클라이언트 암호를 사용하여 WNS에 인증한 후 WNS에 채널 URI와 함께 알림을 보냅니다.
각 단계를 자세히 살펴보겠습니다. 한 가지를 미리 알려 드리자면, HTTP 요청 처리에 어려움을 느끼는 분들을 위해 Windows Azure Mobile Services가 대부분의 작업을 대신 처리합니다. 이에 대해서는 천천히 살펴보겠습니다.
Windows 스토어에 앱 등록
서비스에 사용할 SID 및 클라이언트 암호를 얻는 방법은 Windows 개발자 센터의 WNS(Windows 푸시 알림 서비스)로 인증하는 방법을 참조하십시오. SID는 WNS로 앱을 식별하는 역할을 하고, 서비스는 클라이언트 암호를 사용하여 앱에 알림을 보낼 수 있다는 내용을 WNS로 전달합니다. 다시 한 번 강조하지만 이러한 정보는 반드시 서비스에 저장해야 합니다.
WNS(Windows 푸시 알림 서비스)로 인증하는 방법의 4단계 "클라우드 서버의 자격 증명을 WNS로 보내기"는 서비스에서 푸시 알림을 보낼 때만 하면 되는 작업입니다. 이에 대해서는 잠시 후에 다루겠습니다. 현재 단계에서는 서비스가 알림을 보낼 때 필요한 주요 부품 중 하나인 채널 URI가 없는 상태이기 때문입니다.
채널 URI 획득 및 새로 고침
클라이언트 앱은 런타임에 Windows.Networking.PushNotifications.PushNotificationChannelManager 개체를 통해 채널 URI를 획득합니다. 이 개체에는 다음 두 가지 메서드만 있습니다.
- createPushNotificationChannelForApplicationAsync: 앱의 기본 타일에 사용할 채널 URI와 알림 및 원시 알림을 만듭니다.
- createPushNotificationChannelForSecondaryTileAsync: 'tileId' 인수에 의해 식별되는 특정 보조 타일에 사용할 채널 URI를 만듭니다.
두 비동기 작업의 결과는 PushNotificationChannel 개체입니다. 이 개체의 'Uri' 속성에는 채널 URI와 해당 채널을 새로 고쳐야 하는 기간을 나타내는 'ExpirationTime'이 들어 있습니다. 'Close' 메서드는 필요에 따라 채널을 구체적으로 종료하는데 사용되며, 앱이 포그라운드에 있고 이 채널을 통해 푸시 알림을 받을 때 실행되는 'PushNotificationReceived' 이벤트가 가장 중요합니다.
URI의 수명은 30일입니다. 30일이 지나면 WNS가 해당 채널에 대한 모든 요청을 거부합니다. 따라서 위에서 설명한 'create' 메서드를 앱 코드에 사용하여 적어도 30일마다 URI를 새로 고쳐서 서비스로 보내야 합니다. 다음은 이에 적합한 계획입니다.
- 처음 실행할 때, 채널 URI를 요청하고 로컬 앱 데이터의 'Uri' 속성에 문자열을 저장합니다. 채널 URI는 장치에만 적용되므로 로밍 앱 데이터에 저장하면 안 됩니다.
- 두 번째 실행부터는 채널 URI를 다시 요청하여 이전에 저장한 채널 URI와 비교합니다. 두 채널 URI가 서로 다를 경우 서비스로 보내거나 필요하다면 서비스에서 기존 채널 URI를 대체하도록 합니다.
- 또한 앱이 30일 넘게 일시 중단 상태일 수도 있으므로 앱의 'Resuming' 처리기에서 이전 단계를 수행합니다(본 문서의 시작, 다시 시작 및 멀티태스킹 참조).
- 앱이 30일 이내에 실행되지 않을 경우를 대비하여 유지 관리 트리거로 며칠 또는 일주일 간격으로 실행되는 백그라운드 작업을 구현합니다. 자세한 방법은 오프스크린 상태에서 앱의 효율성 높이기 - 백그라운드 작업을 참조하십시오. 이 경우의 백그라운드 작업은 앱이 채널을 요청할 때와 동일한 코드를 실행하여 서비스로 채널을 보냅니다.
서비스로 채널 URI 보내기
일반적으로 푸시 알림 채널은 이메일 상태, 메신저 대화, 기타 개인 설정된 정보 등 사용자별 업데이트와 함께 작동합니다. 서비스가 모든 사용자 및/또는 모든 타일에 같은 알림을 보내야 하는 경우는 거의 없습니다. 따라서 서비스는 각 채널 URI를 보다 구체적인 정보와 연결해야 합니다. 메일 앱의 경우 사용자 ID로 검사할 계정을 지정하기 때문에 사용자 ID가 가장 중요합니다. 반면 날씨 앱은 각 채널 URI를 특정 위도 및 경도와 연결합니다. 그러면 각 타일(기본 및 보조)이 고유한 위치를 나타냅니다.
앱에서 서비스에 채널 URI를 보낼 때는 이러한 구체적인 정보를 함께 담아서 보내야 하며 서비스에서는 이러한 정보를 나중에 사용할 수 있도록 저장해야 합니다.
사용자 ID의 보안이 우려된다면 서비스별 자격 증명을 사용하거나 Facebook, Twitter, Google 또는 사용자의 Microsoft 계정 같은 OAuth 공급자를 통해 앱에서 별도의 서비스로 사용자를 인증하도록 하는 것이 가장 좋습니다. OAuth를 사용하면 Windows Azure Mobile Services에 여러 가지로 도움이 되며 이에 대해서는 나중에 살펴보겠습니다. 어떤 사정으로 인해 이 방법을 사용할 수 없다면 서비스로 보내는 모든 사용자 ID를 암호화하거나 HTTPS를 통해 사용자 ID를 보내야 합니다.
헤더에 연결하든지, 메시지 본문의 데이터를 통해 보내든지, 서비스 URI의 매개 변수로 보내든지 이 정보를 보내는 방식은 개발자의 선택입니다. 이 부분의 통신은 철저하게 앱과 서비스 사이에서 발생합니다.
예를 하나 들어보겠습니다. 'receiveuri.aspx'라는 페이지(다음 단원에서 나올 페이지)를 사용하는 어떤 서비스가 있고 이 페이지의 전체 주소는 'url'이라는 변수에 있다고 가정해 봅시다. 다음 코드는 앱에 사용할 기본 채널 URI를 Windows에 요청하여 HTTP를 통해 해당 페이지에 게시합니다. 이 코드는 푸시 및 주기적 알림 클라이언트 쪽 샘플을 가져와서 간단하게 고친 것으로, 'itemId' 변수를 다른 곳에 정의하여 보조 타일을 식별하는 데 사용했습니다. 또한 여기에는 보이지 않지만 이 샘플에는 C++ 변형도 들어 있습니다.
JavaScript:
Windows.Networking.PushNotifications.PushNotificationChannelManager .createPushNotificationChannelForApplicationAsync() .done(function (channel) { //Typically save the channel URI to appdata here. WinJS.xhr({ type: "POST", url:url, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: "channelUri=" + encodeURIComponent(channel.uri) + "&itemId=" + encodeURIComponent(itemId) }).done(function (request) { //Typically update the channel URI in app data here. }, function (e) { //Error handler }); });
C#:
using Windows.Networking.PushNotifications; PushNotificationChannel channel = await PushNotificationChannelManager.CreatePushNotificationChannelForApplicationAsync(); HttpWebRequest webRequest = (HttpWebRequest)HttpWebRequest.Create(url); webRequest.Method = "POST"; webRequest.ContentType = "application/x-www-form-urlencoded"; byte[] channelUriInBytes = Encoding.UTF8.GetBytes("ChannelUri=" + WebUtility.UrlEncode(newChannel.Uri) + "&ItemId=" + WebUtility.UrlEncode(itemId)); Task<Stream> requestTask = webRequest.GetRequestStreamAsync(); using (Stream requestStream = requestTask.Result) { requestStream.Write(channelUriInBytes, 0, channelUriInBytes.Length); }
다음 ASP.NET 코드는 이 HTTP POST를 처리하는 기본적인 'receiveuri.aspx' 페이지를 구현하는 것으로, 해당 항목의 유효한 채널 URI, 사용자 및 식별자를 수신하도록 합니다.
"기본" 코드라는 것을 강조한 이유는 보시다시피 'SaveChannel' 함수가 요청 콘텐츠를 고정된 텍스트 타일에 쓰기만 할 뿐 여러 사용자로 확대되지는 않기 때문입니다. 물론 실제 서비스에서는 이 용도로 사용하는 데이터베이스가 따로 있지만 구조는 이와 비슷합니다.
<%@ Page Language="C#" AutoEventWireup="true" %> <script runat="server"> protected void Page_Load(object sender, EventArgs e) { //Output page header Response.Write("<!DOCTYPE html>\n<head>\n<title>Register Channel URI</title>\n</head>\n<html>\n<body>"); //If called with HTTP GET (as from a browser), just show a message. if (Request.HttpMethod == "GET") { Response.StatusCode = 400; Response.Write("<p>This page is set up to receive channel URIs from a push notification client app.</p>"); Response.Write("</body></html>"); return; } if (Request.HttpMethod != "POST") { Response.StatusCode = 400; Response.Status = "400 This page only accepts POSTs."; Response.Write("<p>This page only accepts POSTs.</p>"); Response.Write("</body></html>"); return; } //Otherwise assume a POST and check for parameters try { //channelUri and itemId are the values posted from the Push and Periodic Notifications Sample in the Windows 8 SDK if (Request.Params["channelUri"] != null && Request.Params["itemId"] != null) { // Obtain the values, along with the user string string uri = Request.Params["channelUri"]; string itemId = Request.Params["itemId"]; string user = Request.Params["LOGON_USER"]; //TODO: validate the parameters and return 400 if not. //Output in response Response.Write("<p>Saved channel data:</p><p>channelUri = " + uri + "<br/>" + "itemId = " + itemId + "user = " + user + "</p>"); //The service should save the URI and itemId here, along with any other unique data from the app such as the user; SaveChannel(uri, itemId, user); Response.Write("</body></html>"); } } catch (Exception ex) { Trace.Write(ex.Message); Response.StatusCode = 500; Response.StatusDescription = ex.Message; Response.End(); } } </script> protected void SaveChannel(String uri, String itemId, String user) { //Typically this would be saved to a database of some kind; to keep this demonstration very simple, we'll just use //the complete hack of writing the data to a file, paying no heed to overwriting previous data. //If running in the debugger on localhost, this will save to the project folder string saveLocation = Server.MapPath(".") + "\\" + "channeldata_aspx.txt"; string data = uri + "~" + itemId + "~" + user; System.Text.ASCIIEncoding encoding = new System.Text.ASCIIEncoding(); byte[] dataBytes = encoding.GetBytes(data); using (System.IO.FileStream fs = new System.IO.FileStream(saveLocation, System.IO.FileMode.Create)) { fs.Write(dataBytes, 0, data.Length); } return; }
이 서비스의 코드는 제가 무료로 제공하고 있는 전자책의 13장 'HTML, CSS 및 JavaScript로 Windows 8 앱 프로그래밍', 그 중에서도 특히 HelloTiles 샘플을 보면 잘 나와 있습니다. 이 샘플은 이전에 언급했듯이 클라이언트 쪽 SDK 샘플과 작동하도록 설계되었습니다. 로컬 호스트가 지원되는 상태에서 Visual Studio 2012 Ultimate 또는 Visual Studio Express 2012 for Web 같은 디버거로 HelloTiles을 실행하면 'https://localhost:52568/HelloTiles/receiveuri.aspx'와 같은 URL이 생성됩니다. 이 URL을 클라이언트 쪽 SDK 샘플에 붙여 넣으면 됩니다. 샘플이 해당 URL에 요청을 보내면 여러분은 서비스 내의 아무 중단점에서 중지하여 코드를 살펴볼 수 있습니다.
알림 보내기
실제 서비스에는 데이터 원본을 모니터링하면서 적절한 시기가 되면 특정 사용자에게 푸시 알림을 보내는 지속적인 프로세스가 있을 것입니다. 이 동작은 다음과 같은 여러 가지 방식으로 발생합니다.
- 알림이 실행되는 빈도에 따라 예약된 작업을 통해 특정 위치의 날씨 알림을 확인하고(예: 15-30분마다 한 번) 그에 대한 응답으로 푸시 알림을 실행할 수 있습니다.
- 백엔드 메시징과 같은 다른 서비스는 새 메시지가 준비되면 서버의 한 페이지에 요청을 보낼 수 있습니다. 그러면 해당 페이지에서 적절한 알림을 실행합니다.
- 사용자가 서버에서 해당 페이지와 상호 작용할 때 사용자의 작업이 특정 채널에 푸시 알림을 트리거합니다.
요약하면 푸시 알림을 트리거하는 방식은 여러 가지가 있으며 그 중에서 백엔드 서비스 또는 웹 사이트의 특성에 따라 전적으로 결정됩니다. 이 글의 목적에 맞도록 13장 'HTML, CSS 및 JavaScript로 Windows 8 앱 프로그래밍'에 있는 동일한 HelloTiles 샘플 서비스에 'sendBadgeToWNS.aspx'라는 페이지를 넣었습니다. 이 페이지는 사용자가 브라우저에서 해당 페이지를 방문할 때마다 이전에 텍스트 파일에 저장된 채널 URI를 사용하여 푸시 알림을 보냅니다. 실제 서비스에서는 파일 콘텐츠를 읽는 대신 데이터베이스를 조회하여 채널 URI를 가져오지만 전체적인 구조는 매우 비슷하다는 점을 다시 한 번 강조합니다.
ASP.NET 페이지:
<%@ Page Language="C#" AutoEventWireup="true" CodeFile="sendBadgeToWNS.aspx.cs" Inherits="sendBadgeToWNS" %> <!DOCTYPE html> <html xmlns="https://www.w3.org/1999/xhtml"> <head runat="server"> <title>Send WNS Update</title> </head> <body> <p>Sending badge update to WNS</p> </body> </html>
숨겨진 C# 코드:
using System; using System.Collections.Generic; using System.Linq; using System.Web; using System.Web.UI; using System.Web.UI.WebControls; using System.Net; using System.IO; using System.Runtime.Serialization; using System.Runtime.Serialization.Json; using System.Text; public partial class sendBadgeToWNS : System.Web.UI.Page { protected void Page_Load(object sender, EventArgs e) { //Load our data that was previously saved. A real service would do a database lookup here //with user- or tile-specific criteria. string loadLocation = Server.MapPath(".") + "\\" + "channeldata_aspx.txt byte[] dataBytes; using (System.IO.FileStream fs = new System.IO.FileStream(loadLocation, System.IO.FileMode.Open)) { dataBytes = new byte[fs.Length]; fs.Read(dataBytes, 0, dataBytes.Length); } if (dataBytes.Length == 0) { return; } System.Text.ASCIIEncoding encoding = new System.Text.ASCIIEncoding(); string data = encoding.GetString(dataBytes); string[] values = data.Split(new Char[] { '~' }); string uri = values[0]; //Channel URI string secret = "9ttsZT0JgHAFveYahK1B6jQbvMOIWYbm"; string sid = "ms-app://s-1-15-2-2676450768-845737348-110814325-22306146-1119600341-293560589-2707026538"; //Create some simple XML for a badge update string xml = "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"; xml += "<badge value='alert'/>"; PostToWns(secret, sid, uri, xml, "wns/badge"); } }
우리가 여기서 할 일은 채널 URI를 검색하여 XML 페이로드를 작성한 후 WNS로 인증하고 해당 채널 URI에 HTTP POST를 수행하는 것이 전부입니다. 마지막 두 단계는 Windows 개발자 센터의 빠른 시작: 푸시 알림 보내기 항목에서 가져오는 'PostToWns' 함수를 통해 실행됩니다. Windows 스토어에서 얻은 클라이언트 암호 및 SID를 사용하여 https://login.live.com/accesstoken.srf에서 OAuth를 통해 WNS로 인증하는 과정이 대부분이므로 코드 전체를 보여 드리지는 않겠습니다. 이 인증의 결과로 액세스 토큰을 얻게 되고 이 액세스 토큰은 채널 URI로 보내는 HTTP POST에 포함됩니다.
C#:
public string PostToWns(string secret, string sid, string uri, string xml, string type = "wns/badge") { try { // You should cache this access token var accessToken = GetAccessToken(secret, sid); byte[] contentInBytes = Encoding.UTF8.GetBytes(xml); // uri is the channel URI HttpWebRequest request = HttpWebRequest.Create(uri) as HttpWebRequest; request.Method = "POST"; request.Headers.Add("X-WNS-Type", type); request.Headers.Add("Authorization", String.Format("Bearer {0}", accessToken.AccessToken)); using (Stream requestStream = request.GetRequestStream()) requestStream.Write(contentInBytes, 0, contentInBytes.Length); using (HttpWebResponse webResponse = (HttpWebResponse)request.GetResponse()) return webResponse.StatusCode.ToString(); } catch (WebException webException) { // Implements a maximum retry policy (omitted) } }
이 예에서는 HTTP 요청의 'X-WNS-Type' 헤더가 'wns/badge'로 설정되었고 'Content-Type' 헤더가 기본값인 'text/xml'로 설정된 것을 눈여겨보시기 바랍니다. 타일의 경우 유형은 'wns/tile'이어야 하고 알림은 'wns/toast'를 사용합니다. 원시 알림의 경우 유형은 'wns/raw'를 사용하고 'Content-Type'을 'application/octet-stream'으로 설정합니다. 헤더에 대한 자세한 내용은 설명서의 푸시 알림 서비스 요청 및 응답 헤더를 참조하십시오.
푸시 알림 실패
이와 같은 HTTP 요청 전송이 항상 성공하는 것은 아니며, WNS가 200 코드(성공) 이외의 다른 코드로 응답하는 몇 가지 이유가 있습니다. 자세한 내용은 푸시 알림 서비스 요청 및 응답 헤더의 "응답 코드" 단원을 참조하십시오. 다음은 일반적인 오류와 그 원인입니다.
- 채널 URI가 올바르지 않거나(404 찾을 수 없음) 만료되었습니다(410 없음). 이 경우 서비스가 데이터베이스에서 채널 URI를 제거하고, 제거된 채널 URI에 대한 요청을 더 이상 만들면 안 됩니다.
- 클라이언트 암호 및 SID가 올바르지 않거나(401 권한 없음) 앱 매니페스트의 앱 패키지 ID와 Windows 스토어의 패키지 ID가 일치하지 않습니다(403 사용 권한 없음). 이와 같은 불일치 문제를 해결하는 가장 좋은 방법은 Visual Studio의 '스토어 > 스토어에 앱 연결' 메뉴 명령을 사용하는 것입니다. 이 명령은 Visual Studio 2012 Ultimate의 '프로젝트' 메뉴에서 찾을 수 있습니다.
- 원시 알림 페이로드가 5KB를 초과합니다(413 요청 엔터티가 너무 큼).
- 클라이언트가 오프라인 상태일 수 있습니다. 이 경우 WNS에서 자동으로 다시 시도하지만 결국에는 실패를 보고합니다. XML 알림의 경우 클라이언트가 다시 온라인에 연결되면 푸시 알림을 캐시하여 전달하는 것이 기본 동작입니다. 원시 알림의 경우 기본적으로 캐시가 비활성화되어 있으므로 채널 URI로 보내는 요청에서 'X-WNS-Cache-Policy' 헤더를 'cache'로 설정하여 이를 변경할 수 있습니다.
기타 이유(400 잘못된 요청)인 경우 XML 페이로드에 UTF-8로 인코딩된 텍스트가 있는지, 원시 알림이 base64로 되어 있고 'Content-Type' 헤더가 'application/octet-stream'으로 설정되어 있는지 확인하십시오. 또한 특정 기간 내에 푸시 알림을 너무 많이 보내려고 하면 WNS가 전송을 제한할 수 있습니다.
앱이 잠금 화면에 없고 장치가 연결된 대기 상태인 경우에도 원시 푸시 알림이 거부될 수 있습니다. Windows는 연결된 대기 상태와 앱이 포그라운드에 있지 않을 때는 비잠금 화면 앱으로 전송되는 원시 알림을 차단하기 때문에 WNS는 전송되지 않을 알림을 삭제하는 기능에 최적화되어 있습니다.
Windows Azure Mobile Services
복잡한 푸시 알림 작업에 대해 알아보았고 저장소 문제는 아예 건너뛰었으니 "이 작업을 좀 더 간편하게 하는 방법은 없나요?"라는 궁금증이 생길 법도 합니다. 여러분이 고객 기반을 차츰 확대하다 보면 채널 URI가 수천에서 수백만 개에 달할 수도 있는데(물론 그렇게 되기를 바라지만!) 그렇게 많은 채널 URI를 관리하려면 어떻게 해야 할까요?
다행히도 여러분 외에도 이런 질문을 한 사람들이 있습니다. Urban Airship에서 제공하는 타사 솔루션 외에도 Windows Azure Mobile Services를 사용하면 채널 URI를 훨씬 간편하게 관리할 수 있습니다.
Windows Azure Mobile Services, 줄여서 AMS는 우리가 살펴본 대부분의 서비스에 사용할 수 있는 이미 제작된 솔루션(기본적으로 수많은 REST 끝점)을 제공합니다. "Mobile Service"는 기본적으로 개발자를 대신하여 데이터베이스를 관리하고 WNS에 페이로드를 간편하게 보낼 수 있는 라이브러리 함수를 제공하는 서비스입니다. AMS에 대한 소개는 Windows Azure Mobile Services로 구현하는 "클라우드 지원 앱"에서 확인할 수 있습니다.
특히 푸시 알림의 경우 우선 Windows Azure Mobile Services SDK for Windows 8에서 클라이언트 쪽 라이브러리를 얻어야 합니다. 그런 다음 Mobile Services에서 푸시 알림 시작(JavaScript 버전도 있음)을 참조하십시오. AMS가 앞에서 살펴본 배선 작업을 어떤 방식으로 지원하는지에 대해 자세하게 설명되어 있습니다.
- Windows 스토어에 앱 등록: Windows 스토어에서 앱에 사용할 클라이언트 암호 및 SID를 얻었으면 Mobile Service 구성에 해당 값을 저장합니다. 방금 언급한 시작 항목의 "Windows 스토어에 앱 등록" 단원을 참조하십시오.
- 채널 URI 획득 및 새로 고침: 앱에서 채널 URI를 요청하고 관리하는 일은 전적으로 클라이언트 쪽 작업이며 절차는 이전과 동일합니다.
- 서비스로 채널 URI 보내기: AMS를 사용하면 이 단계를 훨씬 간단히 수행할 수 있습니다. 먼저 Windows Azure 포털을 통해 Mobile Service에 데이터베이스 테이블을 만듭니다. 그러면 앱에서는 채널 URI 및 연결할 기타 주요 정보를 통해 해당 테이블에 레코드를 삽입할 수 있습니다. AMS 클라이언트 라이브러리는 백그라운드에서 HTTP 요청을 처리할 뿐 아니라 서버에 변경 사항이 있으면 클라이언트 쪽 레코드를 업데이트하는 작업까지 처리합니다. 뿐만 아니라 AMS는 사용자의 Microsoft 계정을 통해 또는 다른 OAuth 공급자인 Facebook, Twitter, Google 중 하나로 앱을 등록한 경우 이 중 하나를 통해 자동으로 인증을 처리할 수 있습니다. 자세한 내용은 Mobile Services에서 인증 시작을 참조하십시오.
- 알림 보내기: Mobile Service 내에서는 JavaScript를 변형한 Node.js로 작성된 스크립트와 JavaScript로 작성된 예약된 작업을 데이터베이스 작업에 연결할 수 있습니다. 이러한 스크립트에서는 채널 URI와 페이로드를 사용하여 'push.wns' 개체를 호출하면 채널에 필요한 HTTP 요청이 생성됩니다. 또한 'console.log'를 사용하여 간단하게 푸시 실패를 캡처하고 응답을 기록할 수 있습니다. 이러한 로그는 Windows Azure 포털에서 쉽게 검토할 수 있습니다.
자세한 내용은 다음 두 자습서 Windows Azure Mobile Services를 사용한 타일, 알림 및 배지 푸시 알림 및 Windows Azure Mobile Services를 사용한 원시 알림을 참조하십시오. 이 글에서는 지침을 모두 복습하지는 않고 몇 가지 핵심만 살펴보겠습니다.
Mobile Service를 설정할 때 특정 서비스 URL을 사용하게 됩니다. AMS SDK에서 'MobileServiceClient' 개체의 인스턴스를 만들 때 사용하는 것이 바로 이 URL입니다.
JavaScript:
var mobileService = new Microsoft.WindowsAzure.MobileServices.MobileServiceClient( "https://{mobile-service-url}.azure-mobile.net/", "{mobile-service-key}");
C#:
using Microsoft.WindowsAzure.MobileServices; MobileServiceClient MobileService = new MobileServiceClient( "https://{mobile-service-url}.azure-mobile.net/", "{mobile-service-key}");
C++ (추가 샘플에서 발췌):
using namespace Microsoft::WindowsAzure::MobileServices; auto MobileService = ref new MobileServiceClient( ref new Uri(L" https://{mobile-service-url}.azure-mobile.net/"), ref new String(L"{mobile-service-key}"));
이 클래스는 서비스와 이루어지는 모든 HTTP 통신을 캡슐화하므로 개발자가 하위 수준의 배관 작업을 신경 쓸 필요가 없습니다.
특정 OAuth 공급자로 인증하려면 'login' 또는 'LoginAsync' 메서드를 사용합니다. 그 결과로 나오는 것이 해당 정보를 앱에 제공하는 'User' 개체입니다. 인증된 후에는 클라이언트 개체의 'CurrentUser' 속성에도 사용자 ID가 포함됩니다. 이와 같이 Mobile Service를 직접 인증할 경우 서비스가 사용자 ID에 액세스할 수 있으므로 클라이언트가 사용자 ID를 명시적으로 보낼 필요가 없습니다.
JavaScript:
mobileService.login("facebook").done(function (user) { /* ... */ }); mobileService.login("twitter").done(function (user) { /* ... */ }); mobileService.login("google").done(function (user) { /* ... */ }); mobileService.login("microsoftaccount").done(function (user) { /* ... */ });
C#:
MobileServiceUser user = await MobileService.LoginAsync(MobileServiceAuthenticationProvider.Facebook); MobileServiceUser user = await MobileService.LoginAsync(MobileServiceAuthenticationProvider.Twitter); MobileServiceUser user = await MobileService.LoginAsync(MobileServiceAuthenticationProvider.Google); MobileServiceUser user = await MobileService.LoginAsync(MobileServiceAuthenticationProvider.MicrosoftAccount);
C++:
task<MobileServiceUser^> (MobileService->LoginAsync(MobileServiceAuthenticationProvider::Facebook)) .then([this](MobileServiceUser^ user) { /* */ } ); task<MobileServiceUser^> (MobileService->LoginAsync(MobileServiceAuthenticationProvider::Twitter)) .then([this](MobileServiceUser^ user) { /* */ } ); task<MobileServiceUser^> (MobileService->LoginAsync(MobileServiceAuthenticationProvider::Google)) .then([this](MobileServiceUser^ user) { /* */ } ); task<MobileServiceUser^> (MobileService->LoginAsync(MobileServiceAuthenticationProvider::MicrosoftAccount)) .then([this](MobileServiceUser^ user) { /* */ } );
서비스에 채널 URI를 보내는 작업도 간단합니다. 서비스의 데이터베이스에 레코드를 저장하면 클라이언트 개체가 HTTP 요청을 만듭니다. 이렇게 하려면 앞에서 링크를 제공한 푸시 알림 자습서 샘플에 나와 있는 아래의 예처럼 데이터베이스 개체를 요청하고 레코드를 삽입하기만 하면 됩니다. 각 코드 조각에서 'ch'는 WinRT API의 'PushNotificationChannel' 개체를 포함하는 것으로 가정합니다. 작성된 'channel' 개체에 보조 타일 ID 또는 채널의 목적을 식별하는 기타 데이터 같은 다른 속성을 넣을 수도 있습니다.
JavaScript:
var channelTable = MobileServicesSample.mobileService.getTable('Channels'); var channel = { uri: ch.uri, expirationTime: ch.expirationTime. }; channelTable.insert(channel).done(function (item) { }, function () { // Error on the insertion. }); }
C#:
var channel = new Channel { Uri = ch.Uri, ExpirationTime = ch.ExpirationTime }; var channelTable = privateClient.GetTable<Channel>(); if (ApplicationData.Current.LocalSettings.Values["ChannelId"] == null) { // Use try/catch block here to handle exceptions await channelTable.InsertAsync(channel); }
C++:
auto channel = ref new JsonObject(); channel->Insert(L"Uri", JsonValue::CreateStringValue(ch->Uri)); channel->Insert(L"ExpirationTime", JsonValue::CreateBooleanValue(ch->ExpirationTime)); auto table = MobileService->GetTable("Channel"); task<IJsonValue^> (table->InsertAsync(channel)) .then([this, item](IJsonValue^ V) { /* ... */ });
일단 채널 레코드가 삽입되면 서버가 해당 레코드에 수행한 변경 또는 추가 작업이 클라이언트에 반영된다는 점에 주의해야 합니다.
뿐만 아니라 'GetTable/getTable' 호출에서 데이터베이스 이름의 철자를 잘못 입력할 경우 레코드를 삽입하려고 시도하기 전에는 어떤 예외도 발견할 수 없습니다. 이러한 버그는 추적하기가 매우 어렵습니다. 모든 것이 제대로 작동한다고 확신했는데 실제로는 그렇지 않은 경우가 발생할 수 있으므로 반드시 데이터베이스 이름을 확인해야 합니다.
다시 한 번 강조하지만 클라이언트 쪽에서 삽입한 항목은 HTTP 요청으로 전환되어 서비스에 전달됩니다. 하지만 서비스 쪽에서도 이러한 요청은 숨김 처리됩니다. 따라서 요청을 받아서 처리하는 대신 각 데이터베이스 작업(삽입, 읽기, 업데이트 및 삭제)에 사용자 지정 스크립트를 연결합니다.
이러한 스크립트는 Node.js에서 사용 가능한 고유의 개체 및 메서드를 사용하여 JavaScript 함수로 작성되며 앱의 클라이언트 쪽 JavaScript에서 해야 할 일은 없습니다. 각 함수에는 적절한 매개 변수가 제공됩니다. 'insert' 및 'update'는 새로운 레코드를 수신하고, 'delete'는 항목의 ID를 수신하고, 'read'는 쿼리를 수신합니다. 또한 모든 함수는 'user' 개체를 수신하며 앱에서 Mobile Service를 사용하여 사용자를 인증할 경우에는 작업을 실행하여 HTTP 응답이 될 항목을 생성할 수 있는 'request' 개체도 수신합니다.
요청을 실행하고 레코드, 즉 'item'을 삽입하는 가장 간단한(기본값) 'insert' 스크립트는 다음과 같습니다.
function insert(item, user, request) { request.execute(); }
레코드에 타임스탬프와 사용자 ID를 연결하는 방법도 매우 간단합니다. 아래와 같이 요청을 실행하기 전에 'item' 매개 변수에 해당 속성을 추가하기만 하면 됩니다.
function insert(item, user, request) { item.user = user.userId; item.createdAt = new Date(); request.execute(); }
이 스크립트에서 데이터베이스에 레코드를 삽입하기 전에 'item'의 내용을 변경하면 변경된 항목이 자동으로 클라이언트에 전달됩니다. 위의 코드에서 삽입이 성공하면 클라이언트의 'channel' 개체에 'user' 및 'createdAt' 속성이 포함됩니다. 정말 편리합니다.
또한 서비스 스크립트에서 'request.execute' 이후, 특히 성공 또는 실패에 응답하여 추가 작업을 수행할 수도 있습니다. 자세한 내용은 서버 스크립트 예제 사용 방법 설명서를 참조하십시오.
다시 푸시 알림으로 돌아가서, 테이블에 채널 URI를 저장하는 작업은 한 쪽에서만 발생하며 서비스가 이 특별한 이벤트에 응답하여 알림을 보낼 수도 있고 보내지 않을 수도 있습니다. 오히려 그보다는 서비스에서 추가 정보를 사용하여 다른 테이블을 만들고, 이 테이블에서 수행되는 작업이 채널 URI의 일부 하위 집합에 알림을 트리거할 확률이 더 높습니다. 다음 단원에서 몇 가지 예를 살펴보겠습니다. 하지만 어떤 경우에도 'push.wns'개체를 사용하여 스크립트에서 푸시 알림을 보낸다는 사실은 변하지 않습니다. 원시를 포함하여 특정 유형의 업데이트를 보낼 수 있는 다양한 메서드가 있으며 사용 가능한 템플릿과 일치하는 이름 메서드를 통해 타일, 알림 및 배지가 직접 작동합니다. 예를 들면 아래와 같습니다.
push.wns.sendTileSquarePeekImageAndText02(channel.uri, { image1src: baseImageUrl + "image1.png", text1: "Notification Received", text2: item.text }, { success: function (pushResponse) { console.log("Sent Tile Square:", pushResponse); }, error: function (err) { console.log("Error sending tile:", err); } }); push.wns.sendToastImageAndText02(channel.uri, { image1src: baseImageUrl + "image2.png", text1: "Notification Received", text2: item.text }, { success: function (pushResponse) { console.log("Sent Toast:", pushResponse); } }); push.wns.sendBadge(channel.uri, { value: value, text1: "Notification Received" }, { success: function (pushResponse) { console.log("Sent Badge:", pushResponse); } });
이번에도 'console.log' 함수가 로그에 항목을 만듭니다. 이 로그는 Azure Mobile Services에서 확인할 수 있습니다. 그리고 위에서 타일 알림을 통해 표시된 로그 호출을 오류 처리기에 넣는 개발자들이 많습니다.
'send*' 메서드를 보면 각각 특정 템플릿에 연결되어 있는 것을 알 수 있습니다. 즉, 타일의 경우 와이드 및 정사각형 페이로드를 두 개의 알림으로 나누어 따로 보내야 한다는 의미입니다. 사용자가 시작 화면에서 어떤 타일 모양을 선택할지 알 수 없기 때문에 대부분의 경우 두 가지 크기로 다 보내야 한다는 점을 기억하십시오. 템플릿별 'push.wns'의 'send' 함수를 사용한다는 것은 호출 두 개를 순차적으로 만들어서 각 호출이 별도의 푸시 알림을 생성한다는 의미입니다.
다양한 태그를 이용하여 한 번에 여러 타일 업데이트를 보내거나 여러 알림을 보내는 다중 업데이트를 결합하려면 'push.wns.sendTile' 및 'push.wns.sendToast' 메서드를 사용합니다. 예를 들면 아래와 같습니다.
var channel = '{channel_url}'; push.wns.sendTile(channel, { type: 'TileSquareText04', text1: 'Hello' }, { type: 'TileWideText09', text1: 'Hello', text2: 'How are you?' }, { client_id: '{your Package Security Identifier}', client_secret: '{your Client Secret}' }, function (error, result) { // ... });
이보다 훨씬 하위 수준에서도 'push.wns.send'를 사용하여 알림 콘텐츠를 매우 정확하게 관리할 수 있으며 원시 알림에 사용할 수 있는 'push.wns.sendRaw'도 있습니다. 자세한 내용은 push.wns 개체 설명서를 참조하십시오.
Windows Azure Mobile Services를 사용한 실제 시나리오
Windows Azure Mobile Services를 사용한 타일, 알림 및 배지 푸시 알림의 샘플 앱은 데이터베이스 테이블에 삽입되는 새 메시지에 응답하여 푸시 알림을 보내는 방법을 잘 보여 줍니다. 하지만 이는 결국 앱이 자기 자신에게 푸시 알림을 보낸다는 의미가 되기 때문에 사용자의 다른 장치에 설치된 같은 앱에 알림을 보내는 경우 외에는 일반적으로 이렇게 할 필요가 없습니다.
그보다는 알림을 최종적으로 수신하는 앱/타일 외부에서 발생하는 이벤트에 응답하여 서비스가 푸시 알림을 보낼 확률이 훨씬 높습니다. 다음과 같은 몇 가지 시나리오를 고려해 볼 수 있습니다.
- 소셜 네트워크 사용:
앱에서 사용자의 소셜 네트워크를 사용하여 친구에게 도전장을 보내는 등의 기능을 구현할 수 있습니다. 예를 들어 한 사용자가 게임에서 신기록을 세우면 타일 업데이트 또는 알림을 통해 같은 게임을 설치한 친구들에게 도전장을 보낼 수 있습니다. 피트니스 앱에서도 같은 방법을 사용할 수 있습니다. 특정 종목에서 신기록이 나오면 기록을 게시하는 것입니다.
이렇게 하려면 앱에서 적절한 Mobile Service 테이블(Scores, BestTimes 등)에 새 레코드를 삽입할 수 있어야 합니다. 삽입 스크립트 내에서 서비스가 데이터베이스에 현재 사용자의 친구 중 조건에 맞는 친구를 쿼리하여 해당 채널 URI에 알림을 보냅니다. 추가 쿼리 조건은 보조 타일에 사용할 게임의 정확한 특징, 특별한 운동 종류 등을 설명합니다.
- 날씨 업데이트 및 알림:
날씨 앱은 일반적으로 앱의 기본 타일에 위치를 할당하고 보조 타일을 만들어서 위치를 추가로 할당할 수 있습니다. 각 타일에서 중요한 정보는 앱이 각 채널 URI와 함께 테이블에 삽입하여 서비스로 보내는 위도 및 경도입니다. 해당 채널로 업데이트를 트리거하려면 중앙의 날씨 서비스에 업데이트 및 알림을 주기적으로 쿼리한 후 응답을 처리하고 Mobile Service의 알림 테이블에 적절한 메시지를 삽입하는 또 다른 프로세스(아래에 설명된 예약된 작업 같은)가 서비스에 필요합니다. 그러면 삽입 스크립트가 적절한 채널 URI를 검색하여 업데이트를 보냅니다. 또는 날씨 서비스 자체에 알림이나 주기적 업데이트를 등록할 수 있다면 서비스의 또 다른 페이지에서 요청(HTTP PUT일 가능성이 가장 높음)을 받아서 처리하고 Mobile Service를 호출하여 레코드를 삽입할 수 있으며 그 결과로 업데이트가 트리거됩니다.
- 메시지:
인스턴트 메시지를 처리하는 방식은 날씨 업데이트를 받는 방식과 매우 흡사합니다. 위에서 설명한 대로, 다른 프로세스에서 새 메시지 수신을 모니터링하거나(들어오는 메시지를 주기적으로 검사하는 프로세스인 경우) 새 메시지가 도착하면 메시지 원본으로 알림을 등록합니다. 두 경우 모두 새 메시지가 도착하면 해당 채널에 푸시 알림이 트리거됩니다. 이 경우에 채널 URI는 특정 종류의 메시지에 사용되는 사용자 타일에 연결됩니다. 이 글의 앞부분에서 설명한 이메일 시나리오와 비슷한 상황이기 때문에 원시 알림을 사용하는 분들이 많을 것으로 생각됩니다.
어떤 시나리오에 해당하든 데이터베이스 테이블에 실제로 어떤 것도 삽입할 필요가 없습니다. 삽입 스크립트에서 'request.execute'를 호출하지 않으면 데이터베이스에는 아무런 변화도 없지만 스크립트 내에서 알림을 보내는 등의 기타 작업을 여전히 수행할 수 있습니다. 다시 말해서 나중에 사용할 일이 없는 레코드로 데이터베이스를 채울 필요가 없습니다. 괜히 데이터 저장 비용만 늘어날 뿐입니다.
Azure Mobile Services에는 작업을 예약하는 기능도 있습니다. 자세한 내용은 Mobile Services에서 반복 작업 예약하기를 참조하십시오. 이러한 작업은 다른 서비스의 데이터를 일상적으로 검색하여 처리하고 서비스의 데이터베이스에 레코드를 삽입하여 다시 푸시 알림을 트리거할 수 있습니다. 이 시리즈의 2부에서 살펴본 것처럼 다른 웹 페이지와 모바일 앱 역시 해당 데이터베이스를 변경하여 푸시 알림을 트리거할 수 있습니다. Mobile Service의 데이터베이스 스크립트는 이 모든 상황에서 실행됩니다.
결론
이제 정리해 보겠습니다. 지금까지 우리는 "생생한 활동 업데이트"가 사용자, 개발자, 앱 및 서비스에 어떤 의미를 주는지에 대해 처음부터 끝까지 살펴보았습니다. 타일, 배지 및 알림 업데이트의 기능, 주기적 알림을 설정하는 방법, 이 프로세스의 일환으로 백그라운드 작업을 사용하는 방법, 주기적 알림과 푸시 알림을 처리하는 서비스 구조에 대해 알아보았습니다. Windows Azure Mobile Services를 사용하면 푸시 알림 작업의 전체 프로세스를 완벽하게 파악할 수 있습니다. 서비스를 처음부터 작성할 필요가 없기 때문에 개발자의 생산성이 향상되는 것은 물론이고 여러 골치 아픈 상황에서 벗어날 수 있습니다.
Kraig Brockschmidt
Windows 에코시스템 팀 프로그램 관리자