본문으로 건너뛰기

화면이 잘리고 손이 미끄러집니다 - 웹뷰는 브라우저가 아니다 ep.05

화면은 하나다. 뷰포트는 하나가 아니다. 키보드가 올라올 때 둘 중 무엇이 줄어드는지가 환경마다 다르고, 우리 CSS는 그 차이를 모른 채 쓰였다.

뷰포트는 하나가 아닙니다

웹에는 뷰포트가 두 개 있습니다. MDN은 이렇게 나눕니다.

레이아웃 뷰포트(layout viewport) 는 CSS가 레이아웃을 계산할 때 기준으로 삼는 상자입니다. 시각 뷰포트(visual viewport) 는 화면에 실제로 보이는 부분입니다. 사용자가 핀치 줌을 하면 시각 뷰포트는 작아지지만 레이아웃 뷰포트는 그대로입니다. 온스크린 키보드처럼 UI가 화면을 점유할 때도 시각 뷰포트만 줄어들 수 있습니다.

안드로이드 공식 문서도 WebView를 같은 방식으로 설명합니다. “WebView는 레이아웃 뷰포트(페이지 크기)와 시각 뷰포트(사용자가 실제로 보는 부분) 두 개로 콘텐츠 정렬을 관리한다. 레이아웃 뷰포트는 대체로 정적이지만, 시각 뷰포트는 사용자가 확대하거나 스크롤할 때, 혹은 소프트웨어 키보드 같은 시스템 UI가 나타날 때 동적으로 변한다.”

이 편의 증상 대부분은 여기서 갈립니다. 100vh, position: fixed, height: 100%는 전부 레이아웃 뷰포트를 보고 계산됩니다. 그런데 사용자가 불평하는 “화면이 잘린다”는 시각 뷰포트에서 일어나는 일입니다. 둘이 어긋난 만큼이 버그로 보입니다.

시각 뷰포트를 직접 읽으려면 Visual Viewport API를 씁니다.

const vv = window.visualViewport;

// 시각 뷰포트의 크기와, 레이아웃 뷰포트 기준 오프셋
console.log(vv.width, vv.height);   // 실제로 보이는 영역
console.log(vv.offsetTop, vv.offsetLeft);
console.log(vv.scale);              // 핀치 줌 배율

vv.addEventListener("resize", () => {
  // 레이아웃 뷰포트 높이와의 차이가 곧 가려진 높이입니다
  const hidden = window.innerHeight - vv.height;
  document.documentElement.style.setProperty("--vv-hidden", `${hidden}px`);
});

지원 범위는 확인해두는 편이 좋습니다. VisualViewport 인터페이스는 Chrome 61(이벤트 핸들러 프로퍼티까지 포함한 완전 지원은 62), Safari 13, Firefox 91부터입니다. 웹뷰 기준으로 옮기면 Android WebView 61 이상, WKWebView는 iOS 13 이상입니다.

iOS의 웹뷰는 OS 버전을 그대로 따라가므로 “iOS 13 이상”이라고 쓸 수 있습니다. 반면 Android WebView는 Play 스토어로 갱신되는 별도 패키지라서 “안드로이드 몇 버전 이상”이 아니라 “WebView 몇 버전 이상”이라고 써야 정확합니다. 같은 안드로이드 13 기기라도 WebView 버전은 제각각입니다. 실기기의 WebView 버전을 확인하는 절차는 ep.01에서 다룹니다.


키보드가 올라오면 무엇이 움직이는가

“키보드가 올라오면 하단 버튼이 가려집니다”는 이 시리즈에서 가장 자주 들어오는 제보입니다. 그런데 이 증상은 iOS와 안드로이드로 이분해서 설명하면 틀립니다. 지금 시점의 구도는 세 갈래입니다.

키보드가 올라올 때 뷰포트가 어떻게 변하는지를 네 가지 환경으로 비교한 다이어그램. 맨 위에는 공통 기준 상태가 하나 놓여 있다. 세로로 긴 기기 화면 사각형 안에 파란 실선 테두리의 '레이아웃 뷰포트'와 주황 반투명 채움의 '시각 뷰포트'가 완전히 겹쳐 있고, 화면 하단에는 '하단 고정 버튼 (position: fixed)'이라는 라벨이 붙은 진한 막대가 놓여 있다. 그 아래 범례에 파란 실선은 레이아웃 뷰포트, 주황 채움은 시각 뷰포트, 회색 빗금은 온스크린 키보드를 뜻한다고 표시되어 있다. 기준 상태 아래에는 같은 크기의 화면 사각형 네 개가 가로로 나란히 놓이고, 각 화면의 하단 약 3분의 1에는 회색 빗금의 '온스크린 키보드' 블록이 올라와 있다. 첫 번째 화면의 제목은 'iOS WKWebView'다. 파란 레이아웃 뷰포트 테두리는 키보드 뒤까지 원래 높이 그대로 이어지고, 주황 시각 뷰포트만 키보드 윗변까지 줄어들어 있다. 하단 고정 버튼 막대는 원래 위치인 화면 맨 아래에 반투명하게 남아 키보드에 가려진 상태로 그려지고, 캡션은 '레이아웃 뷰포트 유지, 시각 뷰포트만 축소'다. 두 번째 화면의 제목은 'Android Chrome 108 이상'이다. 모양은 첫 번째와 같고, 캡션은 '레이아웃 뷰포트 유지, 시각 뷰포트만 축소. 뷰포트 상대 단위 계산값은 그대로. position: fixed는 제자리에 남아 가려짐'이다. 세 번째 화면의 제목은 'Android WebView M139 미만'이다. 이 화면에서는 파란 레이아웃 뷰포트 테두리와 주황 시각 뷰포트가 함께 키보드 윗변까지 줄어들어 두 경계선이 겹쳐 있고, 하단 고정 버튼 막대는 줄어든 영역의 맨 아래, 즉 키보드 바로 위로 올라와 있다. 캡션은 '레이아웃 뷰포트도 함께 축소. 화면 변화는 액티비티의 windowSoftInputMode가 결정'이다. 네 번째 화면의 제목은 'Android WebView M139 이상'이다. 파란 레이아웃 뷰포트 테두리는 원래 높이 그대로 이어지고 주황 시각 뷰포트만 키보드 윗변까지 줄어들며, 시각 뷰포트의 아랫변에만 굵은 강조선과 '하단에만 적용'이라는 라벨이 붙어 있다. 화면의 좌변·우변·윗변에는 각각 회색 취소선과 함께 '좌·우·상단 오버랩은 리사이즈 없음'이라는 라벨이 달려 있다. 캡션은 'IME가 시각 뷰포트를 직접 리사이즈'다. 다이어그램 맨 아래에는 네 화면을 가로지르는 한 줄 요약 띠가 있고 '레이아웃 뷰포트가 줄어드는 경우와 시각 뷰포트만 줄어드는 경우가 갈린다'라고 적혀 있다.

