웹뷰와 소프트 키보드에 관한 문제 회고

#WebView#Flutter
• • •

작성 중

1

이 글에서는 제가 웹뷰를 사용하며 만났던 문제와 그 해결 방법에 대해 공유하려고 합니다. 기술 스택은 Next.js와 Flutter입니다.

2

웹뷰는 브라우저 앱이 아니라, 앱 화면 안에 브라우저 엔진을 심어 웹 페이지를 그려내는 방식입니다. 모바일 브라우저가 페이지를 띄우는 것도 웹뷰인지 궁금했던 적이 있는데, 둘을 가르는 것은 엔진이 아니라 앱이 그 안을 들여다보고 손댈 수 있는지입니다. 웹뷰라면 앱이 자바스크립트를 실행시키고 네트워크 요청을 가로채고 네이티브 코드와 메시지를 주고받을 수 있지만, 브라우저로 열면 주소를 넘기는 것으로 끝입니다. iOS는 정책상 서드파티 브라우저도 WebKit을 써야 했고(17.4부터 EU만 예외), 그래서 iOS용 Chrome도 WKWebView 기반입니다. 시스템 웹뷰로는 안드로이드의 android.webkit.WebView와 애플의 WKWebView가 있습니다. 전자는 패키지명과 달리 Chromium 기반이며, OS 업데이트와 별개로 Play 스토어를 통해 갱신되기 때문에 같은 OS 버전에서도 웹뷰 버전이 다를 수 있습니다. 웹뷰는 같은 엔진의 브라우저가 제공하는 기능을 대부분 지원하지만 전부는 아닙니다. 웹 푸시와 PWA 설치는 지원되지 않고, 자동재생과 스토리지 격리는 브라우저보다 제약이 큽니다. MDN 호환성 표도 WebView Android와 WebView on iOS를 별도 열로 표시합니다. Custom Tabs와 SFSafariViewController는 웹뷰가 아닙니다. 앱 안에 나타나지만 브라우저와 세션을 공유하는 별도 프로세스라, 앱이 페이지 내부에 개입할 수 없습니다. 플러터에서는 webview_flutter(공식)나 flutter_inappwebview(서드파티) 패키지로 시스템 웹뷰를 다룹니다. 토스, 당근 등 국내 서비스도 화면 단위로 네이티브와 웹뷰를 나눠 씁니다.

3

웹뷰를 주요 스택으로 정하는 가장 큰 이유는 배포입니다. 자바스크립트로 만든 하나의 애플리케이션을 브라우저와 모바일 앱에 함께 올릴 수 있고, 네이티브 코드를 건드리지 않는 변경이라면 앱 심사를 기다리지 않고 갱신하거나 되돌릴 수 있습니다. 스토어 주기에 묶이지 않는 것은 React Native 같은 프레임워크도 부분적으로 제공하지만, 웹과 앱이 같은 산출물을 그대로 공유하는 것은 웹뷰만의 특징입니다. 물론 웹뷰를 얹을 정도의 앱 개발은 필요합니다. 홍보 페이지나 링크 모음에 가까운 앱이라면 심사를 통과하지 못할 수도 있습니다. 그래도 앱 개발자를 따로 채용하지 않고 소수만으로 여러 플랫폼에서 도는 제품을 찍어낼 수 있다는 점은 분명한 이득입니다. 다만 어떤 비용이 줄어드는지는 구분해서 볼 필요가 있습니다. 실제로 줄어드는 것은 구현입니다. 화면을 세 번 만드는 것을 한 번으로 줄일 수 있습니다. 변경마다 반복되는 절감이라 시간이 갈수록 누적됩니다. 플랫폼 사이의 스펙을 맞추던 조정 비용과 심사를 기다리는 시간도 감소합니다. 반면 검증 비용은 줄지 않습니다. 만들 곳이 하나로 줄었을 뿐, 그 코드는 여러 플랫폼에서 동작합니다. iOS 앱과 안드로이드 앱, 두 OS의 브라우저, 그리고 태블릿까지. 오히려 네이티브라면 없었을 비용도 늘어납니다. WKWebView와 사파리의 동작 차이, 기기마다 다른 안드로이드 웹뷰 버전, 그리고 브릿지의 하위 호환 등을 챙겨야 합니다. 네이티브로 나눠 개발하면 iOS 개발자가 iOS를, 안드로이드 개발자가 안드로이드를 확인합니다. 하지만 웹 개발자 한 명이 iOS 앱과 안드로이드 앱, 크롬과 사파리를 모두 봐야 한다면 총량은 그대로인 채 한 사람 몫이 됩니다. Playwright나 BrowserStack으로 일부는 자동화할 수 있지만 실기기에서만 재현되는 문제는 결국 사람이 확인해야 합니다. 그래서 웹뷰의 이득은 만들 것이 많고 확인할 자리가 적을 때 크고, 그 반대일 때 사라집니다. 총 공수의 차이가 크지 않더라도 인력은 균질한 자원이 아니기 때문에 더 얻는 이득이 있긴 합니다. 채용이 어려운 네이티브 앱 개발자 한 자리를 기존 웹 개발자의 추가 시간으로 바꾸는 것은 시간 총량이 같아도 작은 조직에는 실질적인 이득입니다. 동시에 버스 팩터가 1로 떨어지는 위험이기도 하죠. 개발자 입장과 회사 입장이 갈리는 지점입니다.

