Tag Archives: Security

Android In-App Billing 보안 완벽 정리

(2015.11.24일 추가) In-App Billing 상품의 타입에 대한 변경 사항

현재 글에서 설명되는 상품의 타입중에 관리되지 않는 제품 (Consumable/Unmanaged Product)는 언제부터였는지는 정확하지 않지만 현재 제거된 상태입니다. 이제는 모든 앱내 상품들은 Google Play에 의해 관리되게 됩니다. 기본적으로 모든 관리되는 제품은 구매가 성공적으로 이루어졌을 때 보유(Owned) 상태가 됩니다. 이 상태에서는 Google Play를 통해 같은 상품을 중복 구매할 수 없게 됩니다.

스크린샷 2015-11-24 오후 12.16.31

이 보유상태의 제품을 consumePurchase() 를 호출하여 소진(Consume)하여 다시한번 비보유(Unowned) 상태로 되돌릴 수 있습니다. 이 소진행위는 Google Play로 하여금 다시 해당 상품을 구매 가능한 상태로 되돌리며 이전의 구매 정보를 파기하게 됩니다.


현재 출시되고 있는 수많은 Android 어플리케이션이 앱내 구매(In-App Billing) 기능을 제공하고 있습니다. 이러한 구매 기능을 쉽게 접할 수 있는 어플리케이션중에 게임이 있는데요. 많은 게임들이 해킹의 피해를 입고 있고 특히 프리덤(Freedom)과 같은 결제 해킹 앱들에 의해 피해를 보는 경우가 제 생각보다 많다는것을 알았습니다. 이러한 결제 해킹 앱들의 경우 폰의 루팅이 필요한데요. 루팅폰을 사용중인 유저의 비중이 생각보다 많은것 같습니다. 이 문서는 이러한 해킹으로 부터 나의 수익을 지키는 방법에 대해 정리해 보았습니다. 먼저 Android에 대해 기술하고 다음은 iOS에 대해 또 글을 올리겠습니다. Google Play가 제공하는 In-App Billing 버전3를 기준으로 정리하였으며 사전 지식이 부족하실 경우 이전에 작성한 [Android In-App Billing 구현하기 (IAB Version 3)]를 먼저 읽어보시길 권장합니다.

In-App Billing 상품의 타입에 대해 알아보기

android_iab3_01

Android Developer Console에서 “인앱 상품” 메뉴에 들어가서 상품을 추가하게 되면 볼 수 있는 화면입니다. 여기서 3가지 타입을 제공하는것을 볼 수 있습니다. 하지만 IAB 버전3 API의 경우 관리되는 제품/구독 2가지의 타입을 제공한다고 생각하시면 됩니다. 관리되지 않는 제품을 선택할 때 다음과 같은 화면을 볼 수 있습니다.

android_iab3_02

뒤에서 좀 더 자세하게 설명을 드리겠지만 관리되는 제품과 관리되지 않는 제품은 IAB v3에서는 동일하게 관리되는 제품으로 취급됩니다. 하지만 관리되는 제품은 소진이 불가능한(Non-Consumable) 영원히 사용자에게 귀속되는 상품이며 관리되지 않는 제품은 소진이 가능한(Consumable) 상품으로 의미가 갈립니다. 소진이 불가능한 상품이라는 의미는 똑같은 상품을 두번 이상 구매할 수 없는것을 의미합니다. 소진이 가능한 상품의 경우 똑같은 상품을 계속해서 반복 구매하는 것이 가능합니다. 게임의 중간화폐(Currency)가 이경우에 해당될것 같습니다. 구독의 경우에는 다음과 같이 한달 또는 일년 단위로 자동 연장되는 결제 방식을 의미합니다. 음원 서비스들에서 주로 볼 수 있는 상품의 모습이라고 생각됩니다. 구독의 취소는 [Google 월렛의 내 구독 정보]에서 취소할 수 있습니다.

android_iab3_03

상품을 추가하실때 위와 같은 화면을 볼 수 있습니다. 정리해 보자면 Android 에서 제공하는 상품의 종류는 크게 “관리되는 제품”과 “구독” 2가지를 제공하며 “관리되는 제품”에는 반복 구매할 수 없는 귀속되는 소진 불가 상품과 반복구매가 가능한 소진 가능 상품이 존재합니다.

