들어가며
안녕하세요. 카카오뱅크 신사업클라이언트개발팀의 프론트엔드 개발자 Aiden입니다. 저희 팀은 카카오뱅크 앱 내 ‘투자’ 탭과 ‘혜택’ 탭을 중심으로, 네이티브 앱의 강력한 성능과 모바일 웹의 유연함을 결합하여 사용자에게 더욱 풍부하고 매끄러운 서비스 경험을 선사하고 있습니다.
올해 상반기, 저희 팀은 혜택 탭의 새로운 기능인 ‘AI 키워드 퀴즈’ 를 출시하며 까다로운 가상 키보드(On-Screen Keyboard) 이슈와 마주하게 되었습니다. 이 서비스는 AI가 제시하는 퀴즈를 풀며 실시간으로 대화하듯 소통하는 ‘채팅형 인터페이스’가 핵심입니다. 따라서 자연스러운 사용자 경험(UX)을 제공하기 위해 어떤 상황에서도 아래의 두 가지 조건이 안정적으로 유지되어야 했습니다.
- ﹒상단 영역: 고정된 헤더가 존재하고, 헤더 아래의 콘텐츠는 스크롤이 가능해야 함
- ﹒하단 영역: 입력창과 전송 버튼이 가상 키보드 바로 위에 흔들림 없이 고정되어야 함
하지만 iOS 뿐만 아니라 Android에서도 다양한 뷰포트(Viewport) 관련 문제가 발생했습니다. OS와 브라우저 엔진마다 가상 키보드가 활성화될 때 뷰포트 높이를 계산하고 처리하는 방식이 달랐기 때문입니다. 그 결과 입력창이 키보드에 가려지거나 화면 전체가 위로 밀려 올라가는 등 레이아웃이 깨지는 현상이 나타났습니다.
이 글에서는 모바일 환경의 가상 키보드로 인한 화면 밀림과 가려짐 현상을 해결하기 위해 레이아웃 뷰포트(Layout Viewport) 와 비주얼 뷰포트(Visual Viewport) 의 동작 차이를 깊이 있게 추적해 나간 과정을 소개합니다. 나아가 두 OS 환경 모두에서 안정적인 사용자 경험을 보장하기 위해 VirtualViewport 컴포넌트를 직접 설계하고 구현하여 문제를 해결한 여정을 공유하고자 합니다.
iOS 키보드, 도대체 뭐가 문제지?
특히 iOS WebView의 가상 키보드 문제는 널리 알려져 있었기 때문에, 먼저 문제를 재현한 뒤 동작을 추적했습니다. 고정된 상단 헤더, 스크롤 가능한 본문, 고정된 하단 입력 영역으로 구성된 간단한 테스트 화면을 만들었고, iOS WebView에서 입력 영역에 포커스가 생기면 가상 키보드가 나타나도록 했습니다. 이때 iOS WebView에서는 가상 키보드가 표시되는 상황에서 다음과 같은 현상이 함께 나타났습니다.
- 브라우저가 입력 영역이 가려지지 않도록 화면 자체를 들어 올림.
- 페이지가 위로 이동하면서 상단에 고정된 헤더가 화면 밖으로 사라짐.
- 하단 입력 영역이
position: fixed였지만 스크롤에 끌려 올라가는 것처럼 보이는 현상이 나타남. - 화면이 들어올려진 상태에서 아래로 스크롤하면 들어올려진 높이만큼 빈 공간이 나타나기도 함.
이슈가 있다는 것은 인지하고 있었지만, 실제로 확인해 보니 사용자 경험이 크게 저해될 수 있는 수준이었습니다.
안드로이드는 괜찮겠지?
iOS WebView의 이상 동작을 확인한 뒤, 브라우저의 동작 기준과 비교하기 위해 동일한 화면을 Android Chrome에서 테스트했습니다. 그런데 예상과 달리 Android에서도 뜻밖의 동작이 나타났습니다.
화면이 줄어들면서 하단 입력 영역이 가상 키보드 위에 표시되기를 기대했지만, 가상 키보드가 고정 배치(fixed positioning) 요소였던 입력 영역을 가렸습니다. 다만 Android에서는 iOS처럼 화면 전체가 위로 이동하지는 않았습니다.
Android에서는 키보드가 나타나면 화면이 줄어드는 것으로 알고 있었기에, 키보드가 콘텐츠 위에 겹치는 동작은 예상과 달랐습니다. 도대체 뭐가 달라진 걸까요? 답을 찾으려면 모바일 웹의 두 뷰포트를 먼저 이해해야 했습니다.
레이아웃 뷰포트와 비주얼 뷰포트
레이아웃 뷰포트
레이아웃 뷰포트는 브라우저가 페이지의 CSS 레이아웃을 계산할 때 사용하는 뷰포트입니다. 모바일 브라우저에서는 viewport meta를 처리한 뒤 이 영역의 크기가 정해집니다.
레이아웃 뷰포트를 살펴보면 초기 컨테이닝 블록(Initial Containing Block, ICB)이라는 개념이 등장합니다. ICB는 <html> 요소를 포함하며 CSS 레이아웃의 기준이 되는 최상위 컨테이닝 블록입니다.
스크린과 같은 연속 매체에서는 브라우저가 ICB의 크기를 레이아웃 뷰포트를 바탕으로 정하며, vw와 vh 같은 뷰포트 상대 단위도 이를 기준으로 계산합니다.
position: fixed 요소도 ICB와 관련이 있습니다. 고정 배치의 컨테이닝 블록을 만드는 조상이 없다면 ICB가 배치 기준이 됩니다.
브라우저 환경에서 ICB의 크기가 레이아웃 뷰포트와 같은 크기로 정해지기 때문에 단순 레이아웃 작업 시에는 둘을 동일시해도 결과가 같게 느껴질 수 있습니다. 그러나 ICB는 CSS 레이아웃의 기준이 되는 최상위 컨테이닝 블록이라는 점에서 개념적으로 구분됩니다.
비주얼 뷰포트
과거 ‘뷰포트’는 통상 레이아웃 뷰포트를 뜻했지만, 비주얼 뷰포트가 도입되면서 레이아웃 계산 영역과 사용자가 실제로 보는 영역이 구분됐습니다. 비주얼 뷰포트는 레이아웃 뷰포트를 스크롤 영역으로 삼으며, 사용자가 화면을 확대하거나 가상 키보드가 나타나면 레이아웃 뷰포트는 유지된 채 작아질 수 있습니다.
CSSOM View 사양은 가상 키보드가 표시될 때의 동작을 예로 들어 이 관계를 설명합니다. 입력 요소(input)에 포커스(focus)가 생겨 가상 키보드가 나타나며 비주얼 뷰포트를 줄이면, 브라우저는 해당 요소를 보이게 하기 위해 레이아웃 뷰포트를 먼저 스크롤한 뒤 비주얼 뷰포트를 스크롤할 수 있다고 명시하고 있습니다. iOS에서 입력 요소에 포커스할 때 화면이 위로 들어 올려지는 현상도 이 모델로 설명할 수 있었습니다.
Android Chrome의 선택
사실 Android Chrome은 가상 키보드가 나타나면 레이아웃 뷰포트와 비주얼 뷰포트를 함께 줄였습니다. 이때 브라우저가 뷰포트 상대 단위의 계산값과 고정 배치 요소의 배치도 다시 계산하기 때문에, 하단 고정 배치 요소가 자연스럽게 가상 키보드 위로 올라올 수 있었습니다.
하지만 Chrome 108부터 기본 동작이 바뀌었습니다. Android Chrome은 레이아웃 뷰포트를 유지하고 비주얼 뷰포트만 줄이기 시작했습니다. iOS Safari와 유사한 방식입니다. Chrome 팀은 가상 키보드가 열릴 때 전체 레이아웃에 미치는 영향을 줄이고 브라우저 간 동작 차이를 좁히기 위해 이 방식을 기본값으로 선택했다고 밝혔습니다. 앞서 Android Chrome에서 테스트한 결과가 예상했던 것과 달라진 이유가 바로 이 때문이었습니다.
다행히 아무런 대책 없이 이러한 큰 변화가 적용된 것은 아니었습니다. Chrome은 기존 동작이 필요한 페이지를 위해 viewport meta의 interactive-widget 키를 함께 제공했습니다. 아래와 같이 interactive-widget에 resizes-content를 설정하면 변경 전 동작을 유지할 수 있습니다.
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
이 스펙은 현재 CSS Viewport Module Level 1에 정의되어 있지만, Safari에서는 아직 지원되지 않습니다. (2026년 8월 WebKit이 interactive-widget 구현을 드디어 완료했으나, Safari의 지원 시점은 아직 정해지지 않았습니다.)
Android WebView는 예외?
정확한 이유는 공개되지 않았지만, Chrome 108에서 도입된 뷰포트 기본 동작 변경은 Android Chrome에만 적용되고 Android WebView에는 적용되지 않았습니다.
당시 Android WebView는 웹의 뷰포트 처리 대신, 호스트 앱의 windowSoftInputMode="adjustResize" 설정에 따라 동작했습니다. 즉, 안드로이드 OS가 웹뷰 컨테이너의 물리적 높이를 직접 축소하는 방식이었습니다. 이로 인해 웹 내부에서는 레이아웃 뷰포트가 컨테이너 크기에 맞춰 함께 줄어들었습니다.
그러나 Android 15부터 Edge-to-Edge(앱 콘텐츠를 상태 바·내비게이션 바 영역까지 확장해 그리는 방식)를 적용하면서 동작 방식이 달라졌습니다. Edge-to-Edge 환경에서는 호스트 앱의 windowSoftInputMode="adjustResize" 설정에서도 OS가 더 이상 웹뷰의 물리적 크기(레이아웃 뷰포트)를 축소하지 않고 키보드 인셋만 전달합니다. 하지만 비주얼 뷰포트도 축소하지 않기 때문에 웹뷰 내 입력창이 키보드에 가려지는 현상이 나타났습니다. 이런 문제로 인해, Edge-to-Edge를 실무에 적용할 때에는 별도의 대응이 필요했습니다.
WebView M139에 이르러 가상 키보드가 나타날 때 비주얼 뷰포트를 축소하도록 기본 동작이 바뀌었습니다. Chrome 108의 동작을 웹뷰에도 일관되게 적용한 셈입니다. 이 시점부터 웹뷰에서도 비주얼 뷰포트의 축소를 전제로 한 레이아웃 설계가 가능해졌습니다. (저희는 안드로이드 WebView에 Edge-to-Edge를 적용하는 과정에서 인셋을 감지하여 웹뷰의 물리적 크기를 강제로 조정하는 방식을 별도로 적용하고 있었기 때문에, 이러한 기본 동작 변경의 영향을 받지 않았습니다.)
Chrome 108의 변화 과정을 추적하면서 두 뷰포트의 차이와 iOS Safari의 난해한 동작 원인을 이해할 수 있었습니다. 이를 해소하기 위한 스펙인 interactive-widget은 iOS에서 지원되지 않았기 때문에, 웹 코드만으로 두 플랫폼을 함께 제어하려면 다른 방법이 필요했습니다.
새롭게 추가된 dynamic viewport 단위를 사용하면?
Chrome 108과 Safari 15.4부터 동적인 뷰포트 크기에 대응하는 dvw(dynamic viewport width), dvh(dynamic viewport height) 단위가 도입되었습니다.
이름만 보면 마치 가상 키보드에도 반응할 것 같지만, 실제로는 브라우저 기본 인터페이스의 변화에만 반응합니다. CSS Values and Units Module 4 사양에 따르면 콘텐츠 위에 겹쳐져 레이아웃을 이동시키지 않는 UI는 뷰포트 상대 단위에 영향을 주지 않는다고 설명하고 있습니다. 가상 키보드가 이러한 UI에 속하기 때문에, 결국 dvh 단위는 가상 키보드에 의한 레이아웃 높이 변화를 반영하지 못합니다.
반면 비주얼 뷰포트의 높이는 가상 키보드가 나타날 때 실제 보이는 영역에 맞춰 변합니다. 두 플랫폼에서 공통으로 확인할 수 있는 이 값을 새로운 레이아웃 기준으로 삼기로 했습니다.
가상의 “초기 컨테이닝 블록” 만들기
레이아웃 뷰포트 자체를 바꾸는 대신 페이지 최상위에 별도 컨테이너를 만들고, 그 높이를 비주얼 뷰포트와 동기화했습니다. 웹앱의 레이아웃 루트 역할을 할 일종의 VirtualViewport를 만든 것입니다.
Layout Viewport
└── VirtualViewport ← visualViewport.height와 동기화되는 Flex 컨테이너
├── Header
├── Scrollable Content
└── Footer(Input Area)
이 컨테이너를 웹앱 레이아웃의 최상위 컨테이닝 블록으로 사용하면, 브라우저가 내부의 Flexbox 레이아웃을 사용자가 실제로 보는 높이를 기준으로 다시 배치합니다. 헤더와 입력 영역을 position: fixed에 의존하지 않고 Flexbox의 고정 영역으로 구성할 수 있다는 장점도 있었습니다.
먼저 VisualViewport의 resize 이벤트에서 현재 높이를 읽어 컨테이너에 반영했습니다.
(비주얼 뷰포트는 pinch zoom으로도 변경될 수 있으므로, pinch zoom을 허용하는 화면에서는 visualViewport.scale도 함께 확인해야 합니다.)
const container = document.querySelector('#container');
const viewport = window.visualViewport;
function syncViewportHeight() {
if (!viewport) return;
if (viewport.height < window.innerHeight) {
container.style.height = `${viewport.height}px`;
} else {
container.style.removeProperty('height');
}
}
viewport?.addEventListener('resize', syncViewportHeight);
CSS 변수로 값을 전달하는 방법도 가능하지만, 저희 구현에서는 최상위 컨테이너의 높이를 직접 바꾸는 방식을 선택했습니다. 변경 지점이 하나라서 레이아웃 기준을 파악하기 쉬웠고, 키보드가 닫히면 inline height를 제거해 기본 100dvh 또는 100vh로 복원할 수 있었습니다.
키보드만큼 생겨나는 빈 공간
컨테이너 높이를 비주얼 뷰포트에 맞추자 헤더와 입력 영역은 의도한 위치에 배치됐습니다. 하지만 페이지의 레이아웃 뷰포트는 그대로였습니다. 내부 컨테이너만 작아졌을 뿐 문서가 스크롤될 수 있는 바깥 영역까지 사라진 것은 아니었습니다. 그 결과 사용자가 컨테이너 밖으로 스크롤하면 키보드 높이와 비슷한 빈 공간이 드러났습니다.
스크롤 가능한 바깥 영역을 임의로 없애기보다, 아래와 같이 스크롤 영역을 분리해 각각의 동작을 제어했습니다.
- 헤더와 입력 영역에서는
touch-action으로 불필요한 viewport 조작을 제한했습니다. - 본문에는
overscroll-behavior: none을 적용해 스크롤이 상위 문서로 전달되는 스크롤 체이닝(scroll chaining)을 제한했습니다.
.viewport {
display: flex;
flex-direction: column;
height: 100dvh;
overflow-y: auto;
overscroll-behavior: none;
}
.viewport__content {
position: relative;
flex: 1 1 auto;
overflow-y: auto;
overscroll-behavior: none;
touch-action: pan-y;
}
.viewport__header,
.viewport__input {
touch-action: none;
}
touch-action: none은 브라우저의 panning과 zooming을 막을 수 있고, 저시력 사용자의 확대 기능을 제한할 수 있습니다. 따라서 페이지 전체에 일괄 적용하지 않고, 실제로 직접 조작이 필요 없는 제한된 영역에만 사용해야 합니다.
overscroll-behavior의 함정
overscroll-behavior로 빈 공간 노출을 어느 정도 막았지만, 콘텐츠가 짧아 내부 영역에 overflow가 없으면 스크롤 동작이 상위 문서로 전달됐습니다. 이 속성은 스크롤 컨테이너(scroll container)의 경계에서만 작동하기 때문입니다.
그래서 아래와 같이 콘텐츠 영역보다 항상 1px 높은 1px 크기의 가상 요소를 추가해, 콘텐츠 길이와 관계없이 내부 영역에 overflow가 생기도록 했습니다.
.viewport__content::after {
content: '';
position: absolute;
top: 0;
width: 1px;
height: calc(100% + 1px);
pointer-events: none;
}
이 방법을 통해 복잡한 자바스크립트 코드를 작성하지 않고도, 순수한 CSS만으로 스크롤 체이닝을 제한할 수 있었습니다.
이렇게 비주얼 뷰포트 높이와 동기화한 VirtualViewport와 스크롤 방지 방식을 통해 Android WebView의 키보드 동작을 안정적으로 제어할 수 있었습니다.
iOS에 이 방식을 적용하면?
Android에서 동작한 방식을 iOS WebView에 적용했지만 곧바로 같은 결과를 얻지는 못했습니다. iOS가 포커스된 입력 요소를 화면에 보여 주기 위해 화면 전체를 위로 들어올리는 동작을 했기 때문입니다. 그 결과 상단 헤더가 사라졌고, 내부 영역을 스크롤해도 헤더를 다시 노출할 수 없는 상태가 됐습니다.
처음에는 화면이 임의로 들어올려지는 문제를 해결하기 위해, 아래와 같이 가장 단순하게 스크롤 위치를 복원하는 방식을 먼저 시도했습니다.
window.visualViewport?.addEventListener('scroll', () => void window.scrollTo(0, 0));
이 방식은 웹앱 전체가 단일 화면형 레이아웃이고, 실제 스크롤을 내부 콘텐츠 영역에서만 처리한다는 전제에 의존합니다. 일반 문서형 페이지에서 전역 window.scrollTo(0, 0)을 사용하면 사용자의 정상적인 스크롤을 방해할 수 있습니다.
게다가 이 방식은 다시 스크롤이 복원되는 과정에서 화면이 순간적으로 튀는 현상을 피할 수 없었습니다.
결국 사용자 경험이 좋지 않아 다른 접근 방식을 모색하게 되었습니다.
입력 요소로의 자동 스크롤 제어
iOS에서 입력 요소로 자동 스크롤되는 현상을 막을 방법을 찾던 중, 요소의 opacity가 0일 때는 자동 스크롤이 발생하지 않는다는 사실을 발견했습니다.
WebKit은 포커스 시점의 렌더 객체 투명도 및 레이아웃 가시성을 체크하여, 사용자 인터랙션 관점에서 유효한 시각적 영역 내에 있는 경우에만 뷰포트를 이동하도록 설계된 것으로 보입니다.
따라서 focus 이벤트 후 일정 시간이 지나 입력 요소의 opacity를 1로 변경하면 자동 스크롤을 효과적으로 막을 수 있었습니다.
저희는 별도의 자바스크립트 로직을 두지 않기 위해 아래와 같이 CSS 애니메이션을 사용하여 opacity를 변경하는 방식을 적용했습니다. 더 나은 사용자 경험을 고려한다면, 실제 입력 요소 대신 가상 입력 요소를 표시하는 트릭을 고려할 수 있습니다.
.viewport input:focus,
.viewport textarea:focus,
.viewport [contenteditable='true']:focus {
animation: prevent-focus-scroll 1s forwards;
}
@keyframes prevent-focus-scroll {
from { opacity: 0; }
to { opacity: 1; }
}
이 동작은 WebKit이 공개 API나 스펙으로 보장하는 기능이 아닙니다. 따라서 실제 적용 전 충분한 테스트가 필요합니다. 실제로 디버깅 도구인 vConsole을 추가했을 때 레이아웃 조건이 달라져 같은 기법이 동작하지 않는 경우도 있었습니다.
느리게 올라오는 입력 요소
입력 요소의 투명도까지 제어하면서 iOS WebView의 키보드 동작은 꽤 안정되었습니다. 하지만 키보드가 먼저 올라온 뒤 하단 입력 요소가 뒤늦게 따라붙는 딜레이는 여전히 남아 있었습니다.
원인은 플랫폼마다 다른 이벤트 순서였습니다. CSSOM View 사양은 focus와 비주얼 뷰포트의 resize 이벤트 간의 발생 시점이나 순서를 명시하지 않습니다. 실제로 확인해 보니 Android WebView는 resize가 먼저 관찰되는 반면, iOS WebView는 focus가 먼저 발생하고 키보드가 완전히 나타난 뒤에야 비로소 resize가 관찰되었습니다.
처음에는 이 차이를 해결하기 위해 플랫폼별로 분기하여 대응했습니다. 예를 들어 iOS에서는 직전에 측정한 비주얼 뷰포트 높이를 저장해 두었다가, 다음 focus 시점에 컨테이너에 미리 적용해 지연을 줄이는 캐싱 방식을 적용했습니다. 그러나 플랫폼별 예외 처리가 늘어날수록 상태 관리는 점점 복잡해졌습니다.
그래서 이벤트 순서에 얽매이지 않고, 두 플랫폼 모두 결국 비주얼 뷰포트의 실제 높이가 변한다는 본질에 집중했습니다. 즉, resize, focus, blur를 각각 다르게 처리하는 대신, 모두 Viewport 높이를 다시 검사해야 하는 트리거 신호로 단순화했습니다.
새로운 구조에서는 focus와 blur가 발생할 때 비주얼 뷰포트의 resize 이벤트를 강제로 트리거합니다. 이후 단일 resize 핸들러가 일정 시간 동안 실제 높이 변화를 추적하여 화면에 반영하도록 일원화했습니다. 어떤 이벤트가 먼저 발생하든 동일한 검사 흐름을 타기 때문에 플랫폼 간 순서 차이에 구애받지 않게 되었습니다.
마지막으로, 실제 환경에서는 이벤트가 연속적으로 유입되므로 throttle과 trailing debounce를 결합했습니다. 이를 통해 키보드가 여닫히는 첫 순간에는 빠르게 반응하면서도, 움직임이 끝난 최종 상태의 높이까지 정확하게 포착하도록 초기 변화와 최종 높이를 모두 반영했습니다.
이 모든 대응을 하나로 모은 VirtualViewport
앞선 대응을 React 컴포넌트 하나로 모아 VirtualViewport를 만들었습니다. VirtualViewport의 역할은 다음과 같습니다.
- 기본 높이는
100dvh또는100vh로 유지합니다. - 비주얼 뷰포트가 줄어들면 컨테이너 높이를 실제 보이는 높이로 바꿉니다.
- 헤더·본문·입력 영역을 Flexbox로 배치해 고정 배치 의존도를 줄입니다.
- 내부 본문을 독립된 스크롤 컨테이너로 만들고 스크롤 체이닝을 제한합니다.
resize,focus,blur를 공통된 viewport 재검사 흐름으로 통합합니다.- 앱 화면 전환이나 페이지 복원 시 포커스를 정리합니다.
- input 요소의 투명도 애니메이션을 적용하여 포커스 시 화면이 들어올려지는 현상을 방어합니다.
import { useEffect, useLayoutEffect, useRef } from 'react';
interface Props {
id?: string;
children: React.ReactNode | React.ReactNode[];
}
const VIEWPORT_CHECK_LIMIT_MS = 500;
/**
* throttle + trailing debounce 기능을 결합한 함수
*/
function throttled<T extends (...args: any[]) => any>(callback: T, delay: number = 300) {
let throttleTime: number = 0;
let debounceTime: number = 0;
const reset = () => (throttleTime = 0);
return function (...args: Parameters<T>) {
if (!throttleTime) {
throttleTime = window.setTimeout(reset, delay);
callback(...args);
}
clearTimeout(debounceTime);
debounceTime = window.setTimeout(callback, delay, ...args);
};
}
/**
* VirtualViewport 컴포넌트
* Visual Viewport API를 활용하여 비주얼 뷰포트 높이가 변경될 때 컨테이너 높이를 조절
*/
const VirtualViewport: React.FC<Props> = ({ children, id = 'virtual-viewport' }) => {
const containerRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const styleId = `${id}-style`;
document.head.insertAdjacentHTML(
'beforeend',
`<style id="${styleId}">
#${id} {
position: relative;
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
will-change: height;
overflow-y: auto;
overscroll-behavior: none;
scrollbar-width: none;
}
/* 항상 스크롤을 생성해서 스크롤 체이닝 방지 */
#${id}::after {
content: "";
position: absolute;
right: 0;
top: 0;
width: 1px;
height: calc(100% + 1px);
pointer-events: none;
}
/* iOS Safari의 포커스 시 자동 스크롤 방지 */
#${id} input, #${id} textarea, #${id} [contenteditable="true"] {
will-change: opacity;
}
#${id} input:focus, #${id} textarea:focus, #${id} [contenteditable="true"]:focus {
animation: preventScroll 1s forwards;
}
@keyframes preventScroll {
from { opacity: 0; }
to { opacity: 1; }
}
</style>`,
);
return () => {
document.getElementById(styleId)?.remove();
};
}, [id]);
useEffect(() => {
let lastHeight: number = window.visualViewport?.height || window.innerHeight;
let checkHeightRequestId: number;
// 키보드가 올라올 때 높이 조절
const resizeHandler = throttled(() => {
if (window.visualViewport) {
cancelAnimationFrame(checkHeightRequestId);
const startTime = Date.now();
(function checkHeight() {
const currentHeight = window.visualViewport.height;
if (currentHeight !== lastHeight) {
if (currentHeight <= lastHeight) {
containerRef.current?.style.setProperty('height', `${currentHeight}px`);
window.dispatchEvent(new Event('keyboardOpened'));
} else {
containerRef.current?.style.removeProperty('height');
window.dispatchEvent(new Event('keyboardClosed'));
}
lastHeight = currentHeight;
return;
}
if (Date.now() - startTime < VIEWPORT_CHECK_LIMIT_MS) {
checkHeightRequestId = requestAnimationFrame(checkHeight);
}
})();
}
});
window.visualViewport?.addEventListener('resize', resizeHandler);
// iOS는 비주얼 뷰포트 리사이즈 이벤트가 늦게 발생하므로, 강제로 리사이즈 이벤트 트리거
const focusHandler = () => void window.visualViewport?.dispatchEvent(new Event('resize'));
containerRef.current?.addEventListener('focus', focusHandler, true);
containerRef.current?.addEventListener('blur', focusHandler, true);
// 오프 스크린이 될 경우 높이 초기화
const visibilityChangeHandler = () => void (document.activeElement as HTMLElement | null)?.blur?.();
document.addEventListener('visibilitychange', visibilityChangeHandler);
return () => {
cancelAnimationFrame(checkHeightRequestId);
window.visualViewport?.removeEventListener('resize', resizeHandler);
containerRef.current?.removeEventListener('focus', focusHandler, true);
containerRef.current?.removeEventListener('blur', focusHandler, true);
document.removeEventListener('visibilitychange', visibilityChangeHandler);
};
}, []);
return (
<div id={id} ref={containerRef}>
{children}
</div>
);
};
export default VirtualViewport;
해결과 한계
키보드 높이는 미리 알 수 없다
Visual Viewport API는 현재 보이는 영역만 제공하므로 키보드가 나타나기 전에는 변경될 높이를 알 수 없습니다. VirtualKeyboard API는 키보드의 geometry를 제공하지만 Safari와 iOS WebView에서는 현재 지원되지 않습니다. 따라서 키보드가 실제로 나타나 비주얼 뷰포트가 변하기 전까지 정확한 높이를 알 수 없습니다. 다만 테스트 환경에서는 첫 노출 때도 높이 변화를 빠르게 감지해 큰 불편 없이 대응했습니다.
고정 배치 요소는 여전히 주의가 필요하다
레이아웃 뷰포트와 비주얼 뷰포트가 분리된 상태에서 position: fixed 요소를 모든 브라우저와 웹뷰에서 완전히 같은 방식으로 동작시키기는 어렵습니다. 이번 구현에서는 헤더와 입력 영역을 최상위 Flexbox의 고정 영역으로 배치하고, 본문만 독립적으로 스크롤되게 해 고정 배치 의존도를 줄였습니다.
높이 감소가 곧 키보드 노출을 뜻하지는 않는다
비주얼 뷰포트의 높이 감소를 키보드 노출로 해석하는 부분은 일반적인 입력 흐름을 전제로 합니다. pinch zoom도 비주얼 뷰포트의 높이를 바꿀 수 있으므로, 확대를 허용하는 범용 화면에서는 포커스 상태와 visualViewport.scale 등을 함께 확인해야 합니다.
저희는 가로 모드를 지원하지 않으므로 회전에 대해 고려하지 않았지만, 가로 모드를 지원하는 환경에서는 추가적인 처리가 필요합니다.
브라우저 구현에 의존하는 부분은 계속 확인해야 한다
opacity animation을 이용한 iOS의 focus scroll 방지와 웹뷰의 이벤트 발생 순서는 표준 스펙이 아닙니다. 전역 스크롤 복원이나 강제 blur도 사용자 경험에 영향을 줄 수 있습니다. OS와 웹뷰가 업데이트되면 지원 환경에서 다시 검증해야 하며, 디버깅 도구나 DOM 구조 변화만으로도 결과가 달라질 수 있습니다.
맺음말
처음에는 가상 키보드가 올라올 때 iOS가 화면을 불필요하게 밀어 올리는 버그라고 생각했습니다. 하지만 원인을 파헤쳐 보니, 특정 플랫폼의 단순한 이상 동작이 아니라 두 뷰포트의 역할과 플랫폼별 구현 차이에서 비롯된 문제였습니다.
이 문제를 해결하기 위해 브라우저 벤더들도 서로 맞추어가며 노력을 기울이고 있습니다. Android Chrome과 웹뷰도 키보드가 나타날 때 Safari와 같이 비주얼 뷰포트만 축소하는 방향으로 바뀌었고, iOS WebKit의 interactive-widget 구현도 완료되었습니다. 하지만 하위 버전과 플랫폼별 파편화가 남아 있어 하나의 표준 스펙만으로 대응하기는 어렵습니다.
결국 핵심은 브라우저가 화면을 어떻게 나누어 처리하는지 이해하는 데 있었습니다. 비록 완벽한 해결책은 아닐지라도, 플랫폼별 제약 안에서 사용자 경험을 최적화해 나간 이번 고민의 과정이 비슷한 문제를 마주한 분들께 작은 힌트가 되기를 바랍니다.
VirtualViewport의 동작을 직접 확인하고 싶다면 카카오뱅크 앱 혜택 탭에서 AI 키워드 퀴즈를 실행해 보세요!
참고 자료
﹒CSSOM View Module - Visual Viewport
﹒CSS Values and Units Level 4 - Viewport-relative lengths
﹒CSS Positioned Layout Level 3 - Fixed positioning containing block
﹒CSS Viewport Module Level 1 - interactive-widget
﹒Chrome 108의 viewport resize 동작 변경
﹒Android <activity>의 windowSoftInputMode
﹒Android WebView의 Window Insets 이해
﹒CSS Overscroll Behavior Module Level 1
﹒Pointer Events Level 3 - touch-action
﹒MDN - VisualViewport
﹒MDN - VirtualKeyboard API