스펙은 무엇을 기본으로 두는가

CSS Viewport Module Level 1은 뷰포트 메타 태그에 interactive-widget 프로퍼티를 정의했습니다. 키보드 같은 대화형 UI 위젯이 뷰포트에 어떤 영향을 주는지를 지정하는 값입니다.

규범 문구
resizes-visual시각 뷰포트를 리사이즈하되 초기 뷰포트는 리사이즈하지 않는다
resizes-content초기 뷰포트를 위젯만큼 리사이즈한다
overlays-content초기 뷰포트도 시각 뷰포트도 리사이즈하지 않는다

값을 지정하지 않거나 잘못된 값을 넣으면 resizes-visual이 기본으로 적용됩니다. 즉 스펙이 기본으로 삼는 세계는 레이아웃은 그대로 두고 보이는 창만 줄이는 쪽입니다.

다만 이 프로퍼티가 Android WebView나 WKWebView에서 어떻게 동작하는지는 공식 문서로 확인되지 않습니다. 지금은 “스펙의 기본값이 무엇인지”를 이해하는 근거로만 쓰고, 실제 동작은 아래 세 갈래로 판단하는 편이 안전합니다.

안드로이드 크롬은 108에서 갈라섰습니다

Chrome 108부터 안드로이드 크롬은 키보드가 올라와도 레이아웃 뷰포트를 리사이즈하지 않습니다. 시각 뷰포트만 줄어듭니다. 크롬 문서가 명시하는 결과는 둘입니다. 뷰포트 상대 단위의 계산값이 그대로 유지되고, position: fixed 요소는 제자리에 남아 키보드에 가려질 수 있습니다.

그리고 같은 문서에 이 한 줄이 있습니다. “These changes do not affect WebView.” 이 변경은 WebView에 적용되지 않았습니다.

Android WebView는 별도 시간표로 움직였습니다

Android WebView는 M139부터 IME(키보드)가 시각 뷰포트를 직접 리사이즈합니다. 전체화면 여부와 무관하게 모든 WebView에 적용됩니다.

조건이 하나 더 붙습니다. 이 시각 뷰포트 리사이즈는 WebView의 하단에만 적용됩니다. 문서가 직접 말합니다. 좌측·우측·상단 오버랩 리사이즈는 지원하지 않습니다. “키보드가 뜨면 시각 뷰포트가 줄어든다”를 모든 방향으로 일반화하면 안 됩니다.

같은 문서에 함께 실린 경고가 실무적으로 더 중요합니다. 키보드 표시가 시각 뷰포트 리사이즈를 유발하게 되면서 웹 코드가 resize 이벤트를 더 자주 받게 되었고, 이 이벤트에서 요소의 포커스를 해제하면 포커스 상실과 키보드 닫힘이 무한 루프를 이루어 입력 자체가 불가능해집니다.

레이아웃을 재계산하는 오래된 코드 중에 이런 모양이 남아 있을 수 있습니다.

// 이 코드는 WebView 139 이상에서 입력을 막습니다
window.visualViewport.addEventListener("resize", () => {
  document.activeElement.blur();   // 포커스 해제 → 키보드 닫힘 → 다시 resize
  recalcLayout();
});

resize 핸들러에서 해야 할 일은 포커스를 건드리는 것이 아니라 크기를 반영하는 것뿐입니다.

const root = document.documentElement;

function syncViewport() {
  const vv = window.visualViewport;
  // 가려진 높이만 CSS 변수로 내려보냅니다
  root.style.setProperty("--kb", `${Math.max(0, window.innerHeight - vv.height)}px`);
}

if (window.visualViewport) {
  window.visualViewport.addEventListener("resize", syncViewport);
  window.visualViewport.addEventListener("scroll", syncViewport);
  syncViewport();
}
.sticky-cta {
  position: fixed;
  left: 0;
  right: 0;
  /* 키보드가 가린 만큼 위로 밀어 올립니다 */
  bottom: calc(var(--kb, 0px) + env(safe-area-inset-bottom, 0px));
}

Vue를 쓴다면 컴포저블 하나로 묶어두는 편이 낫습니다. 리스너 해제를 잊으면 라우팅 후에도 계속 붙어 있습니다.

