nika-blog

RNIap subscription promotion, ios introductory offer (인앱 결제 이상한 ios 프로모션 정책) React Native 본문

React Native

RNIap subscription promotion, ios introductory offer (인앱 결제 이상한 ios 프로모션 정책) React Native

nika0 2025. 1. 31. 19:38

React Native 기준 In-App Purchase 구독 프로모션 구현

나는 In-App Purchase(IAP) 기능을 제공하는 서비스의 개발자이다.
우리 서비스는 구독, 일회성 결제 등 다양한 수익 모델을 갖추고 있으며, 오늘은 그중 **구독(subscription)**에 대해 이야기하려고 한다.


🔹 In-App Purchase의 구독(Subscription)이란?

구독(subscription)은 쉽게 말해 유튜브 프리미엄과 같은 모델을 생각하면 된다.
IAP는 Apple App Store, Android Play Store의 각 정책을 따라야 하며, 플랫폼별 구현 방식도 차이가 있다.


🔹 구독 프로모션(Promotion) 구현 방법

📌 Android (Google Play Store) 기준
Android에서는 Play Store에서 Promotion OfferTag를 생성한 후, 해당 offerTag를 이용해 상품 정보 조회 및 결제 요청을 수행할 수 있다.
React Native에서는 react-native-iap (RNIap) 라이브러리를 활용하여 이를 구현할 수 있다.


📌 Android 구독 프로모션 예제 코드

import RNIap, { requestPurchase, getSubscriptions } from 'react-native-iap';

const itemSkus = ['your_subscription_sku']; 
// Play Store에서 등록한 SKU, array 형식으로 한번에 여러 susbsription 가져온다. 

const fetchSubscriptionDetails = async () => {
  try {
    const subscriptions = await getSubscriptions(itemSkus);
    console.log('구독 상품 정보:', subscriptions);
  } catch (error) {
    console.error('구독 정보 가져오기 실패:', error);
  }
};

// 구독 결제 요청
const purchaseSubscription = async ({ productId, offerToken, purchaseTokenAndroid, replacementModeAndroid }) => {
  try {
    const result = await requestSubscription({
      purchaseTokenAndroid, // 기존 구독이 있을 경우 대체
      replacementModeAndroid, // 구독 교체 방식 설정
      subscriptionOffers: [
        {
          offerToken, // Play Store에서 설정한 프로모션 OfferToken
          sku: productId, // 구독 SKU
        },
      ],
    });
    // successCallback 처리 등은 알아서..
    console.log('구매 성공:', result);
  } catch (error) {
    console.error('구매 실패:', error);
  }
};

 

🔹 정리

  • Android에서는 Play Store에서 프로모션 OfferTag를 생성 후,
  • RNIap 라이브러리를 사용하여 상품 정보 조회 및 결제 요청을 수행하면 된다.

📌 iOS 구독 프로모션의 차이점

iOS에서는 Android보다 구독 프로모션 정책이 조금 더 복잡하다. 
그 이유는 "Introductory Offer"를 받을 수 있는 유저"Promotional Offer"를 받을 수 있는 유저서로 다르게 분리되어 있기 때문이다.


🔹 iOS 구독 프로모션 정책

1. Introductory Offer (신규 구독자용)

  • 신규 구독자(해당 SKU로 처음 구독하는 유저)만 적용 가능
  • 할인 가격, 무료 체험(free trial), 1개월 50% 할인 등 다양한 옵션 제공
  • 사용자는 Apple ID 기준으로 한 번만 해당 프로모션을 받을 수 있음

2. Promotional Offer (기존 구독자용)

  • 이전에 구독한 적 있는 사용자만 받을 수 있음
  • 구독이 만료되었거나, 현재 구독 중인 사용자를 대상으로 특정 프로모션 제공 가능
  • signedTransactionIdentifier를 포함한 인증이 필요
  • Apple의 서버-투-서버 인증 (Server-to-Server Verification) 과정 필요

📌 공식 문서:


🔹 iOS 구독 프로모션 적용 코드 (React Native)

1️⃣ 구독 가능한 유저 유형 확인하기

나는 isIosIntroductoryOfferEligible 를 originalTransactionIds 기준으로 서버에서 구매 영수증 기준으로 판단하고 

const isIntroductoryPriceUser = (introductoryPrice !== '') && data.ios.isIntroductoryOfferEligible;

 

와 같은 형식으로 유저 유형을 확인했다. client 에서 구매 내역을 아래 코드와 같이 확인할 수도 있을 것이다. 

import RNIap, { getSubscriptions, getPurchaseHistory } from 'react-native-iap';

