Tag Archives: 아이폰

iOS 쿠폰 시스템 구현 방법에 대한 고찰

현재 출시 되어있는 수많은 모바일 게임들을 살펴 보면 홍보를 목적으로 하는 쿠폰 시스템을 차용하고 있는것을 볼 수 있습니다. 이러한 쿠폰은 개발사에서 직접 발행한 문자/숫자로 이루어진 형태로 이루어져 있는 경우가 많으며 올아니 또는 오프라인의 실물 쿠폰을 만들어서 유저에게 뿌리고 있습니다. 가령 프렌즈팝의 경우 다음과 같은 모습의 오프라인 쿠폰이 있습니다.

ios_coupon_system_01

위의 쿠폰의 경우에는 이용방법을 읽어보면 “구글 플레이 혹은 애플 앱스토어에서” 라는 언급이 되어있습니다. 즉 이 쿠폰을 구글플레이/앱스토어에서 다운받은 프렌즈팝 게임에 사용할 수 있는다. 정도로 이해할수가 있습니다. 하지만 백발백중의 오프라인 쿠폰에는 다음과 같은 언급이 있습니다.

ios_coupon_system_02

“안드로이드 OS에서만 쿠폰을 입력할 수 있습니다”라는 부분이 중요합니다. 실제로 백발백중의 쿠폰을 사용하기 위해서 iOS판을 실행하여 설정 메뉴에 들어가 보면 다음과 같은 쿠폰 버튼이 존재하지 않습니다.

ios_coupon_system_03

iOS에서는 이 쿠폰을 사용할 수 없다는 이야기인데요 이는 단순히 개발자가 귀찮아서라는 이유는 아닐것입니다. 실제로 이렇게 쿠폰 시스템을 갖춘 게임/앱을 애플에 심사를 넣어보면 다음과 같은 리젝을 먹게 됩니다.

11.1

We found your app inappropriately unlocks or enables additional functionality with mechanisms other than the App Store, which is not in compliance with the App Store Review Guidelines.

Specifically, we noticed that the app utilizes codes to unlock features.

애플의 [리뷰 가이드라인]을 확인해 보면 11.1 항목에 다음과 같은 내용이 있습니다.

11. Purchasing and currencies

11.1
Apps that unlock or enable additional features or functionality with mechanisms other than the App Store will be rejected

앱스토어 이외의 어떤 매커니즘을 통해서 어떤 추가적인 기능이 언락되거나 활성화된다면 리젝될것이다..라는 무서운 언급인데요. 내가 만든 앱이지만 내 맘대로 하지도 못하게 하는 애플입니다. 결과적으로 앱스토어가 제공하는 기능들(예: In-App Purchase 등)을 제외한 모든 형태의 개발자가 개인적으로 구현한 유저에게 이익을 주는 행위는 리젝 사유입니다.

결론적으로 개발사가 임의로 구현한 쿠폰 시스템(예: 쿠폰 번호를 입력하면 다이아 500개 지급)은 애플에서 허용하지 않습니다. 하지만 iOS용 게임임에도 불구하고 쿠폰 시스템을 사용할 수 있는 게임을 볼 수 있습니다. 지금 부터 그런것을 어떻게 구현하면 될지 더 나아가 좀 더 나은 방법은 없을지 정리해 보겠습니다.

방법1. 서버에서 쿠폰 메뉴를 보여줄지 말지 결정하기

아마 이 방법이 많은 게임사들이 iOS에서 쿠폰 시스템을 구현하기 위해서 사용하는 방법이 아닐까 생각합니다. 게임뿐만 아니라 많은 앱들이 애플의 심사를 통과하는데 불필요한 화면들을 심사 기간동안 감추는 방법을 사용하고 있으며 나중에라도 애플이 걸리게 되면 좋은 일은 없겠지만 그럼에도 불구하고 많이들 사용하고 있는 방법입니다.

이 방법은 서버상에서 “심사” 플래그를 두고, 클라이언트는 실행되는 시점에 이 심사 플래그 값을 서버로부터 읽어갑니다. 심사 플래그가 참일 경우 클라이언트는 심사에 불리한 모든 기능을 보여주지 않는식으로 동작을 하게 합니다.

이후에 “Ready for Sale” 로 상태를 변경하면서 심사 플래그를 거짓으로 변경하면 일반 유저들은 온전히 모든 컨텐츠를 즐길 수 있게 됩니다.

방법2. 커스텀 스킴을 통해 앱 외부에서 쿠폰 정보를 넘겨받기

해외의 포럼글을 읽다가 매우 그럴싸한 글을 하나 발견하였습니다. 위에 언급한 서버에서 플래그를 두어서 쿠폰 메뉴를 보였다가 감추었다가 하는 방식은 애플의 불시 검문에 발각될(?) 가능성이 있습니다. 하지만 이러한 플래그를 두지 않고 앱 바깥에서 쿠폰지급을 처리한다면 문제가 다릅니다. 앱 내부에 쿠폰을 지급처리하는 어떠한 메뉴나 기능의 요소가 존재하지 않으며 바깥에서 지급처리를 하게 됨으로 애플의 불시 앱 검문에서도 문제가 되지 않습니다.

