profile image

L o a d i n g . . .

문제의 시작

 

현재 회사에서 안드로이드 앱들은 MainActivity에서 WebView를 활용해 React로 개발된 웹 화면을 네이티브 앱 내에 띄워서 표시하고 있다. 이전까지는 대부분 외부업체와의 연동이 없고 병원 전산팀과 연동하는 형태라서 특별한 문제가 없었다. 하지만 이번에 결제기능이 추가되면서.. 문제가생겼다.

 

결제 수단을 선택하고 그대로 프로세스를 진행하지않고 뒤로가기버튼을 선택한 경우, 앱내 웹뷰로 다시 돌아오지 않았다. 

 

문제 상황

    override fun onBackPressed() {
        Logger.debug()

        webView.evaluateJavascript("historyBack();") {
            if (it?.toString() == "1") {
                super.onBackPressed()
            }
        }
    }

 

안드로이드의 뒤로가기 버튼을 눌렀을 때 WebView에서는 historyBack() 자바스크립트 함수를 실행하고 그 리턴값에 따라 동작을 분기하도록 구성되어 있다. 하지만 수납 페이지처럼 외부에서 호출된 URL의 경우 해당 함수(historyBack)가 정의되어 있지 않기 때문에 함수 실행 시 웹 디버깅 콘솔에 "함수가 존재하지 않는다"는 오류가 출력되며 뒤로가기가 정상적으로 동작하지 않는 문제가 발생하게 됐다.

 

 

 

 

 

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

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

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

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

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

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

 

개선방법

Android 개선방법

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

 

iOS 개선방법

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

 

 

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

 


 

안드로이드 개선방법 상세 코드

// MainActivity.kt
... 
class MainActivity : AppCompatActivity()  {
     private val pgLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
        if (result.resultCode == Activity.RESULT_OK) {
            val approvalUrl = result.data?.getStringExtra("approval_url")
            Logger.debug("PG 승인 URL 받음: $approvalUrl")
            if (!approvalUrl.isNullOrEmpty()) {
                webView.loadUrl(approvalUrl)
            }
        }
    }
