Tag Archives: 구글

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

Android In-App Billing 서버사이드 보안 완벽 정리

google_developers_logo

이번에는 안드로이드 어플리케이션의 앱내 구매(In-App Billing)를 사용한 상품 구매시에 해킹 여부를 서버에서 검증하는 방법에 대해 정리해보겠습니다. 기존에 인터넷 상에 최종 사용자(End User) 기준의 구글 API 사용 방법에 대한 레퍼런스는 많은편인것 같으니 좀 더 사용이 용이한 서비스 계정(Service Account)를 이용한 결제 검증을 하는 방법에 대해 알아보겠습니다. 이 문서는 [Android In-App Billing 보안 완벽 정리]를 먼저 읽으신 다음에 읽으시길 권장드립니다.

여기서 사용하려는 방식은 구글 사용자의 계정 로그인 후 엑세스/리프레시 토큰을 관리 유지하면서 API를 사용하지 않고 구글이 제공하는 여러 인증 방식중에 하나인 서비스 계정으로 API를 바로 사용하는 방법입니다. 이를 위해서는 먼서 서비스 계정을 만들 필요가 있습니다. 해당 부분은 [Server to Server 어플리케이션을 위한 Google OAuth 2.0]에 정리해 두었으니 이 내용을 참고하셔서 서비스 계정을 우선 준비하시기 바랍니다. 이후의 내용은 이 서비스 계정이 준비된 상태라고 판단하고 진행을 하겠습니다.

서비스 계정 발급을 통해 준비가 되어야 하는 것은 크게 두가지 입니다.

  • 유니크한 구글이 발급한 이메일 주소
  • 구글이 생성한 P12 방식 비밀키 파일

구글 개발자 센터에서 서비스 계정 연결하기

준비에 문제가 없다면 구글 개발자 콘솔에 접속하여 현재 설정 상태가 구글 API를 호출하기에 문제가 없는지 점검을 해보겠습니다. 우선 설정 – API 액세스에 들어가 봅니다.

google_validation_iab_01

오른쪽에 나오는 정보중에 마지막으로 서비스 계정이라는 항목이 있습니다. 여기에 미리 추가했던 서비스 계정이 보여지게 됩니다. 엑세스 권한 부여 버튼을 눌러 해당 서비스 계정으로 안드로이드 관련 API를 호출 할 수 있도록 해 줍니다. 이미 권한이 부여되었다면 권한 보기 버튼이 있을 것입니다.

google_validation_iab_03

여기서 중요한것은 구매내역 및 영수증 검증을 하기 위해서는 재무 보고서 보기 권한이 필요하다는 부분입니다. 역할을 금융으로 선택해 주면 해당 권한이 자동으로 선택됩니다. 즉 영수증 검증을 위해서는 금융 역할을 갖는 서비스 계정이 필요합니다.

google_validation_iab_04

정상적으로 권한 설정이 된것을 확인할 수 있습니다. 이것으로 Google 쪽에서 필요한 작업은 모두 끝났습니다. 이과정은 하나의 안드로이드 개발자 당 한번만 수행하면 됩니다. 앱을 추가할때마다 하실 필요는 없습니다. 이제 코드를 보도록 하겠습니다.

구글 클라이언트 라이브러리를 이용하여 API 호출하기

복잡하게 토큰을 관리하며 HTTP/REST API를 사용할것 없이 구글이 제공하는 클라이언트 라이브러리를 사용하도록 합시다. [이곳]에서 자신의 언어에 맞는 라이브러리를 찾으실 수 있습니다. 여기서는 Java로 진행하도록 하겠습니다. Java 기준 필요한 라이브러리는 두가지입니다. google-api-client 와 google-api-services-androidpublisher 입니다. 저의 경우에는 gradle을 사용하여 예제 프로젝트를 만들어보았습니다. 다음과 같이 dependencies를 추가 해 주면 됩니다.

dependencies {
    compile 'com.google.api-client:google-api-client:1.21.0'
    compile 'com.google.apis:google-api-services-androidpublisher:v2-rev22-1.21.0'
}

이제 다음과 같은 방법으로 GoogleCredential을 생성해주도록 합시다. 여기서는 기존에 서비스 계정 발급을 통해 받은 이메일주소와 P12 비밀키 파일을 잘 지정해 주셔야 합니다.

String emailAddress = "my-new-service-account@api-project-000000.iam.gserviceaccount.com";

JsonFactory JSON_FACTORY = JacksonFactory.getDefaultInstance();
HttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
GoogleCredential credential = new GoogleCredential.Builder()
     .setTransport(httpTransport)
     .setJsonFactory(JSON_FACTORY)
     .setServiceAccountId(emailAddress)
     .setServiceAccountPrivateKeyFromP12File(new File("src/GooglePlayAndroidDeveloperPrivateKey.p12"))
     .setServiceAccountScopes(Collections.singleton("https://www.googleapis.com/auth/androidpublisher"))
     .build();

