[Android] 웹뷰 크래시 처리에 활용되는 onRenderProcessGone의 함정

Kame·5일 전

Android

목록 보기
10/10
post-thumbnail

안드로이드 앱에서 웹뷰를 다룰 때는 웹뷰가 앱의 일부처럼 보이지만, 실제로는 안드로이드 8(Oreo) 버전부터 둘은 완전히 다른 프로세스에서 동작하는 별개의 엔진이라는 점을 고려해야 합니다. 이 간극을 이해하지 못하면 웹뷰에 크래시가 발생했을 때 적절한 대응이 힘들어질 수 있습니다. 최근 웹뷰를 사용하는 프로젝트를 유지보수하며 겪었던 문제를 바탕으로 해당 내용을 정리해보고자 합니다. onRenderProcessGone 콜백에서 단순히 true를 반환하도록 처리를 하면 어떤 문제가 생기는지, 그리고 그것을 어떻게 해결하는지를 정리합니다.


별도의 프로세스에서 동작하는 웹뷰

원활한 이해를 위하여 안드로이드 웹뷰 아키텍처를 다루는 이 글을 먼저 읽고 오는 것을 권장드립니다.

Oreo 버전(Android 8.0, API 26)부터 안드로이드 웹뷰는 보안과 안정성을 위해 멀티 프로세스 아키텍처를 사용합니다. 즉 앱이 돌아가는 '브라우저 프로세스'와 웹을 그리는 '렌더러 프로세스'가 분리된 구조입니다.

참고) Oreo 이상의 버전의 모든 디바이스에서 멀티 프로세스 아키텍처가 사용되는 것은 아니고, 다음 조건들 중 하나를 만족하는 기기에서 활성화됩니다.

  • Android 11(API 31) 이상을 실행하는 모든 기기
  • 모든 64비트 기기
  • 메모리가 충분한 32비트 기기 (메모리가 부족한 32비트 기기에서 API 버전 26~30가 적용된 디바이스는 이전처럼 단일 프로세스 렌더러를 사용합니다.)

멀티 프로세스 아키텍처의 핵심

(출처 : How Does WebView Actually Work on Android?)

위 그림에서 볼 수 있듯이, 안드로이드 웹뷰는 두 개의 소스 트리로 구성됩니다. 이 두 영역은 구분되어 있으며, 이는 물리적으로 분리된 프로세스임을 나타냅니다.

  • Android framework (왼쪽 영역) 앱이 실제로 사용하는 android.webkit.WebView API와 WebViewProvider를 포함하는 접착 계층으로 구성되어 있습니다. 이 부분이 브라우저 프로세스에 해당합니다.
  • Chromium content layer (오른쪽 영역) 실제 웹 콘텐츠를 렌더링하는 AwContents, ContentViewCore, Rendering engine을 포함합니다. 이 부분이 렌더러 프로세스에 해당합니다.

프로세스 분리의 의미

"이제 앱의 WebView 객체가 다중 프로세스 모드에서 실행됩니다. 보안 강화를 위해, 포함하는 앱의 프로세스와는 별개로 격리된 프로세스에서 웹 콘텐츠가 처리됩니다."

앱 프로세스(그림의 ‘MyWebViewApp’)는 Android framework의 WebView API를 통해 웹뷰와 상호작용하지만, 실제 웹 콘텐츠 렌더링은 완전히 분리된 샌드박스 프로세스(그림의 ‘android_webview/’)에서 이루어집니다.

샌드박스 프로세스

샌드박스(Sandbox)는 아이들이 모래 상자 안에서만 놀듯이 프로세스가 제한된 영역 안에서만 동작하도록 격리하는 보안 기법입니다. 샌드박스 프로세스는 다음과 같은 제한을 받습니다.

웹뷰의 렌더러 프로세스는 샌드박스로 동작함으로써, 악의적인 웹 페이지가 기기의 민감한 정보에 접근하거나 시스템을 손상시키는 것을 방지합니다.

안드로이드에서 구현하는 방법

Android는 매니페스트의 android:externalService 속성을 통해 샌드박스 프로세스를 구현합니다. 이는 웹뷰 시스템 컴포넌트에 내장된 기능으로, 개발자가 별도로 설정할 필요 없이 자동으로 적용됩니다.

