모바일 앱(안드로이드)

안드로이드 앱 만들기 구글 인앱결제 쉽게 따라 하기...

Billcoreatech Billcoreatech 2021. 5. 15. 00:50
반응형

인앱 결제를 하기 위해서 오늘도 구글링을 하시는 분들께...  기본적은 헤맴을 줄여보기 위해서 정리를 해 둡니다.

 

 

인앱 결제를 하려면 일단, 할 일은 앱을 하나 만들어서 구글 플레이에 등록을 하는 것이다. 등록하는 가이드는 구글링을 통해 많이 나와 있으니 생략한다.

 

여기서 등록한다고 해서 꼭 출시상태를 만들 필요는 없다. 알파테스트(비공개 테스트) 단계까지만 등록해도 된다. 

그리고 해야할 것은 인앱 상품을 만들어서 등록하는 것이다. 

 

이처럼 등록을 하고 나면 금방이 승인이 나지 않는 다.  등록하고 다음날 확인해 보는 게 마음 편한 방법이다.

승인이 확인되면,  manifest 부터 설정을 해 봐야 한다. 

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

권한은 꼭 등록이 되어야 한다. internet 사용 권한도 등록해 두어야 한다. 

그다음은 build.gradle의 내용 중에서 다음은 선언 있어야 한다. 

dependencies {


    implementation 'com.android.billingclient:billing:4.0.0'
    implementation 'com.google.code.gson:gson:2.8.6'

}

이런저런 다른 것들도 필요하면 넣어야 한다. 저 예시는 빌링을 위한 필수 항목이다.  2021.8.2. 부터는 모든 앱이 Ver 3.0을 사용해야 한다고 권고하고 있다.  빌링은 아래 예시 처럼 전체 소스를 확인해야 쉽게 이해가 쉽게 될 것 같다.

반응형

아래 예시 처럼 하나 만들어 두고 사용하는 것도 좋은 방법이 될 것 같다. 이 예제에서 수정할 부분은 

billcode는 구글스토어에서 등록해 놓은 인앱 상품의 상품 ID가 되며,  아래 예시에는 firebase의 realtime database와 연동을 통해서 구매한 사용자의 목록을 관리하기 위한 billingUserBean 항목이 있으나, 이건 개발자의 선택에 따라 필요 없을 수 도 있다.  그런 부분을 제거해 버리면 그냥 그대로 사용할 수 있다.

import android.app.Activity;
import android.util.Log;
import android.widget.Toast;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.SkuDetails;
import com.android.billingclient.api.SkuDetailsParams;
import com.android.billingclient.api.SkuDetailsResponseListener;
import com.billcoreatech.dream314.R;
import com.billcoreatech.dream314.util.StringUtil;
import com.google.gson.JsonObject;

import org.json.JSONException;
import org.json.JSONObject;

import java.util.ArrayList;
import java.util.List;

public class BillingManager<mConsumResListnere> implements PurchasesUpdatedListener {
    String TAG = "BillingManager" ;
    BillingClient mBillingClient ;
    Activity mActivity ;
    public List<SkuDetails> mSkuDetails ;
    public enum connectStatusTypes { waiting, connected, fail, disconnected }
    public connectStatusTypes connectStatus = connectStatusTypes.waiting ;
    private ConsumeResponseListener mConsumResListnere ;
    String billCode = "210420_onetime_pay" ; // 인앱결제 상품ID는 그때 그때 달라요