import { onMounted, onUnmounted, ref } from "vue";

export function useKeyboardInset() {
  const inset = ref(0);
  const sync = () => {
    const vv = window.visualViewport;
    inset.value = vv ? Math.max(0, window.innerHeight - vv.height) : 0;
  };

  onMounted(() => {
    window.visualViewport?.addEventListener("resize", sync);
    window.visualViewport?.addEventListener("scroll", sync);
    sync();
  });
  onUnmounted(() => {
    window.visualViewport?.removeEventListener("resize", sync);
    window.visualViewport?.removeEventListener("scroll", sync);
  });

  return inset;
}

웹이 키보드 높이를 직접 읽는 길

VirtualKeyboard API는 키보드 크기를 CSS 환경 변수로 노출합니다. env(keyboard-inset-top), keyboard-inset-bottom, keyboard-inset-height 등입니다. 지원 범위가 한쪽으로 치우쳐 있습니다.

환경지원
Chrome / Edge94 이상
Android WebView94 이상
Safari / Safari iOS / WKWebView미지원

WKWebView에서는 쓸 수 없으므로, 크로스 플랫폼으로 하나만 고른다면 Visual Viewport API 쪽입니다.

앱에 요청할 지점

안드로이드에서 키보드가 떴을 때 액티비티의 레이아웃이 어떻게 반응할지는 매니페스트의 android:windowSoftInputMode가 정합니다. 지정 가능한 값은 stateUnspecified, stateUnchanged, stateHidden, stateAlwaysHidden, stateVisible, stateAlwaysVisible, adjustUnspecified, adjustResize, adjustPan입니다.

안드로이드 가이드가 권장하는 값은 adjustResize입니다. “시스템이 레이아웃을 남은 공간에 맞게 리사이즈하도록 하려면 adjustResize를 쓰라”고 하고, 텍스트 입력 도중이나 직후에 사용자가 접근해야 하는 컨트롤이 UI에 있다면 이 지정이 중요하다고 못박습니다. 소프트 키보드 문서도 AndroidX 구현과의 하위 호환을 위해 같은 값을 지정하라고 안내합니다.

<!-- AndroidManifest.xml — 앱에 요청할 한 줄입니다 -->
<activity
    android:name=".WebViewActivity"
    android:windowSoftInputMode="adjustResize" />

여기서 자주 엇갈리는 지점이 하나 있습니다. deprecated 된 것은 프로그래밍 상수 쪽입니다. WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE가 API 레벨 30에서 deprecated 되었을 뿐, 매니페스트 속성값 adjustResize 자체는 deprecated로 표시되지 않았고 여전히 권장됩니다. 앱 개발자가 “그거 deprecated 아닌가요”라고 답한다면 이 구분을 짚어주면 됩니다.

키보드가 올라오고 내려가는 애니메이션에 앱 화면을 맞추는 일은 Android 11(API 30) 이상에서 WindowInsetsAnimationCompat으로 처리하고, 키보드 크기는 WindowInsetsCompat.Type.ime()로 조회합니다. 여기까지가 앱의 영역입니다.

폼 컨트롤이 어떤 키보드를 띄우는지, 그 키보드가 왜 내 디자인이 아닌지는 ep.03에서, 키보드로 한글을 입력하는 도중의 조합 상태는 ep.04에서 다룹니다.


100vh는 어느 뷰포트를 가리키는가

브라우저에서 height: 100vh가 화면보다 길어지는 문제는 널리 알려져 있습니다. 주소창이 접혔다 펴지는 동안 기준이 흔들리기 때문입니다. 해결책으로 나온 것이 뷰포트 단위의 세 갈래입니다.

단위기준안정성
svh / svw브라우저 UI가 모두 펼쳐졌을 때의 가장 작은 뷰포트뷰포트 자체가 리사이즈되지 않는 한 고정
lvh / lvwUI가 모두 접혔을 때의 가장 큰 뷰포트뷰포트 자체가 리사이즈되지 않는 한 고정
dvh / dvwUI 개폐에 따라 실시간으로 변하는 뷰포트뷰포트가 그대로여도 안정적이지 않음

MDN은 기본 단위 vh·vw가 현재는 각각 lvh·lvw와 같다고 서술합니다. 즉 100vh는 가장 큰 뷰포트를 가리킵니다.

지원 하한선을 정확히 잡아야 합니다. small·large·dynamic 세 계열 모두 Chrome 108, Safari 15.4, Firefox 101부터입니다. 웹뷰로 옮기면 Android WebView 108 이상, WKWebView는 iOS 15.4 이상입니다. WebKit도 Safari 15.4 릴리스 노트에서 svh·lvh·dvh와 논리 축 변형(svi, svb, lvi, lvb, dvi, dvb)의 도입을 밝혔습니다.

그러니 폴백 없이 쓰면 그 아래 버전에서는 선언 자체가 무시됩니다.

.screen {
  height: 100vh;        /* 하한선 아래 버전을 위한 폴백 */
  height: 100dvh;       /* 지원하면 이쪽이 이깁니다 */
}

그리고 dvh를 만능 해결책으로 쓰지 않는 편이 좋습니다. MDN이 직접 경고합니다. 동적 뷰포트 단위를 쓰면 사용자가 스크롤하는 동안 콘텐츠가 리사이즈되어 UI 품질이 떨어지고 성능에도 영향을 줍니다. 화면 전체를 채우는 컨테이너 하나 정도면 몰라도, 목록 아이템마다 dvh를 물리면 스크롤 중에 레이아웃이 계속 흔들립니다.