externalService는 기존의 일반 서비스와 달리 다음과 같은 차이를 보입니다.

  • 앱의 컨텍스트에서 실행: 웹 콘텐츠 제공자가 아닌, 실제 웹뷰를 사용하는 앱의 권한과 컨텍스트 내에서 동작합니다.
  • 격리: 각 앱의 렌더러 프로세스가 독립적으로 격리되어, 서로 다른 앱의 웹 콘텐츠가 섞이지 않습니다.
  • 샌드박스 강제: 시스템 수준에서 프로세스에 최소한의 권한만 부여합니다.

이를 통해 같은 웹사이트를 다른 앱에 띄울 때, 앱 A의 웹뷰와 앱 B의 웹뷰가 완전히 분리되며, Android의 앱 격리 보안 모델을 그대로 유지할 수 있습니다.

거듭 언급하지만 코드를 개발자가 직접 짤 필요는 없습니다. 이미 안드로이드 시스템이 자체적으로 웹뷰를 구동할 때 이 속성을 사용하여 앱 A의 웹뷰가 앱 B의 데이터에 함부로 접근하지 못하도록 설정하기 때문입니다.


웹뷰의 크래시 확인하기

웹뷰를 사용하는 와중, 다양한 이유로 웹뷰의 렌더러 프로세스에서 크래시가 발생할 수 있습니다. 메모리 부족, 잘못된 JavaScript 코드, 렌더링 엔진의 버그 등이 원인이 될 수 있습니다.

개발자 측에서 상황을 감지하고 처리하기 위해, 안드로이드는 WebViewClient의 멤버로 onRenderProcessGone 콜백을 제공합니다. 이 메서드는 렌더러 프로세스가 종료되었을 때 호출됩니다. 해당 콜백 처리 방식에 따라 두 프로세스가 모두 크래시될 수도, 렌더러 프로세스만 크래시될 수도 있습니다.

WebViewClient란?
웹뷰의 다양한 이벤트를 처리하는 클래스로, 페이지 로딩 시작(onPageStarted), 로딩 완료(onPageFinished), 에러 발생(onReceivedError) 등의 이벤트를 처리할 수 있습니다.

onRenderProcessGone

val webView = WebView(context)
webView.webViewClient = object : WebViewClient() {
    // 다른 콜백 - onPageStarted, onPageFinished, onReceivedError...
    
    // 렌더러 프로세스 크래시의 기본적인 감지 및 처리(해당 콜백 생략 가능)
    override fun onRenderProcessGone(
        view: WebView?, 
        detail: RenderProcessGoneDetail?
    ): Boolean {
        return super.onRenderProcessGone(view, detail) // 기본값 : false
    }
}

onRenderProcessGone(view, detail)렌더러 프로세스가 충돌하거나 시스템에 의해 종료되었을 때 호출되는 콜백입니다.

매개변수

  • view: 렌더러 프로세스가 종료된 WebView 인스턴스
  • detail: 종료에 대한 상세 정보
    • detail.didCrash(): true면 크래시, false면 시스템에 의한 종료 (메모리 부족 등)

반환값

  • true: 개발자가 직접 처리하였다는 의미로, 앱 크래시를 막을 수 있음
    • 쉽게 말해 "크래시된 웹뷰를 개발자가 직접 핸들링했으니 앱 전체의 크래시를 막아달라” 는 의미입니다.
  • false: 기본값 - 시스템이 알아서 처리하도록 맡기며, 앱 전체가 크래시됨

재현 방법

크롬 엔진에서 제공되는 테스트를 위한 특수 주소들을 활용할 수 있습니다.

  • chrome://crash: 렌더러를 즉시 충돌시킵니다. detail.didCrash은 true를 반환합니다.
  • chrome://kill: 프로세스를 강제로 종료합니다. detail.didCrash는 false를 반환합니다.

onRenderProcessGone 내부에 로그를 찍어보았을 때, 테스트 버튼을 만들어 저 주소를 로드해보면 결과를 확인해볼 수 있습니다.


웹뷰의 크래시 처리하기

안드로이드 공식문서에서 이미 해결책을 제시하고 있습니다.

공식 문서는 렌더러가 종료된 웹뷰는 '사용 불가능' 상태로 간주하며, 뷰 계층에서 제거(removeView)하고 인스턴스를 파괴(destroy)한 뒤 새로 만들 것을 권장하고 있습니다. 실제로 공식 문서에서 이 절차는 반드시 진행되어야 함을 강조하고 있습니다.

권장되는 처리 방법

콜백이 발생한 시점에 기존 웹뷰를 뷰 계층에서 들어내고 메모리에서 완전히 해제합니다.

