유니버셜 플랫폼 콘트롤

블레이저에 관한 문서를 보면, 약간 헷갈리게 해놓은 부분이 있습니다.

가령, 자바 스트립트와 C# 코드 간의 협업 같은 것 말이죠.

ASP.NET Core Blazor JavaScript interoperability (JS interop) | Microsoft Learn

마치 일반적으로 사용할 수 있는 것처럼 글을 작성해 놓았지만, 블레이저의 호스팅 모델 별로 제약 사항이 많아 막상 적용하려면 안되는 경우가 많습니다.

전부 다 나열하면 머리 아프지만, 가장 헷갈리는 차이를 보이는 MAUI 와 블레이저 WASM에 대해 끄적여 보겠습니다.

마이크로 월드 : BlazorWebView

마우이 블레이저 앱에서는 블레이저는 블레이저웹뷰 콘트롤에 호스팅됩니다.

쉽게 말하면, 마우이 입장에서는 웹뷰는 일개 콘트롤에 지나지 않고 블레이저는 그 콘트롤 내부에 존재하는 마이크로 월드라 할 수 있습니다.

네이티브 앱 (앱 컨텍스트) => MauiApp => Main 페이지 => BlazorWebView 콘트롤 => 레이저 요소 (블레이저 컨텍스트)

블레이저 웹뷰는 자바 스크립트를 실행할 수 있지만, 그건 어디까지나 블레이저 컨텍스트에 한정된 얘기입니다. 당연한 얘기 같지만, 자바 스트립트 협업 측면에서는 매우 중요한 의미가 숨겨져 있습니다.

마우이 블레이저 하이브리드 앱에서 닷넷 코드는 팔레스타인이 이스라엘에 의해 찢어진 것처럼 웹뷰 콘트롤은 닷넷 컨텍스트를 마우이 컨텍스트와 블레이저 컨텍스트 두 개로 갈라 놓는다는 점입니다.

이는 블레이저 컨텍스트의 닷넷 코드만 자바 스크립트와 협업할 수 있다는 의미가 됩니다.

불행하게도, 위 링크의 닷넷 문서는 이러한 구조적 한계점을 거의 설명하고 있지 않습니다.

나의 우주 : Blazor Wasm

이에 반해 블레이저 웹 어셈블리는 브라우저의 실행 컨텍스트에서 실행됩니다.

브라우저 => .wasm (블레이저 컨텍스트)

이러한 구조에서는 모든 닷넷 코드가 자바 스크립트와 협업할 수 있습니다.

위 링크의 닷넷 문서는 이 단촐한 컨텍스트 구조를 전제합니다.

물론 블레이저 컨텍스트라도 언제나 자바스크립트 협업을 할 수 있는 것은 아닙니다.
모든 블레이저 호스팅 모델에 적용되는 index.html 파일은 닷넷 코드의 영역이 아니기 때문입니다.

뭐 어찌저찌 해서 index.html 의 자바 스크립트가 블레이저 코드를 호출하게 만들 수는 있지만, 차라리 자바 스크립트 프로젝트를 하는 게 더 좋습니다.

이러한 구조적 차이가 코드 운영에 미치는 영향을 좀 더 자세히 살펴 봅시다.

자바 스크립트 호출이 가능한 장소

블레이저 와즘

브라우저 입장에서는 와즘은 그냥 함수나 똑 같습니다.
웹페이지(.html)와 자바 스크립트 등등이 다운로드되면, 호출되도록 정의된 자바 스크립트를 실행하고, 거기에 와즘 코드가 섞여 있으면 그것도 실행하는 식입니다.

호출되도록 정의된 자바 스크립트에 블레이저 스크립트가 포함되어 있습니다.

즉, 블레이저 스크립트는 최초로 접속했을 때 실행됩니다.
이 최초 접속 프로세스는 아래와 같이 닷넷 코드가 브라우저 리프레시를 유발한 경우에도 실행됩니다.

NavigationManager.NavigateTo("myPage", true);

이는, 화면 리프레시가 일어 날 때마다, 블레이저 와즘 프로젝트의 Program.cs 의 코드의 첫 줄부터 다시 실행됨을 의미합니다.

