Android WebView safe-area-insets-* 이슈(top, bottom inset 추가 적용)

easyhooon·2026년 2월 4일

WebView

목록 보기
2/4

서두

이번 주에 갑자기 Android 앱의 UI가 깨진다는 문의가 들어왔다.

┌─────────────────┐    ┌─────────────────┐
│ ▓▓ Status Bar ▓▓│    │ ▓▓ Status Bar ▓▓│
├─────────────────┤    ├─────────────────┤
│ ≡ Header        │    │     (빈 공간)     │ ← 추가 패딩
│                 │    │ ≡ Header        │
│     컨텐츠        │    │                 │
│                 │    │     컨텐츠        │
│                 │    │                 │
│ [  Tab Bar  ]   │    │ [  Tab Bar  ]   │
├─────────────────┤    │     (빈 공간)     │ ← 추가 패딩
│ ▓▓▓ Nav Bar ▓▓▓ │    ├─────────────────┤
└─────────────────┘    │ ▓▓▓ Nav Bar ▓▓▓ │
                       └─────────────────┘

앱의 UI가 위에 처럼 위아래로 빈 공간이 추가되어 찌부(?)가 되어 보인다는 것이었다.

앱이랑 프론트에서 따로 추가적인 배포는 없는 상황이었다.

이러한 문제가 발생한 원인을 무엇인지 파악하고 어떻게 해결하면 되는지 방법을 정리하고자 한다.

기존 상황 (Chromium <= 140)

safe-area-inset-* values are always 0px in webview

  • Android WebView에서 env(safe-area-inset-*) 값이 항상 0px 반환 (Chromium 버그)
    -> 앱에서 네이티브 기기의 inset 값을 계산 후 JS 브릿지로 전달하여 직접 주입하는 방식이 필요했음
// https://medium.com/androiddevelopers/make-webviews-edge-to-edge-a6ef319adfac
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.unit.Dp
// ...

@Composable
fun MainScreen() {
    Box(modifier = Modifier.fillMaxSize()) {
        
        // Retrieve insets as raw pixels
        val insets = WindowInsets.safeDrawing
        WebViewInCompose(
            initialUrl = "file:///android_asset/example.html",
            insets = insets
        )
    }
}

@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebViewInCompose(
    initialUrl: String,
    insets: WindowInsets,
    density: Density = LocalDensity.current,
    layoutDirection: LayoutDirection = LocalLayoutDirection.current,
    myWebViewClient: CustomWebViewClient = remember { CustomWebViewClient(
        insets, density, layoutDirection
    ) }
) {
    
    // Don't apply insets to the container 
    AndroidView(
        factory = { context ->
            WebView(context).apply {
                webViewClient = myWebViewClient
                settings.javaScriptEnabled = true
                loadUrl(initialUrl)
            }
        }, update = { view ->
            
            // Updates webpage when software keyboard expands or collapses.
            // If your webpage doesn't have an input field that opens the 
            // software keyboard, remove this line.
            applySafeAreaInsetsToWebView(insets, density, layoutDirection, view)
        }
    )
}

class CustomWebViewClient(
    private val insets: WindowInsets,
    private val density: Density,
    private val layoutDirection: LayoutDirection
) : WebViewClient(){

    override fun onPageFinished(view: WebView?, url: String?) {
        super.onPageFinished(view, url)
        
        // Inject insets into the webpage once the page has fully loaded
        applySafeAreaInsetsToWebView(insets, density, layoutDirection, view)
    }
}

private fun applySafeAreaInsetsToWebView(
    insets: WindowInsets,
    density: Density,
    layoutDirection: LayoutDirection,
    webView: WebView?){

    // Convert raw pixels to density independent pixels
    val top = insets.getTop(density).toDp(density).value
    val right = insets.getRight(density, layoutDirection).toDp(density).value
    val bottom = insets.getBottom(density).toDp(density).value
    val left = insets.getLeft(density, layoutDirection).toDp(density).value

    val safeAreaJs = """
            document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
            document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
            document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
            document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
            """

    // Inject the density independent pixels into the CSS variables as CSS pixels
    webView?.evaluateJavascript(safeAreaJs, null)
}

private fun Int.toDp(density: Density): Dp = with(density) { this@toDp.toDp() }

변경된 상황 (Chromium > 140)

  • Chromium v141 부터 WebView가 env(safe-area-inset-*) 값을 정상적으로 반환
    -> 위에 언급한 JS 브릿지 주입 코드 호출이 필요 없어짐
  • 기존에 0px이던 값이 갑자기 실제 inset 값(status bar, navigation bar 높이 등)으로 반영됨

