■ Android

안드로이드 앱내결제 (Android In App Billing)

오랜만에 안드로이드(Android) 포스팅입니다.

오늘은 안드로이드의 앱내결제(In App Billing, 이하 IAB)에 대해서 알아보도록 하겠습니다.

IAB는 어플리케이션 개발에서 매우 중요한 부분이기 때문에 결제플로우의 명확함, 보안성 등을 모두 고려해야합니다.

또한 현재 안드로이드에서는 draft상태인 앱에서는 결제테스트가 잘되지 않기 때문에 여러가지 고려해야할 상황에 놓이게 됩니다.

기본적인 SDK Manager나 기본상황은 모두 생략하겠습니다. 또한 개발환경은 Mac환경이고, 안드로이드 스튜디오(Android Studio)를 기준으로 설명합니다.

서버는 node.js 서버입니다.

 

1. 구현

1-1.  기본설정

먼저 aidl파일을 import해야합니다. 이파일은 SDK Manager로 다운받은 이후 설치된 sdk폴더를 기준으로

extras/google/play_billing 폴더 내에 있습니다. 또한 sample폴더내에서 MainActivity.java 파일을 찾으신후 그 옆에 있는 util 폴더를 복사해옵니다.

aidl은 (Android Interface description language) 인터페이스만 정의된 언어입니다.

간단하게 말해서 이기종 혹은 다른 방식의 안드로이드 앱들간, 프로세스간 등의 통신이 필요할때의 프로토콜이 되는 기준을 인터페이스만으로 정의한 것입니다.

여기선 IPC (Inter process communication) 를 위해서 사용됩니다.

스크린샷 2015-01-05 오후 3.14.36

폴더 구조는 다음처럼 됩니다. 이클립스와는 구조가 다르니 확인해보세요!

aidl안에 패키지가 정의되어있습니다.

이때 주의할 것은 aidl폴더내 패키지를 만들때 한번에 “com.android.vending.billing”으로 만들면 안되고,

“com” 을 먼저 만들고 그 내부에 “android” 그내부에 “vending”, “billing”이런식으로 만들어야 합니다.

즉 패키지가 실질적으로 “com/android/vending/billing”의 형태가 될 것입니다.

다음으로는 안드로이드 기본

2-2. 랩퍼 구현.

이제 구현을 쉽게 하기위해 추상화된 형태의 헬퍼클래스를 구현해보도록 하겠습니다.

코드설명은 주석으로 읽어보시면 되겠습니다.

public class InAppBillingHelper {

    public static final String TAG = "InAppBillingHelper";
    
    // onActivityResult에서 받을 requestCode.
    public static final int REQUEST_CODE = 1001;

    // publicKey 개발자콘솔에서 앱을 생성후 얻을 수 있다.
    private String mPublicKey;
    
    // 테스트를 하기 위한 테스트용 productId(SKU)
    private static final String TEST_SKU = "android.test.purchased";

    // 구매되고 소진되지 않은 아이템을 캐시해놓을 변수.
    private ArrayList<String> mOwnedItems = new ArrayList<>();
    
    // 가져온 util클래스에서 제공하는 클래스. 아이템리스트를 갖고 있다.
    private Inventory mInventory;
    
    // 가져온 util클래스의 실제 헬퍼클래스
    private IabHelper mHelper;
    
    // 자체적으로 서버와 통신할 서비스 클래스. (여기선 구현은 생략)
    private ItemService mItemService;
    
    private Activity mActivity;
    
    // 테스트 여부인지.
    private boolean mIsTest;

    // 실제 우리가 알고있는 아이템목록. (어플리케이션 서버로부터 받아오면됨.)
    public List<String> mItems = new ArrayList<>();

    // 로드 이후 호출될 리스너.
    public interface InventoryLoadListener {
        public void onBefore();
        public void onSuccess(Inventory inventory);
        public void onFail();
    }

    public void init(Activity activity) {
        mActivity = activity;
        mItemService = new ItemService(activity);
        mIsTest = false;
    }

    // 공개키가 없는 생성자.
    public InAppBillingHelper(Activity activity) {
        init(activity);
    }

    // 공개키가 있는 생성자.
    public InAppBillingHelper(Activity activity, String publicKey) {
        mPublicKey = publicKey;
        init(activity);
    }

    // 테스트 여부의 세터.
    public void setTest(boolean isTest) {
        mIsTest = isTest;
    }
}

 