하지만 여기서도 생각해볼 문제가 하나 있습니다. 검증된 유저에게 한번의 지급만을 해야 한다면, 앱 외부에서 어떻게 해당 유저의 인증 여부를 명확하게 판단할 수 있을까요? 대부분의 인증 시스템의 구현은 앱 내부에서 구현되어있습니다. 하지만 이 부분은 앱 외부에서 쿠폰 정보를 들고 게임으로 넘어가는 방법으로 구현할 수 있습니다.

  1. 먼저 쿠폰 입력이 가능한 프로모션 페이지를 개발합니다. 이곳에는 일반적으로 게임 내부에서 볼 수 있는 쿠폰을 입력할 수 있는 폼이 존재하게 됩니다.
  2. 유저에게 전달될 쿠폰에 “구글플레이/앱스토어에서 XXX 게임을 검색해서 설치 후 설정 – 쿠폰 메뉴에서 다음의 코드를 입력” 이 문구 대신에 단순히 프로모션 페이지 URL에 접속하여 사용하라고만 안내합니다.
  3. 유저가 쿠폰입력 사이트에 접속하여 쿠폰을 입력한 뒤 “확인” 버튼을 누르면 앱이 설치되어있다면 해당 쿠폰 정보를 들고 앱을 실행하고 앱이 설치되어있지 않다면 앱 다운로드 URL로 이동시킵니다.
  4. 앱이 실행될 때 쿠폰 정보가 커스텀 스킴을 통해 입력되었다면 곧바로 지급 처리를 하고 팝업을 띄워 정상적으로 쿠폰 지급이 되었음을 알립니다.

대충 서버에서 쿠폰 페이지를 만든다면 예제는 다음과 같은 형태를 가질 것입니다.

<html>
<head>
  <script type="text/javascript">
    function formSubmit() {
		var couponValue = document.forms['couponForm']['coupon'].value;
		document.location = 'myCouponApp://?coupon=' + couponValue;
	}
  </script>
</head>
<body>
  <form name="couponForm" onsubmit="formSubmit(); return false;">
    쿠폰: <input type="text" name="coupon"/>
	<input type="submit" value="확인" />
  </form>
</body>
</html>

그리고 iOS의 경우 커스텀 스킴에서 저렇게 넘겨 받은 coupon의 값을 읽어서 아이템 지급 처리에 사용합니다. 다음의 메소드는 AppDelegate.m 파일에 추가해야할 부분입니다.

- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url
  options:(NSDictionary<NSString *,id> *)options {

    NSString *query = [url query];
    NSArray *queryComponents = [query componentsSeparatedByString:@"&"];
    
    for (NSString *component in queryComponents) {
        NSArray *keyValue = [component componentsSeparatedByString:@"="];
        if ([[keyValue objectAtIndex:0] isEqualToString:@"coupon"]) {
            NSString *couponValue = [keyValue objectAtIndex:1];
            NSLog(@"입력된 쿠폰번호 : %@", couponValue);
            
            // 이곳에서 사용자 정보를 가져와 아이템을 지급 처리 합니다.
            // 처리 결과를 팝업 형태로 띄워서 알려줍니다.
        }
    }
    
    return YES;
}

이곳의 처리는 앱 내부에서 진입하여 처리되는 부분이 아니며 앱의 외부를 통해 접근할 때 실행되는 로직의 영역입니다. 하지만 앱 내부이기 때문에 기 로그인되어있는 유저의 정보에도 접근이 가능합니다. 즉 “쿠폰정보”와 “사용자 정보” 둘 모두가 있기 때문에 정상적으로 상품 지급의 처리를 할 수 있습니다. 물론 중복 처리를 막는 처리도 할 수 있겠지요.

참고 :

  • https://forums.coronalabs.com/topic/37264-rejected-by-apple-cause-of-promo-code-custom-solution/
  • https://forums.coronalabs.com/topic/37264-rejected-by-apple-cause-of-promo-code-custom-solution/

GCM Topic을 이용해서 한번의 발송으로 단체 푸시를 발송하기

google_developers_logo

이 문서는 Google Cloud Messaging (GCM) 이라는 서비스를 사용하여 Android와 iOS에 푸시 노티피케이션을 손쉽게 대량 발송하는 방법에 대해 정리한 글입니다. 만약에 이 GCM에 대해 이해가 부족하시다면 다음의 글을 먼저 읽어보시길 권장 해 드립니다.

많은 중소 개발사들이 푸시 시스템을 구축하면서 고민하게 되는 부분이 대용량 푸시 노티피케이션 발송일 것입니다. 수많은 유료 서비스들도 존재하고 단체 푸시를 발송했을 때 도달율도 시원찮고 뭐가 문제인지도 모르겠고 자체 구축을 하자니 비용과 운영에 걱정이 앞서게 됩니다. 보내야 하는 유저가 백만명이라면? 천만명이라면? 자체 푸시 시스템을 구축해서 운영하기에는 벅찬 부분이 있을것입니다.

사실 구글이 제공하는 GCM만으로도 누구나 손쉽게 단체 푸시를 발송 할 수 있습니다. 대용량의 발송 시스템을 갖출 필요도 없고 심지어 서버 한대도 필요없습니다. GCM이 제공하는 메시지의 종류로는 크게 4가지가 있습니다.

gcm_downstream_messaging

첫번째로 다운스트림 메시지(Downstream Message)가 있습니다. 이것이 우리가 일반적으로 말하는 푸시 메시지입니다. 서버가 GCM 커넥션 서버에 메시지를 전달하면 GCM 커넥션 서버는 적당한 시점에 혹은 그 즉시 디바이스에 전달을 하게 됩니다.

