리액트 네이티브 (React native) 인앱 구매: 간단한 구현. 사용 지침서
Updated: 5월 23, 2023
10 min read
플랫폼간 앱 (app)개발프레임워크는 확실히 개발자의 삶을 더 쉽게 만들어주어, 한번에 여러 플랫폼용 앱을 빌드할 수 있게 해줍니다.하지만몇 가지 단점이 있습니다.예를들어,리액트네이티브 (ReactNative)에는인앱 구매 (in-apppurchase)를구현하기 위한 기성 도구가 없습니다.따라서,제3자라이브러리를 바라볼 수 밖에 없습니다.
인앱구매 구현 (purchase implementation)을위한 옵션
리액트네이티브 앱의 인기 있는 인앱 구독 (in-appsubscription) 라이브러리는react-native-iap와expo-in-app-purchases입니다.하지만react-native-adapty에대해 이야기할 건데,다른라이브러리에 비해 상당한 이점이 있기 때문입니다:
- 다른 라이브러리와는 달리, 서버 기반 구매 검증 (purchase validation)을 제공합니다.
- 프로모션 할인부터 선불 (pay-upfront) 기능에 이르기까지, 앱스토어에서 최근 구현된 모든 기능을 지원합니다. 또한 새로운 기능을 빠르게 지원합니다.
- 코드는 더 명확하고 간단해집니다.
- 전체 출시 주기가 지나지 않아도, 제품 판매를 수정하고 새로운 판매를 추가하거나 제거할 수 있습니다. 베타 버전을 출시하고 승인을 기다릴 필요가 없습니다.
AdaptySDK에는그보다 훨씬 더 많은 것이 있습니다.모든주요 메트릭,코호트분석 (cohortanalysis), 서버기반 구매 검증,페이월(paywall)에대한 AB테스트(ABtesting), 유연하게세분화할 수 있는 프로모션 캠페인,제3자분석 도구 통합 등을 위한 내장 분석 도구가 제공됩니다.
이기사에서 다룰 내용
지금은리액트 네이티브 앱에서 인앱 구매를 설정하는 방법에대해 이야기해 보겠습니다.오늘다룰 내용은 다음과 같습니다.
- 리액트 네이티브 앱의 인앱 구매에 Expo가 작동하지 않는 이유.
- 개발자 계정 만들기.
- Adapty 환경설정하기:
App Store 환경설정
Play Store 환경설정 - 구독을 추가하기.
- 페이월 생성하기.
- react-native-adapty 설치하기.
- 샘플 앱 및 결과.
이가이드에서는,구독한사용자에게 고양이 사진을 표시하고 다른 모든 사용자에게구독 제안을 표시하는 앱을 빌드하려고 합니다.
리액트네이티브 앱의 인앱 구매에 Expo가작동하지 않는 이유
간단하게말하자면:Expo”managed”는구매 처리를 위해 앱 스토어에서 제공하는 네이티브메소드 (스토어키트 (storekits)라고도함)를지원하지 않습니다.순수한RN을고수하거나,Expo 베어워크플로우를 사용해야 합니다.
단도직입적으로,Expo를사용하려고 생각한 사람들에게는 실망스럽지만,이것은작동하지 않습니다.Expo는앱 개발을 훨씬 쉽게 해주는 리액트 네이티브프레임워크입니다.그러나매니지드 워크플로우는 구매/구독처리와 호환되지 않습니다.Expo는스토어 키트에 필요한 메소드와 컴포넌트 (둘다 JS전용)에네이티브 코드를 사용하지 않습니다.JavaScript를사용하는 모바일 스토어에서 인앱 구매를 구현할 수있는 방법이 없으므로,”나야합니다”.
개발자계정 만들기
먼저앱 스토어 계정을 설정하고 iOS및Android모두에대한 구매 및 구독을 생성 및 구성해야 합니다.20분이상 걸리지 않을 것입니다.
AppStore Connect 및/또는GooglePlay Console에서개발자 계정 및 제품을 아직 구성하지 않은 경우,다음가이드를 참조하세요.
- iOS용: 가이드를 처음부터 “SKProduct 목록 가져오기” 제목까지 읽으십시오. 여기에서 네이티브 구현에 대해 다루기 시작하는 부분이기 때문입니다.
- Android용: 가이드를 처음부터 “앱에서 제품 목록 가져오기” 제목까지 읽으십시오.
Adapty 환경설정
react-native-adapty의경우 먼저 Adapty대시보드를구성해야 합니다.시간이많이 걸리지는 않지만,위에나열된 Adapty가하드 코딩에 비하여 갖는 모든 이점을 얻을 수 있습니다.
세번째 단계에서는 AppStore 및GooglePlay 구성을묻는 메시지가 표시됩니다.
iOS의경우,
- 번들 ID를 지정합니다.
- App Store 서버 알림 (Server Notifications)을 설정합니다.
- App Store Connect 공유 암호를 지정합니다.
이필드는 구매가 작동하는 데 필요합니다.
각필드에는 단계별 방법 가이드가 포함된 ‘Readhow’ 힌트가있습니다.질문이있는 경우 이를 확인하십시오.
번들ID는앱의 고유 ID입니다.Xcode의Targets> [앱이름]> General에서지정한 것과 일치해야 합니다:
Android의경우,필수필드는 패키지 이름 및 서비스 계정 키 파일입니다.이모든 필드에는 각각 Readhow 힌트도있습니다.Android에서패키지 이름은 iOS에서번들 ID와같은 역할을 합니다.이는android.defaultConfig.applicationId의/android/app/build.gradle파일에서찾을 수 있는 코드에 지정한 것과 일치해야 합니다.
네번째 단계에서는 AdaptySDK를앱에 연결하라는 메시지가 표시됩니다.지금은이 단계를 건너뛰십시오.잠시후에 다시 설명하겠습니다.
가입한후 설정 탭을 확인하고,여기에서공개 SDK키를찾을 수 있다는 점을 기억하십시오.나중에키가 필요합니다.
구독추가하기
Adapty는다양한 구독에 제품을 사용합니다.고양이사진 구독은 주간,6개월또는 연간이 될 수 있습니다.이러한각 옵션은 별개의 Adapty제품이됩니다.
하나의제품이 있다고 대시보드에 지정해 보겠습니다.이렇게하기 위해,Products & A/B Tests → Products 로이동하여 Createproduct를클릭합니다.
여기에서제품 이름,즉이 구독이 Adapty대시보드에표시되는 방식을 지정해야 합니다.
AppStore 제품ID와PlayStore 제품ID도지정해야 합니다.원하는경우,분석을위한 기간과 이름도 지정합니다.Save를클릭합니다.
페이월생성하기
이제사용자의 프리미엄 기능 접근을 제한하고 구독 제안을표시하는 화면인 페이월을 디자인해야 합니다.생성한제품을 페이월에 추가해야 합니다.그렇게하려면 동일한 섹션에서 Createpaywall을클릭합니다.(Products & A/B Tests → Paywalls)
- 귀하와 귀하의 팀이 이름만 보고 어떤 페이월인지 쉽게 알 수 있는 페이월 이름을 선택하십시오.
- 페이월 ID를 사용하여 앱에 이 페이월을 표시합니다. 샘플 앱의 경우, “cats_paywall”을 사용하겠습니다.
- 제품 드롭다운에서 구독을 선택합니다.
Save &publish를클릭합니다.
이렇게하면 환경 설정이 됩니다.이제종속성을 추가하고 코드를 작성합니다.
react-native-adapty 설치하기
1. 먼저종속성을 추가합니다.
yarn add react-native-adapty
2. iOS포드를설치합니다.아직CLI 포드가없다면 다운로드하기를강력히 추천합니다.iOS 개발에서확실히 많이 필요할 것입니다.
#pods get installed into the native iOS project, which, by default, is the /ios folderpod install --project-directory=ios
3. iOS리액트네이티브 프로젝트는 Obj-C로작성되었으므로,Obj-C가Swift라이브러리를읽을 수 있도록 SwiftBridging Header를생성해야 합니다.그렇게하려면 Xcode프로젝트를열고 새 Swift파일을생성하기만 하면 됩니다.Xcode는브리징 헤더를 생성할 것인지 물어보는데,바로그걸 하려는 거죠.Create을클릭합니다.
4.Android의경우,프로젝트(기본적으로/android/build.gradle)가버전 1.4.0이상의kotlin-gradle-plugin을사용하고 있는지 확인하세요.
... buildscript { ... dependencies { ... classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.4.0" } } ...
5.Android의경우,앱의구성 파일 (기본적으로/android/app/build.gradle)에서찾을 수 있는 multiDex를활성화해야 합니다.
... android { ... defaultConfig { ... multiDexEnabled true } }
자,이제준비가 끝났고 코딩을 시작할 수 있습니다!
앱에서제품 목록 검색하기
react-native-adapty아래에서많은 일이 일어나고 있습니다.조만간이것들이 반드시 필요할 것이므로,플로우의시작 지점에서 라이브러리를 초기화해야 합니다.앱코드에서 가능한 한 높이 이동하여 (App.tsx에서도바로 수행할 수 있음)초기화를시작합니다.
// import the method import { activateAdapty } from 'react-native-adapty'; // We’ve had this App component in our app’s root const App: React.FC = () => { ... // we’re invoking it once in a root component on mount useEffect(() => { activateAdapty({ sdkKey: 'MY_PUBLIC_KEY' }); },[]); ... }
여기에서MY_PUBLIC_KEY를대시보드 설정에 있는 공개 SDK키로바꾸십시오.사실,activateAdapty() 메소드는한 번 이상 그리고 여러 곳에서 호출될 수 있지만,우리는이 디자인을 고수할 것입니다.
이제Adapty대시보드에추가한 제품을 검색할 수 있습니다.
import { adapty } from 'react-native-adapty'; async function getProducts() { const {paywalls, products} = await adapty.paywalls.getPaywalls(); return products; }
이제연습해 보겠습니다.우리는페이월에서 제품을 검색하고 구매할 수 있는 작은 앱을만들어 볼 것입니다.
샘플앱
기본논리가 지나치게 복잡해지지 않도록 이 정도로 짧게유지하겠습니다.또한TypeScript로코딩하여,사용되는유형과 위치를 보여줍니다.테스트를위해 iPhone8을사용하겠습니다.iOS 14부터AppStore는에뮬레이터에서 스토어 키트 사용을 금지합니다.물리적기기를 사용해서만 테스트할 수 있습니다.
App.tsx루트컴포넌트
1. 먼저,페이월표시 버튼이 있는 App.tsx루트컴포넌트를 생성합니다.우리는이미 react-native-navigation을통해 네비게이션을 구성했습니다.공식문서에서 권장하는 react-navigation옵션보다훨씬 낫다고 생각합니다.
문제
import React, { useEffect, useState } from "react"; import { Button, StyleSheet, View } from "react-native"; import { adapty, activateAdapty, AdaptyPaywall } from "react-native-adapty"; export const App: React.FC = () => { const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]); useEffect(() => { async function fetchPaywalls(): Promise<void> { await activateAdapty({ sdkKey: "MY_PUBLIC_KEY" }); const result = await adapty.paywalls.getPaywalls(); setPaywalls(result.paywalls); } fetchPaywalls(); }, []); return ( <View style={styles.container}> <Button title="Show the paywall" onPress={() => { const paywall = paywalls.find( (paywall) => paywall.developerId === "cats_paywall" ); if (!paywall) { return alert("There is no such paywall"); } // Switching to a paywall... }} /> </View> ); }; const styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center" }, });
무슨일이죠?마운트시,fetchPaywalls() 함수가호출됩니다.SDK를활성화하고 페이월을 스테이트에 저장하여,사용자가버튼을 탭한 후 가져오기를 기다릴 필요가 없습니다.겉으로보기에는,이전에대시보드에서 디자인한 페이월로 사용자를 안내하는버튼 하나만 있습니다.
사실,페이월을스테이트에 저장하지 않고 바로 여기에서 가져올 수있습니다.기본적으로adapty.paywalls.getPaywalls()는(시작할때 캐시한 후)캐시저장소에서 페이월을 가져오기 때문에,메소드가서버와 통신할 때까지 기다릴 필요가 없다는 뜻입니다.
결과는다음과 같습니다.
페이월컴포넌트
2. 같은파일에 페이월 컴포넌트를 작성해 보겠습니다.
// there are more imports here import React, { useEffect, useState } from "react"; import { Button, SafeAreaView, StyleSheet, Text, View, PlatformColor, } from "react-native"; import { adapty, activateAdapty, AdaptyPaywall, AdaptyProduct, } from "react-native-adapty"; import { Navigation } from "react-native-navigation"; // ... interface PaywallProps { paywall: AdaptyPaywall; onRequestBuy: (product: AdaptyProduct) => void | Promise<void>; } export const Paywall: React.FC<PaywallProps> = ({ paywall, onRequestBuy }) => { const [isLoading, setIsLoading] = useState<boolean>(false); return ( <SafeAreaView style={styles.container}> {paywall.products.map((product) => ( <View key={product.vendorProductId}> <Text>{product.localizedTitle}</Text> <Button title={`Buy for за ${product.localizedPrice}`} disabled={isLoading} onPress={async () => { try { setIsLoading(true); await onRequestBuy(product); } catch (error) { alert("Error occured :("); } finally { setIsLoading(false); } }} /> </View> ))} </SafeAreaView> ); }; // A new key const styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center" }, paywallContainer: { flex: 1, alignItems: "center", justifyContent: "space-evenly", backgroundColor: PlatformColor("secondarySystemBackground"), }, });
여기서는페이월의 제품을 매핑하고 각 제품 옆에 구매 버튼을표시하기만 하겠습니다.
화면등록하기
3. 이화면이 어떻게 나타나는지 보기 위해,react-native-navigation에이 화면을 등록해 봅시다.다른네비게이션을 사용하는 경우 이 단계를 건너뜁니다.루트index.js파일은다음과 같습니다.
import "react-native-gesture-handler"; import { Navigation } from "react-native-navigation"; import { App, Paywall } from "./App"; Navigation.registerComponent("Home", () => App); Navigation.registerComponent("Paywall", () => Paywall); Navigation.events().registerAppLaunchedListener(() => { Navigation.setRoot({ root: { stack: { children: [{ component: { name: "Home" } }] } }, }); });
“Display the paywall” 버튼
4. 이제”Display the paywall” 버튼에작업을 할당하기만 하면 됩니다.우리의경우,네비게이션을통해 모달을 표시하게 됩니다.
Navigation.showModal<PaywallProps>({ component: { name: "Paywall", passProps: { paywall, onRequestBuy: async (product) => { const purchase = await adapty.purchases.makePurchase(product); // Doing everything we need console.log("purchase", purchase); }, }, }, });
전체App.tsx파일:
import React, { useEffect, useState } from "react"; import { Button, SafeAreaView, StyleSheet, Text, View, PlatformColor, } from "react-native"; import { adapty, activateAdapty, AdaptyPaywall, AdaptyProduct, } from "react-native-adapty"; import { Navigation } from "react-native-navigation"; export const App: React.FC = () => { const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]); useEffect(() => { async function fetchPaywalls(): Promise<void> { await activateAdapty({ sdkKey: "MY_PUBLIC_KEY", }); const result = await adapty.paywalls.getPaywalls(); setPaywalls(result.paywalls); } fetchPaywalls(); }, []); return ( <View style={styles.container}> <Button title="Show paywall" onPress={() => { const paywall = paywalls.find( (paywall) => paywall.developerId === "cats_paywall" ); if (!paywall) { return alert("There is no such paywall"); } Navigation.showModal<PaywallProps>({ component: { name: "Paywall", passProps: { paywall, onRequestBuy: async (product) => { const purchase = await adapty.purchases.makePurchase(product); // Doing everything we need console.log("purchase", purchase); }, }, }, }); }} /> </View> ); }; interface PaywallProps { paywall: AdaptyPaywall; onRequestBuy: (product: AdaptyProduct) => void | Promise<void>; } export const Paywall: React.FC<PaywallProps> = ({ paywall, onRequestBuy }) => { const [isLoading, setIsLoading] = useState<boolean>(false); return ( <SafeAreaView style={styles.paywallContainer}> {paywall.products.map((product) => ( <View key={product.vendorProductId}> <Text>{product.localizedTitle}</Text> <Button title={`Buy for ${product.localizedPrice}`} disabled={isLoading} onPress={async () => { try { setIsLoading(true); await onRequestBuy(product); } catch (error) { alert("Error occured :("); } finally { setIsLoading(false); } }} /> </View> ))} </SafeAreaView> ); }; const styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center" }, paywallContainer: { flex: 1, alignItems: "center", justifyContent: "space-evenly", backgroundColor: PlatformColor("secondarySystemBackground"), }, });
다됐습니다!이제이러한 페이월을 사용자에게 표시할 수 있습니다.
샌드박스(sandbox)에서iOS 구독을테스트하려면,자신만의샌드박스테스터 계정을생성해야 합니다.샌드박스구독은 테스트를 더 쉽게 하기 위해 금방 무효화된다는것을 기억하시기 바랍니다.Android의경우 추가 계정이 필요하지 않습니다.에뮬레이터에서테스트를 실행할 수도 있습니다.
사용자에게활성 구독이 있는지 확인하기
최종사용자에게 프리미엄 콘텐츠에 대한 접근 권한을부여하기 위해,활성구독 데이터를 저장할 위치를 결정해야 합니다.Adapty는여기서도 유용한데,사용자와관련된 모든 구매를 저장하기 때문입니다.이렇게합시다.사용자가구독이 없으면,페이월버튼이 표시됩니다.구독이있는 경우,고양이사진을 보여줍니다.
활성구독 데이터는 서버 또는 캐시 저장소에서 검색되므로,로더가필요합니다.단순화를위해 isLoading및isPremium상태를추가하겠습니다.
// ... export const App: React.FC = () => { const [isLoading, setIsLoading] = useState<boolean>(true); const [isPremium, setIsPremium] = useState<boolean>(false); const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]); useEffect(() => { async function fetchPaywalls(): Promise<void> { try { await activateAdapty({ sdkKey: "MY_PUBLIC_KEY", }); const profile = await adapty.purchases.getInfo(); const isSubscribed = profile.accessLevels.premium.isActive; setIsPremium(isSubscribed); if (!isSubscribed) { const result = await adapty.paywalls.getPaywalls(); setPaywalls(result.paywalls); } } finally { setIsLoading(false); } } fetchPaywalls(); }, []); // ... } // ...
변경된사항은 다음과 같습니다.스테이트에플래그 2개를추가했습니다.이제fetchPaywalls()의전체 내용이 try-catch블럭에싸여 있어서,가능한모든 시나리오에서 코드가 setIsLoading(false)에이를 수 있게 합니다.사용자에게활성 구독이 있는지 확인하기 위해,(모든구독 데이터 포함하는)사용자프로필을 검색하고 profile.accessLevels.premium.isActive값을확인합니다.원하는만큼 많은 접근 권한 수준 (accessLevels)을사용할 수 있지만 (기본적으로골드 또는 프리미엄 등의 구독 계층),지금은기본값을 유지하겠습니다.Adapty는프리미엄 접근 권한 수준을 자동으로 생성하며,대부분의앱에서는 이것으로 충분합니다.이접근 권한 수준의 활성 구독이 있는 동안 isActive는true로남게 됩니다.
여기서부터는모든 것이 매우 간단합니다.사용자가프리미엄 등급 구독 상태인 경우,페이월을가져올 필요가 없습니다.로더를비활성화하고 콘텐츠를 표시하기만 하면 됩니다.
export const App: React.FC = () => { // ... const renderContent = (): React.ReactNode => { if (isLoading) { return <Text>Loading...</Text>; } if (isPremium) { return ( <Image source={{ url: "https://25.media.tumblr.com/tumblr_lugj06ZSgX1r4xjo2o1_500.gif", width: Dimensions.get("window").width * 0.8, height: Dimensions.get("window").height * 0.8, }} /> ); } return ( <Button title="Show paywall" onPress={() => { const paywall = paywalls.find( (paywall) => paywall.developerId === "cats_paywall" ); if (!paywall) { return alert("There is no such paywall"); } Navigation.showModal<PaywallProps>({ component: { name: "Paywall", passProps: { paywall, onRequestBuy: async (product) => { const purchase = await adapty.purchases.makePurchase(product); const isSubscribed = purchase.purchaserInfo.accessLevels?.premium.isActive; setIsPremium(isSubscribed); Navigation.dismissAllModals(); }, }, }, }); }} /> ); }; return <View style={styles.container}>{renderContent()}</View>; };
여기에서onRequestBuy에대한 일부 논리와 콘텐츠를 렌더링하는 함수를 추가합니다.즉,isPremium의상태를 업데이트하고 모달을 닫습니다.
최종결과는 다음과 같습니다.
전체파일:
import React, { useEffect, useState } from "react"; import { Button, SafeAreaView, StyleSheet, Text, View, PlatformColor, Image, Dimensions, } from "react-native"; import { adapty, activateAdapty, AdaptyPaywall, AdaptyProduct, } from "react-native-adapty"; import { Navigation } from "react-native-navigation"; export const App: React.FC = () => { const [isLoading, setIsLoading] = useState<boolean>(true); const [isPremium, setIsPremium] = useState<boolean>(false); const [paywalls, setPaywalls] = useState<AdaptyPaywall[]>([]); useEffect(() => { async function fetchPaywalls(): Promise<void> { try { await activateAdapty({ sdkKey: "MY_PUBLIC_KEY", }); const profile = await adapty.purchases.getInfo(); const isSubscribed = profile.accessLevels.premium.isActive; setIsPremium(isSubscribed); if (!isSubscribed) { const result = await adapty.paywalls.getPaywalls(); setPaywalls(result.paywalls); } } finally { setIsLoading(false); } } fetchPaywalls(); }, []); const renderContent = (): React.ReactNode => { if (isLoading) { return <Text>Loading...</Text>; } if (isPremium) { return ( <Image source={{ uri: "https://25.media.tumblr.com/tumblr_lugj06ZSgX1r4xjo2o1_500.gif", width: Dimensions.get("window").width * 0.8, height: Dimensions.get("window").height * 0.8, }} /> ); } return ( <Button title="Show a paywall" onPress={() => { const paywall = paywalls.find( (paywall) => paywall.developerId === "cats_paywall" ); if (!paywall) { return alert("There is no such a paywall"); } Navigation.showModal<PaywallProps>({ component: { name: "Paywall", passProps: { paywall, onRequestBuy: async (product) => { const purchase = await adapty.purchases.makePurchase(product); const isSubscribed = purchase.purchaserInfo.accessLevels?.premium.isActive; setIsPremium(isSubscribed); Navigation.dismissAllModals(); }, }, }, }); }} /> ); }; return <View style={styles.container}>{renderContent()}</View>; }; interface PaywallProps { paywall: AdaptyPaywall; onRequestBuy: (product: AdaptyProduct) => void | Promise<void>; } export const Paywall: React.FC<PaywallProps> = ({ paywall, onRequestBuy }) => { const [isLoading, setIsLoading] = useState<boolean>(false); return ( <SafeAreaView style={styles.paywallContainer}> {paywall.products.map((product) => ( <View key={product.vendorProductId}> <Text>{product.localizedTitle}</Text> <Button title={`Buy for ${product.localizedPrice}`} disabled={isLoading} onPress={async () => { try { setIsLoading(true); await onRequestBuy(product); } catch (error) { alert("An error occured :("); } finally { setIsLoading(false); } }} /> </View> ))} </SafeAreaView> ); }; const styles = StyleSheet.create({ container: { flex: 1, alignItems: "center", justifyContent: "center" }, paywallContainer: { flex: 1, alignItems: "center", justifyContent: "space-evenly", backgroundColor: PlatformColor("secondarySystemBackground"), }, });
전체요약
우리는멋지고 매우 유용한 구독 앱을 구축하게 되었습니다.구독료를내는 사람들은 고양이를 보게 될 것이고,그외에는 대신 페이월을 받게 될 것입니다.이가이드를 통해 앱에서 인앱 구매를 구현하는 데 필요한모든 것을 배웠을 것입니다.스토어키트에 대해 더 자세히 알아보고 싶은 분들은,계속주목하고 계시기 바랍니다.감사합니다!