아주 기본적인 클래스입니다.

우리는 IabHelper를 통해서 실질적으로 통신할 것입니다. 이 헬퍼클래스는 내부적으로 구글 서버와 통신하여 우리에게 응답을 주는 코드들을 랩핑해 놓은 것입니다.

또한 ItemService는 신경쓰지 않아도 됩니다. 자체적으로 어플리케이션 서버와 통신하기 위한 Rest클래스 입니다.

이어서 계속 코딩해 보겠습니다.

public class InAppBillingHelper {

    ....

    public void startSetup(ArrayList<String> items, final InventoryLoadListener listener) {
        // before() 를 호출함 으로서 로딩바를 보여주는 등의 액션을 취할 수 있다.
        listener.onBefore();

        // 실제 구글 서버에 요청할 sku리스트.
        mItems = items;

        // 서버에서 테스트 sku목록까지 넣어놓았었다면 제외.
        for (int i = 0; i < mItems.size(); ++i) {
            if (mItems.get(i).equals(TEST_SKU)) {
                mItems.remove(mItems.get(i));
                break;
            }
        }

        // 실제 util에서 가져온 헬퍼생성.
        mHelper = new IabHelper(mActivity, mPublicKey);

        // startSetup함수를 호출함으로서 현재 통신가능여부를 확인하고, 정상적으로 커넥션이 이루어졌는지 확인할 수 있다.
        mHelper.startSetup(new IabHelper.OnIabSetupFinishedListener() {

            @Override
            public void onIabSetupFinished(IabResult result) {
                if (!result.isSuccess()) {
                    listener.onFail();
                } else {
                    // 성공적으로 연결이 되었다면 이제 실질적인 아이템을 로드해야한다.
                    loadItemInventory(listener);
                }
            }
        });
    }
}

이제 초기화를 위해 실질적으로 startSetup메소드를 호출하였습니다. 최종적으로는 loadItemInventory를 구현함으로서 마무리될 것이다.

해당 메소드는 아래에서 구현하도록 하겠습니다.

public class InAppBillingHelper {

    ....

    // 아이템을 로드하는 메소드
    private void loadItemInventory(final InventoryLoadListener listener) {
        // 실제 아이템목록을 받아올 수 있음.
        // mItems에 올바른 값이 할당되어야 함.
        // 만약 mItems가 null값이라면 queryInventoryAsync메소드는 콜백이 호출될때 성공했다고 나오지만
        // 어떠한 아이템 리스트 값도 받아올 수 없음.
        mHelper.queryInventoryAsync(true, mItems, new IabHelper.QueryInventoryFinishedListener() {

            @Override
            public void onQueryInventoryFinished(IabResult result, Inventory inv) {

                // IabResult 값에는 다양한 에러코드및 성공여부를 담고 있음.
                if (result.isSuccess()) {
                    // 멤버변수로 받아온 아이템리스트를 갖고 있는 inventory를 담음.
                    mInventory = inv;

                    // mInventory에는 구매는 했지만 소진되지 않은 값을 얻어올 수 있음.
                    // 따라서 새롭게 받아온 인벤토리를 통해 갱신하기 위해 클리어.
                    mOwnedItems.clear();

                    // 이제 mItems의 sku값을을 갖고 inv에 조회하여 소진되지 않은 아이템 목록을 담음.
                    for (String sku : mItems) {
                        if (inv.hasPurchase(sku)) {
                            mOwnedItems.add(sku);
                        }
                    }

                    // 테스트sku는 가끔 소진되지 않을 때가 있음. 이럴땐 무조건 헬퍼가 수행될 때 소진시킴.
                    if (mInventory.hasPurchase(TEST_SKU)) {
                        // consumeItem에 첫번째 인자는 해당 sku이며, 두번째 인자는 Purchase 인스턴스가 됨. 이후에 다시 설명.
                        consumeItem(TEST_SKU, null);
                    }

                    listener.onSuccess(inv);
                }
                else {
                    listener.onFail();
                }
            }
        });
    }
}

 

여기서 아이템 소진, 구매가 별도로 있다는 것을 알 수 있습니다. 보통 플로우로, 먼저 아이템을 구매하고, 구매가 성공하면 어플리케이션 서버에 아이템구매요청을 해서 실제 어플리케이션 내부에서 쓰일 캐시나 아이템을 지급하고, 요청이 성공되면 comsume 즉 소진을 하여 다시 재구매가 가능하게 합니다.

