profile image

L o a d i n g . . .

문제의 시작



현재 회사에서 안드로이드 앱들은 MainActivity에서 WebView를 활용해 웹 화면을 네이티브 앱 내에 띄워서 표시하고 있다. 이전까지는 대부분 외부 연동이 없는 조회 형태라서 특별한 문제가 없었다. 하지만 이번에 결제기능이 추가되면서.. 문제가생겼다.
 
결제 수단을 선택하고 그대로 프로세스를 진행하지않고 뒤로가기버튼을 선택한 경우, 앱내 웹뷰로 다시 돌아오지 않았다. 
 


사담을 좀 하자면.. 최근 회사 상황이 꽤나 정신없다. 기존 팀장님께서 퇴사를 선언하신 뒤 사실상 칩거 상태에 들어가셨고(ㅠ) 이후 월초에 새로 채용된 팀장님도 하루 만에 퇴사를 결정하면서 팀장 자리는 공석이다.

현재 팀 구성은 총 4명으로 나, 입사 동기, 2년 차 백엔드 개발자, 신입 프론트엔드 개발자 이렇게만 남아 있는 상황이다.

그렇기에 팀장님이 없는 상태에서 의사결정이나 기술 논의가 쉽지 않았다. 특히 문제 해결 과정에서 누군가에게 조언을 받기는 어려운 상황에서 입사 동기와 이야기하던 중 두 가지 해결 방향이 나왔지만 각각 단점이 있어 쉽게 결정할 수 없었다.

그러다 문득 평소 자주보는 오픈카톡방이 떠올랐고 고민 끝에 두 군데에 상황을 올려보았다.

그 중 한 방에서 비슷한 이슈를 겪고 해결한 분의 조언을 받을 수 있었고, 정말 큰 도움이 되었다. (갓...ㅠㅠ)

조언을 바탕으로 적용한 해결책은 아래와 같다.


 

개선방법

Android 개선방법

안드로이드에서는 MainWebView 외 결제 수단용으로 별도의 자식 WebView(PGWebViewActivity)를 하나 더 만들어 결제요청 시 자식 WebView를 띄우고 뒤로가기 버튼을 누르면 해당 WebView를 닫아 다시 부모 WebView로 돌아올 수 있도록 구성했다. 이 방식으로 팝업 결제 페이지의 흐름을 안정적으로 처리할 수 있었다. 또한 뒤로가기 뿐 아니라 승인까지 진행하는 프로세스 결과도 MainActivity에서 전달받을 수 있는 구조로 개선했다.

 

[React&Kotlin] 웹-네이티브 브리지를 활용한 PG 결제 연동 구조 설계기 - 안드로이드 편

문제의 시작 현재 회사에서 안드로이드 앱들은 MainActivity에서 WebView를 활용해 React로 개발된 웹 화면을 네이티브 앱 내에 띄워서 표시하고 있다. 이전까지는 대부분 외부업체와의 연동이 없고 병

h-owo-ld.tistory.com

 

 

iOS 개선방법

iOS 역시 비슷한 방식으로 자식 WebView를 사용하는 구조이지만 기기 특성상 하드웨어 뒤로가기 버튼이 없기 때문에 상단에 커스텀 뒤로가기 버튼을 별도로 추가해주는 작업을 덧붙였다. 

 

 

모바일의 갓 핑관님 덕분에 꽤나 복잡했던 결제 플로우 문제를 안정적으로 처리할 수 있었고 무엇보다도 커뮤니티의 힘이 얼마나 큰지를 다시 한 번 느낄 수 있었다. 🙏


 
 


iOS 개선방법 상세 코드

 

/// ViewController

...
class ViewController: UIViewController,
                      CLLocationManagerDelegate,
                      CBCentralManagerDelegate,
                      WKNavigationDelegate,
                      WKScriptMessageHandler,
                      WKUIDelegate,
                      PGWebViewControllerDelegate {    
    
    func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
...
      // 결제 시 결제전용 웹뷰를 열도록 하는 브릿지 함수
        case "openPGWebView":
            Logger.log(self, "openPgWebView")
            print("[PRINT DEBUG] openPgWebView message.body: \(String(describing: message.body))")
            
            // JS Bridge로부터 받은 message.body가 String(결제 URL)인지 확인, URL 타입으로 변환 시도
            guard let link = message.body as? String, let url = URL(string: link) else {
                Logger.log(self, "PGWebView: 잘못된 URL \(String(describing: message.body))")
                return
            }
            
            // JS Bridge로부터 받은 message.body가 String(결제 URL)인지 확인, URL 타입으로 변환 시도
            let storyboard = UIStoryboard(name: "Main", bundle: nil)
            if let pgVC = storyboard.instantiateViewController(withIdentifier: "PGWebViewController") as? PGWebViewController {
                
                // 결제 URL 및 delegate(콜백 받을 부모 컨트롤러) 주입
                pgVC.pgUrl = url // 원하는 결제 URL 전달
                pgVC.delegate = self
                
                // 결제창을 UINavigationController로 감싸서 present (상단에 네비게이션바, 뒤로가기 가능)
                let nav = UINavigationController(rootViewController: pgVC)
                nav.modalPresentationStyle = .fullScreen // 전체화면 모달로 띄움(iOS 기본은 half-modal이니 꼭 명시)
                present(nav, animated: true)
            }
        }
    ...
    // 결제승인 URL 콜백
    func pgWebViewControllerDidFinish(url:URL){
        Logger.log(self, "[PGWebView] 승인 URL 반환:", url)
        // 바로 부모 WKWebView에서 페이지 이동!
        webView.load(URLRequest(url: url))
    }
}

