Friday, July 25, 2025

Flutter 웹뷰(WebView)로 PG 결제 연동 가이드 (SDK 없을 때)

모바일 앱에 결제 기능을 추가할 때, PG(Payment Gateway)사가 제공하는 네이티브 SDK를 사용하는 것이 가장 일반적입니다. 하지만 프로젝트 요구사항이나 특정 PG사의 정책으로 인해 Flutter 전용 SDK가 없는 난감한 상황에 직면하기도 합니다. 이 글에서는 Flutter SDK 없이 웹뷰(WebView)를 활용하여 국내 PG사 결제 연동을 성공적으로 구현한 경험과, 그 과정에서 마주한 기술적 난관들을 해결한 방법을 상세히 공유합니다.

1. SDK 없는 PG 연동, 웹뷰 기반 아키텍처 설계하기

Flutter SDK가 없다는 것은 PG사가 제공하는 결제 과정을 네이티브 코드로 직접 제어할 수 없다는 의미입니다. 대안은 PG사가 제공하는 '웹 결제창'을 앱 내에서 띄우는 것이며, 이를 위한 가장 확실한 기술이 바로 웹뷰(WebView)입니다.

안정적인 결제 처리를 위해, 저희는 다음과 같이 데이터 흐름과 아키텍처를 설계했습니다.

  1. [Flutter App] 결제 요청: 사용자가 앱에서 '결제하기' 버튼을 누르면, 앱은 상품 정보(이름, 가격)와 주문자 정보를 백엔드 서버로 전송합니다.
  2. [백엔드 서버] PG사에 결제 준비 요청: 서버는 앱에서 받은 정보를 기반으로 고유 주문번호(orderId)를 생성하고, 이 정보를 포함하여 PG사 결제 준비 API를 호출합니다.
  3. [PG 서버] 결제 페이지 URL 응답: PG사 서버는 요청을 검증한 후, 해당 결제 건을 위한 고유한 웹 결제 페이지 URL을 생성하여 백엔드 서버로 반환합니다.
  4. [백엔드 서버] 앱으로 URL 전달: 백엔드 서버는 PG사로부터 받은 결제 페이지 URL을 다시 Flutter 앱으로 전달합니다.
  5. [Flutter App] 웹뷰로 결제 페이지 로드: 앱은 서버로부터 받은 URL을 웹뷰에 로드하여 사용자에게 보여줍니다. 이제 사용자는 PG사가 제공하는 웹페이지 내에서 카드 정보 입력, 인증 등 모든 결제 절차를 진행합니다.
  6. [PG 서버 → 백엔드 서버] 결제 결과 통보 (웹훅/Webhook): 사용자가 결제를 완료하면(성공/실패/취소), PG사 서버는 사전에 약속된 백엔드 서버의 특정 URL(Callback/Webhook URL)로 결제 결과를 비동기적으로 통보합니다. 이 서버 간 통신(Server-to-Server)이 가장 신뢰할 수 있는 유일한 결제 결과입니다.
  7. [PG Web → Flutter App] 결제 완료 후 리디렉션: 웹 결제창의 모든 과정이 끝나면, PG사는 웹뷰를 우리가 지정한 '결과 페이지' URL(예: https://my-service.com/payment/result?status=success)로 리디렉션시킵니다. 앱은 이 특정 URL로의 이동을 감지하여 웹뷰를 닫고, 사용자에게 결과 화면을 보여줍니다.

이 구조에서 서버는 PG사와의 안전한 통신, 결제 데이터 위변조 검증, 최종 상태 관리를 담당하고, 은 사용자 인터페이스 제공과 웹뷰를 통한 PG 결제창 중계 역할을 수행합니다. 언뜻 보면 간단해 보이지만, 진짜 문제는 국내 결제 환경의 특수성에서 발생했습니다.

2. 가장 큰 난관: 웹뷰와 외부 결제 앱(App-to-App) 연동

국내 PG사 웹 결제창은 단순히 카드 정보만 입력받고 끝나지 않습니다. 보안과 편의성을 위해 다양한 외부 앱을 호출하는 '앱투앱(App-to-App)' 방식이 필수적으로 포함됩니다.

  • 카드사 앱카드: 신한플레이, KB Pay, 현대카드 앱 등 각 카드사 앱을 직접 호출하여 인증 및 결제를 진행합니다.
  • 간편결제 앱: 카카오페이, 네이버페이, 토스페이 등 간편결제 앱을 호출합니다.
  • 보안/인증 앱: ISP/페이북, 모바일안심클릭 등 별도의 인증 앱을 호출합니다.

이러한 외부 앱들은 일반적인 http://, https:// 링크가 아닌, 커스텀 URL 스킴(Custom URL Scheme) 또는 안드로이드 인텐트(Intent)라는 특별한 형식의 주소를 통해 호출됩니다. 예를 들면 다음과 같습니다.

  • ispmobile://: ISP/페이북 앱 호출 스킴
  • kftc-bankpay://: 계좌이체 관련 앱 호출 스킴
  • intent://...#Intent;scheme=kb-acp;...;end: KB Pay 앱을 호출하는 안드로이드 인텐트 주소

문제는 Flutter의 공식 웹뷰 플러그인 webview_flutter가 기본적으로 이러한 비표준(non-HTTP) URL을 처리하지 못한다는 점입니다. 웹뷰는 이를 잘못된 주소로 인식하고 '페이지를 찾을 수 없음' 오류를 표시하거나 아무런 반응도 하지 않습니다. 이 문제를 해결하는 것이 이번 프로젝트의 성패를 가르는 가장 큰 허들이었습니다.

3. 해결 전략: navigationDelegate로 URL 가로채기

이 문제 해결의 핵심 열쇠는 webview_flutter가 제공하는 navigationDelegate에 있습니다. navigationDelegate는 웹뷰 내에서 발생하는 모든 페이지 이동(URL 로딩) 요청을 가로채서 개발자가 원하는 대로 커스텀 로직을 수행하게 해주는 강력한 기능입니다.

우리의 전략은 명확합니다.

  1. navigationDelegate를 설정하여 모든 URL 로딩 요청을 감시합니다.
  2. 요청된 URL이 일반적인 http/https가 아닌 비표준 스킴일 경우, 웹뷰의 기본 동작(NavigationDecision.navigate)을 막습니다 (NavigationDecision.prevent).
  3. 가로챈 URL을 분석하여 안드로이드용 intent인지, iOS용 커스텀 스킴인지 판단합니다.
  4. 플랫폼에 맞는 네이티브 코드나 헬퍼 패키지(url_launcher)를 호출하여 외부 앱을 직접 실행시킵니다.

먼저 Flutter 측 WebView 위젯의 기본 골격 코드입니다.


import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'dart:io' show Platform;

class PaymentWebViewScreen extends StatefulWidget {
  final String paymentUrl;
  const PaymentWebViewScreen({Key? key, required this.paymentUrl}) : super(key: key);

  @override
  State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState();
}

class _PaymentWebViewScreenState extends State<PaymentWebViewScreen> {
  late final WebViewController _controller;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('결제하기')),
      body: WebView(
        initialUrl: widget.paymentUrl,
        javascriptMode: JavascriptMode.unrestricted,
        onWebViewCreated: (WebViewController webViewController) {
          _controller = webViewController;
        },
        navigationDelegate: (NavigationRequest request) {
          // 1. 최종 결제 완료/취소/실패 URL 감지
          if (request.url.startsWith('https://my-service.com/payment/result')) {
            // TODO: 결제 결과 처리 후 현재 웹뷰 화면 닫기
            Navigator.of(context).pop('결제 시도 완료');
            return NavigationDecision.prevent; // 웹뷰가 해당 URL로 이동하는 것을 막음
          }

          // 2. 외부 앱 호출 URL(비-http) 처리
          if (!request.url.startsWith('http://') && !request.url.startsWith('https://')) {
            if (Platform.isAndroid) {
              _handleAndroidIntent(request.url);
            } else if (Platform.isIOS) {
              _handleIosUrl(request.url);
            }
            return NavigationDecision.prevent; // 웹뷰의 기본 동작을 막는 것이 중요
          }

          // 3. 그 외 모든 http/https URL은 정상적으로 로드
          return NavigationDecision.navigate;
        },
      ),
    );
  }

  // 안드로이드 인텐트 처리 로직 (아래에서 상세 구현)
  void _handleAndroidIntent(String url) {
    // ...
  }

  // iOS 커스텀 스킴 처리 로직 (아래에서 상세 구현)
  void _handleIosUrl(String url) {
    // ...
  }
}