여기서는 즉 unmanaged item에 대해서 설명한 것인데, unmanaged는 소진가능한 아이템이라고 보시면 됩니다. 그밖에도 managed 아이템 및 subscription 아이템이 있습니다. managed 아이템은 한번 구매하면 다시 구매할 수 없는 아이템이며,subscription 같은 경우는 말그대로 구독아이템입니다. (기간을 정해놓고 일정 기간간격으로 지속적으로 구매되어지는 아이템)

여기서는 아이템을 구매하고 해당 앱에서 처리를 한 후 재구매가 된다고 가정하였습니다.

    

public class InAppBillingHelper {

    ....

    // 실제 구매 (인앱결제)
    public void purchaseItem(final String sku) {

        // 만약 test가 true라면 테스트SKU를 갖고 구매요청을 한다.
        final String refinedSKU = mIsTest ? TEST_SKU : sku;

        // 구매후 소진되지 않은 아이템이라면 다시 구매할 수 없다.
        if (!mOwnedItems.contains(refinedSKU)) {

            // 실제 구매 & 결제요청
            // 해당 메소드를 호출하면 내부적으로 팝업창형태의 결제창이 나온다.
            mHelper.launchPurchaseFlow(mActivity, refinedSKU, REQUEST_CODE, new IabHelper.OnIabPurchaseFinishedListener() {

                @Override
                public void onIabPurchaseFinished(IabResult result, final Purchase info) {
                    if (result.isSuccess()) {

                        // 구매되고 소진되지 않은 목록에 캐시.
                        mOwnedItems.add(info.getSku());
                        
                        // 실제 어플리케이션 서버에서 소진되도록 호출.
                        consumeItemForServer(info);
                    } else {
                        if (result.getResponse() == 0) {
                            // 구매가 실패하였고 이유가 아직 소진되지 않은 것이라면
                            // 소진을 위해 서버에 요청.
                            consumeAllItemsForServer();
                        }
                        CommonHelper.showMessage(mActivity, result.getResponse() + "");
                        ErrorHandler.handleLocalError(mActivity, LocalErrorCode.ITEM_PURCHASE_ERROR);
                    }
                }
            });
        } else {
            // 이미 구매한 아이템이라면 모두 소진시킨다.
            consumeAllItemsForServer();
        }
    }
}

 

이제 구매요청을 합니다. 여기서 REQUEST_CODE가 있는데, 바로 이 요청을 하는 순간 해당 mActivity의 onActivityResult를 통해서 응답을 준다는 의미입니다.

만약 구매가 성공하면 소진을 위해 어플리케이션 서버에 요청을 하게 됩니다.

public class InAppBillingHelper {

    ....

    // 외부 액티비티나 프래그먼트의 onActivityResult함수에서 호출해야 한다.
    // 해당 메소드가 호출되지 않으면 정상적으로 프로세스가 끝나지 않는다.
    // mHelper.handleActivityResult 내부에서 onIabPurchaseFinished메소드가 호출된다.
    public void onActivityResult(int requestCode, int resultCode, Intent data){
        if (!mHelper.handleActivityResult(requestCode, resultCode, data)) {
            onActivityResultError(requestCode, resultCode, data);
        }
    }

    // 해당 메소드를 상속받아 오버라이딩하여 에러를 처리할 수 있다.
    public void onActivityResultError(int requestCode, int resultCode, Intent data) {

    }
}

이제 onActivityResult에서호출할 헬퍼 메소드를 작성합니다.

에러처리는 콜백이 아닌 상속을 통해서 해결하였습니다.

 

public class InAppBillingHelper {

    ....