    public BillingManager (Activity _activity) {
        mActivity = _activity ;

        mBillingClient = BillingClient.newBuilder(mActivity)
                .setListener(this)
                .enablePendingPurchases()
                .build() ;
                
        mBillingClient.startConnection(new BillingClientStateListener() {
            @Override
            public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
                Log.i(TAG, "respCode=" + billingResult.getResponseCode() ) ;
                if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
                    connectStatus = connectStatusTypes.connected ;
                    Log.i(TAG, "connected...") ;

                    Log.i(TAG, "resp=" + mBillingClient.queryPurchases(billCode).getBillingResult()
                             + "=" + mBillingClient.queryPurchases(billCode).getResponseCode());

                    getSkuDetailList() ;

                } else {
                    connectStatus = connectStatusTypes.fail ;
                    Log.i(TAG, "connected... fail ") ;
                }
            }

            @Override
            public void onBillingServiceDisconnected() {
                connectStatus = connectStatusTypes.disconnected ;
                Log.i(TAG, "disconnected ") ;
            }
        });

        mConsumResListnere = new ConsumeResponseListener() {
            @Override
            public void onConsumeResponse(@NonNull BillingResult billingResult, @NonNull String purchaseToken) {
                if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
                    Log.i(TAG, "사용끝 + " + purchaseToken) ;
                    return ;
                } else {
                    Log.i(TAG, "소모에 실패 " + billingResult.getResponseCode() + " 대상 상품 " + purchaseToken) ;
                    return ;
                }
            }
        };
    }

    public int purchase(SkuDetails skuDetails) {
        BillingFlowParams flowParams = BillingFlowParams.newBuilder()
                .setSkuDetails(skuDetails)
                .build();
        return mBillingClient.launchBillingFlow(mActivity, flowParams).getResponseCode();
    }


    public void getSkuDetailList() {
        List<String> skuIdList = new ArrayList<>() ;
        skuIdList.add(billCode);

        SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder();
        params.setSkusList(skuIdList).setType(BillingClient.SkuType.INAPP); // INAPP 가 인앱결제라는 구분임.
        mBillingClient.querySkuDetailsAsync(params.build(), new SkuDetailsResponseListener() {
            @Override
            public void onSkuDetailsResponse(@NonNull BillingResult billingResult, @Nullable List<SkuDetails> skuDetailsList) {
                if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
                    Log.i(TAG, "detail respCode=" + billingResult.getResponseCode()) ;
                    return ;
                }
                if (skuDetailsList == null) {
                    Toast.makeText(mActivity, mActivity.getString(R.string.msgNotInfo), Toast.LENGTH_LONG).show();
                    return ;
                }
                Log.i(TAG, "listCount=" + skuDetailsList.size());
                for(SkuDetails skuDetails : skuDetailsList) {
                    Log.i(TAG, "\n" + skuDetails.getSku()
                            + "\n" + skuDetails.getTitle()
                            + "\n" + skuDetails.getPrice()
                            + "\n" + skuDetails.getDescription()
                            + "\n" + skuDetails.getFreeTrialPeriod()
                            + "\n" + skuDetails.getIconUrl()
                            + "\n" + skuDetails.getIntroductoryPrice()
                            + "\n" + skuDetails.getIntroductoryPriceAmountMicros()
                            + "\n" + skuDetails.getOriginalPrice()
                            + "\n" + skuDetails.getPriceCurrencyCode()) ;
                }
                mSkuDetails = skuDetailsList ;

            }
        });
    }

    /**
     * @param billingResult
     * @param purchases
     */
    @Override
    public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> purchases) {

        userinfoDB = FirebaseDatabase.getInstance().getReference("UserInfoDB");
        if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK && purchases != null) {
            Log.i(TAG, "구매 성공>>>" + billingResult.getDebugMessage());
            JSONObject object = null ;
            String pID = "" ;
            String pDate = "" ;
            for(Purchase purchase : purchases) {
                Log.i(TAG, "성공값=" + purchase.getPurchaseToken()) ;
                Log.i(TAG, "성공값=" + purchase.getOriginalJson());
                try {
                    object = new JSONObject(purchase.getOriginalJson());
                    pID = object.getString("purchaseToken");
                    pDate = StringUtil.getDate(object.getLong("purchaseTime")); // 날자를 구하기 위해서

                } catch (JSONException e) {
                    e.printStackTrace();
                }
                Log.i(TAG, "token=" + pID + "" + pDate) ;
                ConsumeParams params = ConsumeParams.newBuilder()
                        .setPurchaseToken(pID)
                        .build() ;
                mBillingClient.consumeAsync(params, mConsumResListnere);
            }
        } else if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.USER_CANCELED) {
            Log.i(TAG, "결제 취소");
        } else {
            Log.i(TAG, "오류 코드=" + billingResult.getResponseCode()) ;
        }
    }
}

 

이제 위에서 작성한 billingManager 을 호출하는 MainActivity의 예시를 보자

onCreate 등에서 미리 billingManger 에 대한 선언을 해 두고,  화면에서 버튼을 클릭했거나, 결제 메뉴를 선택했을 떄 아래 부분만 추가해 주면 결제 흐름은 끝이다. 

BillingManager billingManager = new BillingManager(getActivity());

...

               if(billingManager.connectStatus == BillingManager.connectStatusTypes.connected) {
                    Log.i(TAG, "connected ..") ;
                    SkuDetails skuDetails = (SkuDetails) billingManager.mSkuDetails.get(0);
                    int iResp = billingManager.purchase(skuDetails) ;
                    Log.i(TAG, "iResp=" + iResp) ;
                }

물론 이 예시는 1회성 인앱 결제의 흐름을 따라가는 예시 이므로 정기 결제 흐름에 대한 부분은 추가로 더 확인을 해야 할 필요가 있다.  (정기결제 흐름을 만들다가 포기(?)한 이유는 정기결제 후에 결제 소모 구현을 해야 하는데, 잘 되지 않아서... ㅠㅠ;;)

 

이 처럼해 정리해 두면 차후에 라도 다른 거 인앱 결제를 구현할 때 복사해서 쓸 기본 소스는 준비하는 것이 될 것 같다.

 

p.s 2021.11.14 : 테스트는 어떻게 할 것 인가 ?

이번에 수정을 하면서 느낀건... 자주 해 봐야 한다는 것이다. 이것도 오랜만에 한번 해 볼려 했더니... 기억 가물거린다. 

아무튼... 추가해서 적어 두어야 하는 것은 테스트 하려면... 

 

1. 먼저 playstore 에 앱을 게시하자... 단, 내부테스트 까지만, 게시하면 가능 하다.  물론 테스트 해야 하는 이메일 계정은 등록을 해 두어야 한다.

 

2. 다음은 인앱 상품을 만들어 두어야 한다.  그것이 등록하면 바로 테스트를 할 수 있는 게 아니라서,  등록하고 나서 하루쯤 지나서 테스트 한다고 생각하는 것이 마음편하다. 그래서 인앱 상품을 개발할 꺼라면, 미리 테스트 앱을 게시하고, 인앱상품도 만들어 등록해 두어야 한다. 

 

3. 테스트는 어떻게 할 것인가 ? AVD 에서 테스트를 해도 되기는 하지만, 이번에 하다 보니, AVD에 계정을 2개 등록했다고 하면, 테스트 계정으로 2개다 등록을 해야 하는지 확인이 필요해 보인다. 그래서 깔끔하게 AVD 을 밀어버리고 계정은 한개만 등록된 상태에서 테스트를 진행 했다.  

 

이런 정도의 주의 사항을 상기 시키기 위해 추가 글을 적는다.  이만...

 

 

끝.

반응형