3.1. Android: intent:// 와의 사투와 MethodChannel

안드로이드의 intent:// 스킴은 가장 까다로운 상대입니다. 이 URL에는 실행할 앱의 패키지 이름, 앱이 없을 경우 이동할 대체 URL(주로 구글 플레이 스토어 링크) 등 복잡한 정보가 포함되어 있습니다. 이를 Dart 코드만으로 파싱하고 실행하는 것은 거의 불가능하며, 네이티브 안드로이드 코드의 도움이 절대적으로 필요합니다. 이를 위해 Flutter의 메소드 채널(MethodChannel)을 사용합니다.

Flutter (Dart) 측 코드

먼저 url_launcher 패키지를 추가합니다. intent://를 직접 처리하진 못하지만, market:// 같은 간단한 스킴이나 대체 URL을 열 때 유용합니다.


flutter pub add url_launcher

이제 _handleAndroidIntent 함수를 구체화합니다. intent://로 시작하는 URL은 네이티브로 넘기고, 그 외 스킴은 url_launcher로 실행을 시도합니다.


import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';

// ... _PaymentWebViewScreenState 클래스 내부 ...

// 안드로이드 네이티브 코드와 통신할 채널 정의
static const MethodChannel _channel = MethodChannel('com.mycompany.myapp/payment');