한 가지 덧붙일 것이 있습니다. 웹뷰에는 접혔다 펴지는 주소창이 없습니다. 그렇다고 해서 “웹뷰에서는 세 단위가 항상 같은 값”이라고 단정할 근거는 없습니다. Apple이 WKWebView에 뷰포트 인셋 관련 API를 두고 있으나 문서에 설명이 비어 있어 그 관계를 확인할 수 없습니다. 실기기에서 세 값을 찍어보고 판단하십시오.

// 진단용 한 줄. 세 값이 같은지 다른지부터 확인합니다
const probe = document.createElement("div");
probe.style.cssText = "position:fixed;visibility:hidden;height:100svh";
document.body.append(probe);
console.log("svh", probe.offsetHeight);
probe.style.height = "100lvh";
console.log("lvh", probe.offsetHeight);
probe.style.height = "100dvh";
console.log("dvh", probe.offsetHeight);
probe.remove();

화면 가장자리: 안전 영역은 누가 넣어주는가

노치, 다이내믹 아일랜드, 홈 인디케이터, 제스처 내비게이션 바. 화면은 직사각형이 아니고, 그 바깥쪽으로 콘텐츠가 밀려 나가면 잘립니다.

iOS는 기본값이 안전한 쪽입니다

WebKit이 정의한 viewport-fit의 기본값은 auto이고, 이 값에서는 브라우저가 자동으로 콘텐츠를 안전 영역 안쪽으로 들여씁니다. 페이지를 화면 전체 크기로 레이아웃하려면 cover로 바꿔야 하고, 바꾸는 순간부터 안전 영역 패딩은 우리 책임이 됩니다.

동작
auto (기본)초기 레이아웃 뷰포트에 영향을 주지 않고, 페이지 전체가 보이도록 함
contain디스플레이 안에 내접하는 가장 큰 직사각형에 맞게 스케일
cover디스플레이를 채우도록 스케일. 안전 영역 인셋 변수를 써야 함
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

cover를 켰다면 중요한 콘텐츠를 담은 요소에 선택적으로 패딩을 넣습니다. WebKit이 제시한 패턴이 그대로 쓸 만합니다.

@supports (padding: max(0px)) {
  .app-bar {
    /* 인셋이 0인 기기에서도 최소 12px는 확보합니다 */
    padding-left: max(12px, env(safe-area-inset-left));
    padding-right: max(12px, env(safe-area-inset-right));
    padding-top: max(12px, env(safe-area-inset-top));
  }
  .bottom-cta {
    padding-bottom: max(16px, env(safe-area-inset-bottom));
  }
}

safe-area-inset-* 값은 뷰포트가 직사각형이고 툴바나 동적 키보드 같은 기능이 뷰포트를 점유하지 않으면 0입니다. 그래서 max()로 감싸지 않으면 일반 기기에서 패딩이 0이 되어버립니다.

지원 하한선은 env()가 Chrome 69 / Safari 11.1, safe-area-inset-* 자체는 Safari 11부터입니다. 웹뷰로 옮기면 Android WebView 69 이상, WKWebView는 iOS 11.1 이상입니다. iOS 11.0에서는 env()가 아니라 구형 constant() 함수를 써야 했습니다.

Chrome 135부터는 safe-area-max-inset-*도 쓸 수 있습니다. 동적 UI가 모두 접혔을 때의 정적 최대값이라, 값이 계속 변하는 safe-area-inset-*와 달리 상수입니다. 다만 Safari 계열 지원 여부는 확인되지 않아, 지금은 Chromium 전용 최적화로만 취급하는 편이 안전합니다.

안드로이드는 봐야할 게 여러 개입니다

안드로이드 쪽은 세 가지 변화가 겹쳐 있어 헷갈리기 쉽습니다. 하나씩 조건을 붙여 정리합니다.

Chrome for Android 135 이상: 뷰포트가 안드로이드의 제스처 내비게이션 바 영역까지 확장되고, viewport-fit=cover를 지정하면 기본적으로 하단 끝까지 확장됩니다. safe-area-inset-bottom 값은 chin이 비켜나는 동안 동적으로 갱신됩니다. 이 변경은 소형 화면 기기를 대상으로 하고, 대형 화면 기기의 크롬은 현재 제외되어 있습니다.

Android WebView M136 / M144: WebView는 M136에서 전체화면 WebView에 한해, M144부터는 전체화면 여부와 무관한 모든 WebView에서 displayCutout()systemBars() 인셋을 CSS safe-area-inset-* 변수로 웹 콘텐츠에 전달합니다. 즉 그 이전 WebView 버전에서는 CSS에서 안전 영역 값을 받지 못할 수 있습니다. 웹에서 아무리 잘 짜두어도 값이 0으로 내려오면 소용이 없습니다.

targetSdk 35 + Android 15 기기: Android 15를 타깃하는 앱은 Android 15 기기에서 기본적으로 edge-to-edge가 됩니다. 상태 표시줄과 제스처 내비게이션 바가 투명해지고 상단 오프셋이 비활성화되어, 인셋을 적용하지 않으면 콘텐츠가 상태 표시줄 아래로 그려집니다. layoutInDisplayCutoutModeALWAYS로 강제되고, SHORT_EDGES·NEVER·DEFAULT는 모두 ALWAYS로 해석됩니다. Window#setDecorFitsSystemWindowsWindow#setStatusBarColor는 deprecated이자 비활성화 목록에 올라갔습니다.