...
    inner class WebBridgeClass {
   		@JavascriptInterface
        fun openPGWebView(url:String){
            Logger.debug("🍺openPGWebView: $url")
            val intent = Intent(this@MainActivity, PGWebViewActivity::class.java)
            intent.putExtra("pg_url",url)
            Logger.debug("🍶PGWebViewActivity onCreate() called!!!")

            (this@MainActivity as MainActivity).pgLauncher.launch(intent)
        }
...

 

먼저 아래의 WebBridgeClass 안의 openPGWebView함수를 통해 웹에서 PG 결제 URL을 넘기면 안드로이드에서 해당 URL을 Intent로 감싸서 PGWebViewActivity로 이동하도록 구현하는 웹-네이티브 연동 브리지를 추가해줬다. 

결제는 외부 PG사 페이지로 이동하게 되므로 이후 결제가 끝나면 PGWebViewActivity에서 MainActivity에 선언된 ActivityResultLauncher를 통해 결제 결과를 받아오는 구조를 추가해줬다.

 

 

 

 

 

 

// PGWebViewActivity.kt
...

class PGWebViewActivity : AppCompatActivity() {
    private lateinit var webView: WebView

    @SuppressLint("SetJavaScriptEnabled", "MissingInflatedId")
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_pgwebview)

        // (1) Lollipop 이하 호환용 - 쿠키 동기화 매니저 초기화
        @Suppress("DEPRECATION")
        CookieSyncManager.createInstance(this)

        // (2) Safe Area 대응 (상단/하단 시스템 영역 패딩 적용)
        val webviewContainer = findViewById<FrameLayout>(R.id.webviewContainer2)
        ViewCompat.setOnApplyWindowInsetsListener(webviewContainer) { v, insets ->
            val sysInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(0, sysInsets.top, 0, sysInsets.bottom)
            insets
        }

        // (3) Android 11(R) 이상에서 전체화면 표시 설정
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            window.setDecorFitsSystemWindows(false)
        }

        // (4) 상태바 배경색/아이콘 색상 설정 (화이트 배경 + 다크 아이콘)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            window.statusBarColor = Color.WHITE
            window.decorView.systemUiVisibility =
                window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
        }

        // (5) PG 결제 페이지 URL 인텐트로 전달받기
        val pgUrl = intent.getStringExtra("pg_url")
        Logger.debug("🍶pgUrl: $pgUrl")

        // (6) WebView 초기 설정
        webView = findViewById(R.id.pgWebView)
        webView.settings.javaScriptEnabled = true // 자바스크립트 허용
        webView.settings.setSupportMultipleWindows(true) // 다중창 지원
        webView.settings.javaScriptCanOpenWindowsAutomatically = true // JS에서 window.open 허용
        webView.settings.domStorageEnabled = true // localStorage 등 DOM 스토리지 허용

        // (7) Third-party 쿠키 허용 (PG 결제 시 필수)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)
        }

        // (8) WebViewClient 설정 - URL 제어 및 외부앱/마켓 호출 처리
        webView.webViewClient = object : WebViewClient() {

            @Deprecated("Deprecated in Java")
            @Suppress("DEPRECATION")
            override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
                val url = request?.url.toString()
                Logger.debug("🍶[shouldOverrideUrlLoading] url=$url")

                // (8-1) 결제 승인 URL 처리
                if (url.contains("/approval")) {
                    val resultIntent = Intent()
                    resultIntent.putExtra("approval_url", url)
                    setResult(RESULT_OK, resultIntent)
                    finish()
                    return true
                }

                // (8-2) 전화 연결 처리
                if (url.startsWith("tel:")) {
                    startActivity(Intent(Intent.ACTION_DIAL, url.toUri()))
                    return true
                }

                // (8-3) 외부 앱 호출/마켓 이동 처리
                if (!URLUtil.isNetworkUrl(url) && !URLUtil.isJavaScriptUrl(url)) {
                    try {
                        val uri = Uri.parse(url)
                        if (uri.scheme == "intent") {
                            return startSchemeIntent(url)
                        } else {
                            startActivity(Intent(Intent.ACTION_VIEW, uri))
                            return true
                        }
                    } catch (e: Exception) {
                        return false
                    }
                }

                // (8-4) 기본 WebView 처리
                return false
            }

            override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                super.onPageStarted(view, url, favicon)
                Logger.debug("🍶onPageStarted: $url")
            }

            override fun onPageFinished(view: WebView?, url: String?) {
                super.onPageFinished(view, url)
                Logger.debug("🍶onPageFinished: $url")
            }
        }

        // (9) 팝업 등 지원을 위한 WebChromeClient 등록
        webView.webChromeClient = WebChromeClient()

        // (10) PG 결제 페이지 로딩 시작
        if (!pgUrl.isNullOrEmpty()) {
            webView.loadUrl(pgUrl)
        }
    }

    // (11) intent:// 스킴 처리 메서드 (앱 호출 or 마켓 fallback)
    private fun startSchemeIntent(url: String): Boolean {
        return try {
            val schemeIntent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
            try {
                startActivity(schemeIntent)
                true
            } catch (e: ActivityNotFoundException) {
                val packageName = schemeIntent.`package`
                if (!packageName.isNullOrEmpty()) {
                    startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=$packageName")))
                    true
                } else {
                    false
                }
            }
        } catch (e: URISyntaxException) {
            false
        }
    }

...

    // (14) 백버튼 → 결제 취소 처리
    @Deprecated("Deprecated in Java")
    @Suppress("DEPRECATION", "MissingSuperCall")
    override fun onBackPressed() {
        setResult(RESULT_CANCELED)
        finish()
    }
}

 

PGWebViewActivity는 결제에 필요한 PG URL을 인텐트로 전달받아서 화면이 표시된다.

주요 기능은 2가지이다.

1. /approval 등의 URL 탐지 시 setResult(RESULT_OK)와 함께 부모 액티비티에 복귀

2. intent://, ispmobile:// 등 다양한 스킴 처리 및 마켓 fallback 로직을 추가

 

OneUI 7.0부터는 상단, 하단 시스템 영역(노치, 소프트 키 등)에 대응이 필요해서 safe-area 패딩을 적용했고, Android 11 이상에서는 status/navigation bar가 화면 위에 그려지도록 window 설정을 추가했다. 이대로 했더니 시계가 다크모드에서는 제대로 안보이는 이슈가 생겨서 상태바 배경도 흰색으로 바꾸고 아이콘이랑 글씨는 검정색으로 바꿔서 가독성을 높였다.

그리고 코드 개선의 시작이었던.. 백버튼을 누르면 setResult(RESULT_CANCELED)로 결제 취소 결과를 돌려주고 액티비티를 종료하도록했다.

 

// activity_pgwebview.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=".PGWebViewActivity">

    <FrameLayout
        android:id="@+id/webviewContainer2"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <WebView
            android:id="@+id/pgWebView"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />

    </FrameLayout>

</androidx.coordinatorlayout.widget.CoordinatorLayout>

activity_pgwebview.xml은 결제 전용 WebView 레이아웃을 위한 XML로 레이아웃 형태는 MainActivity와 똑같이 배치했다. 

 

 

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

/**
 * 모바일 네이티브의 `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)
  }
}

 


결과

 

 

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

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

 

 

 

 

 

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

반응형
복사했습니다!