하이브리드 앱 브릿지(Bridge) 리팩토링 후기

Fitpet Developer
13 min readMay 27, 2022

--

네이티브 로 하이브리드 앱을 개발하게된 배경

하이브리드 앱을 만드는 프레임 워크는 현재 많습니다. React Native, flutter, XAMARIN, NativeScript, 여기에 Apache Cordova 계열로 가면 더 많아 집니다. 이런 계열의 프레임 워크로 하이브리드 앱을 만들게 되면, 기본적으로 플러그인 형태로 웹뷰와 네이티브 디바이스 를 연결 해서 접근할 수 있게 지원해 줍니다. 그렇기 때문에 사실 프레임 워크를 사용하면 웹뷰와 네이티브를 연결해주는 브릿지를 알 필요가 없습니다. 만들 필요도 없습니다. 플러그인으로 제공 하기 때문에 쓰기만 하면 됩니다.

하지만 이런 프레임 워크를 이용해서 개발하면서 저는 플러그인 관련 몇가지 문제에 부딪치게 되었습니다.

첫 번째, OS 버전이 최신 업데이트 될때마다 해당 플러그인 역시 업데이트 되야 하는경우가 있습니다. 대부분 유명한 프레임 워크는 잘 대응되나. 일부 플러그인은 잘 대응되지 않아서 패치가 될때까지 문제를 해결하지 못할수 있습니다.

두 번째, 하위호환 OS 버전을 고려해야 하고 하위 버전이 5년 이전에 출시된 버전인경우 플러그인을 쓰지 못하는 경우가 발생합니다. 결국 플러그인 버전 마다 버그가 존재 하고, 버그에 따라서 특정 버전 이하에서는 해당 플러그인을 못쓰는 경우도 많이 발생하기 때문에 하위 호환을 하지 못하거나 플러그인때문에 하위 버전을 강제로 올려야 하는 경우가 발생합니다.

세 번째, 경우에 따라서 새로운 플러그인 형태의 브릿지가 필요할 수 있습니다. 이 경우 해당 프레임 워크에 맞게 플러그인을 개발 해야 하는데, 결국은 이부분은 네이티브 개발자가 작업을 해줘야 한다는 문제가 생깁니다.

하이브리드앱 프레임 워크를 선택하는 이유는 여러가지가 있겠지만 네이티브 개발자 없이 앱 개발을 하기 위해서가 대다수 일텐데 결국 네이티브 개발이 필요한 상황이 발생합니다.

그래서 결국 네이티브 개발자가 만드는 하이브리드앱 으로 개발 하기로 결정하기로 합니다. 이 과정에서 웹뷰와 네이티브를 연결해주는 브릿지를 만들어야 하는 상황이 발생합니다.

웹뷰와 네이티브를 연결하는 방법

웹뷰와 네이티브를 연결하는 방법은 대부분 비슷합니다.

첫번째, Scheme 를 이용한 방법 입니다.

이 방법은 Scheme 을 정의하고 해당 Scheme 을 클릭하면 앱이 구동되게 하거나 앱 내에서 동작을 호출 하는 방식입니다. 회사마다 자체규격을 만들어서 사용하는데 저희는 location.href = “fitpet://fitpetmall/main” 형식으로 사용하고 있습니다.

이방식의 장점은 웹뷰 내에서 Scheme 를 호출하면 네이티브 내부 동작을 호출할 수 있다는 것입니다. 페이지 이동 부터, 카메라 호출같은 네이티브 기능까지도 호출이 가능합니다. 또한 Scheme 뒤에 쿼리 스트링을 붙여서 데이터도 전달이 가능합니다. 사실상 API 의 GET 호출 하는 방식과 별 다를게 없는 구조 입니다.

단점은 호출 이후 결과를 받을 방법이 없다는 것입니다. 동작이 제대로 되었는지, 동작후에 별도의 수행을 한다던가. GPS 처럼 동작의 결과를 받지 못합니다. 즉 단방향 통신 입니다. 양방향을 못 만든다는 것은 아닙니다. 대부분 단방향 통신 방식으로 구현되어 있다는 것입니다.