targetSdk 36 + Android 16 기기: Android 16을 타깃하면 edge-to-edge에서 빠져나올 수 없습니다. 탈출구였던 R.attr#windowOptOutEdgeToEdgeEnforcement가 deprecated 되고 비활성화됩니다.

조건을 다 붙이면 문장이 길어지지만, 붙이지 않으면 틀린 말이 됩니다. “안드로이드 15부터는 다 그렇다”가 아니라 “targetSdk 35 앱이 안드로이드 15 기기에서 돌 때”입니다.

인셋이 두 번 들어가는 문제

앱 화면에서 상단 여백이 두 배로 벌어져 보인다면 대개 이 경우입니다. 네이티브 컨테이너가 인셋을 패딩으로 이미 소비했는데, 같은 인셋이 WebView로도 내려가 CSS가 한 번 더 패딩을 넣은 것입니다.

안드로이드 문서가 제시하는 처리는 명확합니다. 이미 소비한 타입은 Insets.NONE으로 0 처리해서 내려보내고, WindowInsetsCompat.CONSUMED를 반환하지는 말라는 것입니다. CONSUMED는 상황에 따라 동작하지만, 앱의 핸들러가 인셋을 바꾸거나 자체 패딩을 더할 때 문제가 됩니다.

// 앱에 요청할 지점. 이미 쓴 인셋은 0으로 만들어 WebView에 내려보냅니다
ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, windowInsets ->
    val types = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()
    val insets = windowInsets.getInsets(types)
    view.setPadding(insets.left, insets.top, insets.right, insets.bottom)
    WindowInsetsCompat.Builder(windowInsets).setInsets(types, Insets.NONE).build()
}

iOS에도 대응되는 스위치가 있습니다. WKWebView.scrollViewUIScrollView이고, 그 contentInsetAdjustmentBehavior가 안전 영역 인셋을 콘텐츠 영역에 어떻게 반영할지 결정합니다. 기본값은 .automatic이고 .never로 끌 수 있습니다. 앱이 이미 컨테이너에서 안전 영역을 처리하고 있다면 이 값을 확인해달라고 요청하는 지점입니다.

iOS 26 이상에서는 WKWebView.obscuredContentInsets로 브라우저 UI에 가려지는 영역을 앱이 직접 지정할 수 있습니다. 새 API이므로 지원 하한선을 확인하고 쓰십시오.


레이아웃 너비의 출발점부터 다를 수 있습니다

여기까지 뷰포트의 높이를 이야기했는데, 안드로이드에서는 너비의 출발점부터 확인해야 할 때가 있습니다.

WebSettings.setUseWideViewPort의 javadoc은 이렇게 말합니다. 값이 false이면 레이아웃 너비가 항상 WebView 컨트롤의 너비(디바이스 독립 CSS 픽셀)로 고정되고, 뷰포트 메타 태그의 width 값은 사용되지 않습니다. 값이 true이고 페이지에 뷰포트 메타 태그가 있을 때만 태그에 지정된 너비가 쓰입니다.

javadoc에 이 설정의 기본값은 명시되어 있지 않습니다. 다만 Chrome for Developers의 WebView 문서는 “기본적으로 WebView는 뷰포트를 device-width로 설정하며, 데스크톱 뷰포트를 기본값으로 삼지 않는다”고 하면서도, 신뢰할 수 있고 통제된 동작을 위해 뷰포트 메타 태그를 넣으라고 권고합니다. 같은 문서에 이런 문장도 있습니다: 관련 메서드가 설정되지 않고 뷰포트도 지정되지 않으면 WebView는 콘텐츠 크기를 기준으로 뷰포트 너비를 추정하려 합니다.

정리하면 이렇습니다. 뷰포트 메타 태그는 무조건 넣습니다. 그런데 넣어도 반영되지 않는 것 같다면, setUseWideViewPort 값을 앱에 확인하십시오. 확대 배율과 글꼴 배율 쪽 설정은 ep.06 소관입니다.


손이 미끄러지는 자리

화면이 잘리는 문제가 뷰포트의 문제라면, 손이 미끄러지는 문제는 제스처의 소유권 문제입니다. 같은 스와이프를 웹과 네이티브가 동시에 자기 것이라고 주장할 때 사용자 입장에서는 화면이 제멋대로 움직입니다.

스크롤 체이닝과 overscroll-behavior

모달 안의 목록을 끝까지 내렸는데 뒤의 본문이 따라 움직이는 현상, 스크롤 박스 끝에서 화면 전체가 당겨지는 현상은 스크롤 체이닝입니다.

.modal-body {
  overflow-y: auto;
  /* 인접 스크롤 영역으로 체이닝되지 않게 막습니다 */
  overscroll-behavior: contain;
}

contain은 체이닝을 막고, 여기에 더해 브라우저 기본 내비게이션까지 비활성화합니다. MDN이 명시하는 대상은 세로 당겨서 새로고침 제스처와 가로 스와이프 내비게이션입니다. none은 여기에 오버스크롤 자체(바운스)까지 막습니다.

지원 하한선이 함정입니다. Chrome 63, Firefox 59, Safari 16입니다. WKWebView 기준으로는 iOS 16 미만에서 동작하지 않습니다. Chrome 63과 나란히 놓고 “널리 지원된다”고 판단하면 iOS 15 단말에서 그대로 무너집니다.

