| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 |
- 시스템디자인
- 알고리즘
- 이펙티브타입스크립트
- Framer motion
- 글또 10기
- JUNCTION2023
- 개발자 원칙
- JSBridge
- TS
- 밴쿠버개발자
- React-Router-Dom
- Effective Typescript
- CSS방법론
- typescript
- 캐나다취준
- ASP.NET
- 해외취업
- 타입스크립트
- react
- 캐나다개발자
- 회고
- framer
- SemVer
- 글또
- 테오의 스프린트
- framer-motion
- Semantic Versioning
- CSS
- 개발자를 위한 글쓰기 가이드
- 코드트리
- Today
- Total
큰 꿈은 파편이 크다!!⚡️
웹뷰 C# MAUI ↔ React JS Bridge 본문
회사 업무를 하면서 새롭게 해결하는 문제들이 주기적으로 나오다보니 기록을 겸해서 문제-해결에 관련된 글을 쓰게 된다 😜
얼마 전, 기존에 만들어놓았던 리액트 웹사이트를 웹뷰 위주의 C#기반 모바일 앱에 붙이는 작업을 진행했었다. 이 과정에서 ‘웹뷰’란 정말.. 브라우저도 신경쓰면서 모바일 os까지 신경써야 하는 것인가 하는 생각이 들 정도로 예상대로 동작하지 않는.. 어려운 작업이라는 것을 깨달았다.
아무튼 그 과정에서 내가 알고 있고 익숙한 흐름을 제공하는 웹뷰 앱들(ex. 토스)과 내가 만든 앱을 비교해보니, 모바일-웹뷰 간의 데이터 교환이 필요하다고 생각했다. 예를 들면 이런 흐름을 제공할 수 있도록.
- (웹뷰) 게시글 목록에서 게시글을 누르면 (모바일) 페이지로 게시글이 열림
- (모바일) 신규 게시글을 작성하고 게시글 작성 페이지가 닫히면 (웹) 게시글 목록을 업데이트함
이와 같이 네이티브 모바일과 웹이 상호작용하며 데이터를 교환할 수 있도록 하는 동작을 “js bridge”라는 표현을 쓴다. 이름 그대로 JS와 네이티브의 다리 역할을 한다.
Javascript/native code cross-calls
https://www.resco.net/javascript-bridge-reference/
이 글에서는 회사에서 사용한 MAUI(C#) 와 리액트(JS)를 사용해서 구현하는 방법을 설명한다.
실제로 구현할 때에는 MAUI jsbridge example에서 코드를 대부분 가져왔기에 이 글에서는 핵심적인 부분을 추리고 실제로 적용했던 기능들도 표현할 수 있도록 흐름 위주의 설명을 해보려 한다. 쉬운 이해를 위해 변수명은 일부 변경했다.
📌 만들어볼 것들
- 웹뷰를 눌러서 모바일 페이지로 열기
- 웹뷰를 눌러서 모바일 페이지에 데이터(페이지 제목)를 전달하기
📌 구현 기술 스택
- .NET7 MAUI
- React 17
📌 나는 프론트이긴 한데.. 대부분의 코드는 모바일, 그것도 크로스 플랫폼이라 구현의 흐름에 초점을 맞췄다.
구현 시작!
HybridWebView라는, 커스텀 웹뷰를 만든다. 이 웹뷰에서는 Delegate를 사용해서 함수를 먼저 정의하고 ⇒ 추후에 그 함수가 뭘 하는것인지 정할 수 있다. (크게 중요한 부분은 아니다)
public class HybridWebView : WebView, IHybridWebView
{
public event EventHandler<SourceChangedEventArgs> SourceChanged;
public event EventHandler<JavaScriptActionEventArgs> JavaScriptAction;
public event EventHandler<EvaluateJavaScriptAsyncRequest> RequestEvaluateJavaScript;
public HybridWebView(){}
public async Task EvaluateJavaScriptAsync(EvaluateJavaScriptAsyncRequest request)
{
await Task.Run(() => RequestEvaluateJavaScript?.Invoke(this, request));
}
public void InvokeAction(string data)
{
JavaScriptAction?.Invoke(this, new JavaScriptActionEventArgs(data));
}
//...
}
MAUI는 크로스 플랫폼이기 때문에 각 OS(안드로이드, iOS, Windows.. )에서 Platform-Specific한 구현이 필요할 수 있다. 이럴 때 OS별로 커스텀한 동작을 지원하는 것를 “핸들러” 라고 한다. 큰 맥락은 다 비슷하기에 이 글에서는 안드로이드를 구현한다.

핸들러는 이렇게 앱이 시작할 때 등록해주어야 하며, HybridWebView일 때 HybridWebViewHandler를 쓰도록 설정한다.
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureMauiHandlers(handlers =>
{
handlers.AddHandler(typeof(HybridWebView), typeof(HybridWebViewHandler));
});
;
JavascriptWebViewClient라는 WebViewClient를 만들었다. 함수를 오버라이딩하여 페이지에 접속하면 우리가 원하는 JS를 실행할 것이다.
public class JavascriptWebViewClient : WebViewClient
{
string _javascript;
public JavascriptWebViewClient(string javascript)
{
_javascript = javascript;
}
public override void OnPageStarted(WebView view, string url, Bitmap favicon)
{
base.OnPageStarted(view, url, favicon);
view.EvaluateJavascript(_javascript, null);
}
}
그렇다면 그 “우리가 원하는 JS”란 무엇일까? 그것을 이제 만들어보겠다.
InvokeCSharpAction이라는 이름의 string 값을 정의한다. 내용물은 invokeCSharpAction이라는 이름의 JS 함수다.
const string InvokeCSharpAction = "function invokeCSharpAction(data){jsBridge.invokeAction(data);}";
이제 안드로이드 네이티브 성질을 갖는 JSBridge 클래스를 생성한다. 다른 내용은 그러려니.. 보되, data를 받아 실행하는 함수를 invokeAction 이라는 이름으로 내보내는(Export) 부분을 살펴보자.
invokeCSharpAction 함수 내부에서, 받은 데이터로 jsBridge를 실행하던 코드의 함수명임을 알 수 있다. jsBridge.invokeAction(data)
public class JSBridge : Java.Lang.Object
{
//...
[JavascriptInterface]
[Export("invokeAction")]
public void InvokeAction(string data)
{
HybridWebViewHandler hybridRenderer;
if (hybridWebViewRenderer != null && hybridWebViewRenderer.TryGetTarget(out hybridRenderer))
{
hybridRenderer.VirtualView.InvokeAction(data);
}
}
}
웹뷰를 생성할 때 앞서 만든 JavascriptWebViewClient를 사용할 WebViewClient로 설정하여, 함수로 동작할 InvokeCSharpAction를 함께 집어넣는다.
또한, JavascriptInterface 로 JSBridge를 사용함을 선언한다.
public class HybridWebViewHandler : ViewHandler<IHybridWebView, Android.Webkit.WebView>
{
//...
protected override WebView CreatePlatformView()
{
//...
webView.SetWebViewClient(new JavascriptWebViewClient($"javascript: {InvokeCSharpAction}"));
jsBridgeHandler = new JSBridge(this);
webView.AddJavascriptInterface(jsBridgeHandler, "jsBridge");
return webView;
}
}
📌 모바일에서 웹의 기능들을 사용하기 위해서는 이런 설정을 추가해줘야 한다. 아마 다른 모바일 개발 시에도 특정 설정들을 정의할 필요가 있을 것으로 보인다.
webView.Settings.JavaScriptEnabled = true;
webView.Settings.DomStorageEnabled = true;
그렇다면 이제 처음이자 마지막일 리액트 부분을 보도록 하자.
navigateOnApp이라는 함수를 통해 invokeCSharpAction 함수가 존재하는 경우 해당 함수로 데이터를 전달하여 실행한다.
invokeCSharpAction은 아까 모바일 쪽에서 단순 문자열로 선언했던 안에 있던 함수명이다.
export function navigateOnApp({ to, title = null }: dataProps) {
// @ts-ignore
if (typeof invokeCSharpAction === "function") {
// @ts-ignore
invokeCSharpAction(
JSON.stringify({ to, title })
);
}
}
여기에서 슬픈 건 불가피하게 ts-ignore을 사용할 수밖에 었었다는 점이다..ㅠㅠ
뭔가 설정을 잘 하면 안써도 되었을것같긴한데.. 가지고 있는 지식으로는 알 수가 없었다. 언젠가 해결하길 바라며.. 🫠
그리고 리액트에서 실제로 사용하는 방법은 이렇다.
웹뷰의 글쓰기 버튼을 눌렀을 때,
- 웹뷰인 경우에는 ⇒ 앱으로 데이터 전달하는 함수 실행,
- 아닌 경우에는 ⇒ 일반적인 navigate를 수행한다.
const onWriteClick = () => {
if (IsWebView()) {
navigateOnApp({
to: `/write`,
title: "글쓰기",
});
} else {
navigate(`/write`);
}
};

어떤 식으로 다리들이 연결되는지 그림을 그려봤는데, React ↔ MAUI ↔ Native 서로서로 다리가 놓여져 있어서 꽤 복잡해 보인다.
아무튼 이제 마지막으로 이 웹뷰를 사용해 보자.
문제의 웹뷰 페이지 BoardPage에서, UI 구성을 나타내는 BoardPage.xaml에는 앞서 만들었던 HybridWebView라는 컨트롤을 사용해서 웹뷰를 나타낸다.
<control:HybridWebView x:Name="JSBridgeWebView" Source="{Binding Source}" />
코드 비하인드인 BoardPage.xaml.cs에서는,
페이지를 초기화할 때, 처음에 만들어놓았던 HybridWebView의 JavaScriptAction이 어떤 동작을 하도록 할 지 이제야 추가한다. 데이터를 받아서 새로운 네이티브 페이지를 열고 있다.
public partial class BoardPage : ContentPage
{
public BoardPage()
{
InitializeComponent();
//...
JSBridgeWebView.JavaScriptAction += WebView_JavaScriptAction;
}
public BoardPage(string path = "/board", string title = null)
{
InitializeComponent();
//...
JSBridgeWebView.JavaScriptAction += WebView_JavaScriptAction;
}
private void WebView_JavaScriptAction(object sender, Controls.JavaScriptActionEventArgs e)
{
Dispatcher.Dispatch(async () =>
{
var payload = JsonSerializer.Deserialize<Props>(e.Payload);
Console.WriteLine("The Web Button Was Clicked! " + payload);
await Navigation.PushAsync(new BoardPage(payload.to, payload.title));
});
}
}
맺으며
- 크로스플랫폼, 그리고 국내에는 자료가 적은.. ^^ MAUI 구현방법이 국내 MAUI 개발자들에게 조금이나마 선택지가 되길 바라며 작성했다.
- 예상외로 웹 쪽에서는 크게 작업할 일이 없어서, 만약 추후에 모바일 개발자가 따로 있다면 데이터 모델만 잘 협의하면 지금보다는 편하게(?) 개발할 수 있겠다는 생각이 들었다.
- 다만 그와는 별개로 웹뷰는 생각보다 어렵고, 특히 웹뷰-모바일간의 흐름에 대해서 고민할 것들이 많겠다는 깨달음을 얻었다!