여기서 굉장히 중요한것 한가지는 “관리되는 제품 – 소진불가” 상품은 구글측에서 구매 내역을 신뢰할 수 있는 수준에서 관리해 준다는것입니다. “구독”역시 마찬가지입니다. 하지만 “관리되는 제품 – 소진 가능”한 상품은 개발사에서 구매 내역을 직접 관리해야 합니다.

유저로부터 발생하는 CS중 “결제를 분명히 했는데 아이템이 들어오지 않았다”는 대부분 이 “관리되는 제품 – 소진가능”한 상품들의 구매 과정에서 발생합니다.

In-App Billing 버전3 결제 과정

iab_v3_purchase_flow

먼저 중요하게 봐야 하는 Google In-App Billing 플로우입니다. isBillingSupported() 메소드 호출을 통해 앱내 결제가 가능한지 확인합니다. 디바이스와 OS버전의 상황을 체크하게 되는데 한국의 경우 여기서 실패하는 경우는 없다고 보시면 될것 같습니다.

다음은 getPurchases() 메소드입니다. 이 메소드는 현재 유저가 (이미 구매하여) 소유하고 있는 상품들의 정보를 반환합니다. 결제 플로우인데 왜 갑자기 쌩뚱맞게 이것을 호출하는가 의구심이 들 수 있습니다. 여기서 이 메소드를 호출함으로써 다음과 같은 정보를 얻을 수 있습니다.

  • 유저가 기존에 구매한 “관리되는 상품 – 소진 불가” 상품의 리스트
  • 유저가 기존에 구매한 “관리되는 상품 – 소진 가능” 상품중 아직 소진되지 않은 상품의 리스트
  • 유저의 구독 상품의 리스트

결론부터 말씀드리자면 이 메소드는 앱의 구동시점에 호출해줄 필요가 있습니다. 앱의 구동 시점 또는 로그인 기반의 경우 로그인이 성공하는 시점(카카오 로그인 성공 등)에 이 메소드를 호출함으로써 유저가 기존에 구매한 상품들의 정보를 불러와 앱에 세팅할 수 있습니다.

그렇다면 “우리 회사는 유저가 구매한 아이템의 모든 정보를 우리 서버에 직접 저장하고 관리하고 있다. 그럼 이것을 호출할 필요가 없는가?” 라고 반문하실 수 있습니다. 제가 알기로는 대부분의 한국의 게임사들은 직접 서버를 보유하고 있고 구매한 상품의 정보를 서버에 직접 보관하시는것으로 알고 있습니다. 그렇다면 이 호출을 건너뛰셔도 상관없습니다. 하지만 그럼에도 불구하고 호출하셔야 하는 이유는 뒤에 좀 더 자세히 설명하겠지만 “관리되는 상품 – 소진 가능”한 상품의 경우 구매 직후 바로 소진을 하게 되는데요(게임내에 통용되는 화폐로 교환), 구매는 성공했는데 유저에게 상품을 지급하기 전에 오류로 인해 앱이 죽는다거나 하는 문제로 소진을 못하는 경우가 발생할 수 있습니다.

이러한 상품들의 경우에도 getPurchases()를 통해 정보를 받아오실 수 있습니다. 결제는 성공했지만 지급에는 실패한 상품의 경우 바로 지급을 처리해주시면 됩니다. 가령 상품 구매가 성공할 때 “500골드의 구매가 정상적으로 처리되었습니다” 라는 팝업을 띄우게 되어있다고 가정해 봅시다. 유저가 결제를 정상적으로 성공한 시점에 앱이 어떤 문제로 죽었습니다. 유저는 깜짝 놀라 앱을 다시 구동할 것입니다. 그리고 앱이 켜지자 마자 진행중이던 결제처리를 마저 진행하고 해당 팝업을 띄우시면 됩니다.

기존의 IAB v2의 결제직후 아이템 지급까지 안정성이 보장되지 않는 상황을 대처하기 위해 v3에서는 결제후 아이템의 지급시점까지 결제 내역을 관리해주도록 변경되었습니다. (심지어 “관리되지 않는 상품”조차도 소진시점까지 관리되는 상품으로써 관리를 해줍니다. 이말은 소진하기 전까지는 관리되는 상품과 동일하게 중복 구매가 불가능하다는것을 의미합니다.)

