모바일 웹 뷰 UX 개선 노하우 7가지
요즘은 빠른 배포와 다양한 실험을 위해 웹 기술이 단순히 브라우저 안에 머무르지 않고, 앱 안으로 자연스럽게 녹아드는 경우가 많아지고 있다. 특히 모바일 웹 뷰는 이제 많은 IT 서비스에서 중요한 역할을 하고 있고, 실제로 많은 회사들이 모바일 웹 뷰 개발 경험을 요구하는 것을 쉽게 볼 수 있다.
하지만 모바일 웹 뷰 개발은 혼자서 경험을 쌓기가 조금 어려운 영역이다. 코드 베이스와 플랫폼을 여러 개 준비해야 하는 등 진입 장벽이 있기 때문이다. 그럼에도 모바일 웹 뷰의 사용성을 높이는 일은 중요하다. 브라우저 안에서 웹 개발만 하던 개발자가 앱 사용 경험과의 간극을 좁혀, 앱과 자연스럽게 어우러지는 느낌을 주는 게 핵심이기 때문이다.
이번 글에서는 실무에서 겪었던 시행착오를 통해 얻은, 모바일 웹 뷰 사용성을 높이기 위한 7가지 노하우를 공유한다.
- Safe Area
- Virtual Viewport (키보드 대응)
- Input Type (키패드)
- Clipboard (AOS/iOS 호환)
- Header 영역 Fixed와 Sticky (스크롤 바운스 차이)
- Hover와 Active (포인터)
- 안드로이드 백 키 대응
1. Safe Area
상단 노치는 viewport-fit=cover만 잡아주면 대부분 자동으로 처리되는데, 실제로 손이 더 갔던 건 하단 홈 인디케이터 쪽이었다. CTA 버튼이나 하단 고정 탭이 인디케이터 영역과 겹치면, 사용자가 버튼을 누르려다 홈 제스처가 발동하기도 한다. 이 영역만큼은 비워둬야 한다.
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>.bottom-cta {
position: fixed;
bottom: 0;
padding-bottom: env(safe-area-inset-bottom);
}viewport-fit=cover가 빠지면env()값이 0으로 적용된다. 동작이 안 보이면 meta 태그를 먼저 확인한다.bottom: env(safe-area-inset-bottom)으로 띄우면 버튼 아래쪽이 비고 그 사이로 컨텐츠가 비쳐 보인다. 보통은padding-bottom을 늘리는 쪽이 자연스럽다.
2. Virtual Viewport (키보드 대응)
input에 포커스가 들어가면 키보드가 올라온다. 이때 입력 중인 글자를 보려면 input이 키보드 바로 위에 붙어 있어야 한다. 문제는 iOS와 Android가 이 상황을 처리하는 방식이 서로 다르다는 점이다.
- iOS는 layout viewport는 그대로 두고 visual viewport만 키보드 높이만큼 줄어든다. CSS의
100vh는 키보드가 올라와도 전체 화면 높이를 그대로 가리킨다. - Android는
windowSoftInputMode설정에 따라 layout viewport 자체가 줄어들기도 한다.
100vh나 position: fixed로 키보드 위 영역을 잡으려는 시도가 한쪽 OS에서만 동작하고 다른 쪽에선 어긋나는 이유다. 분기 처리로 OS별 코드를 따로 짜는 것보다, visualViewport API로 통일하는 쪽이 훨씬 단순했다.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
document.documentElement.style.setProperty("--vvh", `${vv.height}px`);
};
vv.addEventListener("resize", update);
update();
return () => vv.removeEventListener("resize", update);
}, []);.app-shell {
height: var(--vvh, 100vh);
}화면 컨테이너의 높이를 visualViewport.height로 잡아두면, 그 안의 고정 요소들이 모두 키보드 위 영역 안에서 동작한다. 두 OS가 같은 결과를 낸다.
3. Input Type (키패드)
폼 input은 type, inputmode, autocomplete을 함께 지정해야 키패드와 자동 완성이 사용 시나리오에 맞게 동작한다.
<!-- 전화번호: 다이얼 키패드 + 전화번호 자동완성 -->
<input type="tel" autocomplete="tel" />
<!-- 인증번호: 숫자 키패드 + SMS 자동 입력 (iOS) -->
<input inputmode="numeric" autocomplete="one-time-code" />
<!-- 카드번호: 숫자 키패드 + 카드 자동완성 -->
<input inputmode="numeric" autocomplete="cc-number" />
<!-- 이메일: @ 키가 포함된 키패드 -->
<input type="email" autocomplete="email" />type="number"도 숫자 키패드를 띄우지만 화살표 spinner가 붙고 첫 자리에 0을 못 넣는 등 부작용이 많다. 인증번호·카드번호처럼 "숫자 키패드만 띄우고 싶을 때"는 inputmode="numeric"이 더 안전하다.
autocomplete="one-time-code"는 iOS에서 키보드 위에 "SMS에서 코드 자동 입력" 칩을 띄워준다. 인증번호 입력 단계에서 마찰을 크게 줄여주는 한 줄이다.
4. Clipboard (AOS/iOS 호환)
iOS에서는 navigator.clipboard.writeText가 안정적으로 동작했지만, 안드로이드 웹 뷰는 그렇지 않은 케이스가 있었다. 구버전 안드로이드나 일부 웹 뷰 환경에서는 navigator.clipboard 자체가 정의돼 있지 않거나 호출이 실패한다.
플랫폼을 분기해서 풀었다. iOS는 그대로 navigator.clipboard를 쓰고, Android는 실패 가능성에 대비해 폴백을 둔다. 폴백은 복사 대상 텍스트를 미리 selection 상태로 만들어두고, 시스템 컨텍스트 메뉴에서 "복사"를 한 번 더 누르도록 유도하는 방식이다.
async function copy(text: string, target: HTMLElement) {
const isAndroid = /Android/i.test(navigator.userAgent);
if (isAndroid) {
try {
await navigator.clipboard.writeText(text);
return "copied";
} catch {
const range = document.createRange();
range.selectNodeContents(target);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
return "selected";
}
}
await navigator.clipboard.writeText(text);
return "copied";
}반환값을 그대로 토스트 분기에 썼다. "copied"면 "복사되었습니다", "selected"면 "꾹 눌러서 복사하세요". 폴백으로 빠진 사용자도 한 번 더 누르면 같은 결과에 도달한다.
5. Header 영역 Fixed와 Sticky
처음에는 헤더를 position: sticky로 잡았다. iOS에서 위·아래 끝까지 스크롤하면 발생하는 rubber band(고무줄 스크롤) 구간에서, 헤더가 본문과 함께 위로 튀어 오르는 현상이 있었다. sticky 요소는 결국 body의 normal flow 안에 있어서, body가 움직이면 함께 움직이기 때문이다.
position: fixed로 바꿨더니 헤더는 viewport에 붙어 rubber band 영향을 받지 않았고, pull-to-refresh로 위쪽을 잡아당겨도 헤더는 화면 상단에 그대로 고정돼 있었다.
// ❌ sticky 헤더 — body 흐름 안에 있어 rubber band 시 본문과 함께 움직임
<>
<Header className="sticky top-0" />
<Main />
</>
// ✅ fixed 헤더 — viewport에 고정, pull-to-refresh에도 안정적
<>
<Header className="fixed top-0" />
<Main className="pt-[56px]" />
</>차이는 기준점이다. sticky는 스크롤 컨테이너 안의 normal flow에 따라 위치가 결정되므로, 컨테이너가 움직이면 함께 움직인다. fixed는 viewport 기준이라 스크롤이나 rubber band 동작과 무관하게 자기 자리를 지킨다.
다만 fixed는 키보드가 올라올 때 visual viewport와 함께 움직이지 않아, 키보드 위로 사라지는 케이스가 있다. 키보드 영역에 맞물려야 하는 fixed 요소(하단 입력 툴바 등)는 2번에서 본 visualViewport 처리와 함께 적용해야 한다.
6. Hover와 Active
데스크톱에선 자연스러운 :hover 효과가 모바일에선 함정이 된다. 카드를 한 번 터치하면 hover 상태가 그 카드에 그대로 남아있어, 다른 곳을 터치하기 전까지 강조 색이 풀리지 않는다. 사용자 입장에선 "왜 이 카드만 색이 다르지?"가 된다.
해결은 hover 효과를 호버 가능한 디바이스에서만 적용하고, 터치 디바이스에는 :active로 누르는 순간의 효과만 주는 것이다.
.card {
background: white;
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
.card:hover {
background: #f3f3f3;
}
}
.card:active {
background: #ebebeb;
}@media (hover: hover)는 마우스 같은 정밀 포인터가 있는 환경에서만 매칭된다. 터치 디바이스는 이 블록을 건너뛴다.:active는 손가락이 닿아 있는 동안만 적용되므로, 모바일에선 누르는 순간 짧게 강조됐다가 손을 떼면 바로 풀린다.-webkit-tap-highlight-color: transparent로 iOS의 기본 회색 강조를 끄면,:active스타일이 깔끔하게 보인다.
7. 안드로이드 백 키 대응
안드로이드의 시스템 백 버튼은 기본적으로 웹 뷰의 history를 한 칸 뒤로 보낸다. 모달이나 바텀시트가 열린 상태에서 이걸 그냥 두면, 백 버튼 한 번에 화면 자체가 이전 페이지로 가버린다. 사용자 입장에선 "모달만 닫혔으면" 하는 의도였는데 페이지가 통째로 사라지는 셈이다.
네이티브 ↔ 웹 브릿지
네이티브와 웹 뷰가 서로를 호출할 수 있도록 window 객체에 브릿지를 하나 만들어둔다. 안드로이드 쪽은 addJavascriptInterface로 window에 객체를 주입해주고, iOS는 webkit.messageHandlers가 같은 역할을 해준다.
// 네이티브 → 웹: 안드로이드 백 버튼이 눌리면 호출되는 콜백
window.onAndroidBackPressed = () => {
// 웹의 상태에 따라 모달을 닫을지, 뒤로 갈지를 결정
};
// 웹 → 네이티브: 웹 뷰를 닫고 싶을 때
function closeWebView() {
if (window.AndroidBridge) {
window.AndroidBridge.closeWebView();
} else if (window.webkit?.messageHandlers?.closeWebView) {
window.webkit.messageHandlers.closeWebView.postMessage("");
}
}네이티브(Android) 코드에선 OnBackPressedCallback에서 위 콜백을 호출한다.
webView.evaluateJavascript(
"window.onAndroidBackPressed && window.onAndroidBackPressed()",
null,
)백 버튼 처리 권한이 웹으로 넘어오면, 웹은 자기 상태(모달 열림 여부 등)에 맞게 닫을지 뒤로 갈지를 결정할 수 있다.
history pushState + popstate로 모달 닫기
브릿지를 안 거치고도 history 스택만으로 같은 효과를 낼 수 있다. 모달을 열 때 history.pushState로 한 칸 푸시하고, popstate에서 모달을 닫는다. 안드로이드 백 키는 결국 history.back()을 트리거하므로, popstate가 호출되면 모달이 자연스럽게 닫힌다.
function useBackToClose(open: boolean, onClose: () => void) {
useEffect(() => {
if (!open) return;
window.history.pushState({ modal: true }, "");
const handlePopState = () => {
onClose();
};
window.addEventListener("popstate", handlePopState);
return () => {
window.removeEventListener("popstate", handlePopState);
// 모달이 백 키가 아닌 다른 경로로 닫혔다면, 푸시했던 한 칸을 정리
if (window.history.state?.modal) {
window.history.back();
}
};
}, [open, onClose]);
}이 패턴이면 네이티브 코드를 건드리지 않고도 안드로이드 백 키로 모달을 닫는 동작이 완성된다. 일반 브라우저에서도 똑같이 동작하므로, 웹과 웹 뷰의 동작을 굳이 분기할 필요가 없다는 점도 장점이다.
마무리
7가지 모두 디테일이고, 하나하나는 작다. 하지만 빠진 채로 출시하면 사용자는 "앱이 아니라 그냥 웹이구나"를 즉시 알아챈다. 반대로 잘 맞춰져 있으면, 웹 뷰인 줄 모르고 그냥 쓰는 화면이 된다.
새 모바일 웹 뷰 프로젝트를 시작할 때 체크리스트로 한 번 훑어보면 좋겠다.