리액트에서 openPGWebView 라는 브릿지메시지를 보내면 해당 메시지 내의 url을 PGWebViewController로 보내준다. 이때 iOS의 경우는 뒤로가기 버튼이 따로없기때문에 별도로 상단에 navigation바에 뒤로가기 버튼을 추가해줬고, iOS에서는 모달이 기본적으로 half로 띄워지기 때문에 명시적으로 풀스크린을 지정해서 전체화면으로 띄우도록 했다.

 

이후 결제가 성공적으로 완료되면 결제승인 콜백을 통해 부모컨트롤러인 해당 컨트롤러로 승인 URL이 다시 전달된다. 전달받은 URL은 기존의 메인웹뷰에서 다시 로딩되어 웹페이지 흐름이 이어지게 된다.

 

 

 

 

 

 

// PGWebViewController
...

/// 결제 전용 WebView의 결과를 콜백받기 위한 프로토콜
protocol PGWebViewControllerDelegate: AnyObject {
    /// 결제 성공 또는 승인 URL이 감지된 경우 호출됨
    func pgWebViewControllerDidFinish(url: URL)
    /// 사용자가 [<-] 뒤로가기/닫기 버튼을 눌러 결제창을 닫은 경우 호출됨
    func pgWebViewControllerDidCancel()
}

/// PG(결제) 전용 웹뷰 컨트롤러
class PGWebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
    /// 결제 URL (외부에서 반드시 할당해야 함)
    var pgUrl: URL!
    weak var delegate: PGWebViewControllerDelegate?
    private var webView: WKWebView!

    override func viewDidLoad() {
        super.viewDidLoad()
        print("[PGWebView] viewDidLoad - pgUrl:", pgUrl ?? "nil")
        
        // WebView 환경설정: JS, 여러창 등 활성화
        let config = WKWebViewConfiguration()
        config.preferences.javaScriptEnabled = true
        config.preferences.javaScriptCanOpenWindowsAutomatically = true
        
        // WebView 인스턴스 생성 및 설정
        webView = WKWebView(frame: self.view.bounds, configuration: config)
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        self.view.addSubview(webView)

        // 결제 URL 로드
        if let url = pgUrl {
            print("[PGWebView] loading URL:", url)
            webView.load(URLRequest(url: url))
        } else {
            print("[PGWebView] pgUrl is nil, cannot load webview")
        }

        print("[PGWebView] NavigationController?", navigationController != nil)
        print("[PGWebView] parent:", parent ?? "nil")
    }
    
    /// 상단 네비게이션 바의 [<-] 버튼 (Left bar button items)
    @IBAction func didTapBack(_ sender: Any) {
        delegate?.pgWebViewControllerDidCancel()
        dismiss(animated: true)
    }

    /// 네비게이션 이벤트 감지(결제 승인/에러/카드앱 딥링크 등 처리)
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            print("[PGWebView] navigationAction: url is nil")
            decisionHandler(.allow)
            return
        }
        print("[PGWebView] navigating to:", url)

        // 결제 승인/에러 리턴 URL 감지
        if url.absoluteString.contains("/approval") {
            print("[PGWebView] approval detected, dismissing")
            delegate?.pgWebViewControllerDidFinish(url: url)
            self.dismiss(animated: true)
            decisionHandler(.cancel)
            return
        }

        // 카드/간편결제/은행 앱 등 외부앱 연동 스킴 감지시, 시스템으로 오픈
        if let scheme = url.scheme, paymentSchemes.contains(scheme) {
            print("[PGWebView] open external scheme:", scheme)
            UIApplication.shared.open(url)
            decisionHandler(.cancel)
            return
        }

        // 그 외는 웹뷰 기본동작
        decisionHandler(.allow)
    }
    ...
    
}

...

 

리액트에서 요청하면 메인을 거쳐 열리는 결제 웹뷰 페이지 컨트롤러다.

해당 컨트롤러에서는 외부로부터 전달받은 결제 URL을 로딩하고 결제 흐름중 발생하는 승인 완료나 간편결제 같은 외부 앱 호출 같은 이벤트를 감지해서 처리하게 된다. 

 

 

리액트에서 사용한 브릿지코드

/**
 * 모바일 네이티브의 `openPGWebView` 메서드 호출.
 *
 * @method openPGWebView
 * @param {String} link pg호출을 위해 별도의 웹뷰를 띄우는 URL.
 */
const openPGWebView = (link) => {
  if (mobile.isMobile) {
    if (mobile.isAndroid) {
      if (window?.androidWebBridge?.openPGWebView) {
        window.androidWebBridge.openPGWebView(link)
      } else {
        window.open(link)
      }
    } else if (mobile.isIOS) {
      if (window?.webkit?.messageHandlers?.openPGWebView) {
        window.webkit.messageHandlers.openPGWebView.postMessage(link)
      } else {
        window.open(link)
      }
    }
  } else {
    window.open(link)
  }
}




 

 

결과

이제 뒤로가기 버튼을 눌러도 다시 원래 페이지로 복귀가 자연스럽게 된다.

결제 시나리오 완료후에도 메인 뷰로 승인결과를 URL에 담아서 가져오게되었다.

 

 

 

 

 

동기랑 다음에는 앱개발 하게되면 RN를 진지하게 고려해보자는 이야기를 했다.

반응형
복사했습니다!