안녕하세요. 비브로스 웹프론트엔드팀 박서영입니다.
똑닥 앱은 네이티브 앱과 웹이 결합한 하이브리드앱으로 구성되어있습니다. 하이브리드 앱이란 빠른 실행 속도와 운영체제 및 디바이스 제어가 가능한 네이티브 앱의 장점, 강제 업데이트와 스토어 심사 없이 최신화면을 제공하는 웹의 장점을 합친 어플리케이션을 의미합니다.
현재 병원 상세와 이벤트/투표, 공지사항과 주소 찾기 등 똑닥 앱 내 다양한 화면이 웹뷰로 제공되고 있습니다. 이 중 병원 상세 화면을 통해 웹과 앱은 어떻게 통신하는지, 웹뷰 개발 시 고려해야 할 부분은 어떤 것들이 있는지, 그리고 기존 구현 방식에서 어떤 부분을 개선할 수 있는지 간략하게 살펴보겠습니다.
웹뷰와 앱은 어떻게 통신하는가?
웹 → 안드로이드
웹은 보안 등의 이유로 디바이스의 자원(주소록, 사진 등)이나 하드웨어(카메라, 마이크 등)에 자유롭게 접근할 수 없습니다. 그래서 네이티브 코드를 통해 해당 자원에 접근해야 하는데 앱의 네이티브 코드와 웹의 자바스크립트 코드 간의 인터페이스를 지원하는 것이 바로 JavaScript Interface 입니다.
public class WebViewInterface {
@JavascriptInterface
public void showSnackBar(String message) {
...
}
}
webView.addJavascriptInterface(new WebAppInterface(), "ddInterface")
앱에서는 웹뷰와 통신할 클래스를 만들고 웹뷰에서 호출할 메서드들을 정의합니다. 이 때, Javascript Interface 어노테이션 붙은 메서드만 자바스크립트의 접근이 허용됩니다. 그리고 addJavascriptInterface 메서드를 사용하여 클래스를 웹뷰에 등록하고 웹뷰에서 호출할 전역 변수명을 전달합니다.
const ToastButton = () => {
function onClickCreateButton () {
window.ddInterface.showSnackBar('등록이 완료되었습니다.');
}
return (
<button onClick={onClickCreateButton}>사용자 등록</button>
);
}
웹의 자바스크립트 코드는 window 객체의 ddInterface 프로퍼티를 통해 앱 메서드를 호출할 수 있습니다. window 객체 란 웹 브라우저 창을 나타내는 객체입니다. 브라우저 안의 요소들이 모두 포함되어있는 최상위 객체이기 때문에 어디서나 window.ddInterface에 접근하여 앱 메서드를 호출할 수 있게 됩니다.
웹 → iOS
override func viewDidLoad() {
let config = WKWebViewConfiguration()
let userContentController = WKUserContentController()
userContentController.add(self, name: "ddInterface")
config.userContentController = userContentController
...
}
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard message.name == 'domainUrl',
let command = message.body.command else { return }
switch command {
case .showToast:
return onShowToast();
case ....
}
}
}
- WKWebViewConfiguration : 웹페이지 렌더링 속도, 미디어 재생 처리 방법 등 옵션 설정 객체
- WKUserContentController : 앱과 자바스크립트 코드 사이의 통신을 관리하는 객체
- WKScriptMessageHandler : 자바스크립트 코드로부터 메세지를 수신하기 위한 인터페이스
먼저 앱에서 WKUserContentController 객체의 add 메서드를 통해 자바스크립트 코드가 호출할 메시지 핸들러를 설치합니다. 그리고 WKWebViewConfiguration 객체의 userContentController 프로퍼티에 WKUserContentController를 할당하면, 자바스크립트에서 window.webkit.messageHanlders.{handlerName}.postMessage()
를 통해 메시지를 보낼 수 있습니다.
declare global {
interface Window {
webkit?: Webkit;
}
}
export interface Webkit {
messageHandlers: {
ddInterface: {
postMessage(message: any): void;
};
};
}
window.webkit.messageHandlers.ddInterface.postMessage({
command: 'showToast',
data: {
args: { ... },
},
});
기본적으로 스크립트는 동일 출처 정책에 의해 프로토콜, 도메인, 포트 번호가 다른 경우 통신이 불가능합니다. 그러나 window.postMessage 메서드는 (올바르게 사용할 경우) 이 제약 조건을 우회하여 출처가 다른 window와도 안전하게 cross-origin 통신할 수 있도록 도와줍니다. 그리고 자바스크립트에서 postMessage 메서드를 사용해 JSON 형식으로 메시지를 보내면, ios에서 WKScriptMessage 형식으로 자동 변환하여 메시지를 수신합니다.
앱 → 웹
반대로 앱에서 자바스크립트 코드를 호출해야 하는 경우도 있습니다. 예를 들어 병원 상세 페이지의 북마크(☆)는 로그인이 필요한 기능입니다. 비로그인 유저가 북마크 버튼을 클릭하면 자바스크립트에서는 앱의 로그인 화면을 호출합니다.
만약 유저가 로그인을 완료하고 돌아왔다면 웹뷰는 북마크 API를 조회해 북마크 여부를 표시해주어야 합니다. 그러나 유저가 로그인을 완료하고 돌아온 건지, 로그인 화면을 닫고 돌아온건지 웹뷰는 알 수가 없습니다. 따라서 앱에서 유저의 로그인 결과에 대한 커스텀 이벤트를 전달해주어야 하는데요.
먼저 웹에서 loginFinish 라는 이벤트 타입을 가진 커스텀 이벤트를 만들어 window 객체의 ddLoginFinish라는 프로퍼티에 할당합니다. 그리고 이벤트 리스너를 등록하는데 첫 번째 인자로는 트리거할 이벤트이름을, 두 번째 인자로는 이벤트가 트리거 되었을 때 실행시킬 메서드를 넘겨줍니다.
useEffect(()=>{
window.ddLoginFinish = new Event('loginFinish');
const afterLoginFinish = () => {
//...로그인 성공 후 실행할 로직
}
window.addEventListener('loginFinish', afterLoginFinish);
return () => {
window.removeEventListenter('loginFinish', afterLoginFinish);
}
},[])
앱에서는 WebView 클래스의 evaluateJavaScript라는 메서드를 사용하여 자바스크립트 코드를 실행시킬 수 있습니다. 유저가 로그인을 완료했다면, dispatchEvent라는 Web API를 사용하여 이전에 약속했던 로그인 완료 이벤트를 window 객체로 발송하면 됩니다.
WebView.evaluateJavaScript(String, ValueCallback<String>
override fun onLoginFinish() {
webView.evaluateJavascript("(function() { window.dispatchEvent(ddLoginFinish); })();", null);
}
이미지 출처 - https://gradler.tistory.com/32
이미지 출처 - https://gradler.tistory.com/33
웹뷰 개발 시 고려해야할 부분은 무엇인가?
OS별 UX
window 객체에는 다양한 프로퍼티가 존재합니다. window의 navigator는 스크립트를 구동 중인 브라우저의 정보를 담고 있는 객체입니다. 그리고 navigator의 userAgent는 HTTP 요청 헤더의 프로퍼티 중 하나로 디바이스, 브라우저, OS 등 유저 식별 정보를 포함하고 있는 문자열입니다. 예를 들어 2019 Intel Macbook pro가 window.navigator.userAgent를 브라우저 콘솔에 출력하면 아래와 같이 표시됩니다.
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36
userAgent를 통해 유저 정보를 파악하면, OS별 유저 특성을 반영하여 개발할 수 있습니다. 일반적으로 안드로이드 유저는 바텀시트나 팝업을 닫을 때 뒤로가기 버튼을 클릭하여 닫는 경향이 있습니다. 아쉽게도 자바스크립트 코드는 안드로이드 디바이스의 물리 버튼 클릭 여부를 감지할 수 없습니다. 그러나 브라우저에서 뒤로가기란 이전 페이지로 탐색하는 동작이고, URL의 변화는 자바스크립트 코드가 감지할 수 있습니다.
그래서 병원 상세의 바텀시트는 hash(#) 라우팅을 기반으로 구현되어있습니다. URL의 hash 값에 따라 해당하는 바텀시트를 호출하고, 일치하는 hash 값이 없으면 바텀시트는 닫히게 됩니다. URL에 따라 바텀시트가 동작하기 때문에 안드로이드 유저는 뒤로가기 버튼을 클릭하여 바텀시트를 닫을 수 있게 되었습니다.
문제는 아이폰이었습니다. iOS의 뒤로가기 제스쳐 사용 시, 뒤로가기 애니메이션과 바텀시트 닫기 애니메이션이 차례대로 실행되면서 부자연스럽게 느껴졌습니다.
자바스크립트는 뒤로가기 제스쳐를 제어할 수 없으므로 history 스택을 활용하였습니다. URL을 접속하고 이동할 때마다 브라우저에는 history 스택이 쌓이게 됩니다. 스택을 쌓는 방식을 정할 수 있는데 router.push는 스택 위에 새로운 history를 쌓고, router.replace는 제일 위에 쌓여있는 history를 새로운 history로 교체합니다.
그래서 바텀시트를 열 때 안드로이드면 스택을 쌓아(push) 뒤로가기를 눌렀을 때 닫히게 되고, iOS는 스택을 교체해서(replace) 뒤로가기 제스쳐 사용 시 바텀시트 애니메이션이 충돌하지 않고 병원검색목록 화면으로 이동하도록 구현했습니다.
크로스 브라우징
웹뷰의 장점 중 하나는 브라우저만 있으면 모든 OS와 디바이스에서 동일한 정보를 확인할 수 있다는 것입니다. 그래서 개발과정에서 크로스 브라우징은 필연적인데요. 크로스 브라우징이란 웹페이지를 개발할 때 기종과 플랫폼에 관계없이 동일한 웹페이지를 제공하는 작업을 의미합니다. HTML 문서를 꾸미는 스타일 시트 언어 CSS의 경우, 브라우저 별로 지원하는 속성이 다릅니다.
safari로 고통받는 팀원들 🥲
예를 들면, 수평/수직 배치한 요소 간 간격을 설정하는 flex-gap 속성은 90.75%의 웹 브라우저에서 지원하나 iOS 14 이하 버전의 safari에서 지원하지 않습니다. 특히 iOS의 경우, OS에 종속적인 Webkit webview를 사용하기 때문에 브라우저 버전이 OS 버전을 따라가게 됩니다. 그리고 개발환경에서 사용하는 Mac OS safari와 실제 유저가 보게 될 iOS safari의 지원 속성이 다르고 간혹 webkit 엔진의 버그도 존재합니다.
그래서 웹뷰 개발 시 사용하게 되는 생소한 css 속성들 위주로 정리해보았습니다.
ios - border-radius 버그
isolation: isolate;
safari 개발자도구 레이어로 본 병원상세
safari에서 border-radius 속성이 적용되지 않는 버그가 있습니다. 개발 커뮤니티에서는 safari의 렌더링 엔진인 webkit의 버그로 z-index가 렌더링 과정에 영향을 끼쳐 발생한 것으로 추정하고 있습니다. isolation 속성으로 새로운 쌓임 맥락을 생성하여 해결하는 방법이 있으나 버그이기 때문에 작동하지 않는 케이스도 있다고 합니다.
ios - textarea auto-zoom 제어
<meta
name="viewport"
content="width=device-width, maximum-scale=1.0"
/>
iOS에서는 input 태그나 text-area 태그의 폰트 사이즈가 16px보다 작으면 focus 시 자동으로 zoom-in이 발생합니다. meta tag에 ‘maximum-scale=1.0’ 옵션을 넣어 문서의 최대 배율 범위를 설정하면 zoom-in은 발생하지 않습니다. 그러나 구글 lighthouse 웹 접근성 지표에 감점이 적용될 수 있습니다.
안드로이드 - 가상 키보드 숨김 처리
const [inputMode, setInputMode] = useState('none');
const onClickTextarea = () => {
setInputMode('input');
};
const onBlurTextarea = () => {
setInputMode('none');
};
<textarea
inputMode={inputMode}
onClick={onClickTextarea}
onBlur={onBlurTextarea}
/>
안드로이드에서는 input 태그, textarea태그에 focus 시 자동으로 가상 키보드를 호출합니다. inputMode 속성을 활용하여 가상 키보드 호출 시점을 정하고, 상황에 맞는 가상 키보드 타입(‘none’, ‘text’, ‘numeric’, ‘phone’, ‘email’, ‘‘url’,)을 설정할 수 있습니다.
a태그 하이라이팅 효과 제거
-webkit-tap-highlight-color: transparent;
모바일 디바이스에서 a태그 클릭 시 하이라이팅 효과가 발생합니다. webkit 엔진 기반의 브라우저에서 발생하는데, 전역 스타일 시트에 속성을 투명으로 적용하면 하이라이팅 효과를 제거할 수 있습니다.
추가로 개선할 부분은 무엇이 있는가?
화면 전환 - DeepLink
웹뷰 간 이동, 웹뷰 종료, 앱 화면 호출 등 화면 전환 시 딥링크를 사용할 수 있습니다. 자바스크립트에서는 약속된 interface 메서드로 딥링크를 호출하거나 window.location.href={scheme}
구문으로 화면이동이 가능합니다.
scheme://host/path?query-parameters
이외에도 인터페이스 메서드를 기능의 성격에 따라 분류할 수 있습니다. 현재 똑닥 인터페이스에는 화면 전환 / 앱 데이터 전달 / 네이티브 메서드 호출 등이 구분 없이 나열되어있습니다. 웹뷰 화면이 늘어나고 기능이 많아질수록 코드 파악이 어려워지기 때문에 기능 분류 및 메서드 네이밍 컨벤션을 적용하면 차후 유지보수에 도움이 될 것이라고 생각합니다.
interface Window {
ddInterface: {
closeView(msg?: string): void;
openWebview(url: string): void;
showHeader(visible: boolean): void;
};
}
class NativeProtocal {
//앱 화면 호출
public open(msg) {}
//인터페이스(웹 -> 앱)
public post(method, params) {}
}
DD.open('로그인');
DD.open('병원 목록');
DD.post('헤더', {visible: true})
DD.post('웹뷰', {url})
데이터 교환 - Callback function
콜백 함수로 데이터를 교환할 수 있습니다. 예를 들어 전달해야 할 데이터가 의사 정보라고 가정해봅니다. 먼저 웹뷰에서 의사 정보를 파라미터로 받아 처리하는 메서드를 window 객체에 등록합니다. 그리고 자바스크립트 인터페이스를 통해 앱 메서드를 호출하고, 후속 처리용 콜백 함수를 파라미터로 넘겨줍니다. 그러면 앱 메서드가 콜백 함수의 파라미터로 회원 정보를 전달합니다.
//APP
@JavasciprtInterface {
getDoctorData();
}
//4. 콜백함수에 의사정보 전달
getDoctorData(string func) {
window.func(의사 정보);
}
//WEB
//1. 의사 정보 처리 메서드 생성
getDoctorDataCallback = (doctorData) => {
//5. 의사 정보 처리
}
//2. window 객체에 메서드 등록
window.getDoctorDataCallback
//3. 네이티브 메서드 호출하며 콜백함수를 파라미터로 전달
window.ddInterface.getDoctorData(getDoctorDataCallback)
웹뷰 간 컨텍스트 동기화 - Broadcast Channel API
커뮤니티 웹뷰의 경우 이벤트/투표 웹뷰와 다르게 페이지를 이동할 때마다 새로운 탭, 즉 인터페이스를 통해 새로운 웹뷰 액티비티를 호출하게 됩니다. 예를 들어 피드 목록 화면에서 피드 상세 화면을 연 뒤, ‘좋아요’를 누르고 피드 목록 화면으로 돌아가면 해당 피드에 ‘좋아요’가 반영되어야 하기 때문에, 액티비티 간 컨텍스트 동기화가 필수적입니다.
Web API 중 동일한 origin의 브라우징 컨텍스트(window, tap, iframe) 간 양방향 통신을 지원하는 Broadcast Channel API 가 있습니다.
먼저, BroadcastChannel 객체를 생성하여 브로드캐스트 채널을 생성하거나 참여할 수 있습니다.
const bc = new BroadcastChannel("test_channel");
그리고 BroadcastChannel 내장 메서드인 postMessage와 onmessage를 사용하거나 addEventListener 방식을 사용해 메세지를 송/수신할 수 있으며, close를 사용하여 채널 연결을 해제하고 가비지 컬렉터의 수집을 허용할 수 있습니다.
// Example of sending of a very simple message
bc.postMessage("This is a test message.");
// A handler that only logs the event to the console:
bc.onmessage = (event) => {
console.log(event);
};
// addEventListener 방식
bc.addEventListener('message', function(event) {
console.log(event);
});
// Disconnect the channel
bc.close();
그러나 BroadcastChannel API는 지원 브라우저 스펙(safari 15.4부터 사용 가능) 이 협소하다는 치명적 단점이 있습니다. 이를 보완해 indexedDB, localStorage, websocket 등을 함께 지원하는 broadcast-channel 라이브러리도 존재합니다. 그리고 react-query에서 queryClient 상태를 공유하는 broadcastQueryClient 실험 플러그인 도 제공하고 있습니다.
마치며
온보딩을 끝내고 처음으로 맡게 된 핵심과제가 병원 상세 웹뷰 전환 프로젝트였습니다. 프로젝트를 통해서 웹앱 통신, 웹 성능 개선, 크로스
브라우징, SEO 최적화 등 웹프론트엔드 개발자에게 요구되는 소양들을 쌓을 수 있었습니다.
웹프론트엔드팀은 매 프로젝트에서 쌓은 노하우를 바탕으로 웹뷰 사용성을 개선하기 위해 노력하고 있습니다. 차후 공개될 웹뷰 프로젝트도 많은 기대 부탁드립니다. 감사합니다.