실제 피해 사례

  • WebView 업데이트 후 safe area가 갑자기 적용되면서 UI가 깨지는 이슈 다수 보고, 우리 회사 앱도

  • env(safe-area-inset-*)를 사용하는 Android WebView UI 영역에 의도하지 않은 패딩 발생

  • Ionic 팀이 "Chromium WebView Regression(회귀 버그)"로 확인, v141에서 수정

  • https://github.com/ionic-team/ionic-framework/issues/30654

    기존에 정상 동작하던 것이 업데이트로 인해 깨지는 현상을 소프트웨어에서 Regression이라 부름.
    이 맥락에서는 env(safe-area-inset-*) = 0px이 기술적으로는 버그였지만, 많은 앱이 그 동작에 의존하고 있었음 → Chromium 141에서 "수정"했더니 기존 앱 UI가 깨짐 → Ionic 팀 입장에서 이걸 regression으로 분류한 것.

Android 16 (API 36) 영향

https://developer.android.com/about/versions/16/behavior-changes-16?hl=ko

  • Android 16부터 edge-to-edge가 강제 -> 앱이 opt-out 불가(targetSdk 36 시 코드 변경 없이도 시스템이 강제 적용)

앱에 미치는 영향

상황기존 (Chromium ≤ 140)현재 (Chromium > 140)
env(safe-area-inset-*)0px (WebView 버그)실제 system bar 높이
웹에서 해당 값 사용 시0px 이므로 레이아웃 영향 없음의도하지 않은 추가 패딩 발생
앱에서 브릿지로 주입한 값정상 적용브라우저에서 계산한 값과 충돌 가능

즉, 기존에는 env(safe-area-inset-*)가 항상 0px을 반환(Chromium 버그)했기 때문에, 앱에서 JS 브릿지로 주입하지 않는 이상 웹에서 해당 값을 사용하더라도 레이아웃에 영향이 없었지만, Chromium 140 이후 실제 inset 값이 반환되면서 의도하지 않은 추가 패딩이 발생하게 됨.

또한 앱에서 브릿지로 해당 값을 직접 주입하더라도, env(safe-area-inset-*)는 브라우저가 관리하는 글로벌 CSS 환경 변수이므로 주입 타이밍에 따라 브라우저 값에 덮어씌워지거나, 반대로 앱 주입 값이 브라우저 값을 덮어쓸 수 있어 문제가 발생할 수 있음

대응

  • 단기적으로, UI 깨짐을 해결하기 위해선 env(safe-area-inset-*) 사용하는 곳을 0px로 교체하는 식으로(사용하는 부분 제거) 당장을 해결 가능

    단, iOS 환경에서 영향을 받지 않으려면 User Agent를 통한 분기 처리 필요

  • 장기적으로는 현재 JS 브릿지를 통해 inset 값을 주입 중이라면 브릿지 주입 제거 + env() 정상 동작 수용(Android WebView도 edgeToEdge로 SystemBar 영역까지 영역 확장)

┌─────────────────┐
│ ▓▓(빈 공간)▓▓▓▓   │ ← safe-area-inset-top (status bar 영역)
│ ≡ Header        │
│                 │
│     컨텐츠        │
│                 │
│                 │
│ [  Tab Bar  ]   │
│ ▓▓(빈 공간)▓▓▓▓   │ ← safe-area-inset-bottom (nav bar 영역)
└─────────────────┘
  • 단, Chromium <= 140 기기 호환을 위해 WebView 버전 체크 후 분기 처리하거나, 기존 브릿지 주입을 유지하여 버전에 관계없이 실제 Inset값이 주입되도록 구현
  • 장기적으로 최소 WebView 버전이 140 이상으로 올라가면 브릿지 로직 완전 제거 가능

Chromium 140 이하 기기에서 JS 브릿지를 통한 Inset 주입 없이, enableEdgeToEdge로 뷰 영역을 확장할 경우, 위 아래 빈 공간이 0px로 잡혀 SystemBar 영역에 Header, Tab Bar등의 컨텐츠가 침범할 수 있다.

요약

서두에 언급한 문제의 원인

  • Android/iOS 공통으로 사용하는 WebView내 UI에 env(safe-area-insets-*)을 사용하고 있었음
  • Android WebView에 safe-area-insets-* 값들이 WebView 엔진 업데이트로 정상화 되면서 inset 값이 반영되어 추가 패딩이 적용됨.