즉, “관리되는 상품 – 소진 불가”과 “구독”의 경우 결제가 성공한 시점부터 언제든지 getPurchases()를 호출하여 구매내역을 꺼내볼 수 있습니다. 하지만 “관리되는 상품 – 소진가능” 상품의 경우 결제 → 소진(지급)을 거쳐서 처리하도록 되어있습니다. 이 소진을 하기 전까지는 “관리되는 상품 – 소진가능”(관리되지 않는 제품)일지라도 Google이 관리해줍니다. 소진에 대해서는 뒤에서 또 이야기 하기로 하고 계속해서 플로우를 설명해 보겠습니다.

getSkuDetails()는 판매 가능한 상품들의 상세 정보를 리스트로 반환합니다. 여기서 중요한점은 기존에 Google Play에 정의해둔 상품의 ID들을 모두 알고 있어야 하며 이 ID들을 이용하여 메소드를 호출하게 됩니다. 이 메소드를 호출하여 얻을 수 있는 정보는 상품의 가격, 이름, 설명, 구매 타입이 있습니다.

유저가 보유하고 있는 상품의 경우 getBuyIntent()를 사용하여 구매를 진행할 수 있습니다. 이 메소드를 호출하기 위해서는 기존에 Google Developer Console에 정의해두었던 상품의 ID와 다른 추가적인 파라미터가 사용됩니다. 이후의 결제 진행은 다음과 같은 순서로 이루어집니다.

  1. getBuyIntent()를 호출하면 Google Play는 구글 체크아웃 결제창을 시작할 수 있는 PendingIntent를 포함한 Bundle을 반환합니다.
  2. 당신의 어플리케이션에서 startIntentSenderForResult를 이용하여 위의 PendingIntent를 실행합니다.
  3. 체크아웃 결제가 종료된다면 (성공적으로 결제가 되었던지 유저가 결제를 중도에 취소하였던지) Google Play는 결과를 담은 Intent를 onActivityResult 메소드로 보내줍니다. 결과 코드를 통해 구매가 성공적으로 진행되었는지 취소되었는지 여부를 확인할 수 있습니다. 응답 Intent에는 구매 트랜젝션을 식별하는데 사용가능한 유니크한 purchaseToken을 포함한 구매한 상품의 정보를 담고 있습니다.

iab_v3_consumption_flow

이번에는 소진에 대해 알아보겠습니다. Google Play를 통해 판매할 수 있는 앱내 상품중에 유일하게 “관리되는 상품 – 소진가능 (관리되지 않는 상품)”만이 이 소진 메소드인 consumePurchase()를 사용합니다.

좀 더 정리하여 보면 IAB v3에서는 모든 앱내 상품이 관리됩니다. Google Developer Console에는 “관리되지 않는 상품”이라고 표시되지만 실제로는 관리됩니다. 다만 이 “관리되지 않는 상품”은 소진을 하기 전까지만 관리됩니다. 즉, 상품을 구매하여 소진하기 전까지 Google은 이 상품을 유저가 소유하였다고 직접 관리하게 됩니다. 이미 소유한 상품은 두번 구매할 수 없습니다. 그리고 명시적으로 소진을 하게 되면 이후에 다시 이 상품은 소유하지 않은 상태가 되고 재구매가 가능하게 됩니다.

여기서 말하는 소진은 게임에서는 게임내 화폐로의 교환을 의미합니다. 좀 더 쉽게 설명해 보자면 게임내에서 500골드를 1,000원에 판매하고 있었다면 결제를 통해서는 500골드 교환권을 구매한것이 됩니다. 이 500골드 교환권은 실제 500골드로 교환할때까지 Google이 관리해줍니다. 그리고 모든 상품들에 대해 똑같은 교환권을 2개 이상 가지는것은 불가능합니다. 500골드 교환권을 500골드로 교환해야 다시 500골드 교환권을 구매할 수 있습니다.

정리

너무 길게 설명한것 같은데 정리해 보면 각각의 상품들은 다음과 같은 과정을 거처 구매가 진행됩니다.

상품 타입 v3에서의 의미 결제 플로우
 관리되는 제품  관리되는 제품 – 소진 불가 (Non-Consumable)  getSkuDetails() → getBuyIntent() → startIntentSenderForResult() → 상품 지급
 관리되지 않는 제품  관리되는 제품  – 소진 가능 (Consumable)  getSkuDetails() → getBuyIntent() → startIntentSenderForResult() → consumePurchase() → 상품 지급
 구독  관리되는 제품 – 특정 기간동안 보유 (자동 결제)  getSkuDetails() → getBuyIntent() → startIntentSenderForResult() → 구독 시작