gcm_upstream_messaging

두번째로 업스트림 메시지(Upstream Message)가 있습니다. 이것은 반대의 개념입니다. 클라이언트 앱에서 서버로 메시지를 발송하게 됩니다. 이때에 서버는 기존에 흔하게 사용하는 방식인 HTTP를 이용하지 않고 XMPP라는 프로토콜을 이용하여 구축하여야 합니다. 이 방식은 XMPP를 구현한 인스턴스가 지속적으로 GCM서버에 연결되어 메시지를 수신하는 형태로 동작합니다.

세번째로 디바이스 그룹 메시지(Device Group Message)가 있습니다. 각각의 디바이스들은 특정 그룹에 가입하고 서버는 해당 그룹의 키로 메시지를 발송하면 해당 그룹에 가입되어있는 모든 디바이스들이 그 메시지를 수신받게 됩니다. 이것만 해도 정말 많은것을 구현할 수 있을것 같습니다.

gcm_device_group_messaging

마지막으로 토픽 메시지(Topic Message)가 있습니다. 이 글에서 말하고 싶은 내용입니다. 일명 Publish – Subscribe 패턴을 가지는 방식으로 디바이스가 특정 주제를 구독하며 해당 주제에 푸시를 발송하면 구독하는 모든 디바이스가 메시지를 수신하게 됩니다.

이미 느낌이 오셨겠지만 모든 디바이스가 같은 하나의 토픽을 구독하게 하고(예: 공지사항) 해당 토픽에 푸시를 발송하면 모든 디바이스에 해당 푸시 메시지가 도착하게 됩니다. 단지 전체 공지를 하기 위해서 대용량 발송 시스템을 구축하는것보다는 이쪽이 더 좋은 방법일 것입니다. 심지어 Android뿐만 아니라 iOS푸시까지 GCM이 지원을 해줍니다. 정말 말도 안되는것 같지만 어떻게 하는것인지 한번 알아보겠습니다.

Android 디바이스 대상 푸시 메시지 발송을 위한 구글 서비스 가입

사실 구글의 개발자 콘솔 웹사이트는 복잡하고 어렵습니다. 그걸 의식했는지 굉장히 이쁜 서비스를 만들었네요. GCM 푸시 서비스를 이용하기 위해서 우선 기존에 이미 발급한 키가 있으시다면 그것을 사용하시면 됩니다만 여기서는 이 이쁜 사이트를 이용하여 새로운 키를 발급 받아 보도록 하겠습니다. [이곳]을 방문하도록 합니다.

gcm_topic_usage_01

위의 스크린샷과 같은 화면이 등장하게 됩니다. 여기서 “Pick a platform” 버튼을 눌러 원하는 플랫폼을 선택하는 화면으로 이동해 보겠습니다.

gcm_topic_usage_02

우선 여기서는 Android 앱부터 구현을 해볼 예정입니다. 그러므로 “Android App”을 선택해 주도록 합시다.

gcm_topic_usage_03

그럼 기존에 이미 만들어져 있는 프로젝트를 선택할 수도 있고 새로 만들 수도 있는 창이 등장합니다. 일단 저는 새로운 프로젝트를 생성해 보도록 하겠습니다. 새로 만들 안드로이드 프로젝트의 이름은 “My GCM Example”이로 패키지네임은 “kr.pe.theeye.gcm.example.android”로 하겠습니다. 밑에 구글의 개발자 서비스를 이용하는 데이터를 구글에게 제공할 것인가 체크하는 부분이 있는데 생각하시는데로 하시면 될 것 같습니다. 그 밑으로 아래의 버튼을 누르면 약관에 동의가 된다고 하는데요 상관없이 “Choose and configure services” 버튼을 눌러 계속 진행하도록 하겠습니다.

gcm_topic_usage_04

활성화 가능한 서비스들의 목록이 나옵니다. 실제로 제공되는 서비스가 훨씬 많지만 이 이쁜 사이트에서는 이정도만 설정 가능한 모양입니다. GCM을 사용하기 위해서는 “Cloud Messaging”을 선택해 주시면 됩니다.

gcm_topic_usage_05

이렇게 화면이 확장되는데 “ENABLE GOOGLE CLOUD MESSAGING”을 선택해 주도록 합니다.

gcm_topic_usage_06

이런식으로 새로운 “Server API Key”와 “Sender ID”가 발급된 것을 확인할 수 있습니다. 이 부분 밑으로 다음과 같은 버튼이 등장합니다.

gcm_topic_usage_07

기존의 GCM을 구현할 때와 달리 이제는 Play Services 들에 대해 단체로 설정 파일을 내려주는 방식으로 변경되었습니다. “Generate configuration files”를 선택하여 계속 진행하도록 합시다.

gcm_topic_usage_08

위와 같이 다운로드 버튼이 보여지게 됩니다. 해당 버튼을 누르게 되면 google-services.json 파일을 다운받게 됩니다. 설명에 보면 이 파일을 app/ 또는 mobile/ 디렉토리 이하로 복사해서 사용하라고 하는군요.

메시지 수신을 위한 Android 앱 구현하기

gcm_topic_usage_09

이번엔 안드로이드 스튜디오로 넘어와보겠습니다. 프로젝트 하이라키에서 app을 선택하고 마우스 오른쪽 클릭을 해보면 “Reveal in Finder”라는 메뉴가 나옵니다. 저것을 눌러서 어찌되었던 app 디렉토리로 이동합니다.