Future<void> _launchAndroidIntent(String url) async {
  if (url.startsWith('intent://')) {
    try {
      // 네이티브 코드로 intent URL을 전달하고 실행 요청
      await _channel.invokeMethod('launchIntent', {'url': url});
    } on PlatformException catch (e) {
      // 네이티브 호출 실패 시 (예: 처리할 수 없는 인텐트)
      debugPrint("Failed to launch intent: '${e.message}'.");
    }
  } else {
    // intent가 아닌 다른 스킴 (예: market://, ispmobile:// 등)
    // url_launcher로 실행 시도
    _launchUrl(url);
  }
}

// url_launcher를 사용하는 공용 함수
Future<void> _launchUrl(String url) async {
  final Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    // 앱이 설치되지 않았거나 처리할 수 없는 URL
    // 여기서 사용자에게 알림을 보여줄 수 있습니다.
    debugPrint('Could not launch $url');
  }
}

// navigationDelegate에서 호출할 최종 함수
void _handleAndroidIntent(String url) {
  _launchAndroidIntent(url);
}

네이티브 Android (Kotlin) 측 코드

android/app/src/main/kotlin/.../MainActivity.kt 파일에 메소드 채널을 수신하고 intent를 처리하는 코드를 작성합니다.


package com.mycompany.myapp

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import java.net.URISyntaxException

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.mycompany.myapp/payment"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "launchIntent") {
                val url = call.argument<String>("url")
                if (url != null) {
                    launchIntent(url)
                    result.success(null) // 성공적으로 처리했음을 Flutter에 알림
                } else {
                    result.error("INVALID_URL", "URL is null.", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }

    private fun launchIntent(url: String) {
        val intent: Intent?
        try {
            // 1. 인텐트 URL을 안드로이드 Intent 객체로 파싱
            intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
            
            // 2. 해당 인텐트를 처리할 수 있는 앱이 있는지 확인
            val packageManager = context.packageManager
            val existPackage = intent.`package`?.let {
                packageManager.getLaunchIntentForPackage(it)
            }
            
            if (existPackage != null) {
                // 3. 앱이 설치되어 있으면 실행
                startActivity(intent)
            } else {
                // 4. 앱이 없으면 fallback URL(주로 마켓 URL)로 이동
                val fallbackUrl = intent.getStringExtra("browser_fallback_url")
                if (fallbackUrl != null) {
                    val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse(fallbackUrl))
                    startActivity(marketIntent)
                }
            }
        } catch (e: URISyntaxException) {
            // 잘못된 형식의 URI 처리
            e.printStackTrace()
        } catch (e: Exception) {
            // 기타 예외 처리
            e.printStackTrace()
        }
    }
}