const checkUserEligibility = async (productId) => {
  try {
    // 기존 구매 내역 확인 (구독 이력이 있는지)
    const purchaseHistory = await getPurchaseHistory();
    const hasSubscribedBefore = purchaseHistory.some(purchase => purchase.productId === productId);

    if (hasSubscribedBefore) {
      console.log('기존 구독자 → Promotional Offer 적용 가능');
      return 'PROMOTIONAL_OFFER';
    } else {
      console.log('신규 구독자 → Introductory Offer 적용 가능');
      return 'INTRODUCTORY_OFFER';
    }
  } catch (error) {
    console.error('구독 내역 확인 실패:', error);
    return null;
  }
};

 

isIosIntroductoryOfferEligible 일 때 적용할 수 있는 product 정보는 subscription.introductoryPriceAsAmountIOS 으로 내려오는데 introductoryPriceAsAmountIOS 이 대상유저인 경우에만 내려오면 좋겠지만.. 해당 가설을 확인해주는 근거를 공식문서에서는 찾지 못했다. (introductoryPrice 도 introductoryPriceAsAmountIOS 에서 가져온 데이터)

 

다만 경험적으로는 isIosIntroductoryOfferEligible 일때만 introductoryPriceAsAmountIOS 이 내려오는 것 같다..?

 

요 부분에 대한 근거는 또 복잡한 이야기인데 originalTransactionIds 는 거의 app stroe 결제유저의 id 처럼 쓰인다. 하지만 바뀌는 경우가 종종 있기 때문에 originalTransactionIds 기준으로 isIosIntroductoryOfferEligible 으로 판단하는 것은 100% 확신할 수는 없다. 따라서 프로모션 대상자이지만 일반 플랜으로 iap 결제 팝업이 뜨는 엣지 케이스가 발생한다는 이야기가된다. 하지만 프로모션 진행시 관련 CS 는 한건도 없었다

 

기능:

  • getPurchaseHistory()를 사용하여 이전 구독 이력이 있는지 확인
  • 구독 이력이 없으면 Introductory Offer 가능
  • 구독 이력이 있으면 Promotional Offer 가능

2️⃣ 구독 결제 요청 (Introductory Offer 또는 Promotional Offer 적용)

그냥 promotion offer 로 통일해서 결제 요청하면 안되나? 하고 생각하는 사람이 있겠지만 그러면 isIosIntroductoryOfferEligible true 인 유저에게 결제 에러가 난다. 프로모션을 진행하면서 어마무시한 CS 공격을 받고 싶은 개발자는 없을 것 이다..

 

에러 예시

 

import RNIap, { getPurchaseHistory, requestSubscription, requestPurchase } from 'react-native-iap';

const handleSubscription = async ({ productId, options, introductoryPrice }) => {
  try {
    const eligibility = await checkUserEligibility(productId);

    if (eligibility === 'PROMOTIONAL_OFFER') {
      console.log('📌 Promotional Offer 적용 시도');

      try {
        const iosOffer = await onIosOffer({
          offerId: options.discount.identifier,
          originalTransactionIds: options.originalTransactionIds,
          productId,
        });

        if (iosOffer && iosOffer.body?.data) {
          console.log('✅ Promotional Offer 적용 성공');
          const offerData = iosOffer.body.data;

          // Promotional Offer 적용된 구독 요청
          const result = await requestPurchase({
            appAccountToken: (offerData.authId || offerData.fakeId || 'not-determined').toString(),
            sku: productId,
            withOffer: {
              identifier: offerData.offerId,
              keyIdentifier: offerData.keyId,
              nonce: offerData.nonce,
              signature: offerData.signature,
              timestamp: offerData.timestamp,
            },
          });

          return result;
        }
      } catch (err) {
        console.error('❌ onIosOffer 실패:', err);
      }
    }

    console.log('📌 Introductory Offer 또는 일반 구독 요청');
    return await requestSubscription({ sku: productId });

  } catch (error) {
    console.error('❌ 구독 처리 실패:', error);
    throw error;
  }
};

 

기능:

  • Introductory Offer (신규 구독자) → offerToken만 사용
  • Promotional Offer (기존 구독자) → offerToken + signedTransactionIdentifier 필요

📌 정리

  • iOS 구독 프로모션은 신규 유저(Introductory)와 기존 유저(Promotional)를 분리해서 처리해야 함
  • getPurchaseHistory()를 통해 사용자의 구독 이력을 먼저 확인
  • Introductory Offer는 신규 유저만 가능
  • Promotional Offer는 기존 구독자만 가능하며, 추가 인증(signedTransactionIdentifier)이 필요 -> 여기서는 offerData 를 서버에서 받아오는 것으로 대체

혹시 모르니까 fallback 으로 normal plan 결제 요청 코드를 넣어두자. 

 


 

정리하면, 

인앱결제로 구독을 구현하는 개발자들은 프로모션 피쳐를 시작할 때 ios introductory offer 를 어떻게 유연하게 대쳐할지 고민해야 한다. 오늘 포스팅은 그런 고민에 대한 내 답변이다.