    // 실제 어플리케이션 서버에 아이템 구매요청을 한다.
    private void consumeItemForServer(final Purchase purchase) {

        final String refinedSKU = mIsTest ? TEST_SKU : purchase.getSku();

        // mItemService는 내부적으로 rest방식의 통신을 어플리케이션 서버와 해주는 통신인스턴스이다.
        // 각자 서버와 통신에 맞게 구현.
        mItemService.purchaseItem(refinedSKU, purchase.getToken(), new ItemPurchaseListener() {
            @Override
            public void onBefore() {
                FullScreenProgressBar.show(mActivity);
            }

            @Override
            public void onSuccess() {
                if (mActivity != null) {
                    FullScreenProgressBar.hide();
                }
                CommonHelper.showMessage(mActivity, "Success!~");
                // 통신이 성공하면 실제 소진을 시킨다.
                // 여기서 두번째 인자로 purchase 인스턴스를 준다.
                // 이때에는 mInventory가 갱신이 되어 있지 않는 경우가 종종있다.
                // 따라서 mInventory에서 purchase를 가져오는 것이다니고
                // 해당 구매 콜백으로 부터 가져온다.
                consumeItem(refinedSKU, purchase);
            }

            @Override
            public void onFail(com.slogup.frameworks.models.Error error) {
                if (mActivity != null) {
                    if (error != null) {
                        ErrorHandler.handleNetworkError(mActivity, error);
                    }
                }
            }
        });
    }
}

이제 어플리케이션 서버에 구매요청을 하고 성공하면 소진시키기 위해 consumeItem을 호출합니다.

여기서 mInventory갱신 문제가 있는데, 실제 구매를 하면 mInventory의 값이 갱신된다. 하지만 갱신이 잘되지 않는 경우가 종종있어서

mInventory.getPurchase(sku)를 호출하기 보단 purchase값을 그대로 가져다 쓰는게 좋습니다.

만약 여기서 null을 입력하면 mInventory.getPurchase(sku)를 통해서 가져옵니다.

 

// 실제 소진 처리.
    private void consumeItem(String sku, Purchase purchase) {

        final String refinedSKU = mIsTest ? TEST_SKU : sku;

        if (purchase != null || mInventory.hasPurchase(refinedSKU)) {

            // purchase가 있다면 해당 구매 인스턴스를 통해 처리하고, 그게 아니라면 mInventory를 통해처리
            // 보통 구매성공, 소진실패일 경우일때 mInventory.hasPurchase(refinedSKU)를 통해서 가져오면 된다.
            Purchase refiendPurchase = (purchase == null) ? mInventory.getPurchase(refinedSKU) : purchase;
            
            // 소진처리.
            mHelper.consumeAsync(refiendPurchase, new IabHelper.OnConsumeFinishedListener() {
                @Override
                public void onConsumeFinished(Purchase purchase, IabResult result) {
                    if (result.isSuccess()) {
                        // 소진에 성공하면 캐시된 값을 지운다.
                        mOwnedItems.remove(purchase.getSku());
                    } else {
                        ErrorHandler.handleLocalError(mActivity, LocalErrorCode.ITEM_FATAL_ERROR);
                    }
                }
            });
        }
        else {
            ErrorHandler.handleLocalError(mActivity, LocalErrorCode.ITEM_FATAL_ERROR);
        }
    }

이제 소진처리를 해봅니다. 특별한 것은 없고 헬퍼메소드를 호출하고 캐시된 값만 지우면 됩니다.

 

public class InAppBillingHelper {

    ....

    private static int sPurchaaseCount = 0;
    public void consumeAllItemsForServer() {

        if (mOwnedItems.size() > sPurchaaseCount && mOwnedItems.get(sPurchaaseCount) != null) {

            final String sku = mOwnedItems.get(sPurchaaseCount);
            Purchase purchase = mInventory.getPurchase(sku);

            if (purchase != null) {

                String token = purchase.getToken();
                mItemService.purchaseItem(sku, token, new ItemPurchaseListener() {

                    @Override
                    public void onBefore() {
                        FullScreenProgressBar.show(mActivity);
                    }

                    @Override
                    public void onSuccess() {
                        sPurchaaseCount++;
                        consumeItem(sku, null);
                        consumeAllItemsForServer();
                    }

                    @Override
                    public void onFail(com.slogup.frameworks.models.Error error) {
                        if (mActivity != null) {
                            if (error != null) {
                                ErrorHandler.handleNetworkError(mActivity, error);
                            }
                        }
                    }
                });
            }
            else {
                handleError();
            }
        }
        else {
            handleError();
        }
    }

    private void handleError() {
        sPurchaaseCount = 0;
        mOwnedItems.clear();
        if (mActivity != null) {
            FullScreenProgressBar.hide();
        }
    }
}

마지막코딩으로 재귀적으로 소진요청에 실패했던 값들을 모두 소진시키는 함수를 만듭니다.