국내의 대부분의 게임이 결제를 통해 게임내에서 사용가능한 중간 화폐를 구매하는 모습을 차용하고 있다는것을 볼때 위의 3가지중에서 가장 중요한부분은 “관리되지 않는 제품”입니다. 소진을 하는 시점부터 Google이 관리를 해주지 않기 때문에 보안에 가장 취약한 상품 타입이기도 합니다.

결제 보안 시작하기

안드로이드 결제 보안을 설명하는데에는 구글이 IAB v3 예제로 제공하는 TriviaDrive 프로젝트를 이용하는것이 가장 좋다고 생각합니다. 해당 샘플 프로젝트를 Gradle 프로젝트로 컨버팅 하여 [이곳]에 올려두었으니 참고하시기 바랍니다. 프로젝트의 설명은 MainActivity의 코드를 가지고 해보겠습니다.

지금부터는 상품의 타입을 Google Developer Console에서 볼 수 있는 “관리되는 제품”, “관리되지 않는 제품”, “구독” 3가지로 명명하도록 하겠습니다. “관리되지 않는 제품”일지라도 소진 전까지는 관리가 된다는것을 유념해 주시기 바랍니다.

IAB v3를 이용하도록 만들어진 TriviaDrive 샘플 프로젝트는 3가지 타입의 상품을 구매하는 모든 로직이 포함되어있는 프로젝트입니다. 부가적인 기능들이 모두 잘 구현되어있는 예제이므로 개발중인 프로젝트에서 결제를 구현하실때는 이 프로젝트의 aidl 파일뿐만 아니라 util 디렉토리 이하의 모든 Java소스도 복사해서 사용하시기 바랍니다.

이 프로젝트는 단순한 운전 게임을 모티브로 만들어진 예제이며 자동차는 가스로 움직이며 가스를 저장하는 저장 탱크가 있습니다. 플레이어가 가스를 구매할때마다 탱크의 가스가 1/4씩 충전이 됩니다. 플레이어가 운전을 시작하면 이 가스가 점차 감소됩니다. (물론 이건 게임이니깐 한번에 1/4씩 감소됩니다)

플레이어는 “프리미엄 업그레이드”를 할 수 있습니다. 이 업그레이드를 하게 되면 유저는 기본적으로 부여되는 파란차가 아닌 빨간 차를 부여받게 됩니다.

마지막으로 플레이어는 “무한 가스” 상품을 구독할 수 있습니다. 이 상품을 구독하게 되면 구독하는 동안은 가스를 사용하지 않고 달릴 수 있게 됩니다.

각 상품의 소진 메커니즘에 대해 정리해보자면 다음과 같습니다.

프리미엄 업그레이드 : 이 상품은 소진을 하지 않습니다. 구매를 하는 시점부터 유저에게 귀속되며 이후 유저는 영원히 파란차 대신에 빨간차를 소유할 수 있게 됩니다.

무한 가스 : 이 상품은 “구독” 상품입니다. 마찬가지로 “구독” 상품은 소진되지 않습니다.

가스 : 가스를 구매하는 순간 상품은 유저에게 귀속됩니다. 그리고 이것을 소진함으로써 당신의 어플리케이션에 적용할 수 있습니다. 이 예제에서는 가스 탱크를 1/4 채우는 것을 의미합니다. 실제로는 구매 직후에 바로 가스탱크가 채워질것입니다. 이 시점에서 가스 상품은 소진되고 그로인한 영향이 당신의 게임에 영향을 끼치게 됩니다. 예를 들면 다음과 같은 시나리오로 동작합니다.

상태 설명
 구매 전  가스 탱크에 가스가 1/2 차 있음
 구매 진행중  가스 탱크에 가스가 1/2 차 있고 “가스” 상품을 보유함
 구매 직후  “가스” 상품을 소진함, 가스 탱크에 가스가 3/4가 됨
 구매 완료 후  가스 탱크에 가스가 3/4 차 있고 “가스” 상품을 더이상 보유하고 있지 않음

여기서 알아두어야 하는 다른 중요한 점은 유저가 “가스”상품을 구매하였지만 그것을 소진하기 전에 어플리케이션이 크래시 나거나 또는 다른 어떤 일이 발생할 경우입니다. 그래서 우리는 게임이 시작될 때 유저가 “가스”아이템을 보유하고 있는지를 확인할 것입니다. 만약 보유중이라면 그것을 바로 소진하고 게임상의 가스 탱크를 채울것입니다. 이것은 매우 중요한 부분입니다.