gcm_topic_usage_10

아까 다운로드 받은 파일을 이곳으로 복사해 줍니다. 프로젝트의 app 디렉토리 안쪽인것을 유의 해 주세요. 복사를 정상적으로 하였다면 다시 안드로이드 스튜디오로 돌아옵니다. 이제부터 필요한 구글의 라이브러리들을 추가해 주도록 하겠습니다.

gcm_topic_usage_11

먼저 build.gradle 파일을 수정해야 하는데 위와 같이 두개의 build.gradle 파일이 존재하는것을 볼 수 있습니다. 자세히 보시면 하나는 Project 용이고 하나는 Module용입니다. 프로젝트용 파일을 먼저 수정하겠습니다.

buildscript {
    repositories {
        jcenter()
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:1.5.0'
        classpath 'com.google.gms:google-services:1.5.0-beta2'
    }
}

allprojects {
    repositories {
        jcenter()
    }
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

위의 내용을 보시면 루트에 “classpath ‘com.google.gms:google-services:1.5.0-beta2′” 가 추가되어있는것을 확인하실 수 있습니다. 현재 1.5.0-beta2가 최신인데 이 글을 읽는 시점에 맞는 적절한 버전을 선택하시면 됩니다.

이번에는 Module용 build.gradle 파일을 설정 해 보겠습니다.

apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services'

android {
    compileSdkVersion 23
    buildToolsVersion "23.0.2"

    defaultConfig {
        applicationId "kr.pe.theeye.gcm.example.android"
        minSdkVersion 14
        targetSdkVersion 23
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    compile "com.google.android.gms:play-services:8.3.0"
    compile 'com.android.support:appcompat-v7:23.2.0'
}

여기서는 다음의 추가 작업을 해주었습니다.

  • 루트에 apply plugin: ‘com.android.application’ 추가
  • dependencies 에 compile “com.google.android.gms:play-services:8.3.0” 추가
  • dependencies 에 compile “com.android.support:appcompat-v7:23.2.0” 추가

이 부분 역시 시대에 맞는 적절한 버전을 선택 해 주시면 됩니다. 이번에는 AndroidManifest.xml 파일의 내용을 한번 보시겠습니다.

<?xml version="1.0" encoding="utf-8"?>
<manifest package="kr.pe.theeye.gcm.example.android"
          xmlns:android="http://schemas.android.com/apk/res/android">

    <permission android:name="kr.pe.theeye.gcm.example.android.permission.C2D_MESSAGE"
                android:protectionLevel="signature" />
    <uses-permission android:name="kr.pe.theeye.gcm.example.android.permission.C2D_MESSAGE" />
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:supportsRtl="true"
        android:theme="@style/AppTheme">

        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <receiver
            android:name="com.google.android.gms.gcm.GcmReceiver"
            android:exported="true"
            android:permission="com.google.android.c2dm.permission.SEND" >
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
                <action android:name="com.google.android.c2dm.intent.REGISTRATION" />
                <category android:name="kr.pe.theeye.gcm.example.android" />
            </intent-filter>
        </receiver>
        <service
            android:name=".MyGcmListenerService"
            android:exported="false" >
            <intent-filter>
                <action android:name="com.google.android.c2dm.intent.RECEIVE" />
            </intent-filter>
        </service>
        <service
            android:name=".MyInstanceIDListenerService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.android.gms.iid.InstanceID" />
            </intent-filter>
        </service>
        <service
            android:name=".RegistrationIntentService"
            android:exported="false">
        </service>
    </application>

</manifest>

이 설정을 보시고 알아두셔야 하는 부분은 다음과 같습니다.

  • 이 프로젝트의 패키지 네임은 kr.pe.theeye.gcm.example.android 입니다.
  • <패키지 네임> + permission.C2D_MESSAGE 퍼미션은 다른 앱이 디바이스를 등록하거나 메시지를 수신하는것을 막아줍니다. 이 퍼미션 이름은 정확하게 위와 같은 형태여야 합니다. 그렇지 않을 경우 메시지를 수신받을 수 없게 됩니다.
  • GCM이 발송한 메시지를 처리하기 위해서 GcmReceiver를 선언하게 됩니다. 이 서비스가 GCM 으로부터 메시지를 수신받기 위한 퍼미션이 필요하므로 com.google.android.c2dm.permission.SEND 퍼미션을 이 리시버에게 추가해 주어야 합니다. (라고 레퍼런스에 써있긴한데 왜 수신을 하기 위해서 송신 퍼미션을 추가하는지는 저도 이해가 잘 안됩니다)
  • GcmListenerService를 상속하여 구현한 MyGcmListenerService는 다양한 형태의 다운스트림 메시지를 처리한다거나 업스트림 메시지의 발송 상태를 지정하거나 앱을 대신에서 자동으로 보여지게 될 노티피케이션을 직접 받아서 처리할 수 있게 해줍니다.
  • InstanceIDListenerService를 상속하여 구현한 MyInstanceIDListenerService는 Registration Token의 신규 발급, 순환, 업데이트를 처리해 줍니다.
  • 부가적으로 android.permission.WAKE_LOCK 퍼미션을 추가함으로 써 디바이스가 슬립 상태에서도 메시지를 받았을 때 깨우는등의 작업을 할 수 있게 됩니다.
  • 만약 이 GCM 기능이 어플리케이션에 중요한 요소라면 반드시 android:minSdkVersion=”8″ 또는 그 이상의 값을 설정하여 주십시오. 이 값이 설정되지 않을 경우 GCM이 정상적으로 동작할 것이라는 보장을 할 수 없게 됩니다.

먼저 메시지를 수신할 때 노티피케이션을 등록하는 코드를 직접 구현한 MyGcmListenerService를 보겠습니다. 노티피케이션은 iOS의 APNS 규격에 Android가 맞춰가는 방향으로 진행하기 위해서 title, body를 읽어서 쓰는것으로 진행하겠습니다.

public class MyGcmListenerService extends GcmListenerService {

    private static final String TAG = "MyGcmListenerService";

    @Override
    public void onMessageReceived(String from, Bundle data) {
        Log.i(TAG, "GCM Message Received From: " + from);
        Log.i(TAG, "GCM Message Bundle: " + data.toString());

        Intent intent = new Intent(this, MainActivity.class);
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
        PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_ONE_SHOT);

        Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
        NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this)
                .setSmallIcon(R.drawable.ic_setting_light)
                .setContentTitle(data.getString("gcm.notification.title"))
                .setContentText(data.getString("gcm.notification.body"))
                .setAutoCancel(true)
                .setSound(defaultSoundUri)
                .setContentIntent(pendingIntent);

        NotificationManager notificationManager =
                (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);

        notificationManager.notify(0, notificationBuilder.build());
    }
}

