웹뷰 개발은 일반 웹앱 개발과 어떻게 다를까?
실제 프로덕트 개발을 하다보면 웹뷰를 통해 앱에 기능을 추가해야 되는 경우가 있다. 저번 회사를 다닐 때도 종종 웹뷰가 필요한 경우가 있었다. 당시에는 웹뷰 개발을 해봤던 사람이 없어서 웹뷰로 전환되는 과정이 매끄럽지 못한 상태로 마무리된 적이 있었다. 해당 기능은 웹뷰로 전환이 느껴지지 않을 정도로 만드는 것이 중요한 것은 아니었기 때문에 큰 문제는 없었다. 하지만 네이티브에서 웹뷰로 전환되는 과정에서 어떻게 회원 정보를 유지하고, 하나의 앱에서 모두 이뤄지는 것처럼 동작하게 하는지 언젠가 정리해보고 싶었다.
그래서 이번 기회에 웹뷰 개발과 일반적인 프론트엔드 개발의 다른 점과 어떻게 하면 웹뷰에서도 네이티브 앱처럼 느껴지게 할 수 있을지를 중점으로 정리해보려고 한다.
앱 개발 환경별 Webview
Android
Android 4.4 이전
- 크로미움 기반 프로토타입 웹뷰 사용
Android 4.4.3
- Chrome 30.0.0.0 기반 웹뷰
Android 5
- 시스템 상에서 System WebView 제공되었고, Play Store 통해서 지속적으로 업데이트.
- 이전까지는 OS에 번들링되어 OS 업데이트시에만 웹뷰가 업데이트되었다.
Android 7
- Chrome 앱의 엔진이 웹뷰에 사용되어, Chrome 앱 업데이트시 System WebView도 깉이 업데이트
- System WebView와 Chrome WebView 중에 선택 가능
Android 10
- System WebView만 제공하도록 다시 돌아옴. Chrome WebView는 더이상 제공하지 않음.
- Canary, Dev, Beta 버전 제공
iOS
UIWebView
- 애플 개발자 문서에서 deprecated 됐으니, 사용하지 않도록 권장
- iOS 2.0에 출시
- 성능 문제, 렌더링 속도 느림
WKWebView
- 일반적으로 가장 많이 사용
SFSafariView
- 사파리의 기능을 사용할 수 있는 웹뷰
- 사파리 기본 설정, 쿠키 등을 동일하게 지원한다.
- 사파리의 정보를 앱 내로 읽어올 순 없다.
- iOS 9.0에 출시
Flutter
flutter_inappwebview
- 가장 범용성 있게 사용되는 패키지
- 확장성이 좋다. 하지만 기본 자바스크립트 api를 직접 구현해야한다.
url_launcher
- 가볍게 사용하기 좋다.
- 사용할 수 있는 기능에 제약이 있어서 단순한 정보 전달 정도에 활용하는 것이 좋다.
webview_flutter
- fluuter_inappwebview와 유사하지만 필요한 메서드들만 따로 모아둔 패키지라고 보면 된다.
- 버전 3 기준으로 새창 열기가 불가능하다.
- 버전 4 이후에는 시간 관계상 확인해보지 못 했다.
이렇게 간단히 정리해봤지만 실제 웹 브라우저의 엔진과 웹뷰에서의 엔진이 서로 조금씩 다르기 때문에 브라우저에선 동작이 잘 됐지만 웹뷰에선 잘 동작하지 않을 수도 있다.
웹뷰 개발시 고려사항
웹뷰는 기본적으로 모바일 앱 위에서 동작하는 것이라 일반적인 웹앱을 개발할 때와 달리 추가적으로 고려해야할 사항들이 있다.
기본적인 HTML, CSS 세팅
이 부분은 반응형 웹페이지를 개발해봤다면 알고 있을 내용들이 많다.
기본적으로 viewport meta 태그를 적절히 설정해서 사용자가 콘텐츠를 보는데 불편함이 없어야 한다.
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
모바일 기기에서 확대를 했을 때 레이아웃이 깨지거나, input에 focus됐을 때 자동확대가 되어 되돌리는데 불편함 등의 이유로 대부분의 서비스에선 확대/축소를 막고 있다. 하지만 웹 접근성 가이드에선 2배까지 확대를 권장하고 있으니 참고하길 바란다.
이외에도 앱에서 웹뷰를 띄울 때 설정을 통해서 확대/축소를 막는 방법이 있는데 이는 핀치 줌 속성만을 비활성화한다고 한다. 참고로 화면 확대는 두 손가락으로 화면 확대(pinch)
, input focus
, double tap
이렇게 3가지 경우에 발생한다.
스마트폰은 상단 상태바, 노치나 펀치홀 영역 등 콘텐츠가 가려져서 보여지지 않을 수 있는 부분을 제외한 Safe Area
를 갖고 있다. 만약 앱을 화면 전체에 보여주고 싶다면 viewport-fit
을 사용해야 한다.
<meta name="viewport" content="viewport-fit=cover" />
viewport-fit
는 기본적으로 auto
로 설정되어 safe area로 제한하지만 cover
로 설정하면 꽉찬 화면을 볼 수 있게 된다.
하지만 아이폰의 경우엔 노치 영역이 콘텐츠를 가리는 문제가 발생하는데 css에서 safe-area-inset-*
속성을 사용하면 해결할 수 있다.
.element {
padding-left: env(safe-area-inset-left);
/* iOS 11.0 이전 대응 */
padding-left: constant(safe-area-inset-left);
}
아이폰과 Safe Area에 대한 자세한 설명은 WIT 블로그 - 아이폰X 안전영역(Safe Area) 대응 사례 공유에서 자세히 설명하고 있다.
웹뷰와 UI 분리
여기서부터는 예제를 위해서 React, React Native로 개발을 진행했다. 앱 개발자가 아니라 앱 쪽은 미흡한 부분이 많다.
웹앱의 특정 페이지를 웹뷰로 모바일 앱에서 보여줘야할 경우 특정 UI가 모바일 앱에서는 보이지 않아야하는 경우가 있다. 가장 먼저 생각나는 예시는 헤더 부분이었다. 웹 페이지에서도 헤더는 대부분의 경우 존재하고, 앱의 스크린에서도 헤더 영역은 대부분 존재한다. 이런 경우에 웹뷰를 보여주게 되면 웹의 헤더와 앱의 헤더가 중복으로 보이게 되어 사용자에게 불필요한 UI가 중복으로 보이게 되는 문제가 생길 것이다.
이런 경우 일반적인 브라우저에서 접근한 건지, 앱의 웹뷰를 통해서 접근한 건지 구분해서 조건부 렌더링을 해줘야한다. 다른 많은 해결책이 있겠지만 웹뷰를 로드할 때 자바스크립트를 inject해서 웹뷰 여부를 나타내는 flag를 하나 추가하는 것이 가장 간단하면서도, 안전해 보였다.
// api/screen/Home.js
...
function Home() {
const runFirst = `
window.isWebView = true;
`;
return (
<WebView
source={{ uri: 'web url' }}
injectedJavaScriptBeforeContentLoaded={runFirst}
/>
);
}
...
react-native-webview에서 injectedJavaScriptBeforeContentLoaded
로 자바스크립트를 inject할 수 있도록 하는데 이 때 window
객체에 새로운 property로 웹뷰인지 구분할 수 있는 flag를 추가했다. 앱 개발환경에 따라 inject하는 방식이 다르니 앱 개발자와 협의하면 될 것이다.
이렇게 window 객체에 추가를 했다면 이 프로퍼티를 이용해서 조건부 렌더링을 해주면 된다.
// web/hooks/useWebView.js
...
function useWebView() {
const isWebViewRef = useRef(window.isWebView ?? false);
useEffect(() => {
if (isWebViewRef.current) return;
if (window.isWebView) {
isWebViewRef.current = true;
}
}, []);
return {
isWebView: isWebViewRef.current,
};
}
...
// web/components/BaseHeader.jsx
...
function BaseHeader() {
const { isWebView } = useWebView();
if (isWebView) return null;
return (
<header>
...
</header>
)
}
...
웹과 앱 사이의 통신
웹뷰를 사용할 때 웹뷰 내에서 발생한 이벤트를 앱으로 전달하거나 앱의 데이터를 웹에서 받아야하는 경우 등등 여러 상황에서 웹과 앱이 서로 통신이 필요한 경우가 있다. 웹과 앱 사이에서 브릿지를 통해서 통신할 수 있는데 React Native에서는 postMessage
를 통해서 통신한다.
postMessage
는 원래 Window 객체 사이에서 안전하게 cross-origin 통신을 할 수 있게하는 메서드이다. 그래서 iframe 사이의 통신에서 많이 사용되는데 React Native에선 이 메서드를 통해 웹뷰와 앱이 서로에게 메세지를 전달할 수 있도록 한다.
// 웹뷰에서 앱으로 메세지 보내기
window.ReactNativeWebview.postMessage('message');
// 앱에서 웹뷰로 메세지 보내기
const webviewRef = useRef();
webviewRef.current.postMessage('message');
<WebView ref={webviewRef} />
이런식으로 서로에게 메세지를 보낼 수 있는데 웹뷰에선 message 이벤트 핸들러에서, 앱에선 onMessage를 통해 메세지를 받을 수 있다. 웹에선 OS에 따라 message 이벤트가 바인딩되는 객체가 다른데, 안드로이드에선 document
, ios에선 window
에 message 이벤트가 전달된다.
// 웹뷰에서 메세지 받기
// android
document.addEventListener('message', callback);
// ios
window.addEventListener('message', callback);
// 앱에서 메세지 받기
<WebView
onMessage={(event) => {...}}
/>
페이지 이동
웹에서의 페이지 이동과 앱에서의 페이지 이동은 약간의 차이점이 있다. 웹에서는 브라우저가 라우트 히스토리를 관리해주지만 앱에서는 네비게이션을 통해 스크린 컴포넌트를 관리하게 된다.
- Stack: 스크린 위에 이동하려는 스크린을 하나 더 띄우는 방식
- Tab: 다른 영역으로 전환할 수 있는 라우트 스크린
- Drawer: 스크린 사이드에 제스처를 통해 나타나는 스크린
React Native에선 이런 네비게이션들이 있는데 다른 프레임워크들에서도 통용되는 용어인지는 모르겠지만 앱에서 페이지 전환 방식 개념은 일맥상통할 것이다.
만약 웹뷰 내에서 앵커 태그를 클릭해서 일반적으로 브라우저가 동작하는 방식으로 페이지가 이동되면 어떻게 될까? 앱의 네비게이션을 통한 것이 아니라 뒤로가기를 했을 때 웹뷰의 이전 페이지로 이동하는 것이 아니라, 웹뷰를 띄우기 이전의 스크린으로 이동될 것이다. 그리고 앱 자체적으로 네비게이션 이동에서 공통적으로 사용하고 있는 애니메이션이 있었다면 스크린 이동 애니메이션이 따로 트리거되지 않아 사용자가 이질감을 느낄 수도 있다.
따라서 우리는 기본적인 웹 브라우저의 라우트 이동을 인터셉트해서 앱의 네비게이션이 트리거되도록 전달해야한다. 이는 앞서 봤던 postMessage
를 통해 라우트 이벤트가 발생했다고 앱으로 알려주면 된다.
다음은 react-router의 Link 컴포넌트를 클릭했을 때 앱에서 Stack을 쌓는 예시이다.
// web/components/Link.js
import {
useHref,
useLinkClickHandler,
} from 'react-router-dom';
import { useWebView } from '../hooks/useWebView';
const Link = React.forwardRef(
(
{
onClick,
replace = false,
state,
target,
to,
...rest
},
ref
) => {
const href = useHref(to);
const handleClick = useLinkClickHandler(to, {
replace,
state,
target,
});
const { isWebView, postMessage } = useWebView();
const wrapOnClick = (event) => {
onClick?.(event);
if (isWebView) {
event.preventDefault();
postMessage(JSON.stringify({
type: 'ROUTER_EVENT',
path: to,
}));
return;
}
if (!event.defaultPrevented) {
handleClick(event);
}
}
}
...
);
링크를 눌렀을 때 일반적인 브라우저인 경우 기본적인 동작을 수행하고, 웹뷰인 경우 앱으로 postMessage를 보내는 커스텀 Link 컴포넌트를 만든다.
// app/components/WebViewContainer.js
function WebViewContainer() {
...
const onMessage = (event) => {
const nativeEvent = JSON.parse(event.nativeEvent.data);
if (nativeEvent.type === 'ROUTER_EVENT') {
const pushAction = StackActions.push('Home1', {
url: `${webUrl}${nativeEvent.path}`,
});
navigation.dispatch(pushAction);
}
}
...
}
WebViewContainer는 postMessage로 전달받은 path를 새로운 Stack의 WebView에 보여주도록 한다.
이번에는 커스텀 Link 컴포넌트를 만들었지만 버튼의 클릭 이벤트에서 route를 변경시키는 경우엔 router의 push 또는 replace 메서드를 감싸서 사용하면 될 것이다.
유저 정보 유지
웹뷰로 화면을 띄웠을 때 앱의 로그인 정보를 이용해서 개인화된 화면을 보여줘야하는 경우가 있을 것이다. 만약 웹뷰로 아무런 데이터 전달을 해주지 않았다면 웹뷰를 띄울 때마다 로그인을 다시 해야하는 불상사가 일어날 것이다.
이 부분도 브릿지로 필요한 데이터를 웹뷰로 전달하거나, javascript inject를 통해 해결할 수 있다. 사실상 앞서 알아봤던 두가지 방식을 통한다면 앱과 웹뷰 사이에 데이터 동기화를 달성할 수 있을 것이다.
예전에 회사에서 처음 웹뷰 개발을 했을 때 사용자 정보를 앱과 웹뷰 사이에서 어떻게 유지할 수 있는지 가장 먼저 궁금했다. 당시에 시간적 여유도 없었기도 했지만, 당장 필요한 부분은 아니어서 넘어갔었다. 처음 기획에선 해당 페이지가 유효 기간 내에만 접근할 수 있는 부분이어서 앱에서 입장 코드를 발급하고, 이 코드를 웹뷰에서 입력하도록 했었다. 하지만 점점 시간이 지날수록 해당 페이지는 유저의 개인화된 페이지가 되고 있었기 때문에 먼저 더 찾아보고 적용했다면 좀 더 매끄러운 사용자 경험을 줄 수 있지 않았을까라는 아쉬움이 든다.
마치며
예전부터 정리해보고 싶었는데, 이렇게 앱에서의 동작까지 직접 해보니까 훨씬 명확해져서 좋았다. 처음 자료를 서치해보면서 글로 읽었을 땐 이게 뭔가 싶었는데 직접 해보니까 확실히 괜찮아졌다.
예제는 React Native + React 기반으로 작성해서 실제 개발환경에 따라 사용하는 api와 구체적인 구현 방법은 달라지겠지만 개념을 이해하고 있으니 큰 문제는 아니라고 생각한다. 그리고 이번에 제시한 방법들이 최선의 방법은 아닐 것이기 때문에 더 나은 방법을 알게 된다면 계속 추가해 나갈 생각이다.
전체 코드는 깃허브에서 확인해볼 수 있다.