android_iab3_04

테스트를 위해 위와 같이 상품을 등록하였습니다. 실제로 소스상에서는 다음과 같이 상품의 ID를 미리 정의해 두었습니다.

// 판매되는 상품 : premium (Non-Consumable), gas (Consumable)
static final String SKU_PREMIUM = "premium";
static final String SKU_GAS = "gas";

// 구독 상품 : infinite_gas
static final String SKU_INFINITE_GAS = "infinite_gas";

이번에는 상품의 구매 처리를 담당하는 IabHelper를 초기화 하는 부분을 보겠습니다.

String base64EncodedPublicKey = "MIIBIjANBg...IDAQAB";
mHelper = new IabHelper(this, base64EncodedPublicKey);

여기서 유심히 봐야 하는 부분은 base64EncodedPublicKey라는 변수입니다. 이 값은 앱마다 다른 공개키 이며 Google Developer Console에서 확인이 가능합니다.

android_iab3_05

위의 값을 넣어주시면 됩니다. 이 값을 이용하여 구매가 성공하였을 때 Google Play로 부터 넘겨받은 데이터가 변조되었는지 여부를 검증할 수 있습니다.

보안팁 : 이 키는 공개키로써 이 키 자체가 어떤 비밀 정보를 담고 있지는 않습니다. 하지만 공격자가 이 키값을 손쉽게 위변조 하는것을 원치 않으므로 약간의 불폄함을 제공하는것도 방법입니다. 위의 코드를 게임내의 코드상에 직접 포함하는것보다는 조금 꼬아놓은 데이터를 포함시킨 뒤에 게임이 구동하는 시점에 조작을 통해 풀어서 사용하는것을 권장합니다. (가령 XOR 연산) 자체 서버가 있다면 XOR 연산에 사용할 키를 서버로부터 전송받는것도 방법일 것입니다. 어떤 방법을 사용해서 이부분은 완벽한 보안을 이루긴 어렵겠지만 공격자를 귀찮게 한다는 것에 의의가 있습니다.

mHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() {
    public void onIabSetupFinished(IabResult result) {
        Log.d(TAG, "셋업 완료");

        if (!result.isSuccess()) {
            // 문제 발생
            complain("Problem setting up in-app billing: " + result);
            return;
        }

        // 이 시점에 mHelper가 소거되었다면 (엑티비티 종료등) 바로 종료합니다.
        if (mHelper == null) return;

        // IAB 셋업이 완료되었습니다.
        Log.d(TAG, "Setup successful. Querying inventory.");
        mHelper.queryInventoryAsync(mGotInventoryListener);
    }
});

이제 mHelper를 초기화합니다. 이 메소드를 통해 IAB 서비스 커넥션을 생성하고 IAB 구현이 가능한지 여부를 확인합니다. 여기서 중요한 부분은 queryInventoryAsync() 메소드를 호출한다는 것입니다.

IabHelper.QueryInventoryFinishedListener mGotInventoryListener = new IabHelper.QueryInventoryFinishedListener() {
    public void onQueryInventoryFinished(IabResult result, Inventory inventory) {
        Log.d(TAG, "Query inventory finished.");

        // mHelper가 소거되었다면 종료
        if (mHelper == null) return;

        // getPurchases()가 실패하였다면 종료
        if (result.isFailure()) {
            complain("Failed to query inventory: " + result);
            return;
        }

        Log.d(TAG, "Query inventory was successful.");

        /*
         * 유저가 보유중인 각각의 아이템을 체크합니다. 여기서 developerPayload가 정상인지 여부를 확인합니다.
         * 자세한 사항은 verifyDeveloperPayload()를 참고하세요.
         */

        // 프리미엄 업그레이드를 가지고 있는가?
        Purchase premiumPurchase = inventory.getPurchase(SKU_PREMIUM);
        mIsPremium = (premiumPurchase != null && verifyDeveloperPayload(premiumPurchase));
        Log.d(TAG, "User is " + (mIsPremium ? "PREMIUM" : "NOT PREMIUM"));

        // 무한 가스를 구독중인가?
        Purchase infiniteGasPurchase = inventory.getPurchase(SKU_INFINITE_GAS);
        mSubscribedToInfiniteGas = (infiniteGasPurchase != null &&
                verifyDeveloperPayload(infiniteGasPurchase));
        Log.d(TAG, "User " + (mSubscribedToInfiniteGas ? "HAS" : "DOES NOT HAVE")
                    + " infinite gas subscription.");
        if (mSubscribedToInfiniteGas) mTank = TANK_MAX;

        // 가스를 가지고 있는가? 만약 가스를 가지고 있다면 바로 가스 탱크를 채워줍니다.
        Purchase gasPurchase = inventory.getPurchase(SKU_GAS);
        if (gasPurchase != null && verifyDeveloperPayload(gasPurchase)) {
            Log.d(TAG, "We have gas. Consuming it.");
            mHelper.consumeAsync(inventory.getPurchase(SKU_GAS), mConsumeFinishedListener);
            return;
        }

        updateUi();
        setWaitScreen(false);
        Log.d(TAG, "Initial inventory query finished; enabling main UI.");
    }
};