다음은 Registration 발급, 업데이트를 담당하는 MyInstanceIDListenerService를 보겠습니다. 토큰이 갱신될 때 마다 onTokenRefresh가 호출되어 토큰의 교체를 담당하게 됩니다.

public class MyInstanceIDListenerService extends InstanceIDListenerService {

    @Override
    public void onTokenRefresh() {
        startService(new Intent(this, RegistrationIntentService.class));
    }
}

위의 코드를 보면 단지 RegistrationIntentService 서비스를 실행하는것으로 끝나는것을 보실 수 있습니다. 이 RegistrationIntentService 에서 토큰의 발급을 진행하게됩니다.

public class RegistrationIntentService extends IntentService {

    private static final String TAG = "RegistrationIntentService";
    private static final String GCM_DEFAULT_SENDER_ID = "813017546046";
    private static final String GCM_TOPIC_FOR_NOTICE = "notice-for-all";

    public RegistrationIntentService() {
        super(TAG);
    }

    @Override
    protected void onHandleIntent(Intent intent) {

        try {
            InstanceID instanceID = InstanceID.getInstance(this);
            String token = instanceID.getToken(GCM_DEFAULT_SENDER_ID, GoogleCloudMessaging.INSTANCE_ID_SCOPE, null);

            Log.i(TAG, "GCM Registration Token: " + token);

            GcmPubSub pubSub = GcmPubSub.getInstance(this);
            pubSub.subscribe(token, "/topics/" + GCM_TOPIC_FOR_NOTICE, null);

        } catch (IOException e) {
            Log.e(TAG, e.getLocalizedMessage());
        }
    }
}

이 RegistrationIntentService를 보시면 단지 InstanceID.getToken(…) 메소드를 호출함으로써 Registration Token을 발급하는것을 보실 수 있습니다. 또한 여기서 굉장히 중요한 부분으로 GcmPubSub을 통해서 토큰을 발급받자 마자 “/topics/notice-for-all” 이라는 이름의 토픽을 등록하는 것을 확인하실 수 있습니다.

RegistrationIntentService는 MyInstanceIDListenerService를 통해서 토큰이 갱신될때마다 자동으로 실행되어 새로운 토큰을 다운받게 됩니다. 여기서 발급한 토큰을 잘 보관하거나 개발사의 서버로 전송하는 기능을 구현해야 할 것입니다. 하지만 토픽 메시지의 경우 구독을 하는것만으로 푸시 메시지를 수신받을 수 있으므로 과감하게 토큰정보를 저장하지 않겠습니다.

하지만 MyInstanceIDListenerService는 이미 GCM의 토큰을 한번 발급받은 상태에서 돌아가는 경우에 동작하게 될 서비스입니다. 최초에 한번은 GCM의 토큰을 수작업으로 발급 받아야 합니다. 가장 기본이 될 MainActivity를 한번 보겠습니다.

public class MainActivity extends AppCompatActivity {

    private static final int PLAY_SERVICES_RESOLUTION_REQUEST = 9000;
    private static final String TAG = "MainActivity";

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (checkPlayServices()) {
            Log.d(TAG, "Start RegistrationIntentService");
            Intent intent = new Intent(this, RegistrationIntentService.class);
            startService(intent);
        }
    }

    private boolean checkPlayServices() {
        GoogleApiAvailability apiAvailability = GoogleApiAvailability.getInstance();
        int resultCode = apiAvailability.isGooglePlayServicesAvailable(this);
        if (resultCode != ConnectionResult.SUCCESS) {
            if (apiAvailability.isUserResolvableError(resultCode)) {
                apiAvailability.getErrorDialog(this, resultCode, PLAY_SERVICES_RESOLUTION_REQUEST).show();
            } else {
                Log.i(TAG, "This device is not supported.");
                finish();
            }
            return false;
        }
        return true;
    }
}

checkPlayServices 메소드는 이 디바이스가 Google Play Services 가 동작하는 환경인지를 확인하는 역할을 합니다. 가령 중국의 폰들은 Google Play가 설치되어있지 않은데 여기서 오류가 날 것입니다. 문제가 없다면 RegistrationIntentService를 정상적으로 호출하는것을 보실 수 있습니다.