이 코드는 Flutter에서 launchIntent 메소드를 호출하면, 전달받은 intent:// 문자열을 안드로이드의 Intent 객체로 파싱합니다. 그리고 해당 인텐트를 처리할 앱이 설치되어 있는지 확인 후, 있으면 실행하고 없으면 browser_fallback_url에 지정된 플레이스토어 주소로 이동시킵니다. 이로써 안드로이드의 앱투앱 연동 문제가 해결됩니다.

3.2. iOS: Custom URL Scheme과 Info.plist의 중요성

iOS는 안드로이드보다 상황이 조금 더 간단합니다. intent:// 같은 복잡한 구조 대신 ispmobile://, kakaopay:// 와 같은 단순한 커스텀 스킴을 주로 사용하므로, url_launcher 패키지만으로도 대부분의 경우를 처리할 수 있습니다.

하지만 반드시 선행되어야 할 매우 중요한 작업이 있습니다. iOS 9부터는 개인정보 보호 정책이 강화되어, 앱이 호출하려는 다른 앱의 URL 스킴을 Info.plist 파일에 미리 등록(whitelist)해야 합니다. 이 목록에 없는 스킴은 canLaunchUrl이 항상 false를 반환하여 앱을 호출할 수 없습니다.

ios/Runner/Info.plist 설정

ios/Runner/Info.plist 파일을 열고 LSApplicationQueriesSchemes 키와 함께 연동에 필요한 모든 스킴을 배열에 추가해야 합니다. 이 목록은 이용하는 PG사의 개발 가이드를 반드시 참고하여 빠짐없이 추가해야 합니다.


LSApplicationQueriesSchemes

    kakaotalk
    kakaopay
    ispmobile
    kftc-bankpay
    shinhan-sr-ansimclick
    hdcardappcardansimclick
    kb-acp
    lotteappcard
    nhallonepayansimclick
    mpocket.online.ansimclick
    

Flutter (Dart) 측 코드

이제 _handleIosUrl 함수를 url_launcher를 사용하여 구현합니다. 메소드 채널 없이 Dart 코드만으로 충분합니다.


// ... _PaymentWebViewScreenState 클래스 내부 ...

void _handleIosUrl(String url) {
  _launchUrl(url); // 위에서 만든 공용 _launchUrl 함수 재사용
}

// _launchUrl 함수는 이미 위에서 정의됨
// iOS의 경우, Info.plist에 스킴이 등록되어 있다면 canLaunchUrl이 true를 반환함
Future<void> _launchUrl(String url) async {
  final Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    // externalApplication 모드로 실행해야 Safari를 거치지 않고 바로 앱이 열림
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    // 앱이 설치되지 않은 경우
    // PG사 가이드에 따라 앱스토어 링크로 보내주거나,
    // 사용자에게 앱 설치가 필요하다는 알림을 띄워줍니다.
    // 예: if (url.startsWith('ispmobile')) { launchUrl(Uri.parse('앱스토어_ISP_링크')); }
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('앱 설치 필요'),
        content: const Text('결제를 위해 앱 설치가 필요합니다. 앱스토어에서 해당 앱을 설치해주세요.'),
        actions: [
          TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('확인')),
        ],
      ),
    );
  }
}

이것으로 iOS에서도 외부 앱을 성공적으로 호출할 수 있게 되었습니다. Info.plist 설정이 가장 핵심적인 부분임을 절대 잊지 말아야 합니다.

4. 결제 완료 후 앱 복귀와 가장 중요한 '서버 최종 검증'