여기서 주의할 점은 이미 서버상에서 소진된아이템은 어플리케이션 서버에서 저장하고 값을 비교해서 적절하게 처리해줘야 한다는 점입니다.

 

이제 한가지만 더 세팅해주면 됩니다. 그것은 바로 퍼미션!

<uses-permission android:name="com.android.vending.BILLING" />

이렇게 간단하게 헬퍼메소드를 만들었습니다. 이제 개발자콘솔을 설정해 보도록 하겠습니다.

 

2. 개발자콘솔 설정.

2-1. 앱생성

먼저 개발콘솔에 접속합니다. https://play.google.com/apps/publish/

다음으로 Add new application을 눌러 새로운 어플리케이션을 만듭니다.

스크린샷 2015-01-05 오후 3.05.11

 

이어서 Title을 적고 Prepare Store Listing버튼을 누릅니다.

여기서 title은 현재 자신이 갖고 있는 앱리스트에서 key값이 아닙니다. 즉 중복 타이틀이 가능하다는 이야기입니다. (키가 되는것은 패키지명입니다.)

 

2-2.  앱 업로드

앱은 테스트를 위해 Application Id값을 변경해서 올려주시기 바랍니다. 왜냐하면 publish가 되어야 테스트가 가능한데, 그렇게 되면 해당 패키지명을 계속 써야하기 때문입니다. 위에 퍼미션 설정이 되어있지 않은 앱은 인앱 아이템리스트를 넣어줄 수 없습니다.

스크린샷 2015-01-05 오후 4.23.48

 

앱이 올라가고, published가 된상태가 되면 다음과 같은 화면을 볼수 있습니다.

스크린샷 2015-01-05 오후 4.26.37

 

위에 보는 바와 같이 unmanaged product로 샘플 아이템이 등록되어있습니다.

또한 status가 active상태로 해놔야 실제로 응답을 받을 수 있습니다.

추가적으로 우리는 공개키가 필요합니다. 왼쪽 메뉴중 젤아래 Services & APIs에 가면 공개키를 쉽게 얻을 수 있습니다.

 

2.3. 테스터 등록.

다음으로 테스터를 등록하고, 어플리케이션서버에서 적절하게 벨리데이션할수 있도록 준비해 보도록 하겠습니다.

스크린샷 2015-01-05 오후 4.30.30

해당 설정에서 API access탭으로 가면 제일 아래 Service accounts라는것이 보입니다. create service account버튼을 눌러 안내에 따라 계정을 만듭니다.

해당 계정은 어플리케이션 서버와 구글 인증서버간 OAuth요청을 위해 필요합니다.

여기서 왜 OAuth인증이 필요한지는 바로 해당 구매된 아이템을 verify하기위한 uri 리소스를 구글에서 제공해주는데 이때 로그인이 필요합니다. 하지만 우리는 verify를 어플리케이션 서버내에서 호출해야하고 따라서 두 서버간 (어플리케이션 서버가 클라이언트역할) 인증을 위해 OAuth를 이용하는 것입니다.

이제 아래 그림과 같이 acocunt details탭으로 이동합니다.

스크린샷 2015-01-05 오후 4.30.06

 

제일 아래 Licensse testing이란 제목이 보이는데 여기서 방금 얻은 계정을 등록합니다. 또한 실제 디바이스에서 테스트할때 로그인되어있을 구글계정(이메일)을 등록합니다. (컴마로 구분)

스크린샷 2015-01-05 오후 4.36.26

 

다음으로 user accounts & rights 탭으로 이동합니다. 그리고 invite new user버튼을 눌러 oauth를 위해 생성한 계정을 초대하고 권한을 View financial reports 가 체크되도록 줍니다. 보통 관리자로 주면 됩니다.

다음으로 API access탭의 Service accounts에서 create service account버튼을 누르면 나타나는 개발자콘솔 페이지링크로 이동하여 API 및 인증 탭에 사용자 인증정보로 이동합니다.

스크린샷 2015-01-05 오후 4.41.48

여기서 새클라이언트 ID만들기를 하고 얻은 값들은 무시하고 새 JSON키 생성버튼을 눌러 얻은 값을 저장해 놓습니다.

그럼 이제 테스터 등록 및 서버에서 유효성검사를 하기 위한 준비를 맞췄습니다.

 

서버에서 유효성 검사하는 부분은 다음 포스팅에서 해보도록 하겠습니다.

그럼 안녕~~

 

 

 

Standard