Q. 왜 그러면 그동안 iOS 앱엔 문제가 없었는지

  • iOS 앱만 Safe Area 영역까지 확장한 UI를 사용했었기 때문. Android 앱은 Android 15 버전 이전 처럼 SystemBar 영역은 WebView 콘텐츠 영역이 침범을 못하도록 막아두었음(safe-area-insets-* 값이 항상 0px로 들어올 것을 산정하고 WebView UI 개발)


이슈 제보 2020년 6월...

구글이 5년 넘게 일을 안했다가, 일을 해서 수 많은 앱들에게 문제가 발생했다.

내용 추가

v141 release는 2025년 8월 경으로 보이는데 왜 그동안 문제가 없다가 26년 2월에 이런 문제가 발생했을까?

https://issues.chromium.org/issues/457682720

fix 된 버전이 144.0.7514.0 버전에 정상 반영된 것으로 추측된다.

내용 수정

144.0.7514.0 버전에서 수정된건 키보드가 올라올때 관련 bottom-insets 값 관련 이슈라 살짝 다른 문제인듯 하다. 이것보다 더 예전 버전에서 이미 반영된 것으로 추측

https://github.com/react-native-webview/react-native-webview/issues/3828

본문에서 언급한 140 버전 전후에 반영된게 맞는듯 하다.

또한 기기마다 WebView 엔진 버전의 차이가 있을 수 있어, 사내 테스트 기기에선 그 이전에 위 문제가 발생하지 않았던 것으로 추측...

내용 추가

https://issuetracker.google.com/issues/40699457
해당 이슈 트래커 글의 코멘트를 통해 각 버전별 상태를 전부 정리해보면

버전상태
~135 이하env() = 0 (시스템바 미지원, display cutout만 부분 지원)
136~138동작 (#34 Darryl의 시스템바 포함 커밋, #36에서 136 확인)
139리그레션 - 다시 0 반환 (#44, #45)
140+수정 백포트 완료 (#63, #64)

139 버전에 다시 env(safe-area-insets-*) 값이 0으로 출력되는 버그가 재현이 되었다고 한다...

해결 방법

따라서 앱에서 웹뷰 버전에 따라 분기처리하기엔 좀 깔끔하지 않기에, 앱에선 버전에 상관없이 항상 css --safe-area-inset-* 값들을 웹뷰에 주입하고, 웹뷰에선 기존 env(safe-area-inset-*)를 사용하는 부분들을 var(--safe-area-inset-*, env(safe-area-inset-*)) 로 교체하여

Android에선 주입된 --safe-area-inset- 값을 사용, iOS에선 env(safe-area-inset-)를 그대로 사용(--sare-area-inset-*이 정의 되지 않았기에 fallback)하는 방식이 최선이라고 생각한다..!

네이티브 화면과 WebView 화면이 혼재되어있는 앱의 경우

네이티브 화면 (SplashActivity 등)

enableEdgeToEdge()로 시스템바 영역까지 콘텐츠를 확장한 뒤, WindowInsetsCompat으로 시스템바 높이를 가져와 루트 뷰에
setPadding()으로 패딩을 적용한다. 네이티브 뷰는 CSS가 없으므로 네이티브 코드에서 직접 패딩을 처리해야 한다.

WebView 화면 (MainActivity)

마찬가지로 enableEdgeToEdge()로 콘텐츠를 확장하지만, 루트 뷰에 setPadding()은 적용하지 않는다.

대신, WindowInsetsCompat으로 가져온 시스템바 높이를 onPageFinished 시점에 JavaScript를 통해 CSS 커스텀 속성(--safe-area-inset-top 등)으로 웹에 주입한다.

Padding 처리를 웹 CSS에 위임하는 방식으로, 네이티브에서 패딩을 주면 웹의 env(safe-area-inset-*)와 이중 적용되는 문제를 방지한다.

reference)
https://issues.chromium.org/issues/40699457
https://github.com/ionic-team/ionic-framework/issues/30654
https://medium.com/androiddevelopers/make-webviews-edge-to-edge-a6ef319adfac
https://developer.chrome.com/docs/css-ui/edge-to-edge
https://issuetracker.google.com/issues/396827865
https://developer.chrome.com/docs/css-ui/edge-to-edge?hl=ko
https://issues.chromium.org/issues/457682720
https://www.androidpolice.com/android-16-full-screen-edge-to-edge-no-opt-out/
https://chromereleases.googleblog.com/2026/02/chrome-for-android-update.html
https://github.com/react-native-webview/react-native-webview/issues/3828

profile
실력은 고통의 총합이다. Android Developer

0개의 댓글