하지만 여기서 이런 문제를 다루고자 하는 것은 아닙니다. 웹뷰를 사용하여 채팅 UI를 개발하면서 제가 경험했던 기술적인 문제를 먼저 회고하고자 합니다. 간단한 채팅 UI를 흉내내면서 제가 만났던 문제를 적어보겠습니다.

4

채팅 UI를 만든다고 해봅시다. 채팅 화면에는 일반적으로 상단에 제목이 있고 그 다음 채팅 로그, 그리고 가장 아래에 인풋이 위치합니다.

[png:chat room layout]

사용자가 인풋을 터치하고 키보드가 올라올 때 인풋은 키보드 위에 있어야 할텐데 이를 구현하기 위한 방법은 여러 가지가 있습니다. 그 중 가장 쉬운 방법은 웹뷰 사이즈 자체를 줄이는 방법입니다. 키보드가 올라온 만큼, 웹뷰의 높이가 줄어든다면 화면 하단에 배치된 인풋을 자연스럽게 키보드 위에 배치되겠죠? Flutter에서 최상의 컨테이너라고 할 수 있는 Scaffold에는 resizeToAvoidBottomInset이라는 속성이 있습니다. 이 속성이 켜져 있다면 키보드가 올라온 만큼 위젯 크기도 줄어듭니다. 즉 웹뷰 또한 줄어들죠. 기본값이 true이기 때문에 굳이 명시적인 설정이 필요하지 않습니다.

처음에는 이 방법을 택했습니다. 키보드가 올라올 때 ChatGPT나 여타 앱처럼 화면이 줄어드는 것이 디자인 요구사항이기도 했으니까 말이죠. 그런데 화면에 그려야 하는 DOM 요소가 많아질 수록, 특히 수학 표기를 위한 MathML 요소가 많거나 대화 내역이 많은 경우에는 화면이 줄어들거나 늘어날 때 뭔가 버벅이는 현상을 체감할 수 있었습니다. 대화 내역이 많은 것은 react-virtuoso 같은 DOM 가상화 라이브러리를 통해 어느 정도 해소할 수 있었지만, 그럼에도 화면 리사이즈에 따라 인풋이 떨리는 현상은 거의 개선되지 않았습니다. 당시에는 스와이프를 통해 여러 장으로 넘길 수 있는 형태의 채팅 앱이었기 때문에 구조가 꽤나 복잡했습니다. 그래서 원인을 파악하는데 더 까다로웠어요. 의심가는 모든 것들이 조금씩이라도 영향을 끼치고 있었기 때문인데, 결과적으로 모든 요소를 걷어내고 나니까 웹뷰 레이아웃이 줄어드는 것 자체가 문제라는 사실이 드러났습니다.

[gif:chat log jitter] [caption:키보드가 나타나고 사라질 때, 화면이 버벅이는 모습이 보이시나요?]

5

웹뷰 레이아웃이 줄어드는 것이 문제라는 사실을 알게 되었으니 키보드가 올라오더라도 줄어들지 않도록 하는건 어떨까요? 그렇게 하려면 resizeToAvoidBottomInset 속성을 끄면 되겠죠? 단, 이제 네이티브에서 화면을 줄여주지 않기 때문에 그 책임을 웹뷰가 가져가야 합니다. Flutter에서는 viewInsets에서 이 값을 얻을 수 있습니다. 이걸 logical pixel로 변환하여 브릿지를 통해 웹뷰에 전달해 줍니다. 웹뷰에서는 그 값을 받아 필요에 따라 사용합니다.

