[Android] WebView의 생성 비용은 미리 만들어두면 해결될까?

Kame·2026년 8월 19일

Android

목록 보기
20/21

들어가며

웹뷰의 생성 비용을 다룬 이전 글을 읽고 오시면 이해가 더욱 쉽습니다.

WebView 생성은 일반적인 View보다 훨씬 많은 비용이 소요됩니다. 그래서 WebView를 쓰면서 저사양 디바이스 이용자가 많은 서비스에서는 WebView를 언제 생성하는가에 따라 성능이 좌우되는 경우가 많습니다.

이번 글부터 몇 편에 걸쳐 WebView 생성 관련 비용과 더불어, 사용자가 웹뷰 화면을 열고 실제 콘텐츠를 보기까지 체감하는 전체 로딩 시간을 개선하기 위해 고려 가능한 개선 방안들을 공유해보고자 합니다.

우선 사용자가 페이지를 열고 콘텐츠를 보기까지의 체감 시간을 두 구간으로 쪼개보겠습니다.

  • Native Time : 웹뷰를 만들고 URL을 준비하는 구간
  • Web Time : loadUrl()을 호출한 후, 실제로 페이지를 그리기까지 소요되는 구간

이번 글에서는 안드로이드 측에서 직접 손댈 수 있는 첫 번째 구간(Native Time)과 관련된 방법과 그 한계를 살펴보겠습니다.

Web Time 관련 사항은 이어질 글들에서 다룰 예정입니다.


웹 페이지를 시작하기 전부터 흘러가는 시간

사용자는 WebView가 있는 화면을 여는 순간부터 로딩 화면을 보게 됩니다. (이전에 작성한 웹뷰 로딩 경험 개선 관련 게시글 참고)

이 로딩 시간 동안 아래와 같은 과정을 거치게 됩니다.

화면 진입
→ 네이티브 로딩 화면 표시
웹뷰 및 제반 사항 준비 (= Native Time)
→ 웹 페이지 로딩 (= Web Time)
(+ 필요한 경우 로딩 완료 브릿지 신호 수신)
→ 웹 페이지 로딩 완료
→ 로딩 화면 숨김

이 과정에서 Native Time에 속하면서, 성격이 서로 다르지만 우리가 통제할 수 있는 작업이 두 가지 있습니다.

웹뷰 인스턴스 생성 비용

하나는 WebView 인스턴스를 만드는 비용입니다. 이 AwBrowserProcess를 트리거하는 비용에 대해서는 이미 이전 편에서 다뤘습니다.

URL 완성 비용

또 다른 하나는 loadUrl()에 넣을 URL 자체를 완성하는 비용입니다. 어떤 기능을 구현하느냐에 따라 다르겠지만, 사용자 식별값이나 서버 설정을 조회해서 최종 URL을 조립해야 하는 경우가 흔합니다. 이때 URL을 어떻게 조립하느냐에 따라 성능이 크게 좌우됩니다.

비효율적인 API 호출 줄이기

예를 들어 아래 코드는 비효율적인 예시입니다. API 호출을 순차적으로 거쳐야만 URL이 완성되는 구조라면, 이 구간 자체가 병목이 됩니다.

// 나쁜 예시: URL을 만드는 데 필요한 두 번의 API 호출이 순차적으로 실행됨
class EntryUrlBuilder(
    private val sessionApi: SessionApi,
    private val configApi: ConfigApi,
) {
    suspend fun build(userId: String): String {
        val token = sessionApi.issueToken(userId) // 1차 호출
        val config = configApi.getEntryConfig(userId, token.value) // 2차 호출
        return "${config.baseUrl}?token=${token.value}"
    }
}

두 호출이 순차적으로 이어지면 두 번의 네트워크 왕복 시간이 그대로 더해지고, 이것이 로딩 시간으로 고스란히 이어집니다.

가장 확실한 해결책은 애초에 URL을 만들기 위한 API 호출이 정말 필요한지 따져보는 것입니다. 위 예시의 configApi.getEntryConfig가 서버 값이 실시간으로 바뀌어야 하는 게 아니라면, 네트워크 호출 없이 네이티브가 이미 갖고 있는 값만으로 URL을 직접 조립할 여지가 있습니다.

예를 들어 API 응답 중 일부가 특정 시점에 네이티브 측에 보존 가능한 값이라면, 화면을 열 때마다 다시 조회할 필요가 없게 됩니다. 화면을 열 때마다 거쳐야 했던 API 호출 두 번이 한 번으로 줄어들게 됩니다.

class EntryUrlBuilder(
    private val configApi: ConfigApi,
    private val sessionTokenCache: SessionTokenCache, // 로그인 시점에 이미 발급받아 캐시된 토큰
) {
    suspend fun build(userId: String): String {
        val token = sessionTokenCache.getOrNull(userId)
            ?: sessionTokenCache.refresh(userId) // 캐시가 없을 때만 새로 발급
        val config = configApi.getEntryConfig(userId) // 이제 이 호출 하나만 남음
        return "${config.baseUrl}?token=${token.value}"
    }
}