inner class MyRendererTrackingWebViewClient : WebViewClient() {
    private var mWebView: WebView? = null

    override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
        if (!detail.didCrash()) {
            // 시스템 메모리 부족으로 인하여 렌더러가 제거 (didCrash == false)
            // mWebView는 전역 변수
            mWebView?.also { webView ->
                val webViewContainer: ViewGroup = findViewById(R.id.my_web_view_container)
                webViewContainer.removeView(webView)
                webView.destroy()
                mWebView = null
            }

            // 이 시점부터 웹뷰 객체를 새로 생성하고 mWebView를 안전하게 재초기화 가능
            return true // true를 반환하여 이미 처리가 되었음을 시스템에 알려 크래시 방지
        }

        // 렌더러 내부 오류의 발생으로 인해 발생 (didCrash == true)
        // ...(필요 시 웹뷰 처리)...

        return true | false
    }
}

반전 : 크래시 된 웹뷰가 재사용된다?

크래시된 웹뷰가 재사용이 가능한 경우가 있는가?

공식 문서의 권장 사항을 무시하고 onRenderProcess에서 true만 반환하는 상태로 아래의 시나리오를 수행해 보았습니다.

  • loadButton 클릭 후 웹 페이지 로딩 완료까지 대기
  • crashButton 클릭하여 렌더러 크래시 시도
  • 다시 loadButton 클릭 후 웹 페이지 로딩 시도
class MainActivity : AppCompatActivity() {

    private lateinit var webView: WebView
    private lateinit var urlInput: EditText
    private lateinit var loadButton: Button
    private lateinit var crashButton: Button
    private lateinit var logTextView: TextView

    private val logBuilder = StringBuilder()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        webView = findViewById(R.id.webView)
        urlInput = findViewById(R.id.urlInput)
        loadButton = findViewById(R.id.loadButton)
        crashButton = findViewById(R.id.crashButton)
        logTextView = findViewById(R.id.logTextView)
        
        setupWebView()
        setupButtons()
        addLog("App start")
    }

    private fun setupWebView() {
        webView.settings.apply {
            javaScriptEnabled = true
            domStorageEnabled = true
        }

        webView.webViewClient = object : WebViewClient() {
            override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                super.onPageStarted(view, url, favicon)
                addLog("onPageStarted: $url")
            }

            override fun onPageFinished(view: WebView?, url: String?) {
                super.onPageFinished(view, url)
                if (view?.progress == 100) {
                    addLog("onPageFinished: $url")
                }
            }

            override fun onRenderProcessGone(
                view: WebView?,
                detail: RenderProcessGoneDetail?
            ): Boolean {
                addLog("onRenderProcessGone 호출")
                addLog(" - didCrash: ${detail?.didCrash()}")
                addLog(" - 아무 처리도 하지 않고 true만 반환")
                return true // 크래시만 방지, 웹뷰 정리 부재
            }
        }
    }

    private fun setupButtons() {
        crashButton.setOnClickListener {
            addLog("chrome://crash 로드 시도")
            webView.loadUrl("chrome://crash")
        }

        loadButton.setOnClickListener {
            val url = urlInput.text.toString().ifEmpty { "https://www.google.com" }
            addLog("URL 로드 시도: $url")
            webView.loadUrl(url)
        }
    }

    private fun addLog(message: String) {
        val timestamp = System.currentTimeMillis() % 100000
        val logMessage = "[$timestamp] $message"

        Log.d("WebViewTest", message)
        logBuilder.append(logMessage).append("\n")
        
        runOnUiThread {
            logTextView.text = logBuilder.toString()
        }
    }

    override fun onDestroy() {
        webView.destroy()
        super.onDestroy()
    }
}

아래 환경에서 테스트를 진행하였습니다.

  • Android 14 (API 34) 에뮬레이터
  • WebView 버전: 131.0.6778.200
  • 단일 WebView 환경

놀랍게도, 아래 로그와 같이 크래시된 웹뷰가 성공적으로 다시 로드되는 것을 확인할 수 있습니다.

URL 로드 시도: https://www.google.com
onPageStarted: https://www.google.com/
onPageFinished: https://www.google.com/
chrome://crash 로드 시도
onRenderProcessGone 호출!
- didCrash: true
- 아무 처리도 하지 않고 true만 반환
URL 로드 시도: https://www.google.com
onPageStarted: https://www.google.com/
onPageFinished: https://www.google.com/