[image:keyboard over screen]

그리고 RAF를 사용하는 것이 좋습니다. 결국 fixed 요소의 위치를 조절하거나, 내부 컨텐츠의 사이즈를 조절하는 것 모두 레이아웃을 유발하기 때문에 리페인트 직전에 CSS 속성이 반영하는 것이 최적화에 도움이 됩니다. 경우에 따라 debounce 등도 고려할 수 있습니다.

6

근데 여기까지 하고 나니까, 또다른 문제가 발견됐습니다. 키보드가 올라왔을 때 웹뷰 내에 있는 fixed 요소를 잡고 위로 당기면 뷰포트가 화면 밖으로 밀려나는 현상이 있었어요. 개발자 도구를 통해 html 요소를 확인 했을 때 크기는 문제가 없었지만 실제로 DOM 자체가 화면을 벗어나 있었어요. 그렇다는 것은 HTML 밖에서 벌어지는 일이라는 건데, 이미 Flutter에서는 할 수 있는 것들을 다 했다고 생각했기 때문에 정말 막막했었습니다.

그래서 웹뷰 설정을 들여다 보게 됩니다. flutter_inappwebview에는 수직 스크롤 차단을 허용하는 disableVerticalScroll 설정이 있습니다. 이것을 true로 바꾼다면 문제를 해결할 수 있을까요? 반은 맞고 반은 틀립니다. iOS는 이 설정으로 웹뷰 outer 쉘이 스크롤되는 것을 막을 수 있습니다. 단, 안드로이드의 경우 구현이 조금 다릅니다.

[code:prevent gesture on android]

안드로이드에서 이 설정은 제스처를 원천적으로 차단합니다. 따라서 html 내부에서도 모든 스크롤이 차단됩니다. 이 사실을 알게 되었을 때 웹뷰 레이아웃의 리사이즈를 막는 방식, 즉 resizeToAvoidBottomInset을 false로 설정하는 방법을 철회해야 할지 고민했습니다. 하지만 이렇게 되면 근본적인 문제 원인이라고 할 수 있는 레이아웃 변화를 감수하고 그 위에서 개선을 시도해야 합니다. 모든 웹뷰 화면을 개발할 때 이 한계까지 항상 고민해야 하는거죠. 그래서 몽키 패치를 시도합니다.

[code:prevent scroll only on android]

제스처를 차단하는 것이 아닌, iOS처럼 스크롤만 차단하도록 flutter_inappwebview를 변경했습니다. flutter 특정 커밋으로 패키지를 오버라이드하여 사용할 수 있는 수단을 제공합니다.

[code:override flutter_inappwebview]

7

이제 앱에서는 제가 원하는대로 동작합니다. 키보드가 오르내릴 때 레이아웃 뷰포트가 통째로 리사이즈되지 않기 때문에 웹뷰에서 필요에 따라 키보드를 화면 위로 덮게 할 수도 있고, 그만큼 화면이 줄어들게 할 수도 있습니다. 원하는대로 사용하면 됩니다. 물론 내부 DOM 요소의 일부가 줄어드는 것 또한 레이아웃 비용을 초래하는 건 사실입니다. 하지만 이 비용이 발생하는 범위가 예측 가능하고, 상대적으로 저렴합니다.

[gif:example 1]

[gif:example 2]

8

(근데 이 문제는 웹뷰에서도 발생했었나? - 만약 그게 아니라면, 구분해줘야 할듯?)

하지만 두어야 할 안전 장치가 더 남아있습니다. fixed 요소를 잡고 당기는 것과 무관하게 뷰포트가 화면 밖으로 밀려나는 현상이 발생할 여지가 남아있습니다. 모바일 브라우저 구현마다 차이가 있겠지만, 보통 소프트 키보드에 의해 인풋이 가려지면 그 인풋이 보이도록 뷰포트를 위로 밀도록 설계되어 있습니다. Chrome Android의 경우, meta 요소에 viewport 값을 넣어 이 문제를 방지할 수 있습니다. 키보드가 올라올 때 뷰포트의 사이즈를 줄이도록 말이죠.

<meta name="viewport" content="interactive-widget=resizes-content" />

하지만 안타깝게도, iOS에서는 이 속성이 지원되지 않기 때문에, iOS에서 이 동작을 막기 위해서 인풋에 pointer 이벤트 리스너를 할당하여 기본 동작을 차단합니다.

9

결과가 개선이 되었을까요?

10

published 3 days ago · last updated about 12 hours ago