앱이 구동되지마자 IabHelper를 초기화 하고 가장먼저 호출한 코드입니다. 구매 내역을 가져와서 “프리미엄 업그레이드” 여부를 세팅하고 “무한 가스” 플랜을 구독중인지 여부를 게임내에 세팅하였습니다. 마지막으로 아직 소진되지 않은 “가스”가 존재하는지를 확인하여 소진시켜주는 과정입니다.

제작중인 게임 역시 유저의 결제는 정상적으로 이루어졌지만 어떤 오류로 인해 실제 지급까지 이루어지지 않은 경우에 위와 같은 과정을 통해 앱이 재실행되는 시점에 즉시 지급할 수 있습니다. 여기서 verifyDeveloperPayload 메소드가 매우 중요한데 뒤에서 다시 설명해보겠습니다.

// 가스 구매
String payload = "";
mHelper.launchPurchaseFlow(this, SKU_GAS, RC_REQUEST,
        mPurchaseFinishedListener, payload);

// 프리미엄 업그레이드 구매
String payload = "";
mHelper.launchPurchaseFlow(this, SKU_PREMIUM, RC_REQUEST,
        mPurchaseFinishedListener, payload);

// 무한 가스 구독
String payload = "";
mHelper.launchPurchaseFlow(this,
        SKU_INFINITE_GAS, IabHelper.ITEM_TYPE_SUBS,
        RC_REQUEST, mPurchaseFinishedListener, payload);

각 상품들의 구매 호출시에 실행되는 코드입니다. 각 호출마다 마지막에 payload를 붙이는것을 확인 하실 수 있습니다.

보안팁 : 구매시에 사용되는 이 payload라는 값은 개발자가 지정해주는 임의의 문자열입니다. 여기에 넘겨주는 값이 결제 결과에 다시 그대로 담겨오게 됩니다. 즉 이 값이 구매 직전과 직후에 변동이 있다면 구매 요청에 변조가 있었다고 판단하시면 됩니다. “관리되지 않는 제품”의 경우에도 소진을 하기전까지는 관리가 되므로 소진을 하기전에 변조가 되었는지 여부를 확인하시는 로직을 추가하는것이 중요합니다. 이 예제에서는 verifyDeveloperPayload() 메소드가 그 역할을 하고 있습니다. 소진을 하기전에 게임이 크래시가 날수 있으므로 SharedPreferences등을 활용하여 Persistent하게 저장해두고 진행하는것을 추천합니다. 또는 서버에 developerPayload값을 전달하여 저장하게 하거나 혹은 아예 developerPayload값을 서버로부터 발급받는것도 방법입니다. 이후에 소진하는 시점에 SharedPreferences를 통해 저장한 값을 꺼내쓰거나 서버에 다시 질의하여 사용자가 최종 구매하였던 구매가 완료되지 못한 상품의 developerPayload를 받아오는 식으로 구현하면 될것입니다.

android_iab3_06

즉 위와같은 플로우가 될것입니다. 상품의 지급과 소진은 동시에 한시점에 이루어져야 하겠지만 굳이 순서를 정하자면 상품의 소진 이후에 상품을 지급하시기 바랍니다. 만약에 최악의 경우 소진이 이루어지는 직후에 게임이 크래시 난다면 이 부분은 CS에서 처리를 해야 할것입니다.