그리고 한 가지 더: 스크롤 가능한 오버플로가 없는 컨테이너, 예를 들어 overflow: hidden 요소에 걸었을 때의 동작이 엔진마다 갈립니다. Chrome과 Android WebView는 144, Firefox는 150부터 이런 컨테이너도 항상 스크롤 경계에 있는 것으로 봅니다. 그래서 contain이나 none을 걸면 조상으로의 체이닝이 막히고, MDN은 이를 다이얼로그나 오버레이가 열렸을 때 배경 스크롤을 막는 방법으로 안내합니다. 반면 그 이전 버전과 Safari·WKWebView 16 이상에서는 아무 효과가 없습니다. 콘텐츠가 짧아서 스크롤되지 않는 모달에 걸어두고 “안 먹는다”고 판단하는 경우는 후자에 해당합니다.

iOS 쪽 바운스 자체를 끄는 스위치는 앱에 있습니다. UIScrollView.bounces의 기본값은 true이고, false로 두면 콘텐츠 경계에서 바운스 없이 멈춥니다. WKWebView의 스크롤뷰가 바로 이 UIScrollView입니다. 안드로이드에서는 12 이상에서 오버스크롤의 시각 효과가 글로우에서 늘어났다 되돌아오는 방식으로 바뀌었습니다. 디자인 QA에서 “왜 이 기기만 늘어나요”가 나오면 이 변경입니다.

touch-action

touch-action은 브라우저의 패닝·줌 처리를 요소 단위로 끄고 켭니다. 서명 패드, 드래그 정렬, 캔버스 차트처럼 터치를 직접 해석하는 요소에 필요합니다.

/* 세로 패닝만 브라우저에 넘기고 나머지는 내가 처리합니다 */
.signature-pad {
  touch-action: pan-y;
}

/* 더블탭 줌 같은 비표준 제스처만 끕니다 */
.tap-target {
  touch-action: manipulation;
}

manipulationpan-x pan-y pinch-zoom의 별칭이며, 더블탭 줌 같은 비표준 제스처를 끕니다. 지원은 Chrome 36부터, Safari iOS는 9.3부터(데스크톱 Safari는 13)입니다. pinch-zoom 값은 Chrome 56 / Safari 13부터입니다.

주의할 값이 하나 있습니다. pan-left·pan-right 같은 방향형 값은 Chrome 55만 지원하고 Safari와 Firefox는 지원하지 않습니다. “왼쪽으로만 패닝 허용” 같은 설계는 WKWebView에서 성립하지 않습니다.

MDN의 권고 한 줄도 옮겨둘 만합니다. 터치 이벤트로 제스처를 직접 처리하는 애플리케이션은 preventDefault()를 호출하지만, 이벤트 리스너가 호출되기 전에 브라우저가 의도를 알 수 있도록 touch-action도 함께 써야 합니다.

CSS로 막을 수 없는 제스처

여기가 이 편의 경계선입니다. overscroll-behavior: contain이 끄는 것은 브라우저 기본 내비게이션입니다. 앱이 설치한 네이티브 제스처 인식기는 CSS 바깥에 있습니다.

iOS 가로 캐러셀과 뒤로가기의 충돌을 정확히 보려면 주체가 둘이라는 점을 알아야 합니다.

첫째, 웹뷰 자체의 앞/뒤 내비게이션 제스처: WKWebView.allowsBackForwardNavigationGestures가 제어하고 기본값은 false 입니다. 앱이 켜지 않았다면 이 경로로는 충돌이 나지 않습니다. “웹뷰는 기본적으로 엣지 스와이프로 뒤로 간다”는 서술은 틀립니다.

둘째, 네비게이션 컨트롤러의 pop 제스처: UINavigationController는 화면 선행 가장자리에서의 가로 스와이프로 최상단 뷰 컨트롤러를 스택에서 pop 하는 제스처 인식기를 자기 뷰에 설치합니다. interactivePopGestureRecognizer는 읽기 전용이며, 앱이 다른 제스처와 조율할 때 접근하는 프로퍼티입니다. 실무에서 가로 캐러셀과 부딪히는 쪽은 대개 이쪽입니다.

웹이 할 수 있는 일은 회피이고, 해제는 앱만 할 수 있습니다. 회피 쪽은 이런 모양입니다.

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  /* 화면 선행 가장자리에서 여백을 두어 제스처 시작 지점과 겹치지 않게 합니다 */
  padding-inline-start: 20px;
}
.carousel > * {
  scroll-snap-align: start;
  flex: 0 0 auto;
}

그래도 겹친다면 앱에 요청할 항목으로 넘깁니다. 이 편의 착지점이자 ep.09로 이어지는 지점입니다.

당겨서 새로고침도 같은 구조입니다. 앱이 WebView를 새로고침 컨테이너 안에 넣어두었다면, 그 제스처를 처리하는 주체는 네이티브 뷰입니다. 페이지 최상단에서 아래로 당기는 동작이 웹의 스크롤이 아니라 네이티브 새로고침으로 먹히고, CSS로는 그 결정을 되돌릴 수 없습니다. 무한 스크롤 목록의 상단에서 “당기면 새로고침되어 목록이 초기화된다”는 제보가 나오면 웹 코드부터 보지 말고 컨테이너 구성을 먼저 확인하십시오.

링크를 꾹 누를 때 나타나는 동작도 앱 설정입니다. WKWebView.allowsLinkPreviewtrue면 링크를 눌러 목적지 미리보기가 뜨고, 더 깊게 누르면 Safari로 이탈합니다. iOS 10 이상에서 기본값은 true 입니다. iOS에서는 3D Touch를 지원하는 기기에서 동작합니다. 웹 쪽에서 길게 누르기를 다른 용도로 쓰고 있다면 이 설정과 겹칩니다.

