새로 들어간 회사에서 첫 업무는 인앱 결제 시스템을 어플리케이션 안에 도입하는 것이었다.
상품 등록을 하는 방법은 아래 링크를 따라하면 등록할 수 있고, 이 글에서는 등록된 상품 id들을 바탕으로 어떻게 코드를 짜는지에 대해 다뤄 볼 예정이다.
아래와 같은 플로우를 통해 우리는 인앱 결제 화면을 볼 수 있었다.
Android 인앱 상품 만들기
https://support.google.com/googleplay/android-developer/answer/1153481?hl=ko
iOS 인앱 상품 등록하기
https://wp.swing2app.co.kr/knowledgebase/애플-인앱등록/
코드 리뷰
우선 인앱 상품을 구매하는 페이지에서는 아래와 같은 기능이 필요하다.
하나씩 코드와 함께 적어볼 예정이다.
- in_app_purchase package 설치
- 판매 중인 상품 정보 읽기
- 구매 업데이트 이벤트 감지 기능
- 구매 업데이트 이벤트 감지 스트림을 중지
- 상품 구매
- 서버에 구매 검증 요청
in_app_purchase package 설치
: 아래 링크를 통해 package를 설치하고, 기본 예제들을 확인할 수 있다.
https://pub.dev/packages/in_app_purchase
판매 중인 상품 정보 읽기
인앱 결제 상품 정보를 가져오는 비동기 함수 _getProducts()를 만들어주었다.
// 인앱 결제 상품 정보를 비동기적으로 가져오는 함수
Future<void> _getProducts() async {
// 가져올 상품의 고유 식별자(ID)를 선언
const Set<String> kIds = {
// 여기에 등록한 상품 id들을 나열하면 된다.
};
// 상품 정보를 쿼리하여 가져오기
final ProductDetailsResponse response =
await _iap.queryProductDetails(kIds);
// 찾을 수 없는 상품이 있는지 확인
if (response.notFoundIDs.isNotEmpty) {
// 여기에 처리 로직 추가 가능
}
// 가져온 상품 정보를 상태에 반영하여 UI 갱신
setState(() {
_products = response.productDetails;
});
}
구매 업데이트 이벤트 감지 기능
: 위젯이 초기화 되었을 때 필요한 정보들의 객체를 초기화한 후, 인앱 결제 상태 업데이트를 위한 환경을 만들어준다.
// 필요한 객체 선언
final InAppPurchase _iap = InAppPurchase.instance;
List<ProductDetails> _products = [];
StreamSubscription<List<PurchaseDetails>>? _purchaseUpdatedSubscription;
// 위젯이 상태를 초기화할 때 호출되는 함수
@override
void initState() {
super.initState();
// 상품 정보를 가져오는 비동기 함수 호출
_getProducts();
// 인앱 결제 상태 업데이트 이벤트를 구독하여 처리
_purchaseUpdatedSubscription =
_iap.purchaseStream.listen((purchaseDetailsList) {
// 인앱 결제 상태 업데이트 이벤트 리스트를 순회
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
// 결제 상태가 'purchased'인 경우 (구매 성공)
if (purchaseDetails.status == PurchaseStatus.purchased) {
// 구매가 성공했을 때 Django 서버에 검증 요청
bool isVerified = await _verifyPurchase(purchaseDetails);
// 구매가 검증되었고, 아직 완료되지 않은 구매 트랜잭션이라면
if (isVerified && purchaseDetails.pendingCompletePurchase) {
// 인앱 결제 트랜잭션을 완료함
await InAppPurchase.instance.completePurchase(purchaseDetails);
}
}
// 결제 상태가 'error'인 경우 (구매 실패)
else if (purchaseDetails.status == PurchaseStatus.error) {
// 에러가 발생한 경우 구매 트랜잭션을 완료함
if (purchaseDetails.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchaseDetails);
}
}
});
});
}
구매 업데이트 이벤트 감지 스트림을 중지
: 앱에서 이벤트 스트림을 구독했을 때, 해당 화면이 제거되거나 더 이상 필요하지 않을 때 이벤트 스트림을 중지해야한다. 이렇게 하지 않으면 메모리 누수가 발생할 수 있기 때문이다.
// 구매 업데이트 이벤트 감지 스트림을 중지
@override
void dispose() {
_purchaseUpdatedSubscription?.cancel();
super.dispose();
}
상품 구매
: 상품을 클릭했을 때 인앱 결제 로직이 나올 수 있도록 한다. buyConsumable 함수는 인앱 결제를 통해 소비 가능한 상품(Consumable)을 구매하는 데 사용되는데, 이때 purchaseParam에 구매할 상품의 세부 정보를, autoConsume에 자동 소비를 설정합니다. autoConsume을 true로 하여야 사용자가 바로 재구매가 가능합니다!
// 특정 상품을 구매하는 함수
Future<void> _buyProduct(ProductDetails product) async {
// 구매에 필요한 매개변수 설정
final PurchaseParam purchaseParam = PurchaseParam(productDetails: product);
// 상품을 구매하기 위해 인앱 결제를 실행
await _iap.buyConsumable(purchaseParam: purchaseParam, autoConsume: true);
}
서버에 구매 검증 요청: 만약 서버를 사용하고 있다면 확실한 구매 요청 로직을 추가하는 것이 좋다. 저는 최고의 백엔드 개발자가 뚝딱뚝딱 만들어줘서 금방 했습니다 :) iOS와 Andriod에 대해서 구매 요청을 위한 로직이 다르니 참고 바랍니다 :)
// 서버에 구매 검증 요청
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
// 플랫폼 확인
String platform =
Theme.of(context).platform == TargetPlatform.iOS ? 'apple' : 'google';
// POST 데이터
Map<String, dynamic> purchaseData = {
'platform': platform,
};
// 플랫폼에 따라 필요한 데이터를 추가
if (platform == 'apple') {
purchaseData['encoded_receipt_data'] =
purchaseDetails.verificationData.localVerificationData;
} else if (platform == 'google') {
purchaseData['product_id'] = purchaseDetails.productID;
purchaseData['purchase_token'] = purchaseDetails.purchaseID;
}
// Django Post
try {
{구매 검증 함수를 적어주세요}
return true;
} catch (e) {
return false;
}
}
전체 코드
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:intl/intl.dart';
class PaymentPage extends StatefulWidget {
const PaymentPage({Key? key}) : super(key: key);
@override
State<PaymentPage> createState() => _PaymentPageState();
}
class _PaymentPageState extends State<PaymentPage> {
late final InAppPurchase _iap;
List<ProductDetails> _products = [];
StreamSubscription<List<PurchaseDetails>>? _purchaseUpdatedSubscription;
@override
void initState() {
super.initState();
// 인앱 결제 객체 초기화
_iap = InAppPurchase.instance;
// 판매 중인 상품 정보 가져오기
_getProducts();
// 구매 업데이트 이벤트 감지 구독
_purchaseUpdatedSubscription =
_iap.purchaseStream.listen((purchaseDetailsList) {
// 인앱 결제 상태 업데이트 이벤트 처리
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
if (purchaseDetails.status == PurchaseStatus.purchased) {
// 구매가 성공했을 때 검증 및 처리
bool isVerified = await _verifyPurchase(purchaseDetails);
if (isVerified && purchaseDetails.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchaseDetails);
}
} else if (purchaseDetails.status == PurchaseStatus.error) {
// 에러가 발생한 경우 처리
if (purchaseDetails.pendingCompletePurchase) {
await InAppPurchase.instance.completePurchase(purchaseDetails);
}
}
});
});
}
@override
void dispose() {
// 구매 업데이트 이벤트 감지 스트림 중지
_purchaseUpdatedSubscription?.cancel();
super.dispose();
}
// 판매 중인 상품 정보 읽기
Future<void> _getProducts() async {
const Set<String> kIds = {
'soullinkcoin_10',
'soullinkcoin_20',
'soullinkcoin_50',
'soullinkcoin_100',
'test_soulcoin'
};
final ProductDetailsResponse response =
await _iap.queryProductDetails(kIds);
if (response.notFoundIDs.isNotEmpty) {}
setState(() {
_products = response.productDetails;
});
}
// 상품 구매
Future<void> _buyProduct(ProductDetails product) async {
final PurchaseParam purchaseParam = PurchaseParam(productDetails: product);
await _iap.buyConsumable(purchaseParam: purchaseParam, autoConsume: true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFD84DC), Color(0xFF68E4FF)],
),
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 20),
child: Container(
width: 350,
height: 45,
margin: const EdgeInsets.only(bottom: 26, top: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: <Widget>[
IconButton(
icon: const Icon(Icons.arrow_back_ios, size: 36),
onPressed: () => Navigator.pop(context),
),
],
),
),
),
// 로고 및 설명 등의 위젯들은 여기에 배치
Expanded(
child: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
return ListTile(
title: Center(
child: Text(
_products[index].title,
textAlign: TextAlign.center,
),
),
onTap: () => _buyProduct(_products[index]),
);
},
),
),
],
),
),
);
}
// 상품 구매 검증
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
// 구매 검증 로직은 여기에 작성
return true; // 임시로 true 반환
}
}
혹시 정기 결제(구독상품) 방법이 궁금하다면 아래 링크도 봐주세요!
2024.02.18 - [코딩/Flutter] - Flutter 정기 결제(구독 상품) iOS & Android
'코딩 > Flutter' 카테고리의 다른 글
Flutter 백그라운드 알림 (Cloud Messaging) (4) | 2024.03.03 |
---|---|
Flutter 정기 결제(구독 상품) iOS & Android (3) | 2024.02.18 |
[Flutter] Factory constructors (3) | 2023.11.06 |
[Flutter] flutter 버전 업그레이드 오류 뜰 때 (0) | 2022.10.31 |
[Flutter] Theme 파일 (사진 첨부) (0) | 2022.10.29 |