하지만 수많은 게임이 상품의 지급 정보를 서버에서 수행하므로 (보유 화폐의 증가 등) 원격 서버에서 developerPayload값을 검증을 위해 꺼내간 뒤에 지급이 이루어지지 않은 상태(로그 확인등)에서 유저의 CS가 들어온다면 해당분만큼을 추가 지급처리(보상) 해주면 될것입니다.

boolean verifyDeveloperPayload(Purchase p) {
    String payload = p.getDeveloperPayload();

     /*
      * TODO: 위의 그림에서 설명하였듯이 로컬 저장소 또는 원격지로부터 미리 저장해둔 developerPayload값을 꺼내 변조되지 않았는지 여부를 확인합니다.
      *
      * 이 payload의 값은 구매가 시작될 때 랜덤한 문자열을 생성하는것은 충분히 좋은 접근입니다.
      * 하지만 두개의 디바이스를 가진 유저가 하나의 디바이스에서 결제를 하고 다른 디바이스에서 검증을 하는 경우가 발생할 수 있습니다.
      * 이 경우 검증을 실패하게 될것입니다. 그러므로 개발시에 다음의 상황을 고려하여야 합니다.
      *
      * 1. 두명의 유저가 같은 아이템을 구매할 때, payload는 같은 아이템일지라도 달라야 합니다.
      *    두명의 유저간 구매가 이어져서는 안됩니다.
      *
      * 2. payload는 앱을 두대를 사용하는 유저의 경우에도 정상적으로 동작할 수 있어야 합니다.
      *    이 payload값을 저장하고 검증할 수 있는 자체적인 서버를 구축하는것을 권장합니다.
      */

    return true;
}

이 이야기를 결론을 내보자면 궁극적으로 developerPayload의 발급과 검증을 모두 개발사가 보유한 서버에서 진행하는것을 추천합니다. 계속해서 코드 설명을 진행하겠습니다.

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    Log.d(TAG, "onActivityResult(" + requestCode + "," + resultCode + "," + data);
    if (mHelper == null) return;

    // 결과를 mHelper를 통해 처리합니다.
    if (!mHelper.handleActivityResult(requestCode, resultCode, data)) {
        // 처리할 결과물이 아닐경우 이곳으로 빠져 기본처리를 하도록 합니다.
        super.onActivityResult(requestCode, resultCode, data);
    }
    else {
        Log.d(TAG, "onActivityResult handled by IABUtil.");
    }
}

구글 체크아웃을 통해 결제가 성공하면 바로 onActivityResult로 진입하게 됩니다. 헬퍼의 handleActivityResult를 수행하여 구매 요청시에 파라미터로 사용했었던 구매 완료 콜백이 호출됩니다.

IabHelper.OnIabPurchaseFinishedListener mPurchaseFinishedListener = new IabHelper.OnIabPurchaseFinishedListener() {
    public void onIabPurchaseFinished(IabResult result, Purchase purchase) {
        Log.d(TAG, "Purchase finished: " + result + ", purchase: " + purchase);

        // mHelper 객체가 소거되었다면 종료
        if (mHelper == null) return;

        if (result.isFailure()) {
            complain("Error purchasing: " + result);
            setWaitScreen(false);
            return;
        }

        // 구매 요청이 변조되었는지 여부를 확인
        if (!verifyDeveloperPayload(purchase)) {
            complain("Error purchasing. Authenticity verification failed.");
            setWaitScreen(false);
            return;
        }

        Log.d(TAG, "Purchase successful.");

        if (purchase.getSku().equals(SKU_GAS)) {
            // 가스탱크의 1/4을 채워주는 "가스" 상품을 구매하였다면 소진 진행
            Log.d(TAG, "Purchase is gas. Starting gas consumption.");
            mHelper.consumeAsync(purchase, mConsumeFinishedListener);
        }
        else if (purchase.getSku().equals(SKU_PREMIUM)) {
            // "프리미엄 업그레이드"를 구매하였다면 바로 적용
            Log.d(TAG, "Purchase is premium upgrade. Congratulating user.");
            alert("Thank you for upgrading to premium!");
            mIsPremium = true;
            updateUi();
            setWaitScreen(false);
        }
        else if (purchase.getSku().equals(SKU_INFINITE_GAS)) {
            // "무한 가스"를 구독하였다면 바로 적용
            Log.d(TAG, "Infinite gas subscription purchased.");
            alert("Thank you for subscribing to infinite gas!");
            mSubscribedToInfiniteGas = true;
            mTank = TANK_MAX;
            updateUi();
            setWaitScreen(false);
        }
    }
};

