2024-03-25T17:03:33.000Z
회사에서 App Store 인앱결제 기능을 테스트해볼 필요가 있었다.
따라서 RN을 기본 베이스로 하여, react-native-iap 이라는 인앱결제 라이브러리를 사용해서 이를 테스트해보았다.
이 라이브러리의 주요 기능은 다음과 같다.
이때, StoreKit 2라는 iOS의 최신 결제 API를 사용한다면, 추가로 다음이 가능해진다.
하지만 StoreKit 2는 iOS 15 이상의 기기에서만 사용가능한 API이다.
iOS 15 호환성 목록을 살펴보면, 2015년에 출시된 iPhone 6s까지도 iOS 15를 지원한다. 즉, StoreKit 2 API를 지원한다.
약 10년 전 출시된 기기까지도 iOS 15를 지원하니까, 호환성 이슈는 엄청 크지는 않을 것이다.

개발자 계정을 위해서는 연간 129,000원을 지불해야 하므로, 이 점 유의하며 진행하자… 🥲
코드를 작성하기 전에, 애플 개발자 콘솔에서 다음과 같은 절차를 밟아야 한다.
developer.apple.com/account/resources/identifiers/list
위 링크에서 Bundle ID를 생성할 수 있다.
실제 사용되는 값은 [IDENTIFIER]으로, com.reactjs.native.GreenInappTest로 작성해 주었다.
appstoreconnect.apple.com/apps 으로 접속한 뒤, 앱 생성 버튼(+ 모양)을 클릭한다.
이름, 기본 언어, 번들 ID, SKU를 작성하고, 생성 버튼을 클릭하면, 앱이 대시보드에 생성된다.
생성된 앱을 클릭하여 앱 세부 정보 페이지로 이동한다.
수익화 > 앱 내 구입 > 생성 버튼을 클릭하자.
유형, 식별 정보, 제품 ID를 작성한 후 생성 버튼을 클릭하면 상품을 생성할 수 있다.
1️⃣ 소모품(Consumables)
2️⃣ 비소모품(Non-Consumables)
내가 생성한 앱은 실제 스토어에 등록된 앱이 아니기 때문에, 인앱 결제를 테스트하려면 애플에서 제공하는 테스트 계정인 샌드박스 계정을 사용해야 한다.
appstoreconnect.apple.com/access/users/sandbox
위 링크에 접속해서 테스트 계정을 생성할 수 있다.
여기서 까다로운 부분이 있는데, 이미 Apple ID로 생성되어 있는 계정을 샌드박스 계정으로 등록할 수 없다는 점이다!
이를 막으려면 Apple ID로 사용하지 않은 이메일을 사용해야 하는데, 나의 경우 내가 가진 모든 이메일은 애플 계정으로 등록되어 있는 상태였다… 🥲
하지만, 여기서 꼼수로 기존 이메일을 사용해서 샌드박스 계정을 생성할 수 있는 방법이 있다!! 바로 [id]+[숫자]@[나머지]와 같은 형태로 작성해도 기존 이메일 주소로 이메일 포워딩이 된다는 점을 이용하는 것이다.
예를 들어, 이메일 주소가 shren0812@gmail.com이라면 shren0812+0812@gmail.com으로 보내는 이메일 보내는 메일도 shren0812@gmail.com으로 이메일 포워딩이 되어 수신할 수 있다는 것이다.
상기한 트릭을 사용하면 위 사진과 같이 샌드박스 계정을 생성할 수 있다.
이제 해당 샌드박스 계정을, 테스트할 때 사용할 아이폰에 등록해 주어야 한다.
설정 > App Store > 샌드 박스 계정 항목에, 방금 생성한 샌드 박스 계정을 등록한다.
이제 개발 외적으로 해주어야 할 작업은 마무리되었다. 이번에는 react-native-iap 라이브러리를 사용하여 어떻게 코드를 작성해야 하는 지 알아보자.
프로젝트를 생성해보자. 테스트 환경은 다음과 같다.
[iOS]
- ruby: 2.7.8
- cocoapods: 1.15.2
[JS]
- react-native: 0.73.6
- react-native-iap: `12.13.0`ruby와 cocoapods이란?사실 RN 개발자라면 무엇인지 아실 테지만, 이들은 iOS 환경 설정, 앱의 의존성과 관련되어 있다.
ruby는 프로그래밍 언어로, iOS 개발에 직접적으로 사용되는 언어는 아니다. (직접적으로 사용되는 언어는 objective-c, swift) 하지만 cocoapods이 ruby로 작성되었기 때문에, cocoapods을 사용하기 위해서는 반드시 ruby가 설치되어야 한다.
JS로 비교하자면, node.js와 비슷한 역할을 담당한다고 생각하면 된다.
cocoapods는 iOS 앱의 의존성 관리자이다. 카메라, 인앱 결제, 위치기반 서비스 등의 iOS 네이티브 기능을 지원하는 라이브러리를 사용하고 싶다면, 아무리 개발 환경이 RN이라고 해도 cocoapods를 사용해서 해당 라이브러리를 관리해야 한다.
JS로 비교하자면, NPM과 비슷한 역할을 담당한다고 생각하면 된다.
App.tsx 파일을 다음과 같이 작성한다.
import {
IapIosSk2,
initConnection,
ProductPurchase,
requestPurchase,
setup,
useIAP,
withIAPContext,
} from 'react-native-iap';
const ITEM_SKUS = [
'new.item1',
'new.item2',
'new.item3',
'new.item4',
'phg_item',
'phg_item2',
'phg_item3',
'green_item',
];
function App(): React.JSX.Element {
const { products, getProducts, finishTransaction, currentPurchase } = useIAP();
const buy = async (sku: string) => {
try {
const result = await requestPurchase({
sku,
andDangerouslyFinishTransactionAutomaticallyIOS: false, // requestPurchase 호출 후 자동으로 finishTransaction을 호출할지 여부
});
console.log(result);
} catch (err) {
console.error(err);
}
};
const refund = async (sku: string) => {
try {
const result = await IapIosSk2.beginRefundRequest(sku); // 반환값: 'success' | 'userCancelled'
console.log(result);
} catch (err) {
console.error(err);
}
};
useEffect(() => {
(async () => {
// 결제 API로 StoreKit 2를 사용 (인앱 환불 기능을 사용하기 위함)
setup({storekitMode: 'STOREKIT2_MODE'});
await initConnection();
await getProducts({skus: ITEM_SKUS});
})();
}, [getProducts]);
useEffect(() => {
const checkCurrentPurchase = async (
purchase: ProductPurchase,
): Promise<void> => {
if (purchase) {
try {
console.log(JSON.stringify(purchase, null, 2));
const ackResult = await finishTransaction({purchase});
// andDangerouslyFinishTransactionAutomaticallyIOS 값이 false이기 때문에,
// requestPurchase 호출 후 finishTransaction을 수동으로 호출해야만 결제가 완료된다.
console.log(JSON.stringify(ackResult, null, 2));
} catch (err) {
console.error(err);
}
}
};
if (currentPurchase) {
checkCurrentPurchase(currentPurchase);
}
}, [currentPurchase, finishTransaction]);
// 상품 정보 확인
console.log(JSON.stringify(products, null, 2));
return (
<SafeAreaView>
<StatusBar />
<FlatList
data={ITEM_SKUS}
keyExtractor={item => item}
renderItem={({item: sku}) => (
<View>
<Button title={`${sku} 구매하기`} onPress={() => buy(sku)} />
<Button title={`${sku} 환불하기`} onPress={() => refund(sku)} />
</View>
)}
/>
</SafeAreaView>
);
}
export default withIAPContext(App);위 코드에서 편의상 스타일과 관련된 코드는 제거했지만, UI는 다음과 같이 만들어 주었다.
products 값은 다음과 같이 출력된다.
[
{
"title": "new.item1",
"productId": "new.item1",
"description": "",
"type": "iap",
"price": "1100",
"localizedPrice": "₩1,100",
"currency": "KRW"
},
{
"title": "new.item2",
"productId": "new.item2",
"description": "",
"type": "iap",
"price": "3300",
"localizedPrice": "₩3,300",
"currency": "KRW"
},
...
{
"title": "phg_item",
"productId": "phg_item",
"description": "",
"type": "iap",
"price": "4400",
"localizedPrice": "₩4,400",
"currency": "KRW"
}
]나열된 상품들 중 new.item1을 구매해보자.
구매 버튼을 누른 후, 최종적으로 승인 버튼까지 눌러야만 requestPurchase 메서드의 콜백이 호출된다.
requestPurchase 메서드가 반환하는 값은 다음과 같은 구조를 갖는다.
{
"productId": "new.item1",
"transactionId": "2000000556433469",
"transactionDate": 1711527550206,
"transactionReceipt": "",
"purchaseToken": "",
"quantityIOS": 1,
"originalTransactionDateIOS": 1711527550206,
"originalTransactionIdentifierIOS": 2000000556433469,
"verificationResultIOS": "eyJhb...qoUPmfQ",
"appAccountToken": "",
"transactionReasonIOS": "PURCHASE"
}실제 값은 JSON 문자열이 아닌 실제 JS 객체이다. 가독성을 위해 JSON.stringify 메서드를 사용하였을 뿐이다.
purchaseReceipt, purchaseToken이 빈 문자열인 이유Storekit 2 이전의 API를 사용하는 이전 방식의 경우, 구매 영수증을 수동을 검증하는 로직을 개발자가 작성했어야 했다. 따라서 영수증 정보를 의마하는 인코딩된 문자열을 디코딩하여 검증하는 과정이 필수적이었다고 한다.
하지만 StoreKit 2 API를 사용하는 경우 이러한 과정이 자동으로 처리되기 때문에, 사용 목적이 없어진 purchaseReceipt와 purchaseToken 필드는 단순 legacy로서 남아있기 때문이다.

이후에는 finishTransaction 메서드를 호출하여 결제를 완료해야 한다.
환불의 경우도 마찬가지로, 환불 버튼을 누른 후, 최종적으로 승인 버튼까지 눌러야만 beginRefundRequest 메서드의 콜백이 호출된다.
실제 앱 스토어에서 판매 중인 앱에서 테스트한 것이 아니라 샌드박스 환경에서 테스트한 것이긴 하지만, 인앱 결제를 구현하는 것은 생각보다 어렵지 않았다.
다만 인앱 환불을 사용하기 위해서는 StoreKit 2 API를 사용해야 하는데, 이는 iOS 15 이상의 기기에서만 지원한다는 점을 유의해야 한다.