이곳에서 최초 한번 RegistrationIntentService를 호출해 주면 MyInstanceIDListenerService 를 통해서 토큰이 관리되게 됩니다. 그리고 메시지의 수신은 MyGcmListenerService에서 처리할 수 있습니다. 한번 토픽 메시지 발송을 해보겠습니다.

POST https://gcm-http.googleapis.com/gcm/send
Content-Type:application/json
Authorization:key=AIzaSyZ-1u...0GBYzPu7Udno5aA

{
  "to": "/topics/notice-for-all",
  "notification" : {
      "body" : "This is a GCM Topic Message!",
      "title" : "GCM Title"
    }
}

RegistrationIntentService에서 토큰을 발급 받자 마자 /topics/notice-for-all 이라는 토픽을 구독하도록 하였는데요. 이 토픽으로 메시지를 발송해 보면 잘 도착하는것을 확인하실 수 있습니다.

iOS 디바이스 대상 푸시 메시지 발송을 위한 구글 서비스 추가

이번에는 Android, iOS 구분 없이 전체 디바이스에 푸시 발송을 할 수 있는지 알아보겠습니다. 언제부터인지 모르겠지만 GCM은 애플의 APNS를 통해서 푸시 메시지를 발송하는것도 가능하게 되었습니다. 처음에는 구글이 오버한다고 생각했었습니다만 생각해 보니깐 개발자 입장에서는 GCM 대응만 하면 애플의 디바이스에게도 메시지를 보낼 수 있기 때문에 굉장히 좋은 기능이라 생각되었습니다.

이경우 Android와 달리 애플의 APNS 발송용 인증서를 발급 받아야 합니다. 여기서는 푸시 노티피케이션(Push Notifications)용 인증서 발급과 프로비저닝 프로필(Provisioning Profile) 발급 과정은 생략하겠습니다.

gcm_topic_usage_13

정상적으로 등록을 하였다면 App IDs 메뉴에서 위와 같이 정상적으로 인증서가 발급된것을 확인할 수 있습니다. Development용 Production용 인증서를 모두 발급하였습니다.

gcm_topic_usage_14

이렇게 발급받은 인증서를 다운받아서 더블클릭 하면 키체인에 등록이 됩니다. 이와 같이 키체인 어플리케이션에서 정상적으로 인증서가 발급되었는지 확인해 봅니다. 문제가 없으면 다음과 같이 P12 형식으로 보내기를 해야 합니다.

gcm_topic_usage_16

본인이 생각하는 적절한 비밀번호를 설정하셔서 적당한 이름의 파일로 저장하시면 됩니다. Development, Production 둘 모두 보내기를 합니다. 이제 아까 Android 테스트를 위한 구글 서비스 가입을 했던 사이트에 방문해 보겠습니다. [이곳]으로 가시면 됩니다.

gcm_topic_usage_15

이번에는 iOS 플랫폼을 선택하여 새로운 프로젝트를 생성하지 말고 이미 존재하는 “My GCM Example”을 선택하도록 하겠습니다. “iOS Bundle ID”의 경우에는 “kr.pe.theeye.gcm.example.ios”로 하였습니다.

gcm_topic_usage_17

아까 보내기로 저장했던 두개의 P12 푸시 인증서를 모두 등록합니다. 등록 과정에서 인증서 비밀번호를 물어오게 됩니다. 정상적으로 등록 되었다면 Android 때와 마찬가지로 설정 파일을 다운로드 받도록 합니다.

gcm_topic_usage_18

다운로드 화면이 Android와 조금 다른것을 볼 수 있습니다. 구글이 제공하는 SDK를 사용하기 위해서 CocoaPods를 사용하라는 언급이 있습니다. iOS 개발자 분들에게 CocoaPods은 익숙한 툴일 것입니다. 이번에 다운로드 받을 설정 파일은 JSON 형태가 아닌 GoogleService-Info.plist 파일입니다.

메시지 수신을 위한 iOS 앱 구현하기

이번에는 iOS 앱을 개발해 보도록 하겠습니다. Swift로 구현하면 좋겠지만 아직까지 Objective C로 구현된 프로젝트가 많다고 생각되어 일단 Objective C로 구현해 보도록 하겠습니다. 만들어진 프로젝트의 모습은 다음과 같습니다.

gcm_topic_usage_19

이 프로젝트에서 뷰는 중요한것이 아니므로 대충 넘어가겠습니다. 번들 아이디가 kr.pe.theeye.gcm.example.ios 로 설정 되어있는것을 확인하실 수 있습니다. 위에서 발급받은 GoogleService-Info.plist 파일을 이 프로젝트에 추가해 주시기 바랍니다.

여기서 이 프로젝트를 그대로 개발하는 것이 아니라 CocoaPods 프로젝트로 변경해야 합니다. 터미널을 열어 이 프로젝트가 설치된 디렉토리로 이동합니다. 프로젝트의 루트 디렉토리에서 다음의 명령을 사용하여 기본 Podfile을 생성합니다.

$ pod init

GCM을 iOS 프로젝트에서 사용하기 위해서 생성된 Podfile을 열어 다음을 추가해 줍니다.

$ pod 'Google/CloudMessaging'

마지막으로 필요한 라이브러리를 자동으로 설치하기 위해서 다음의 명령을 수행합니다.