구매가 성공한 직후의 콜백의 처리입니다. developerPayload 검증을 진행한 후 “관리되지 않는 제품”을 제외한 나머지는 곧바로 적용을 합니다. 하지만 “관리되지 않는 제품”의 경우 consumeAsync()를 호출하여 소진을 진행합니다.

IabHelper.OnConsumeFinishedListener mConsumeFinishedListener = new IabHelper.OnConsumeFinishedListener() {
    public void onConsumeFinished(Purchase purchase, IabResult result) {
        Log.d(TAG, "Consumption finished. Purchase: " + purchase + ", result: " + result);

        // mHelper가 소거되었다면 종료
        if (mHelper == null) return;

        // 이 샘플에서는 "관리되지 않는 제품"은 "가스" 한가지뿐이므로 상품에 대한 체크를 하지 않습니다.
        // 하지만 다수의 제품이 있을 경우 상품 아이디를 비교하여 처리할 필요가 있습니다.
        if (result.isSuccess()) {
            // 성공적으로 소진되었다면 상품의 효과를 게임상에 적용합니다. 여기서는 가스를 충전합니다.
            Log.d(TAG, "Consumption successful. Provisioning.");
            mTank = mTank == TANK_MAX ? TANK_MAX : mTank + 1;
            saveData();
            alert("You filled 1/4 tank. Your tank is now " + String.valueOf(mTank) + "/4 full!");
        }
        else {
            complain("Error while consuming: " + result);
        }
        updateUi();
        setWaitScreen(false);
        Log.d(TAG, "End consumption flow.");
    }
};

이 코드는 게임이 최초 실행될 때 실행되었던 queryInventoryAsync() 내에서도 소진할 상품이 있다면 호출되는 코드입니다. 결과를 확인하여 정상적으로 소진이 되었다면 해당 상품의 결과를 적용하면 됩니다.

보안팁 : 이 예제는 게임 클라이언트상에서 상품의 효과를 곧바로 적용하는것을 볼 수 있습니다. 하지만 이러한 상품의 지급은 서버에서 처리하는 것을 권장합니다. developerPayload를 검증하는 시점에 구매한 상품의 정보를 서버에 저장한 뒤에 소진이 성공하면 그 저장된 상품의 정보에 따라 해당 유저의 데이터베이스에 지급을 합니다. 지급이 성공적으로 이루어지면 클라이언트는 다시 서버로부터 최신 데이터를 가져와 화면의 정보를 갱신하도록 구현하면 됩니다.

중요 보안팁 : Google 에서는 상품의 구매 내역을 조회할 수 있는 서버에서 호출 가능한 API를 제공합니다. 패키지명, 상품 아이디, 구매시에 결과로 받았던 구매 토큰을 이용하여 유효한 구매인지를 Google측 서버에 직접 검증할 수 있습니다. 이 API를 사용하여 아이템 지급 전에 실제 유효한 구매였는지를 확인하는것만으로도 해킹의 대부분을 차단할 수 있습니다.

android_iab3_07

결과적으로 위와 같은 플로우가 됩니다. 여기서 중요한 핵심은 Google이 제공하는 앱내결제 체크 API를 사용하라는것과 아이템의 실제 지급을 서버에서 처리하라는것입니다. developerPayload의 경우 서버에서 3단계로 확인하는데요, 최초에는 발급하여 서버에 저장만을 하고 두번째에는 정상적으로 검증되었는지 여부를 저장합니다. 마지막 상품 지급 요청에 대하여 developerPayload가 존재하고 검증까지 마친상태인지를 확인하여 맞을 경우 다음 플로우로의 진행을 하게 됩니다. 만약에 developerPayload가 발급된적이 없거나 검증결과가 기록되지 않았다면 불법적인 지급 요청이겠죠.

구글이 제공하는 서버사이드 결제 검증 API를 이용하여 검증하는 방법은 [Android In-App Billing 서버사이드 보안 완벽 정리]를 확인해 주시기 바랍니다.

또한 이러한 과정을 일일이 기록해 두면 유저 CS가 발생시에 보상을 해야 하는지 여부에 대해 판단하는데에 도움이 됩니다. 위의 방법을 기초로 하여 회사의 상황에 맞는 좀 더 복잡한 보안 요소나 대응 방법, 정책등을 결정하여 개발하시면 될것 같습니다.

참고 :