블레이저 와즘은 그 자체로 닷넷 호스트이고, 그 호스트의 서비스 컨테이너에는 IJSRuntime 이 이미 등록되어 있기에, Program.cs 에서 자바 스크립트를 실행할 수 있습니다.

// Program.cs
        builder.Services.AddComponentsServices();
        ...

        var host = builder.Build();

        CultureInfo? culture = null;

        var js = host.Services.GetRequiredService<IJSRuntime>();
        var value = await js.InvokeAsync<string>("window.localStorage.getItem", "Key");
        if (value != null)
        {
            culture = new(value);
        }
        else
        {
           ...   
        }

        await host.RunAsync();

마우이 블레이저 하이브리드

이에 반해 마우이 프로젝트에서는 BalzorWebView 는 그냥 콘트롤에 지나지 않습니다.
블레이저 코드가 뭐를 실행하건 콘트롤만 찔끔거릴 뿐, 마우이 코드에 미치는 영향은 제로에 가깝습니다.

NavigationManager.NavigateTo("myPage", true);

위 코드에 반응하는 유일한 마우이 코드는 BlazorWebView.OnUrlLoading 뿐입니다.

<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    ...
    <BlazorWebView x:Name="blazorWebView" HostPage="wwwroot/index.html">
        ...
    </BlazorWebView>

</ContentPage>
public partial class MainPage : ContentPage
{
    public MainPage() 
    {  
        InitializeComponent();

        blazorWebView.UrlLoading += BlazorWebView_UrlLoading;
    }

    private void BlazorWebView_UrlLoading(object sender, UrlLoadingEventArgs e)
    {
        var uri = e.Uri;
        // 코드
    }
}

마우이 프로젝트에서 호스트는 MauiApp 이고, 여기에 서비스 컨테이너가 존재합니다.