또한 한 번 완성한 URL은 캐싱해두면, 이후에는 필요한 경우 재사용하면 되기 때문에 URL 조립을 위한 API 호출 자체가 필요 없어지게 됩니다.

class EntryUrlBuilder(
    private val configApi: ConfigApi,
    private val sessionTokenCache: SessionTokenCache,
    private val entryUrlCache: EntryUrlCache, // 완성된 URL 자체를 캐싱
) {
    suspend fun build(userId: String): String {
        // 캐시에 완성된 URL이 있다면, API 호출 없이 즉시 반환
        entryUrlCache.getOrNull(userId)?.let { return it }

        val token = sessionTokenCache.getOrNull(userId)
            ?: sessionTokenCache.refresh(userId)
        val config = configApi.getEntryConfig(userId) // 캐시가 없을 때만 도달하는 호출
        val url = "${config.baseUrl}?token=${token.value}"

        entryUrlCache.put(userId, url) // 다음 진입부터는 이 캐시를 재사용
        return url
    }
}

웹뷰 객체를 미리 만들어 두면 되지 않을까?

URL 조립 비용을 제거했거나 애초에 아예 없었던 상태라고 해도, 이전 글에서 확인한 뷰 생성 비용 자체는 그대로 남습니다.

그렇다면 웹뷰 인스턴스 생성의 체감 시간을 줄여야 하는데, 자연스럽게 다음과 같은 생각으로 이어지게 됩니다.

AwBrowserProcess를 트리거하는 것이 한 번은 반드시 감당해야 할 고정비라면, 사용자가 체감하지 못하는 시점에 미리 만들어두면 되지 않을까?

예를 들어 앱이 켜질 때 빈 WebView 객체를 만들어 캐시해두는 방법을 생각해볼 수 있습니다.

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        initDependencyGraph()
        initFirebase()
        initAnalytics()
        openLocalDatabase()

        warmUpWebView() // 여기서 AwBrowserProcess 기동이 트리거됨
    }

    private fun warmUpWebView() {
        cachedWebView = WebView(applicationContext)
    }

    companion object {
        var cachedWebView: WebView? = null
    }
}

실제로 이렇게 웹뷰 객체를 미리 만들어 두면, 사용자가 화면에 진입할 때는 이미 cachedWebView가 준비되어 있으니, 네이티브 입장에서는 URL 조립 비용만 남게 됩니다.

저사양 디바이스에서 안전한 방식인가?

위 예시의 문제는 Application.onCreate()가 대개 앱에서 무거운 구간이라는 점을 간과했다는 데 있습니다.

위 코드에서 DI 그래프 초기화, Firebase, Analytics, DB 오픈은 각각 몇십 ms 수준이지만, 합치면 이미 수백 ms에 달합니다. 여기에 저사양 기기 기준 웹뷰 웜업 비용(실험 기준 1초 이상)까지 더해지면 상황은 크게 달라집니다.

사실 이 시간은 Application.onCreate()에서 미리 부담하느냐, 나중에 부담하느냐의 차이일 뿐, 어느 쪽이든 메인 스레드는 이 비용을 피해갈 수 없습니다.

이 구간 안에는 스플래시 종료 조건, 콜드 스타트 지표(Time to Initial Display 등)가 걸려 있는 경우가 많아서, 늘어난 시간만큼 지표 악화와 체감 지연으로 바로 이어집니다.

Android의 입력 이벤트 처리 ANR 기준은 약 5초이고, 이전 편의 실험 기준으로 웜업 비용(1,038ms) 하나만으로 이미 그 20% 이상을 소진하고 있었습니다. Application.onCreate()에서 이 비용을 감수하면, 뒤이어 실행되는 다른 초기화 콜백(Firebase, Analytics, DB 오픈 등)과 겹치면서 남은 시간을 갉아먹게 되고, 저사양 기기에서는 앱 초기화로 ANR 임계값에 근접하는 상황이 실제로 발생할 수 있습니다.

시점을 분산하는 방법

여기서 중요한 결정은 웹뷰를 언제, 어떻게 웜업할 것인가입니다.

안전장치로서 IdleHandler

Looper.myQueue().addIdleHandler는 메인 스레드가 첫 프레임을 그리고 할 일이 없는 유휴 상태에 들어갔을 때 콜백을 실행합니다.

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        initDependencyGraph()
        initFirebase()
        initAnalytics()
        openLocalDatabase()

        Looper.myQueue().addIdleHandler {
            warmUpWebView()
            false // 한 번만 실행하고 큐에서 제거
        }
    }

    private fun warmUpWebView() {
        cachedWebView = WebView(applicationContext)
    }

    companion object {
        var cachedWebView: WebView? = null
    }
}

Application.onCreate()에 직접 웹뷰를 초기화하는 것보다는 분명히 낫습니다. 첫 프레임 렌더링을 막지 않기 때문입니다.