공식 문서의 "반드시 재생성하라"는 표현이 무색할 정도로 매끄러운 동작을 보여줍니다. 하지만 이면에는 실무에서 치명적인 버그로 이어질 수 있는 몇 가지 함정이 숨어 있습니다. 내부 구조 관점에서 그 이유를 살펴보겠습니다.

이유 : 크로미움의 설계 방식

이러한 동작이 가능한 근거는 웹뷰의 기반이 되는 크로미움(Chromium)의 멀티 프로세스 아키텍처 설계에서 찾을 수 있습니다.

Chromium 설계 문서(Multi-process Architecture)에서 몇몇 중요 항목들을 확인할 수 있습니다.

  • 프로세스 분리
  • 자동 생성 매커니즘

‘프로세스 분리’의 경우, UI를 관리하는 Browser Process와 콘텐츠를 그리는 Renderer Process가 분리되어 있다는 것입니다. 이는 앞서 살펴본 웹뷰의 아키텍처에서도 확인할 수 있는 바입니다.

중요한 부분은 ‘자동 생성 매커니즘’ 입니다. 아래 원문에서 그 원리를 확인해볼 수 있습니다.

“... If these handles are signaled, the renderer process has crashed and the affected tabs and frames are notified of the crash. Chromium shows a "sad tab" or "sad frame" image that notifies the user that the renderer has crashed. The page can be reloaded by pressing the reload button or by starting a new navigation. When this happens, Chromium notices that there is no renderer process and creates a new one.

즉, 안드로이드의 loadUrl은 크로미움 입장에서 'New Navigation'에 해당하며, 이 명령이 내려지는 순간 엔진은 렌더러 프로세스가 없음을 감지하고 자동으로 새 프로세스를 생성하여 할당할 수 있게 되는 것입니다.

결국 하라는 대로 해야 하는가?

부제) 만약 크래시된 웹뷰를 처리해주지 않는다면 어떤 일이 일어나는가?

기술적으로 loadUrl 호출을 통한 복구가 완벽해 보임에도 불구하고, 안드로이드 공식 문서가 보수적인 가이드를 제공하는 이유는 '기능적 동작'을 넘어선 '무결성'을 보장하기 위함이라고 추정할 수 있습니다. 웹뷰 크래시를 대응할 때 어떤 상황에서도 앱이 오작동하지 않게 만드는 신뢰성 역시 중시해야 하기 때문입니다.

구체적 이유는 더 깊이 분석하여 별도의 글에서 작성해보겠습니다.

따라서 단순히 화면을 다시 띄우는 것(loadUrl)에 만족하기보다, 사용자가 앱을 이용하던 맥락까지 완벽하게 복원하기 위해서는 공식 가이드에 따라 웹뷰 객체 자체를 새로 만드는 것이 가장 안전한 선택일 것입니다.

크래시된 웹뷰 복구를 위해서, 아래 예시를 활용해볼 수 있을 것입니다.

private fun recoverWebView(container: ViewGroup, lastUrl: String?) {
    // 1. 크래시된 웹뷰 처리하기
    webView?.let {
        container.removeView(it)
        it.destroy()
        webView = null
    }

    // 2. 새 웹뷰 만들기
    val newWebView = WebView(this).apply {
        layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT)
        settings.javaScriptEnabled = true
        
        webViewClient = object : WebViewClient() {
            override fun onRenderProcessGone(view: WebView?, detail: RenderProcessGoneDetail?): Boolean {
                recoverWebView(container, view?.url)
                return true
            }
        }
    }

    // 3. 새 웹뷰 띄우기
    container.addView(newWebView)
    newWebView.loadUrl(lastUrl ?: "https://www.google.com")
    
    // 4. 전역 변수에 저장해 두기
    this.webView = newWebView
}

마치며

웹뷰는 앱 내부에 있지만 앱이 아닙니다. onRenderProcessGone 대응의 핵심은 상태가 깨진 객체에 미련을 두지 않는 것입니다.

단순히 크래시를 막는 return true에 만족하지 않고, 뷰 계층에서 확실히 제거하고 새로 생성하는 로직을 갖춰야 견고한 웹뷰 기반 앱을 만들 수 있습니다. 혹시 웹뷰가 원인 모를 먹통 상태가 된다면, 이 과정이 누락되지는 않았는지 확인이 필요할 것입니다.

profile
Software Engineer

0개의 댓글