외부 앱에서 결제를 마치고 우리 앱의 웹뷰로 돌아오면, PG사는 약속된 결과 페이지(예: https://my-service.com/payment/result?status=success&orderId=...)로 리디렉션합니다. 우리는 navigationDelegate에서 이 URL을 감지하여 결제 프로세스를 마무리해야 합니다.


// ... navigationDelegate 내부 ...
if (request.url.startsWith('https://my-service.com/payment/result')) {
  // URL에서 쿼리 파라미터를 파싱하여 결과 확인
  final Uri uri = Uri.parse(request.url);
  final String status = uri.queryParameters['status'] ?? 'fail';
  final String orderId = uri.queryParameters['orderId'] ?? '';

  // 🚨 보안 경고: 여기서 바로 성공/실패를 단정하면 안 됩니다!
  // 이 정보는 클라이언트(앱)에서 쉽게 조작될 수 있습니다.
  // 반드시 우리 서버에 최종 결제 상태를 다시 한번 확인해야 합니다.
  
  // 서버에 최종 검증 요청 API 호출 (예시)
  // Future<void> verifyPayment() async {
  //    try {
  //       bool isSuccess = await MyApiService.verifyPayment(orderId);
  //       if (isSuccess) {
  //         // 최종 성공 처리 후 성공 페이지로 이동
  //       } else {
  //         // 최종 실패 처리 후 실패 페이지로 이동
  //       }
  //    } catch (e) {
  //       // 통신 오류 처리
  //    }
  // }
  
  // 검증 결과에 따라 화면 전환
  Navigator.of(context).pop(status); // 결과와 함께 웹뷰 닫기
  return NavigationDecision.prevent; // 웹뷰가 이 페이지를 로드하지 않도록 함
}

가장 중요한 점은, 리디렉션된 URL의 파라미터(status=success)만 믿고 결제를 최종 성공 처리해서는 절대로 안 된다는 것입니다. 이는 해커가 URL을 조작하여 결제를 하지 않고도 유료 콘텐츠를 이용하게 할 수 있는 심각한 보안 취약점입니다. 앱은 우리 서버에 "이 주문번호(orderId)의 결제가 정말로 성공했는지 확인해줘"라는 최종 검증 요청을 보내야 합니다. 서버는 아키텍처 6단계에서 PG사로부터 받은 웹훅(Webhook) 정보를 데이터베이스에 저장해두고, 이 정보를 바탕으로 진위 여부를 판별하여 앱에 응답해야 합니다. 이 서버 사이드 교차 검증을 거쳐야만 안전한 결제 시스템이 완성됩니다.

5. 결론: 핵심 요약 및 교훈

Flutter SDK 없이 PG 결제를 연동하는 것은 분명 쉽지 않은 과정이었습니다. 특히 안드로이드의 intent와 iOS의 Info.plist 설정은 여러 번의 시행착오를 유발하는 까다로운 부분이었습니다. 하지만 webview_flutternavigationDelegate와 플랫폼별 네이티브 연동(MethodChannel)을 적절히 활용함으로써 모든 문제를 해결할 수 있었습니다.

이번 경험을 통해 얻은 핵심 교훈은 다음과 같습니다.

  • 아키텍처 설계가 반이다: 결제 요청부터 웹뷰 로드, 결과 수신, 최종 검증까지의 전체 흐름을 명확히 정의하는 것이 중요합니다. 서버와 클라이언트의 역할을 명확히 분리하세요.
  • URL 가로채기가 핵심 열쇠: webview_flutternavigationDelegate는 웹뷰 결제 연동의 핵심입니다. 모든 URL 로딩을 제어하여 외부 앱 호출과 결과 처리를 구현할 수 있습니다.
  • 플랫폼의 특성을 존중하라: Flutter는 훌륭한 크로스플랫폼 프레임워크지만, 외부 앱 연동과 같은 기능은 각 플랫폼의 고유한 방식(Android Intent, iOS Custom Scheme)을 이해하고 따라야만 합니다.
  • 보안을 최우선으로: 클라이언트(앱)에서 받은 결제 성공 정보는 절대 신뢰하지 마세요. 항상 서버에서 PG사가 보낸 웹훅(Webhook) 정보를 통해 최종 교차 검증(Server-Side Verification)을 수행해야 합니다.

이 가이드가 Flutter로 결제 기능을 구현하려는 다른 개발자분들께 도움이 되기를 바랍니다.


0 개의 댓글:

Post a Comment