하지만 간단한 사용방법과 굳이 실행결과가 필요 없는 경우에는 최적의 방법이라고 생각합니다. 그래서 간단한 동작이나, 강제 페이지 이동 같은 경우는 브릿지를 이용하기 보다는 Scheme 를 이용해서 처리하게 하고 있습니다.

두번째, 웹뷰와 네이티브를 연결하는 브릿지를 만드는 방식입니다.

이 방식의 장점은 모든 네이티브 동작 을 정의할 수 있고 호출할 수 있다는 것입니다. Scheme 와 달리 쌍향방 통신도 가능합니다.

단점은 웹뷰와 네이티브 둘다 동작에 대한 정의가 구현되어 있어야 합니다. Scheme 는 네이티브에만 구현이 있는 반면, 브릿지는 대부분 쌍방향 통신 이기 때문입니다. 그리고 만약 하나의 앱 안에서 웹뷰의 Domain 이 여러 개인 경우 브릿지가 정의된 파일을 공통으로 관리해야 합니다. 같은 기능을 다른 이름으로 구현하는 경우를 방지 하기 위해서 입니다. 그런 점을 제외하면 딱히 단점이 있는 방식은 아닙니다.

저희는 위 두가지 방식을 혼용해서 사용하고 있습니다. 주로 다른 Domain 간 페이지 이동 및 네이티브 뷰 호출은 Scheme 로 처리 하고 GPS 나 사진첩이나 카메라 접근 권한 같은 네이티브 기능을 호출할 때는 브릿지를 사용하고 있습니다.

현재 구현된 코드의 문제점

입사했을 때 회사는 React Native 에 Next.js 구조의 하이브리드 앱이었습니다. 그리고 입사했을 때 구현되어있던 브릿지 코드를 분석하다 보니 다음과 같은 문제가 발견되었습니다.

아래 코드는 카메라 접근 권한 브릿지 입니다.

아래 코드에서 IMAGE_FILE_ID는 실제 input 태그의 ID 입니다.

useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
inAppBridge?.requestCamera(IMAGE_FILE_ID);
},
[inAppBridge],
<Styles.HiddenInput type=”file” id={IMAGE_FILE_ID} accept=”image/*” onChange={handlePostReviewImage} />

카메라 접근 권한을 물어보고 접근 권한이 있으면 기존 React Native 에서아래과 같이 호출합니다.

직접 스크립트를 실행해서 input 태그의 click 호출해서 파일 업로드를 실행합니다. 권한이 없으면 동작이 없습니다. 양방향 통신 이라기 보다는 네이티브를 호출하고 권한이 있는경우에 직접 DOM 을 조작해서 동작하게 하는 구조 였기 때문에 권한 없는 예외 동작을 할 수 없습니다.

webViewRef?.current?.injectJavaScript(`document.getElementById(‘${targetId}’).click()`);

또 다른 예제 입니다.

로그인 창은 네이티브 화면이었고, 로그인이 필요 시 네이티브 로그인창을 호출하는 코드 입니다.

if (isInApp) {
inAppBridge?.requestMallLogin(`${BASE_URL}${router.asPath}`);
}

네이티브 로그인 창을 호출하고 로그인 성공 시 어느 화면을 이동할지 파라미터로 url을 전달합니다. 그러다 보니 로그인 실패 시 처리를 할 수 없고, 성공시 같은 페이지를 유지하는 경우에도 화면을 이동해서 화면을 처음부터 그려야 하는 문제가 발생합니다.

설계된 브릿지 가 공통적으로단방향 통신에 가깝고 예외 처리를 할 수 없다는게 문제가 있습니다.

이후 React Native 을 걷어 내기로 회사에서 확정된 후 브릿지에 대한 리팩토링을 하기로 했습니다.

리팩토링의 과정

웹뷰에서 네이티브로 데이터 전달 과정

ANDROID 와 IOS 는 브릿지 만들 때 OS 가 다르다 보니 브릿지에서 이 다른 점을 맞추어 줘야 합니다.

메세지 데이터를 전달하는 방법에서의 차이는 ANDROID 는 addJavascriptInderface Class, Method명을 정의 해야 하고 IOS는 messageHandlers 만 정의 하면 됩니다. 구현된 예는 다음과 같습니다.

// IOS
window.webkit.messageHandlers.jsToNative.postMessage(
{
action: eventName, // event 타입 이름
param: {…param}, // event 마다 전달할 param 정보
callback: callbackName // window.nativeCallback 에서 전달 받을 콜백 이름
}
)
// ANDROID
window.bridgeHandler.jsToNative(eventName, JSON.stringify(param), callbackName)

데이터를 주고 받을때 ANDROID 와 IOS 는 다음과 같은 차이점이 있습니다.

WEBVIEW => ANDROID: 보낼때 String 으로만 전달됨
WEBVIEW => IOS: 보낼때 객체 상태로 전달됨
WEBVIEW <= ANDROID: 받을때 객체로 전달 가능
WEBVIEW <= IOS: 받을때 String 으로 받아짐

이렇다 보니 데이터를 주고 받는 방식을 어느정도 통일 해야 했습니다. 줄 때는 각 OS 특성에 맞게 전달 하고 받을 때는 String 타입으로 받아서 일괄 처리 하는 방식으로 받는 쪽만 통일했습니다.

WEBVIEW => ANDROID: 보낼때 String 으로만 전달됨
WEBVIEW => IOS: 보낼때 객체 상태로 받아짐
WEBVIEW <= ANDROID: 받을때 String 으로 받음
WEBVIEW <= IOS: 받을때 String 으로 받음

브릿지 설계 시 가장 중점으로 생각한것은 양방향 통신 입니다. 브릿지로 데이터를 전달하고 전달할때 호출 할 callbackName 를 같이 전달합니다. 그리고 결과 데이터를 받는 진입 경로는 winodw 객체에 window.nativeCallback 함수를 만들고, 이 함수만 통해서 실행 결과를 받습니다.

브릿지를 호출한 곳에서 바로 결과를 받을수 있게 Promise 를 적용하는것이 핵심이었습니다.

다음 예제는 GPS 정보를 호출해서 GPS 정보를 받는 브릿지 예제 입니다.

getCurrentPosition(){
return setCallbackStack(‘getCurrentPosition’, callbackName =>{
if (platformType === platformDetail.IOS_APP) return window.webkit.messageHandlers.jsToNative.postMessage({action: ‘getCurrentPosition’, param: {}, callback: callbackName})
if (platformType === platformDetail.ANDROID_APP) return window.bridgeHandler.jsToNative(‘getCurrentPosition’, JSON.stringify({}), callbackName)return console.error(‘not platformType’, platformType)
})
},

이 함수에서 setCallbackStack 함수의 결과를 리턴 하는데 이 함수의 리턴값이 Promise 입니다. Promise 는 다음과 같은 상태값을 가집니다.

Fulfilled : 성공(resolve)했을 때의 상태
Rejected : 실패(reject)했을 때의 상태
Pending : 성공도 실패도 아닌 상태로, Promise 객체가 생성된 초기 상태

setCallbackStack 에서 전달받은 Promise 는 바로 Pending 상태 입니다. 이상태는 resolve 함수가 호출되면 Fulfilled 로 상태가 변경 되게 되고 , resolve(params) 를 실행하게 되면 넘겨받은 params를 Promise.then(callback) 에서전달받은 콜백 함수에 전달하면서 실행 하게 됩니다.

이런 Promise의 특징을 이용 양방향 통신을 구현 합니다. Promise 에 대한 자세한 이야기는 다음 링크를 참고해 주시기 바랍니다.

const setCallbackStack = (eventName, callback) => {
return new Promise((resolve) => {
// 콜백이름을 uuid 형태로 생성한다.
const callbackName = generateCallbackName(eventName)
// 여기서 실행될 콜백을 등록한다. 네이티브에서 호출해 줄때 Promise가 동작한다.
window.__Bridge__.__CALLBACKSTACK__[callbackName] = (res) => resolve(res)
// 생성된 콜백이름을 인자로 받은 콜백에 전달한다.
callback(callbackName)
})
}

브릿지 호출 예제

inAppBridge?.getCurrentPosition().then((res: LocationBridgeModel) => getLocation(res))

브릿지 함수 getCurrentPosition 를 호출하게 되면 resolve 가 실행되기 전 상태 즉, Pending 상태의 Promise가 호출된 코드에 전달 됩니다.

그리고 네이티브 에서 window.nativeCallback 함수를 통해 결과를 넣어서 호출하면 쌓여있던 콜백 스텍이 호출하고, 저장되어 있던 resolve 에 네이티브로 넘어온 데이터를 담아서 호출 하게 됩니다.

그렇게 되면 inAppBridge?.getCurrentPosition().then() 함수에 네이티브로 부터 전달된 데이터가 전달 됩니다.

네이티브에서 웹뷰로 데이터 전달 과정

window.nativeCallback = (callbackName, paramJSON) => {
const param = JSON.parse(paramJSON)
window.__Bridge__.__CALLBACKSTACK__[callbackName](param)
// 콜백 함수 삭제
delete window.__Bridge__.__CALLBACKSTACK__[callbackName]
}

네이티브 쪽 구현은 webView.evaluateJavascript 같은 함수를 통해 기존에 브릿지로 받은 callbackName 과 결과 객체를 window.nativeCallback 안에 전달하게 됩니다. window.nativeCallback 함수는 setCallbackStack 함수를 통해 등록해 놓은 resolve 를 꺼내서 넘겨 받은 paramJSON 을 넣어서 실행합니다.

이런 방식을 통해서 Promise 를 통한 양방향 통신을 구성할 수 있습니다.

아래 그림은 위 내용을 도식화 한 그림입니다.

좀 더 개선해 나가야 할 방향

이 방식의 문제점은 반드시 네이티브 가 브릿지 호출 시 반드시 콜백을 호출해 줘야 한다는 것입니다. 물론 이 방법도 브릿지 호출시 콜백 함수 이름을 넘긴경우와 아닌 경우로 나누어서 보내면 되긴 하지만 네이티브에서 한번이라도 콜백을 호출하지 않으면 메모리 릭이 될 가능성이 높아지게 됩니다. 현재는 이 케이스가 없도록 네이티브 쪽에서 반드시 호출 하도록 하기로 해서 문제를 피해가고 있습니다.

개선을 더한다고 한다면

첫 번째, timeout 속성을 추가한다.

콜백 등록시 timeout 을 설정해놓고, 네이티브 에서 콜백 호출 시 해당 콜백 호출 후에 남은 모든 콜백을 확인해서 timeout 값을 지난 콜백은 전부 삭제하는 로직을 추가합니다.

두 번째, 함수 콜백 등록 함수를 지역변수로 만든다.

현재는 가비지 수집(Garbage Collection, GC) 를 피하기 위해 window.__Bridge__.__CALLBACKSTACK__ 에 함수 콜백을 등록해 놓았는데 const bridgeCallbackstack = {} 형태로 지역변수로 만들어 저장하면, 자동으로 GC 가 동작 되면서 알아서 함수가 삭제되기 때문입니다. 물론 이방법은 GC 타이밍을 우리가 알 수 없다는 문제가 있습니다.

브릿지 리팩토링을 하면서 알게된 점을 정리 했는데 이 글을 읽는 분들에게 조그만한 도움이라도 되기를 바랍니다.

읽어 주셔서 감사합니다.

written by Zeke

email: duhan.lee@fitpet.co.kr

--

--