$ pod install

이 명령의 수행 결과로 .xcworkspace 파일이 생성됩니다. Xcode로 기존의 프로젝트 파일이 아닌 이 파일을 열어 보도록 합니다.

$ ls
GcmTopicExample/           GcmTopicExample.xcodeproj/ Podfile
$ pod install
Updating local specs repositories
Analyzing dependencies
Downloading dependencies
Installing GGLInstanceID (1.1.6)
Installing Google (1.3.2)
Installing GoogleCloudMessaging (1.1.3)
Installing GoogleIPhoneUtilities (1.1.1)
Installing GoogleInterchangeUtilities (1.1.0)
Installing GoogleNetworkingUtilities (1.0.0)
Installing GoogleSymbolUtilities (1.0.3)
Installing GoogleUtilities (1.1.0)
Generating Pods project
Integrating client project

[!] Please close any current Xcode sessions and use `GcmTopicExample.xcworkspace` for this project from now on.
Sending stats
Pod installation complete! There is 1 dependency from the Podfile and 8 total pods installed.

기존의 프로젝트가 아닌 Pods 프로젝트를 포함한 워크스페이스가 열린것을 확인하실 수 있습니다. iOS의 디바이스가 비활성화 상태일 때 GCM이 푸시 메시지를 전송하기 위해서는 iOS의 Silent Push 라는 기능을 사용합니다. 앱이 활성화 상태가 아닐때라도 백그라운드 프로세스에서 처리할 수 있게 됩니다.

Info.plist 파일에 다음과 같은 설정을 추가해 줍니다.

<key>UIBackgroundModes</key>
<array>
    <string>remote-notification</string>
</array>

정상적으로 추가가 되었다면 다음과 같이 추가가 된 것을 확인하실 수 있습니다.

gcm_topic_usage_20

iOS의 코드는 확인하기 용이하도록 필요한 GCM 코드들을 AppDelegate.m 파일에 모두 정리해 보았습니다.

#import "AppDelegate.h"
#import <Google/CloudMessaging.h>

@interface AppDelegate () <GGLInstanceIDDelegate, GCMReceiverDelegate>

@property(nonatomic, strong) NSDictionary *options;
@property(nonatomic, strong) GGLInstanceIDTokenHandler registrationHandler;
@property(nonatomic, strong) NSString* registrationToken;

@end

NSString * const GCM_DEFAULT_SENDER_ID = @"813017546046";
NSString * const GCM_TOPIC_FOR_NOTICE = @"/topics/notice-for-all";

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

    // APNS 노티피케이션 토큰 발급 요청
    UIUserNotificationType allNotificationTypes = (UIUserNotificationTypeSound | UIUserNotificationTypeAlert | UIUserNotificationTypeBadge);
    UIUserNotificationSettings *settings = [UIUserNotificationSettings settingsForTypes:allNotificationTypes categories:nil];
    [[UIApplication sharedApplication] registerUserNotificationSettings:settings];
    [[UIApplication sharedApplication] registerForRemoteNotifications];
    
    // GCM Registration Token 발급시 결과 처리 핸들러
    __weak typeof(self) weakSelf = self;
    self.registrationHandler = ^(NSString *registrationToken, NSError *error) {
        if (registrationToken != nil) {
            NSLog(@"Registration Token: %@", registrationToken);
            weakSelf.registrationToken = registrationToken;
        } else {
            NSLog(@"Registration to GCM failed with error: %@", error.localizedDescription);
        }
    };
    
    // GCM 시작
    GCMConfig *gcmConfig = [GCMConfig defaultConfig];
    gcmConfig.receiverDelegate = self;
    [[GCMService sharedInstance] startWithConfig:gcmConfig];
    
    return YES;
}

- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
    
    // APNS에 등록된 디바이스 정보를 GCM에 재등록
    self.options = @{kGGLInstanceIDRegisterAPNSOption:deviceToken,
                     kGGLInstanceIDAPNSServerTypeSandboxOption:@YES};
    
    GGLInstanceIDConfig *instanceIDConfig = [GGLInstanceIDConfig defaultConfig];
    instanceIDConfig.delegate = self;
    [[GGLInstanceID sharedInstance] startWithConfig:instanceIDConfig];
    [[GGLInstanceID sharedInstance] tokenWithAuthorizedEntity:GCM_DEFAULT_SENDER_ID
                                                        scope:kGGLInstanceIDScopeGCM
                                                      options:self.options
                                                      handler:self.registrationHandler];
}

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo {
    
    // 메시지 수신시의 처리
    NSLog(@"Notification received: %@", userInfo);
    [[GCMService sharedInstance] appDidReceiveMessage:userInfo];
    
    NSDictionary *aps = [userInfo objectForKey:@"aps"];
    NSDictionary *alert = [aps objectForKey:@"alert"];
    if (alert) {
        NSString *title = [alert objectForKey:@"title"];
        NSString *body = [alert objectForKey:@"body"];
        
        [[[UIAlertView alloc] initWithTitle:title message:body delegate:self
                          cancelButtonTitle:@"OK" otherButtonTitles:nil] show];
    }
}

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
    
    // 앱이 유휴 상태일 때 메시지를 받을 경우의 처리
    [self application:application didReceiveRemoteNotification:userInfo];
    completionHandler(UIBackgroundFetchResultNoData);
}