근본적인 해결책은 아닙니다. 유휴 시점이 언제 올지는 앱이 통제할 수 없기 때문입니다. 그런데도 이를 넣어두는 이유는, 이후 살펴볼 진입 예측 기반 웜업이 어떤 이유로든 트리거되지 않았을 때의 안전망 역할을 하기 때문입니다. 예측 신호를 놓친 사용자라도, 언젠가 메인 스레드가 쉬는 틈에 웜업이 끼어들어 완전히 손 놓고 있는 상태보다는 낫게 만들어 줍니다.

진입 예측 기반의 Lazy Warm up

더 신뢰할 수 있는 방법은 유휴 시간이 오길 기다리는 대신, 사용자가 곧 웹뷰 화면에 들어갈 것이라는 신호가 잡히는 순간 웜업을 시작하는 것입니다.

예를 들어 커머스 앱에서 상품 리뷰가 웹뷰로 렌더링된다면, 리뷰 탭을 실제로 누르는 시점이 아니라 상품 상세 화면에 진입하는 시점을 신호로 쓸 수 있습니다. 상세 화면 진입 자체가 곧 웹뷰가 필요해질 확률이 높다는 충분히 강한 사전 신호이기 때문입니다.

class WebViewWarmupCoordinator(
    private val appContext: Context,
    private val scope: CoroutineScope,
) {
    @Volatile private var warmedUpInstance: WebView? = null
    private var warmupJob: Job? = null

    fun startWarmupRace() {
        if (warmupJob?.isActive == true || warmedUpInstance != null) return
        warmupJob = scope.launch(Dispatchers.Main.immediate) {
            yield() // 진입 애니메이션 등 우선순위 높은 프레임에 한 틱 양보
            warmedUpInstance = WebView(appContext)
        }
    }

    fun consumeOrCreate(): WebView =
        warmedUpInstance?.also { warmedUpInstance = null }
            ?: WebView(appContext) // 예측이 빗나갔을 때의 폴백
}

class ProductDetailViewModel(
    private val warmupCoordinator: WebViewWarmupCoordinator,
) : ViewModel() {

    fun onScreenEntered() {
        warmupCoordinator.startWarmupRace() // 리뷰 탭 클릭 전, 화면 진입만으로 웜업 시작
    }

    fun onReviewTabClicked(): WebView = warmupCoordinator.consumeOrCreate()
}

핵심은 앱을 켤 때마다 무조건 도는 게 아니라, 웜업이 실제로 쓰일 확률이 높은 조건에서만 발동한다는 점입니다. 앱 시작 비용과 완전히 분리되고, 웹뷰 화면에 갈 확률이 낮은 사용자에게까지 저사양 기기의 여유 없는 자원을 강제로 쓰게 만들지 않습니다.

지금까지 다뤄본 케이스들의 전체적인 시간 분포를 비교해 보면 아래와 같은 모습이 나오게 됩니다.

주의 사항: 메모리 누수 방지

웜업해 둔 WebView를 재사용하려면 넘어야 할 함정이 하나 더 있습니다. WebView는 생성 시점의 Context를 강하게 물고 있어서, 미리 만들어 둔 인스턴스를 다른 Activity에서 재사용하면 이미 destroy된 원래 Activity의 Context를 계속 참조하는 메모리 누수로 이어질 수 있습니다.

class WarmedUpWebViewHolder(private val appContext: Context) {

    // 웜업 시점에는 아직 어느 Activity에도 속하지 않으므로 Application Context로 감싼다
    private val contextWrapper = MutableContextWrapper(appContext)
    val webView: WebView = WebView(contextWrapper)

    fun attachTo(activity: Activity, container: ViewGroup) {
        contextWrapper.baseContext = activity // 실제 사용 시점에 진짜 Activity로 교체
        (webView.parent as? ViewGroup)?.removeView(webView)
        container.addView(webView)
    }

    fun detach() {
        contextWrapper.baseContext = appContext // Activity 소멸 시 Application Context로 복원
    }
}

WebView 인스턴스는 재사용하되 그 안의 Context만 갈아 끼우는 방식입니다. Activity가 소멸할 때 detach()로 Context를 되돌리는 것만 빼먹지 않으면, 풀에 담긴 WebView가 이미 죽은 Activity를 계속 붙잡는 사고를 막을 수 있습니다.


마치며

웜업은 하느냐 마느냐의 문제가 아니라, 언제 그리고 무엇과 함께 그 비용을 지불하느냐의 문제입니다. 어떤 전략을 쓰든 뷰 생성 비용 자체는 사라지지 않고 위치만 옮겨질 뿐이므로, 결국 그 위치를 어디로 정할지는 각 서비스의 진입 패턴과 기기 스펙 분포를 가장 잘 아는 개발자의 판단에 달려 있습니다.

프로젝트마다 초기화 목록과 SDK 구성이 다르기 때문에, 그 총합은 실제 기기에서 위와 같은 방식으로 직접 측정해봐야 알 수 있습니다. 그 위에 다른 초기화가 얼마나 더 쌓이는지는 서비스마다 직접 확인해야 할 몫입니다.

profile
Software Engineer

0개의 댓글