즉, 위 이벤트 핸들러 내부( //코드)에서 IJSRuntime 을 사용할 수 있습니다.
그러나, 어벙한 점은 정작 자바 스크립트는 호출하지 못한다는 점입니다.

왜냐하면, 저 자리는 웹뷰 컨텍스트 밖이기 때문입니다. (참고로 자바의 웹뷰는 웹뷰 외부에서 자바 스크립트를 호출할 수 있게 해놨습니다)

예제

이 점이 어떤 차이가 있는 지 구체적인 예를 들어 보겠습니다.

//CultureSelector.razor
<div>
    <label for="language-select" ><span class="oi oi-globe"></span></label>
    <select id="language-select" @bind="Culture">
        @foreach (var culture in SupportedCultures)
        {
            <option value="@culture.Value">@culture.Key</option>
        }
    </select>
</div>

@code {
    public const string StorageKey = "BalzorCulture";

    [Inject]
    public NavigationManager NavMan { get; set; } = null!;

    [Inject]
    public IJSRuntime JS { get; set; } = null!;

    private IDisposable? _disposable;
    private CultureInfo? _newCulture;

    protected override void OnInitialized()
    {
        base.OnInitialized();
        _disposable = NavMan.RegisterLocationChangingHandler(SaveCulture);
    }

    private CultureInfo Culture
    {
        get => CultureInfo.CurrentCulture;
        set
        {
            if (CultureInfo.CurrentCulture != value)
            {
                _newCulture = value;
                NavMan.NavigateTo(NavMan.Uri, forceLoad: true);
            }
        }
    }

    public static Dictionary<string, CultureInfo> SupportedCultures = new()
    {
        "English" = new CultureInfo("en-US"),
        "한국어" = new CultureInfo("ko-KR"),
    };

    private ValueTask SaveCulture(LocationChangingContext context)
    {
        if (_newCulture == null)
            return ValueTask.CompletedTask;

        var value = _newCulture.Name;
        return JS.InvokeVoidAsync("window.localStorage.setItem", StorageKey, value);
    }
    ...
}

이 요소는 사용자가 언어를 바꾸면, 브라우저의 로컬 스토리지에 이를 저장하고, 화면을 리프레시 시킵니다. (참고로, 블레이저의 리렌더링과 화면의 리프레시는 다릅니다.)

와즘앱인 경우, 리프레시 직후 Program.cs 에서 자바 스크립트를 통해 로컬 스토리지에 저장된 값을 확인할 수 있습니다.

// Program.cs
CultureInfo? culture = null;

var js = host.Services.GetRequiredService<IJSRuntime>();
var value = await js.InvokeAsync<string>("window.localStorage.getItem", CultureSelector.StorageKey);

if (value != null)
{
   culture = new(value);
}
else
{
   culture = CultureSelector.SupportedCultures.First().Value;
}
...

그러나, 위 코드는 불행하게도, 마우이 하이브리드에서는 전혀 동작하지 않습니다.
BlazorWebView 의 리프레시는 마우이 코드에 방귀 소리도 못 내니까요.

닷넷스럽게

블레이저, 마우이, 자바스크립트… 이런 거 다 떠나서, 위의 코드 구조를 다시 살펴 봅니다.

블레이저 코드가 의존하고 있는 추상(IJSRuntime)은 특정 컨텍스트에 얽메여 있는 객체입니다.
즉, 블레이저 컨텍스트에서만 유효한 추상이라 할 수 있습니다.

이를 모든 닷넷 컨텍스트에서도 유효하도록 다시 정의합니다.

BlazorApp.ILocalStorageService
{
    ValueTask SaveAsync(string key, string value);
}

CultureSelector는 이 서비스에 의존합니다.

@code {

    // [Inject]
    // public IJSRuntime JS { get; set; } = null!;
    [Inject]
    public ILocalStorageService LocalStorageService { get; set; } = null!;
   ...

    private ValueTask SaveCulture(LocationChangingContext context)
    {
        if (_newCulture == null)
            return ValueTask.CompletedTask;

        var value = _newCulture.Name;
        return LocalStorageService.SaveAsync(StorageKey, value);
    }
    ...
}

블레이저 와즘이든 마우이 하이브리드이든, 서비스 컨테이너를 통해 구현을 제공합니다.

블레이저 와즘

//  Program.cs
builder.Services.AddScoped<LocalStorageService, BrowserLocalStorage>();
class BrowserLocalStorage : ILocalStorageService
{
    IJSRuntime _js;
    public LocalStorage(IJSRuntime js) => _js = js;

    public ValueTask SaveAsync(string storageKey, string value)
    {
        return _js.InvokeVoidAsync("window.localStorage.setItem", storageKey, value);
    }
}

블레이저 마우이 하이브리드

// MauiProgram.cs
builder.Services.AddScoped<LocalStorageService, PreferencesStorage>();
class MauiBlazorApp.PreferencesStorage
{
    public ValueTask SaveAsync(string storageKey, string value)
    {
        Preferences.Set(storageKey, value);
        return ValueTask.CompletedTask;
    }
}

블레이저 컨텍스트에서는 로컬 PC의 물리적 공간에 접근하는 것이 매우 제한되지만, 닷넷 컨텍스트에서는 매우 자유롭습니다.

수만 가지 방법 중에, Preferences 클래스가 제공하는 저장소를 선택했습니다.
이 저장소는 마우이가 제공하는 크로스 플랫폼 저장소인데, 브라우저의 로컬 저장소와 매우 흡사합니다.

이제 CultureSelector.razor 는 모바일부터 웹앱까지 사용할 수 있는 진정한 의미의 유니버셜 콘트롤이 되었습니다.

마치며

최근 블레이저와 자바 스크립트 협업이라는 협소한 주제에 빠져 몇 달을 허우적거리다 문득 깨달은 바를 정리했습니다. 프레임워크에 너무 의존하다 보니 기본을 잠시 잊었던 것 같습니다.

물론, 닷넷 문서의 그 시니컬함이 고생을 더하기는 했지만요.

여기까지 읽으신 분들이 섭섭할까봐 문제 나갑니다. ^^

CultureSelector.razor 가 저장한 값은, 와즘앱은 Program.cs 에서 읽어서 시스템 Culture를 변경했습니다. 그러면, 마우이 앱은 어디에서 읽어서 Culture를 변경해야 할까요? ^^

5개의 좋아요