이제 특정 구글 앱내 구매를 처리하기 위해서 다음과 같은 간단한 코드만으로 API를 호출하고 결과를 확인할 수 있습니다.

String packageName = "pe.kr.theeye.trivialdrive";
String productId = "gas";
String purchaseToken = "njnbhmfid...AuSkyASqY";

AndroidPublisher publisher = new AndroidPublisher.Builder(httpTransport, JSON_FACTORY, credential)
	.setApplicationName(packageName)
	.build();

AndroidPublisher.Purchases.Products.Get get = publisher.purchases().products().get(packageName, productId, purchaseToken);
ProductPurchase productPurchase = get.execute();
System.out.println(productPurchase.toPrettyString());

// 인앱 상품의 소비 상태. 0 아직 소비 안됨(Yet to be consumed) / 1 소비됨(Consumed)
Integer consumptionState = productPurchase.getConsumptionState();

// 개발자가 지정한 임의 문자열 정보
String developerPayload = productPurchase.getDeveloperPayload();

// 구매 상태. 0 구매완료 / 1 취소됨
Integer purchaseState = productPurchase.getPurchaseState();

// 상품이 구매된 시각. 타임스탬프 형태
Long purchaseTimeMillis = productPurchase.getPurchaseTimeMillis();

받을 수 있는 결과의 예시는 다음과 같습니다. 정상적인 상품이라면 HTTP 200 OK가 떨어지며 다음과 같이 정상적인 상품의 정보와 consumptionState가 1이고 purchaseState가 0이게 됩니다.  consumptionState가 0이라면 개발자의 실수라던가 구매 과정에서 앱이 크래시되거나 다양한 이유가 있겠지만 어쨌든 결제 과정에서 오류가 발생했다고 보시면 될 것 같습니다.

{
  "consumptionState" : 1,	// 0 아직 컨슘 안됨, 1 컨슘됨
  "developerPayload" : "",
  "kind" : "androidpublisher#productPurchase",
  "purchaseState" : 0,	// 0 구매완료, 1 취소
  "purchaseTimeMillis" : "1454502702978"
}

purchaseState의 경우 결제 완료 상태의 경우 0으로 오게 되며 개발자가 환불을 해주거나 유저가 임의로 환불을 한 상태라면 1로 바뀌게 됩니다. 이게 즉시 바뀌지는 않고 몇분정도 딜레이가 있는것 같습니다.

만약에 위변조된 구매 토큰을 가지고 검증 요청을 하였다면 다음과 같은 오류 메시지를 받게 됩니다. HTTP 400번대의 오류코드가 떨어지게 됩니다. 인증관련 오류는 401이 존재하지 않는 구매 영수증의 경우 404가 떨어집니다.

{
  "code" : 404,
  "errors" : [ {
    "domain" : "global",
    "location" : "token",
    "locationType" : "parameter",
    "message" : "The purchase token was not found.",
    "reason" : "purchaseTokenNotFound"
  } ],
  "message" : "The purchase token was not found."
}

이런 오류의 경우 get.execute() 호출 할 때에 GoogleJsonResponseException 예외가 발생하니 관련하여 적절한 처리를 해주시면 됩니다.

참고 사항

Google Purchase API 버전별 차이점 (v1.1 / v2)

기존에 v1.1은 권한과 상관없이 아무앱의 결제 정보를 영수증값과 패키지명 등을 이용하여 조회가 가능했다고 들었지만 실제로 테스트 해본 결과 다른 앱의 정보를 조회하는것은 불가능했습니다. v1.1 및 v2 모두 다음과 같이 권한 오류가 발생하였습니다.

{
  "error": {
    "errors": [
      {
        "domain": "androidpublisher",
        "reason": "permissionDenied",
        "message": "The current user has insufficient permissions to perform the requested operation."
      }
    ],
    "code": 401,
    "message": "The current user has insufficient permissions to perform the requested operation."
  }
}

purchaseState의 값을 조회하면 현재 구매 완료된 상태인지 취소된 상태인지 여부를 확인할 수 있습니다. 하지만 v1.1에서는 무조건 0으로 나오는 등 정상적으로 값을 조회할 수 없었습니다. 결제 취소 여부를 조회할 필요가 있으시다면 필히 v2로 구현을 하시기를 추천드립니다.

쿼터 제약 사항

구글플레이 개발자 API를 사용하는 어플리케이션들은 초기 사용 할당량으로 하루에 (하나의 어플리케이션 당) 20만건을 부여받습니다. 이 정도의 수량이면 대부분의 일반적인 어플리케이션들에는 충분한 양이지만 만약에 더 높은 제한 조건이 필요할 경우 구글 개발자 콘솔의 “Request more” 링크를 사용해 주시기 바랍니다.

참고 :

https://stackoverflow.com/questions/11115381/unable-to-get-the-subscription-information-from-google-play-android-developer-ap
https://developers.google.com/android-publisher/api-ref/purchases/products