모바일 앱에 결제 기능을 추가할 때, PG(Payment Gateway)사가 제공하는 네이티브 SDK를 사용하는 것이 가장 일반적입니다. 하지만 프로젝트 요구사항이나 특정 PG사의 정책으로 인해 Flutter 전용 SDK가 없는 난감한 상황에 직면하기도 합니다. 이 글에서는 Flutter SDK 없이 웹뷰(WebView)를 활용하여 국내 PG사 결제 연동을 성공적으로 구현한 경험과, 그 과정에서 마주한 기술적 난관들을 해결한 방법을 상세히 공유합니다.
Deep Dive: 왜 웹뷰 결제는 항상 실패하는가?
웹 개발 환경에서 잘 작동하던 결제 모듈을 앱의 웹뷰에 띄우면, 십중팔구 결제 버튼을 누르는 순간 "페이지를 찾을 수 없습니다" 혹은 "알 수 없는 프로토콜" 에러를 뱉으며 크래시가 납니다. 이것은 Flutter나 WebView의 버그가 아닙니다.
국내 PG사(KG이니시스, 토스, 나이스페이 등)의 결제 로직은 http/https 프로토콜 외에도 각 카드사의 앱(ISP, 앱카드)을 호출하기 위해 Custom URL Scheme(예: ispmobile://)이나 Android의 Intent Scheme(예: intent://)을 사용하기 때문입니다.
http/s 외의 프로토콜을 처리할 줄 모릅니다. intent://로 시작하는 요청이 들어오면 웹뷰는 이를 "탐색해야 할 웹 주소"로 인식하고 로딩을 시도하다 에러를 발생시킵니다.
따라서 우리는 웹뷰가 요청하는 URL을 가로채서(Intercept), 이것이 웹 페이지 이동인지 아니면 외부 앱(카드사 앱) 실행 요청인지를 판단하고 직접 처리해줘야 합니다. Android Intents 메커니즘을 이해해야만 이 문제를 해결할 수 있습니다.
The Solution: NavigationDelegate를 이용한 스킴 가로채기
가장 널리 쓰이는 webview_flutter 패키지를 기준으로 설명합니다. 핵심은 NavigationDelegate의 onNavigationRequest 콜백입니다. 여기서 URL을 검사하여 로직을 분기합니다.
intent:// 스킴을 파싱하여 앱이 설치되어 있지 않을 때 마켓으로 이동시키는 로직이 필수적입니다.
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:url_launcher/url_launcher.dart'; // 외부 앱 실행을 위해 필수
import 'dart:io';
class PaymentWebView extends StatefulWidget {
final String paymentUrl;
const PaymentWebView({Key? key, required this.paymentUrl}) : super(key: key);
@override
State<PaymentWebView> createState() => _PaymentWebViewState();
}
class _PaymentWebViewState extends State<PaymentWebView> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
// WebViewController 초기화 (webview_flutter 4.x 이상)
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (NavigationRequest request) async {
final url = request.url;
// 1. http/https 스킴은 정상적으로 웹뷰 내에서 로딩
if (url.startsWith('http://') || url.startsWith('https://')) {
return NavigationDecision.navigate;
}
// 2. 그 외(Intent, Custom Scheme)는 외부 앱 실행 시도
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
return NavigationDecision.prevent; // 웹뷰 이동 막기
} else {
// 3. Android Intent 처리 (앱 미설치 시 마켓 이동 등)
if (Platform.isAndroid && url.startsWith('intent://')) {
try {
// Intent URL 파싱 로직 (간소화됨)
// 실제로는 'browser_fallback_url'이나 'package' 파라미터를 파싱해야 함
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
} catch (e) {
// Fallback: 파싱 실패 시 처리 (예: 로그 출력)
debugPrint("Failed to launch intent: $e");
}
return NavigationDecision.prevent;
}
debugPrint("Cannot launch url: $url");
return NavigationDecision.prevent;
}
},
),
)
..loadRequest(Uri.parse(widget.paymentUrl));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("PG 결제")),
body: WebViewWidget(controller: _controller),
);
}
}
필수: 플랫폼별 설정 (이거 안 하면 무조건 터짐)
코드를 짰다고 끝이 아닙니다. Android와 iOS 보안 정책에 따라 외부 앱을 실행하려면 권한 명시가 필요합니다.
| Platform | File | Configuration |
|---|---|---|
| Android | AndroidManifest.xml |
<queries> 태그에 카드사/은행 패키지 명시 필요 (Android 11+) |
| iOS | Info.plist |
LSApplicationQueriesSchemes 키에 URL 스킴(ispmobile, kakaotalk 등) 등록 |
iOS의 경우 Info.plist에 아래와 같이 주요 카드사 스킴을 모두 등록해야 canLaunchUrl이 true를 반환합니다. 하나라도 빠지면 해당 카드 결제 시 아무 반응이 없습니다.
<key>LSApplicationQueriesSchemes</key>
<array>
<string>kftc-bankpay</string> <!-- 계좌이체 -->
<string>ispmobile</string> <!-- ISP모바일 -->
<string>itms-apps</string> <!-- 앱스토어 -->
<string>kb-acp</string> <!-- KB국민카드 -->
<string>mpocket.online.ansimclick</string> <!-- 삼성카드 -->
<string>hdcardappcardansimclick</string> <!-- 현대카드 -->
<!-- 필요한 스킴 추가 -->
</array>
Conclusion
Flutter용 공식 SDK가 없다고 해서 웹뷰 연동을 두려워할 필요는 없습니다. 본질적으로 PG 결제창은 웹페이지이며, 모바일 환경에서의 URL Scheme 핸들링만 정확하게 구현한다면 네이티브 못지않은 결제 경험을 제공할 수 있습니다. 위 코드는 기본적인 뼈대이며, 실제 프로덕션 환경에서는 ISP 앱이 설치되지 않았을 때 마켓으로 이동시키는 fallback 로직을 좀 더 정교하게 다듬어야 합니다. 국내 PG 연동의 핵심은 "웹뷰가 모르는 주소는 OS에게 맡긴다"는 원칙만 기억하면 됩니다.
Post a Comment