-
[Flutter] 토스페이먼츠를 활용해 주문·결제 프로세스 적용해보기 feat. 인증 프로세스 #2Flutter/project 2024. 2. 21. 18:43
토스페이먼츠의 장점은 직접 UI를 제작하고 API를 통해 연동하는 방법뿐만아니라 Widget과 결제에 필요한 메서드를 제공해 비교적 수월하게 연동이 가능합니다. 물론 직접 UI를 제작하면 UI/UX 디자이너분이 굉장히 좋아하시겠지만(?) 결제 동의 약관이나 카드사 연동을 일일이 해야하므로 공수가 상당히 많이 들게됩니다. 이번 시간부터는 결제위젯을 사용해 실제 주문·결제 프로세스를 도입한 경험을 정리해보겠습니다.
[Flutter] 토스페이먼츠 결제모듈을 도입하다 #1 설계
결제 시스템을 도입할때, 모든 PG사를 하나하나 연결하는 것은 생각만해도 머리가 아픕니다. 이럴때 결제 플랫폼을 사용하면 여러 PG사와 간편결제 등 한번의 연동으로 쉽게 도입이 가능하죠. 연
nomal-dev.tistory.com
이전 글에서 계속 이어집니다. 1편을 먼저 보고 오시는 걸 추천드립니다.
코드를 하나씩 살펴보며 주문·결제 프로세스를 프로젝트에 어떻게 적용했는지 살펴보도록 하겠습니다.
tosspayments_widget_sdk_flutter: ^1.0.3
주문·결제 프로세스 ✓ Tosspayment Widget 전체 Code
class TossPaymentsView extends ConsumerStatefulWidget { const TossPaymentsView({super.key}); @override ConsumerState<TossPaymentsView> createState() { return _TossPaymentsViewState(); } } class _TossPaymentsViewState extends ConsumerState<TossPaymentsView> { late PaymentWidget _paymentWidget; PaymentMethodWidgetControl? _paymentMethodWidgetControl; AgreementWidgetControl? _agreementWidgetControl; @override void initState() { final int originPrice = ref.read(orderAmountControllerProvider).originPrice; super.initState(); _paymentWidget = PaymentWidget( clientKey: dotenv.env['TOSS_CLIENT_KEY']!, customerKey: Formats.generateShortUuid()); // 결제 ui를 렌더링하는 메서드 _paymentWidget .renderPaymentMethods( selector: dotenv.env['TOSS_METHOD_UI_KEY']!, // 첫 결제 금액 amount: Amount( value: originPrice, currency: Currency.KRW, country: "KR")) .then((control) { _paymentMethodWidgetControl = control; }); // 동의 ui식별자 중요 x _paymentWidget .renderAgreement(selector: dotenv.env['TOSS_AGREEMENT_UI_KEY']!) .then((control) { _agreementWidgetControl = control; }); } @override Widget build(BuildContext context) { final double height = MediaQuery.of(context).size.height; final double width = MediaQuery.of(context).size.width; final orderAmountState = ref.watch(orderAmountControllerProvider); return SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return Column( children: [ // 결제창 띄우기 PaymentMethodWidget( paymentWidget: _paymentWidget, selector: dotenv.env['TOSS_METHOD_UI_KEY']!, ), // 동의 화면 띄우기 AgreementWidget( paymentWidget: _paymentWidget, selector: dotenv.env['TOSS_AGREEMENT_UI_KEY']!, ), Container( height: 100, decoration: const BoxDecoration(color: Colors.white), child: Padding( padding: const EdgeInsets.only( left: 16, right: 16, top: 10, bottom: 35), // 1. 결제 금액 업데이트 // 2. orderId가져오기 // 3. 결제 시도 // 4. 주문테이블 생성 -> 새로운 화면에서 진행 // 결제를 시도하는 창 orderId, orderName 필요 // 고객이 선택한 결제수단의 결제창을 띄우는 메서드 child: GestureDetector( onTap: () async { // 🔥 약관 동의 여부 체크 final agreementStatus = await _agreementWidgetControl?.getAgreementStatus(); if (agreementStatus?.agreedRequiredTerms == null || agreementStatus?.agreedRequiredTerms == false) { // ignore: use_build_context_synchronously await popUp( context: context, mapper: PopUpMapper( title: '결제 약관에 동의해야\n주문을 할수있어요', titleFontSize: 16, rightBtn: '확인', height: height * 0.15, width: width, btnSize: height * 0.05, rightOnTap: () => Navigator.of(context).pop(), )); return; } // 결제가 가능할때 실행 // 1. 결제 금액 변경 -> 쿠폰 또는 포인트 적용 await _paymentMethodWidgetControl?.updateAmount( amount: orderAmountState.amount); // 2. orderName 제작 => 가주문 테이블 api 쏘기 => orderId획득 final String? orderId = await ref .read(temporaryAndPaymentControllerProvider.notifier) .setTemporaryTable(); if (orderId == null) return; // 3. orderId 와 orderName으로 결제 시도 final temporaryOrder = ref.watch(temporaryAndPaymentControllerProvider); final paymentResult = await _paymentWidget.requestPayment( paymentInfo: PaymentInfo( // 무작위한 값 orderId: orderId, // 외 1건 같은 양식 orderName: temporaryOrder.orderName!, )); //4.⭐️ 클라이언트단 결제상태 저장 및 라우팅 ref .read(clientPaymentControllerProvider.notifier) .routePayingScreen(paymentResult: paymentResult); }, child: Container( height: 30, decoration: BoxDecoration( color: PrimaryColors.PRIMARY_COLOR, borderRadius: BorderRadius.circular(50), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '${Formats.calcStringToWon(orderAmountState.amount)} 원', style: const TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w600)), const Text(' 결제하기', style: TextStyle( color: Colors.white, fontSize: 17, fontWeight: FontWeight.w500)), ], ), ), ), ), ), ], ); }, childCount: 1, ), ); } }
✓ Tosspayment Widget Render
@override void initState() { final int originPrice = ref.read(orderAmountControllerProvider).originPrice; super.initState(); _paymentWidget = PaymentWidget( clientKey: dotenv.env['TOSS_CLIENT_KEY']!, customerKey: Formats.generateShortUuid()); // 결제 ui를 렌더링하는 메서드 _paymentWidget .renderPaymentMethods( selector: dotenv.env['TOSS_METHOD_UI_KEY']!, // 첫 결제 금액 amount: Amount( value: originPrice, currency: Currency.KRW, country: "KR")) .then((control) { _paymentMethodWidgetControl = control; }); // 동의 ui: 식별자 중요 x _paymentWidget .renderAgreement(selector: dotenv.env['TOSS_AGREEMENT_UI_KEY']!) .then((control) { _agreementWidgetControl = control; }); } @override Widget build(BuildContext context) { ... return SliverList( delegate: SliverChildBuilderDelegate( (BuildContext context, int index) { return Column( children: [ // 결제창 띄우기 PaymentMethodWidget( paymentWidget: _paymentWidget, selector: dotenv.env['TOSS_METHOD_UI_KEY']!, ), // 동의 화면 띄우기 AgreementWidget( paymentWidget: _paymentWidget, selector: dotenv.env['TOSS_AGREEMENT_UI_KEY']!, ), ... ], ); }, childCount: 1, ), ); }
📌 Issue: 토스페이먼츠 위젯 렌더링 전 결제 버튼을 클릭했을 시 오류 발생
토스페이먼츠에서는 인증위한 메서드를 제공해줍니다. 클라이언트에서는 이를 활용해 선택한 카드사의 인증 프로세스를 진행하는 결제 버튼을 만들게 됩니다.
문제는 토스페이먼츠 위젯을 불러오기위한 전처리 작업때문에 렌더링 속도가 기본위젯보다 상대적으로 느립니다. 네트워크에 문제가 없거나 고사양 휴대폰이라면 인지하지 못하겠지만 저처럼 오래된 휴대폰을 사용하는 유저에게는 항상 토스페이먼츠 위젯이 결제 버튼보다 늦게 노출됩니다.
하단을 보면 결제위젯이 나타나는 시간이 생각보다 오래 걸린다는 것을 알 수 있습니다. 만약 유저가 위젯이 모두 렌더링되기전에 결제하기 버튼을 눌러버리면 앱이 멈춰버리게 되지만, 토스페이먼츠 위젯에서는 렌더링 상태를 알수있는 방법은 없었습니다.
✍🏼 해결방법: 인증 프로세스 실행 전, 직접 결제 동의 여부 체크하기
... final agreementStatus = await _agreementWidgetControl?.getAgreementStatus(); if (agreementStatus?.agreedRequiredTerms == null || agreementStatus?.agreedRequiredTerms == false) { // ignore: use_build_context_synchronously await popUp( context: context, mapper: PopUpMapper( title: '결제 약관에 동의해야\n주문을 할수있어요', titleFontSize: 16, rightBtn: '확인', height: height * 0.15, width: width, btnSize: height * 0.05, rightOnTap: () => Navigator.of(context).pop(), )); return; } ...
유저가 결제동의를 체크하지않고 다음 단계로 넘어가려고하면 토스페이먼츠에서 위젯상에 자동으로 유저의 동의 체크를 유도해줍니다.
또 유저의 결제 동의 여부를 메서드를 통해 직접 확인할 수도 있으며 widget의 렌더링이 완료되기전엔 defalut로 false를 반환해줍니다.
- final agreementStatus = await _agreementWidgetControl?.getAgreementStatus();
이를 활용하기위해선 "결제방법" 위젯과 "결제동의" 위젯의 렌더링이 동시에 이뤄져야한다는 조건이 필요합니다.
테스트를 해본 결과 "결제방법" 위젯과 "결제동의" 위젯의 렌더링은 비동기로 호출되어 서로 독립적으로 진행되지만 유저에게 노출되는 순서의 차이는 육안으로 느낄수 없을만큼 동시에 나타났습니다. 이를 활용해 위젯 렌더링 전에 인증 프로세스가 진행되는 것을 방지할 수 있었습니다.
✓ Client Tosspayment Error 처리
✍🏼 TossResponseModel code
@JsonSerializable() class TossResponseModel { TossSuccessModel? success; TossFailModel? fail; TossResponseModel({ this.success, this.fail, }); TossResponseModel copyWith({ TossSuccessModel? success, TossFailModel? fail, }) { return TossResponseModel( success: success ?? this.success, fail: fail ?? this.fail, ); } factory TossResponseModel.fromJson(Map<String, dynamic> json) => _$TossResponseModelFromJson(json); Map<String, dynamic> toJson() => _$TossResponseModelToJson(this); } @JsonSerializable() class TossSuccessModel { String? paymentKey; String? tossOrderId; int? amount; TossSuccessModel({ this.paymentKey, this.tossOrderId, this.amount, }); TossSuccessModel copyWith({ String? paymentKey, String? tossOrderId, int? amount, }) { return TossSuccessModel( paymentKey: paymentKey ?? this.paymentKey, tossOrderId: paymentKey ?? this.tossOrderId, amount: amount ?? this.amount, ); } factory TossSuccessModel.fromJson(Map<String, dynamic> json) => _$TossSuccessModelFromJson(json); Map<String, dynamic> toJson() => _$TossSuccessModelToJson(this); } @JsonSerializable() class TossFailModel { String? errorCode; String? errorMessage; TossFailModel({ this.errorCode, this.errorMessage, }); factory TossFailModel.fromJson(Map<String, dynamic> json) => _$TossFailModelFromJson(json); Map<String, dynamic> toJson() => _$TossFailModelToJson(this); TossFailModel copyWith({ String? errorCode, String? errorMessage, }) { return TossFailModel( errorCode: errorCode ?? this.errorCode, errorMessage: errorMessage ?? this.errorMessage, ); } }
✍🏼 ClientPaymentController code
// 결제 및 주문 중 페이지로 넘기는 컨트롤러 @Riverpod(keepAlive: true) class ClientPaymentController extends _$ClientPaymentController { @override TossResponseModel build() { return TossResponseModel(); } // server 실 결제 api 실행 전 client단 결제 준비 단계 // 라우팅 => 실패시: 바로 실패 화면, 성공시: 다음화면으로 이동 routePayingScreen({required Result paymentResult}) { if (paymentResult.fail != null || paymentResult.success == null) { // 에러코드 디버깅 print(paymentResult.fail!.errorCode); print(paymentResult.fail!.errorMessage); String tossErrorCode = paymentResult.fail!.errorCode; // 유저가 직접 결제 창을 종료한 경우 if (tossErrorCode == 'PAY_PROCESS_CANCELED') { // 메서드 종료 return; } state = state.copyWith( fail: TossFailModel( errorCode: paymentResult.fail!.errorCode, errorMessage: paymentResult.fail!.errorMessage)); // 결제실패 -> 곧바로 결제실패 페이지로 라우팅 ref.read(routerProvider).goNamed(FailOrderScreen.routeName); return; } paymentResult.success != null; state = state.copyWith( success: TossSuccessModel( amount: paymentResult.success!.amount.toInt(), paymentKey: paymentResult.success!.paymentKey, tossOrderId: paymentResult.success!.orderId, )); // 결제 중 화면으로 라우팅 ref.read(routerProvider).goNamed(PayingScreen.routeName); } }
인증 단계에서 어떠한 문제가 생기면 "승인 요청"을 실행하지않고 곧바로 "결제실패 화면"으로 라우팅 해주어야합니다.
// 유저가 직접 결제 창을 종료한 경우 if (tossErrorCode == 'PAY_PROCESS_CANCELED') { // 메서드 종료 return; }
다만 유저가 직접 결제 창을 종료하는 경우도 토스페이먼츠는 Error를 반환 해주는데 이는 "결제실패"보단 유저의 자의로 인한 "결제취소"에 가깝기때문에 결제 실패 화면을 띄워주는 것보다 메서드를 종료하는 것이 자연스럽습니다.
이로써 토스페이먼츠 측에서 클라이언트에게 요구하는 인증 절차를 모두 마쳤습니다. 토스페이먼츠 선생님께서 워낙 친절하고 따뜻(?)하게 말씀해주시기때문에 제공하는 디테일한 부분과 추가적인 기능들을 다루는 방법에 대해선 적지않았습니다.
필요하시다면 아래 공식문서를 참고해주세요! (토스페이먼츠 공식 디스코드방을 적극 활용하는 것도 추천드립니다!)
결제위젯 Flutter SDK | 토스페이먼츠 개발자센터
결제위젯 Flutter SDK를 추가하고 메서드를 사용하는 방법을 알아봅니다.
docs.tosspayments.com
다음에는 실제 주문이 일어나는 승인 프로세스때와 승인이 완료된 후, 주문중일때 클라이언트가 해야하는 일에대해 알아보도록 하겠습니다.
긴글 읽어주셔서 감사합니다.
[Flutter] 토스페이먼츠를 활용해 주문·결제 프로세스 적용해보기 feat. 승인 프로세스 + 주문 #3
이전 글에서 계속 이어집니다. 링크를 남겨둘테니 필요하시면 참고해주세요. [Flutter] 토스페이먼츠 결제모듈을 도입하다 #1 설계 결제 시스템을 도입할때, 모든 PG사를 하나하나 연결하는 것은
nomal-dev.tistory.com
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 토스페이먼츠를 활용해 주문·결제 프로세스 적용해보기 feat. 승인 프로세스 + 주문 #3 (0) 2024.02.22 [Flutter] 토스페이먼츠 결제모듈을 도입하다 #1 설계 (2) 2024.02.21 [Flutter] 클라이언트의 이미지 처리 전략 두가지 feat. AWS amplify, re-sizing (4) 2024.02.20 [Flutter] API 요청 횟수를 줄여 네트워크 대역폭을 절약해보자! feat. 배민 북마크 버그 (0) 2024.02.14 [Flutter] 무한스크롤 성능 최적화를 해보자 feat. Lazy Loading, Throttle #3 (0) 2024.02.13