터치 유지 시 뜨는 기본 콜아웃 자체는 -webkit-touch-callout: none으로 끌 수 있습니다. 다만 이 프로퍼티는 비표준이고 MDN은 프로덕션 사용을 권장하지 않으므로, 정말 필요한 요소에만 국소적으로 씁니다.

스크롤바가 없어서 생기는 문제

약관 동의 박스처럼 좁은 스크롤 영역에서 “아래에 내용이 더 있다”는 신호가 사라지는 문제가 있습니다. 사용자는 다 읽은 줄 알고 동의 버튼을 찾습니다. 필수 고지 사항을 담은 화면에서는 그냥 UX 문제가 아니라 고지 의무와 얽힙니다.

scrollbar-width로 스크롤바를 제어하는 길은 웹뷰에서 좁습니다. MDN의 브라우저 호환성 데이터 기준으로 Chrome 121, Firefox 64, Safari 18.2이고, Android WebView는 지원하지 않는 것으로(version_added: false) 기록되어 있습니다.

그러니 스크롤바에 기대지 말고 신호를 직접 그립니다.

.terms-box {
  max-height: 220px;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* 아래에 내용이 남아 있으면 하단이 페이드됩니다 */
  --fade: 24px;
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--fade)), transparent);
  mask-image: linear-gradient(to bottom, #000 calc(100% - var(--fade)), transparent);
}
.terms-box[data-at-end="true"] {
  -webkit-mask-image: none;
  mask-image: none;
}

접두사 없는 mask-image는 Android WebView 120 / iOS 15.4 이상입니다. 그 아래를 지원해야 한다면 -webkit- 접두사 선언을 함께 둡니다. 접두사 쪽은 WebView 2 / Safari 4부터 있어 한 줄이면 나머지 구간이 덮입니다.

const box = document.querySelector(".terms-box");

function updateEndState() {
  // 소수점 오차를 감안해 2px 여유를 둡니다
  const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 2;
  box.dataset.atEnd = String(atEnd);
}

box.addEventListener("scroll", updateEndState, { passive: true });
new ResizeObserver(updateEndState).observe(box);
updateEndState();

끝까지 읽었는지를 동의 버튼 활성화 조건으로 쓴다면, 같은 atEnd 판정을 그대로 재사용하면 됩니다. 스크롤 이벤트만으로 판단하지 말고 ResizeObserver를 함께 거는 이유는 키보드가 올라오면서 박스 높이가 바뀌는 경우 때문입니다.

sticky가 죽는 조건

“헤더에 position: sticky를 걸었는데 안 붙습니다”는 웹뷰만의 문제가 아니지만, 웹뷰에서 유독 자주 나옵니다. 키보드나 안전 영역 때문에 래퍼에 overflow를 추가하는 일이 많기 때문입니다.

sticky 요소는 스크롤 메커니즘을 가진 가장 가까운 조상에 붙습니다. 그 메커니즘은 overflowhidden, scroll, auto, overlay일 때 생기며, 그 조상이 실제로 스크롤되지 않아도 마찬가지입니다. 그래서 아래 코드에서 헤더는 붙지 않은 것처럼 보입니다.

/* 가로 잘림을 막으려고 넣은 이 한 줄이 sticky를 죽입니다 */
.page-wrapper {
  overflow-x: hidden;
}
.page-wrapper .section-header {
  position: sticky;
  top: 0;
}

조건이 하나 더 있습니다. 각 축마다 인셋 프로퍼티 중 하나는 auto가 아니어야 합니다. top·bottom이 둘 다 auto이면 세로축에서는 relative처럼 동작합니다. position: sticky만 쓰고 top을 빠뜨린 코드가 여기 걸립니다.


비디오는 어디에서 재생되는가

상품 소개 영상이 브라우저에서는 카드 안에서 재생되는데 앱에서는 전체화면으로 튀어나온다면, 두 조건이 동시에 필요한 항목입니다.

앱 쪽 조건: WKWebViewConfiguration.allowsInlineMediaPlaybackfalse면 HTML5 비디오가 네이티브 전체화면 컨트롤러로 재생됩니다. 기본값은 iPhone에서 false, iPad에서 true 입니다. 기기에 따라 다르므로 “웹뷰는 인라인 재생을 안 한다”로 뭉뚱그리면 안 됩니다.

웹 쪽 조건: Apple 문서가 명시합니다. iPhone에서 HTML 문서에 비디오 요소를 추가할 때는 playsinline 속성도 함께 넣어야 합니다. iOS 10.0 이전에 만들어진 앱은 webkit-playsinline 속성을 씁니다.

<video src="/media/intro.mp4" playsinline muted controls preload="metadata"></video>

playsinline은 비디오를 요소의 재생 영역 안에서 재생하겠다는 불리언 속성입니다. MDN이 덧붙이는 단서도 알아둘 만합니다. 이 속성이 없다고 해서 항상 전체화면으로 재생된다는 뜻은 아닙니다.

안드로이드에서는 자동 재생이 막히는 쪽이 더 자주 이슈가 됩니다. WebSettings.setMediaPlaybackRequiresUserGesture의 기본값이 true라서, 사용자 제스처 없이 시작하는 재생은 기본 상태에서 통과하지 못합니다.

전체화면 요청도 “안 되는 것”이 아니라 “안 켠 것”의 대표 사례입니다. WKWebView에서 웹 콘텐츠가 전체화면을 쓸 수 있는지는 WKPreferences.isElementFullscreenEnabled가 결정하고 기본값은 false 입니다. iOS 15.4 이상에서 사용할 수 있습니다. 다만 켜달라고 요청할 때는 부작용도 함께 적어야 합니다: 이 값이 true이고 페이지가 전체화면을 요청하면 시스템이 WKWebView를 앱의 뷰 계층에서 제거합니다. 앱 입장에서는 화면 구성이 통째로 흔들리는 동작이라, “그냥 켜주세요”로는 합의가 되지 않습니다.

