본문 바로가기
728x90
반응형

새로 들어간 회사에서 첫 업무는 인앱 결제 시스템을 어플리케이션 안에 도입하는 것이었다. 

상품 등록을 하는 방법은 아래 링크를 따라하면 등록할 수 있고, 이 글에서는 등록된 상품 id들을 바탕으로 어떻게 코드를 짜는지에 대해 다뤄 볼 예정이다. 

아래와 같은 플로우를 통해 우리는 인앱 결제 화면을 볼 수 있었다. 

인앱결제 플로우
안드로이드 구매 화면iOS 구매 화면

Android 인앱 상품 만들기

https://support.google.com/googleplay/android-developer/answer/1153481?hl=ko

 

인앱 상품 만들기 - Play Console 고객센터

도움이 되었나요? 어떻게 하면 개선할 수 있을까요? 예아니요

support.google.com

iOS 인앱 상품 등록하기

https://wp.swing2app.co.kr/knowledgebase/애플-인앱등록/

 

[인앱] 애플 앱스토어 인앱 상품 등록하기 – 스윙 도우미

해당 내용까지 모두 완료되면, 앱 상품 심사가 들어갑니다. 애플은 인앱 상품을 등록하게 되면 바로 앱에 인앱 제품이 적용되는 것이 아니구요. 인앱 상품 심사가 다시 진행됩니다.  (구글은 앱

wp.swing2app.co.kr

 

코드 리뷰

우선 인앱 상품을 구매하는 페이지에서는 아래와 같은 기능이 필요하다.

하나씩 코드와 함께 적어볼 예정이다. 

  • in_app_purchase package 설치    
  • 판매 중인 상품 정보 읽기
  • 구매 업데이트 이벤트 감지 기능
  • 구매 업데이트 이벤트 감지 스트림을 중지
  • 상품 구매
  • 서버에 구매 검증 요청

in_app_purchase package 설치

: 아래 링크를 통해 package를 설치하고, 기본 예제들을 확인할 수 있다. 

https://pub.dev/packages/in_app_purchase

 

in_app_purchase | Flutter package

A Flutter plugin for in-app purchases. Exposes APIs for making in-app purchases through the App Store and Google Play.

pub.dev

 

 

 

 

판매 중인 상품 정보 읽기

인앱 결제 상품 정보를 가져오는 비동기 함수 _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 정기 결제(구독 상품) iOS & Android

저번 이야기에서는 인앱 결제를 다루어보았다면, 저의 2번째 업무이었던 정기 결제에 대해 리뷰해보록 하겠습니다. 상품 등록은 이전 포스트에 있는 등록 방식에서 정기 결제 상품을 등록하면

quddkflty.tistory.com

 

 

728x90
반응형