- (void)onTokenRefresh {
    
    // GCM Registration Token이 갱신되었을 때
    NSLog(@"The GCM registration token needs to be changed.");
    [[GGLInstanceID sharedInstance] tokenWithAuthorizedEntity:GCM_DEFAULT_SENDER_ID
                                                        scope:kGGLInstanceIDScopeGCM
                                                      options:self.options
                                                      handler:self.registrationHandler];
}

- (void)applicationDidBecomeActive:(UIApplication *)application {
    
    // 앱이 활성화 되었을 때 GCM에 연결
    [[GCMService sharedInstance] connectWithHandler:^(NSError *error) {
        if (error) {
            NSLog(@"Could not connect to GCM: %@", error.localizedDescription);
        } else {
            NSLog(@"Connected to GCM");
            
            // 토픽 구독 등록
            [[GCMPubSub sharedInstance] subscribeWithToken:self.registrationToken topic:GCM_TOPIC_FOR_NOTICE options:nil handler:^(NSError *error) {
                if (error) {
                    if (error.code == 3001) {
                        NSLog(@"Already subscribed to %@", GCM_TOPIC_FOR_NOTICE);
                    } else {
                        NSLog(@"Subscription failed: %@", error.localizedDescription);
                    }
                } else {
                    NSLog(@"Subscribed to %@", GCM_TOPIC_FOR_NOTICE);
                }
            }];
        }
    }];
}

- (void)applicationDidEnterBackground:(UIApplication *)application {
    
    // 앱이 비활성화 되었을 때 GCM 연결 해제
    NSLog(@"Disconnected from GCM");
    [[GCMService sharedInstance] disconnect];
}

위의 코드는 iOS 환경에서 GCM이 동작하는것을 보여주기 위한 간단한 예제일뿐이며 본인의 앱에 맞게 커스터마이징 하여 사용하시면 됩니다. 이부분에서 특히 중요한 부분은 application:didRegisterForRemoteNotificationsWithDeviceToken: 내부에 있는 옵션을 설정하는 부분입니다.

  • kGGLInstanceIDRegisterAPNSOption : APNS에 디바이스 등록이 성공하고 발급받은 deviceToken을 이곳에 넣어줍니다. 이것을 가지고 GCM에서 자신들의 Registration Token과 매핑한 뒤 발급하는 과정을 거치게 됩니다.
  • kGGLInstanceIDAPNSServerTypeSandboxOption : 구글 개발자 콘솔에 두개의 APNS 인증서를 등록했던것을 기억하실 것입니다. 어떤 인증서를 사용하여 발송할 것인지 결정하는 옵션입니다. Development 인증서를 통해 메시지를 수신받기 위해 YES로 설정하였습니다.

코드를 보면 앱이 활성화 되었을 때와 비활성화 되었을 때 GCM과 연결했다가 끊는것을 반복하는것을 볼 수 있습니다. 앱이 백그라운드에 진입하면 기존에 연결되었던 네트워크 커넥션이 OS에 의해 강제로 끊어지게 되지만 애플이 이것이 나쁜 경우라고 판단하여 심사과정에서 리젝을 하는 사례가 있다고 합니다. 구글에서는 disconnect를 구현할 것을 권장하고 있습니다.

이제 최종적으로 Android와 iOS 두개의 디바이스에 토픽 메시지를 한번만 사용하여 메시지 발송이 되는지 확인해 보겠습니다. 기존에 사용하였던 POST 요청을 재활용 해보겠습니다.

POST https://gcm-http.googleapis.com/gcm/send
Content-Type:application/json
Authorization:key=AIzaSyZ-1u...0GBYzPu7Udno5aA

{
  "to": "/topics/notice-for-all",
  "notification" : {
      "body" : "This is a GCM Topic Message!",
      "title" : "GCM Title"
    }
}

GCM을 통해서 Android에 발송하는것은 자유로운 편입니다. 토픽 메시지를 통해서도 노티피케이션(Notification)뿐만 아니라 데이터(Data) 역시 자유롭게 보낼 수 있고 핸들링 할 수 있다는것을 확인하였습니다. 하지만 iOS의 경우에는 APNS의 룰에 맞춘 노티피케이션 환경에서 정상적으로 동작하는것을 확인하였습니다.

앱이 GCM에 연결된 상태에서는 데이터의 전송에도 무리가 없었지만 앱이 유휴 상태일 경우 노티피케이션만이 전달되는것을 확인하였습니다. 물론 앱이 GCM에 연결되는 순간 데이터의 값들도 수신이 되었습니다만 iOS와 Android에서 둘다 동일한 동작을 하도록 보이기 위해서는 노티피케이션과 데이터를 적절히 혼합하여 사용하면 될 것 같습니다.

단순히 공지 메시지를 푸시로 전달하고자 하는것이라면 위와 같이 notification: { “body”: “…”, “title”: “…” } 정도로만 사용하셔도 전혀 문제가 안될 것 같습니다.

gcm_topic_usage_results

토픽 /topics/notice-for-all 를 구독중인 모든 디바이스에 한번의 발송으로 Android, iOS 구분없이 도착하는것을 확인할 수 있었습니다.

끝으로 아마 이렇게 한번에 보낼 수 있는 디바이스에 제한이 있다거나 토픽 구독 인원에 제한이 있을까 궁금하실텐데요 토픽 메시지가 처음에 나왔을때는 토픽당 100만명만 구독이 가능한 제한이 있었지만 지금은 무제한입니다.

참고 : https://developers.google.com/cloud-messaging/gcm