웹 쪽에서도 조건이 있습니다. Element.requestFullscreen()은 일시적 사용자 활성화를 요구합니다. 사용자가 페이지나 UI 요소와 상호작용해야 동작합니다. iframe 안에서 호출한다면 컨테이너에 allowfullscreen 또는 allow="fullscreen"이 있어야 합니다.

playButton.addEventListener("click", async () => {
  // 사용자 제스처 안에서 호출해야 합니다
  try {
    await videoEl.requestFullscreen();
  } catch (e) {
    // 앱이 전체화면을 허용하지 않은 환경에서는 여기로 떨어집니다
    fallbackToInlineExpand();
  }
});

거부될 수 있다는 전제로 폴백을 준비해두는 편이 낫습니다.


그래서 무엇을 하면 되나

이 편의 항목은 많지만 판단은 하나로 모입니다. 뷰포트가 두 개라는 것을 전제로 짜고, 두 뷰포트를 어긋내는 주체가 웹인지 앱인지를 먼저 가릅니다.

웹 개발자가 할 일

  • 뷰포트 메타 태그를 명시합니다. 안전 영역을 직접 다루기로 했다면 viewport-fit=cover를 함께 넣고, 넣은 순간부터 패딩은 우리 책임이라는 점을 팀에 공유합니다.
  • 전체 높이 컨테이너에 100vh 폴백과 100dvh를 같이 씁니다. dvh를 목록 아이템처럼 반복되는 요소에는 쓰지 않습니다.
  • 하단 고정 요소의 bottomenv(safe-area-inset-bottom)과 Visual Viewport 기반 키보드 인셋의 합으로 계산합니다.
  • visualViewport의 resize 핸들러에서 포커스를 해제하지 않습니다. WebView 139 이상에서 입력 자체가 막힙니다.
  • 중첩 스크롤 영역에 overscroll-behavior: contain을 걸되, iOS 16 미만에서는 동작하지 않는다는 전제로 시각적 대비책을 둡니다. 스크롤되지 않는 컨테이너에 걸어 배경 스크롤을 막는 기법은 Chrome·Android WebView 144와 Firefox 150 이상에서만 성립하고, WKWebView에서는 성립하지 않습니다.
  • 스크롤 가능 신호를 직접 그립니다. 스크롤바가 알려줄 것이라고 가정하지 않습니다.
  • 가로 캐러셀을 화면 선행 가장자리에 붙이지 않습니다. touch-action의 방향형 값은 WKWebView에서 성립하지 않으므로 레이아웃으로 회피합니다.
  • sticky를 걸 때 조상의 overflow와 자기 자신의 인셋 프로퍼티를 함께 점검합니다.
  • 비디오에 playsinline을 넣습니다. requestFullscreen()은 사용자 제스처 안에서 호출하고 실패 폴백을 둡니다.

앱에 요청할 일

요청 항목대상왜 웹에서 못 하나
android:windowSoftInputMode="adjustResize"Android 매니페스트키보드가 떴을 때 액티비티 레이아웃의 반응은 매니페스트가 결정합니다
인셋을 이미 소비했다면 Insets.NONE으로 0 처리 후 전달Android네이티브 패딩과 CSS 패딩이 이중으로 들어갑니다
WebSettings.setUseWideViewPort 값 확인Android WebViewfalse면 뷰포트 메타의 너비가 무시됩니다
WebView 버전 하한선 합의Android WebView안전 영역 전달(M136·M144), IME 시각 뷰포트 리사이즈(M139)가 버전에 걸립니다
contentInsetAdjustmentBehavior 값 확인WKWebView안전 영역이 콘텐츠 영역에 반영되는 방식이 여기서 정해집니다
allowsBackForwardNavigationGestures 상태 확인WKWebView기본값은 false이므로, 켜져 있다면 캐러셀과 충돌합니다
네비게이션 컨트롤러의 pop 제스처 조율iOS 앱CSS 바깥에 있는 네이티브 제스처 인식기입니다
새로고침 컨테이너 구성 확인Android 앱당겨서 새로고침을 처리하는 주체가 네이티브 뷰입니다
allowsLinkPreview 상태 확인WKWebViewiOS 10 이상 기본값이 true라 길게 누르기와 겹칩니다
allowsInlineMediaPlayback 값 확인WKWebViewiPhone 기본값이 false입니다
isElementFullscreenEnabled 켜기 여부 논의WKWebView기본값 false. 켜면 시스템이 WebView를 뷰 계층에서 제거합니다

요청할 때 버전 조건을 함께 적으면 대화가 빨라집니다. “안전 영역이 안 들어옵니다”보다 “WebView M136 미만에서는 CSS로 안전 영역이 전달되지 않는 것으로 문서에 나옵니다. 현재 테스트 기기의 WebView 버전을 확인해주실 수 있을까요”가 훨씬 짧게 끝납니다.


참고 자료


다음 편 예고

ep.06 - 앱이 내 CSS를 덮어씁니다

뷰포트를 다 맞춰놓아도 색과 글자 크기가 내 것이 아닐 수 있습니다. 앱이 켜둔 강제 다크 처리와 시스템 글꼴 배율이 내가 쓰지 않은 스타일을 어디서 밀어 넣는지 추적합니다.