Blazor Hybrid + Windows Forms/WPF로 바이브 코딩 친화적인 UI 현대화 시도하기

템플릿으로는 나와있지 않다보니 간과하기 쉬운 주제가 하나 있는데, Blazor Hybrid가 Windows Forms와 WPF에서 모두 사용이 가능합니다.

이번 아티클에서는 Windows Forms에 DI/IoC 패턴을 입히고, 동일한 컨테이너로 Blazor Hybrid까지 적용해서 Blazor와 Windows 컨테이너 앱이 동일 서비스를 사용하고, 서로 상호 작용할 수 있도록 만드는 방법을 간단히 알아보겠습니다. 참고로 같은 기능을 WPF로도 구현할 수 있습니다.

프로젝트 만들기

프로젝트를 만드는 것 자체는 보통의 Windows Forms나 WPF 프로젝트로 만들기 시작하시면 되겠습니다. 그 다음, SDK를 Blazor SDK로 바꾸기만 하면 손쉽게 전환이 완료됩니다.

<Project Sdk="Microsoft.NET.Sdk.Razor">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net9.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebView.WindowsForms" Version="9.0.70" />
    <PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3296.44" />
    <PackageReference Include="OswaldTechnologies.Extensions.Hosting.WindowsFormsLifetime" Version="1.2.0" />
  </ItemGroup>

</Project>

그리고 Oswaldtechnologies.Extensions.Hosting.WindowsFormsLifetime nuget 패키지를 이용하여 Windows Forms 애플리케이션의 진입점 부분부터 DI/IoC 컨테이너를 사용하도록 구성할 수 있으며, 또한 같은 컨테이너를 Blazor 애플리케이션에까지 전달하도록 하여 동일 서비스 사용 + 상호운용성을 달성하기 위해 이 패키지가 필요합니다.

프로젝트에 wwwroot 폴더 만들기

여느 웹 프로젝트처럼 이제 wwwroot 폴더를 프로젝트에 만들고 여기에 HTML 콘텐츠, CSS, 자바스크립트를 추가할 수 있습니다. 제 경우에는 GitHub Copilot과 Claude 4 Sonnet을 이용하여 디자인한 CSS 스타일 시트와 index.html 페이지를 넣었습니다.

wwwroot/css/app.css 파일

/* Apple App Store Inspired Design */
html, body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    margin: 0;
    padding: 1rem;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    min-height: 100vh;
    color: #333;
    box-sizing: border-box;
}

/* Main Container */
.app-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 3rem;
    background: rgba(255, 255, 255, 0.95);
    backdrop-filter: blur(20px);
    border-radius: 20px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.1);
    margin-top: 3rem;
    margin-bottom: 3rem;
    margin-left: 2rem;
    margin-right: 2rem;
}

/* Hero Section */
.hero-section {
    text-align: center;
    padding: 4rem 2rem;
    background: linear-gradient(135deg, #FF6B6B, #4ECDC4, #45B7D1, #96CEB4, #FFEAA7);
    background-size: 300% 300%;
    animation: gradientShift 8s ease infinite;
    border-radius: 20px;
    margin-bottom: 4rem;
    color: white;
}

@keyframes gradientShift {
    0% { background-position: 0% 50%; }
    50% { background-position: 100% 50%; }
    100% { background-position: 0% 50%; }
}

h1 {
    font-size: 3.5rem;
    font-weight: 700;
    margin: 0;
    text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
    letter-spacing: -0.02em;
}

h1:focus {
    outline: none;
}

/* Cards Layout */
.cards-container {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
    gap: 2.5rem;
    margin: 3rem 0;
    padding: 0 1rem;
}

.feature-card {
    background: linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.7));
    border-radius: 20px;
    padding: 2.5rem;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
    transition: all 0.3s ease;
    border: 1px solid rgba(255, 255, 255, 0.2);
    backdrop-filter: blur(10px);
    animation: slideInUp 0.8s ease-out;
    margin: 0.5rem;
}

.feature-card:hover {
    transform: translateY(-10px);
    box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}

.card-icon {
    width: 60px;
    height: 60px;
    border-radius: 15px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 2rem;
    margin-bottom: 1rem;
    box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}

.card-icon.counter {
    background: linear-gradient(135deg, #FF6B6B, #FF8E53);
}

.card-icon.service {
    background: linear-gradient(135deg, #4ECDC4, #44A08D);
}

.card-icon.info {
    background: linear-gradient(135deg, #667eea, #764ba2);
}

.card-title {
    font-size: 1.5rem;
    font-weight: 600;
    margin: 1rem 0 0.5rem 0;
    color: #333;
}

.card-content {
    font-size: 1.1rem;
    color: #666;
    line-height: 1.6;
}

.btn-primary {
    background: linear-gradient(135deg, #667eea, #764ba2);
    border: none;
    border-radius: 50px;
    padding: 1rem 2rem;
    font-size: 1.1rem;
    font-weight: 600;
    color: white;
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 10px 25px rgba(102, 126, 234, 0.3);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.btn-primary:hover {
    transform: translateY(-3px);
    box-shadow: 0 15px 35px rgba(102, 126, 234, 0.4);
    background: linear-gradient(135deg, #764ba2, #667eea);
}

.btn-primary:active {
    transform: translateY(-1px);
    box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}

a, .btn-link {
    color: #667eea;
    text-decoration: none;
    transition: color 0.3s ease;
}

a:hover, .btn-link:hover {
    color: #764ba2;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid red;
}

.validation-message {
    color: red;
}

#blazor-error-ui {
    background: lightyellow;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1000;
}

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: 0.75rem;
        top: 0.5rem;
    }

/* Responsive Design */
@media (max-width: 768px) {
    .app-container {
        margin: 1.5rem;
        padding: 2rem;
    }
    
    h1 {
        font-size: 2.5rem;
    }
    
    .hero-section {
        padding: 3rem 1.5rem;
        margin-bottom: 3rem;
    }
    
    .cards-container {
        grid-template-columns: 1fr;
        gap: 2rem;
        margin: 2rem 0;
        padding: 0 0.5rem;
    }
    
    .feature-card {
        padding: 2rem;
        margin: 0.25rem;
    }
}

/* Additional Animations */
.feature-card {
    animation: slideInUp 0.8s ease-out;
}

.feature-card:nth-child(1) { animation-delay: 0.1s; }
.feature-card:nth-child(2) { animation-delay: 0.2s; }
.feature-card:nth-child(3) { animation-delay: 0.3s; }

@keyframes slideInUp {
    from {
        opacity: 0;
        transform: translateY(30px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

/* Loading Animation */
.hero-section h1 {
    animation: fadeInDown 1s ease-out;
}

@keyframes fadeInDown {
    from {
        opacity: 0;
        transform: translateY(-30px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

/* Enhanced Interactive Elements */
.card-content strong {
    font-size: 1.2em;
    text-shadow: 0 1px 3px rgba(255, 107, 107, 0.3);
}

/* Glassmorphism Effect Enhancement */
.app-container::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: linear-gradient(45deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05));
    border-radius: 20px;
    pointer-events: none;
}

/* Custom Scrollbar - Improved Design */
::-webkit-scrollbar {
    width: 12px;
    background: rgba(0, 0, 0, 0.05);
}

::-webkit-scrollbar-track {
    background: rgba(255, 255, 255, 0.3);
    border-radius: 10px;
    margin: 5px;
    border: 2px solid rgba(255, 255, 255, 0.1);
}

::-webkit-scrollbar-thumb {
    background: linear-gradient(135deg, #667eea, #764ba2);
    border-radius: 10px;
    border: 2px solid rgba(255, 255, 255, 0.3);
    box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
}

::-webkit-scrollbar-thumb:hover {
    background: linear-gradient(135deg, #764ba2, #667eea);
    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.5);
    transform: scale(1.05);
}

::-webkit-scrollbar-corner {
    background: rgba(255, 255, 255, 0.1);
}

wwwroot/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WinFormsBlazor</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="WinFormsBlazor.styles.css" rel="stylesheet" />
</head>

<body>

    <div id="app">Loading...</div>

    <div id="blazor-error-ui" data-nosnippet>
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>

    <script src="_framework/blazor.webview.js"></script>

</body>

</html>

_Imports.razor 페이지 추가하기

Blazor의 경우 Windows Forms와는 다른 코드 컴파일 단위를 가지므로 해당 코드 컴파일 단위에서 네임스페이스 참조를 간소화할 수 있도록 돕는 _Imports.razor 페이지를 따로 추가하는 것이 편리합니다.

@using Microsoft.AspNetCore.Components.Web

Razor 페이지 추가하기

적절한 위치에 Razor 페이지를 추가하면 자동으로 SDK에 의하여 적절한 컴파일/빌드 과정을 거치게 됩니다. 제 경우에는 프로젝트 디렉터리 아래에 Pages 폴더를 만들고 여기서 Start.razor 페이지 코드를 다음과 같이 만들었습니다.

@using TableCloth.Services

@inject SampleService Service
@inject MainForm MainWindow

<div class="app-container">
    <!-- Hero Section -->
    <div class="hero-section">
        <h1>TableCloth</h1>
        <p style="font-size: 1.3rem; margin: 1rem 0; opacity: 0.9;">
            현대적이고 아름다운 데스크톱 애플리케이션
        </p>
    </div>

    <!-- Feature Cards -->
    <div class="cards-container">
        <!-- Counter Card -->
        <div class="feature-card">
            <div class="card-icon counter">
                🔢
            </div>
            <h3 class="card-title">인터랙티브 카운터</h3>
            <div class="card-content">
                <p>현재 카운트: <strong style="color: #FF6B6B;">@currentCount</strong></p>
                <button class="btn btn-primary" @onclick="IncrementCount">카운트 증가</button>
            </div>
        </div>

        <!-- Service Test Card -->
        <div class="feature-card">
            <div class="card-icon service">
                ⚡
            </div>
            <h3 class="card-title">서비스 테스트</h3>
            <div class="card-content">
                <p>서비스 호출 결과:</p>
                <p style="font-size: 1.5rem; font-weight: 600; color: #4ECDC4;">
                    @(Service.Test(100, 200))
                </p>
            </div>
        </div>

        <!-- System Info Card -->
        <div class="feature-card">
            <div class="card-icon info">
                ℹ️
            </div>
            <h3 class="card-title">시스템 정보</h3>
            <div class="card-content">
                <p>메인 폼 타입:</p>
                <p style="font-family: 'Courier New', monospace; background: rgba(102, 126, 234, 0.1); padding: 0.5rem; border-radius: 8px; font-size: 0.9rem;">
                    @(typeof(MainForm).FullName)
                </p>
            </div>
        </div>
    </div>

    <!-- Additional Features Section -->
    <div style="text-align: center; margin-top: 3rem; padding: 2rem; background: linear-gradient(135deg, rgba(255, 107, 107, 0.1), rgba(78, 205, 196, 0.1)); border-radius: 15px;">
        <h2 style="color: #333; margin-bottom: 1rem;">더 많은 기능이 곧 추가됩니다!</h2>
        <p style="color: #666; font-size: 1.1rem;">TableCloth는 계속 발전하는 애플리케이션입니다.</p>
    </div>
</div>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

@{
    MainWindow.Size = new Size(800, 600);
}

샘플 서비스 추가해보기

Windows Forms 영역과 Blazor 영역이 동일 서비스를 사용하는 것을 테스트해볼 목적으로 샘플 서비스를 추가해보려 합니다.

namespace TableCloth.Services;

public sealed class SampleService
{
    public string Test(int a, int b)
    {
        return $"{a + b}";
    }
}

MainForm 구성

여기서부터가 중요한 부분인데, Blazor 화면으로 사용할 컴포넌트와 서비스 컨테이너를 동일하게 설정해야 합니다. 보통의 Windows Forms가 아닌 DI/IoC 기반 Windows Forms를 사용하고, 디자이너에 의존하지 않을 것이므로 Visual Studio 디자이너와 연결하지 않도록 어트리뷰트까지 지정해주었습니다.

using Microsoft.AspNetCore.Components.WebView.WindowsForms;
using System.ComponentModel;
using TableCloth.Pages;

[DesignerCategory("")]
public sealed class MainForm : Form
{
    public MainForm(IServiceProvider services)
    {
        SuspendLayout();
        
        _webView = new BlazorWebView()
        {
            Parent = this,
            Dock = DockStyle.Fill,
            HostPage = @"wwwroot\index.html",
            Services = services,
        };
        _webView.RootComponents.Add<Start>("#app");

        ResumeLayout(false);
    }

    private readonly BlazorWebView _webView = default!;
}

Program.cs

마지막으로 진입점 코드는 다음과 같이 작성했습니다.

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TableCloth.Services;
using WindowsFormsLifetime;

var builder = Host.CreateApplicationBuilder(args);

// Logging
builder.Services.AddLogging(o =>
{
    o.AddConsole();
});

// Blazor Hybrid
builder.Services.AddWindowsFormsBlazorWebView();

// Windows Forms
builder.Services.AddWindowsFormsLifetime<MainForm>(o =>
{
    o.EnableVisualStyles = true;
    o.CompatibleTextRenderingDefault = false;
    o.HighDpiMode = HighDpiMode.PerMonitorV2;
});

// Services
builder.Services.AddSingleton<SampleService>();

var app = builder.Build();
app.Run();

마무리

이렇게 프로젝트를 구성한 후에는 Blazor 페이지에서만 UI 작업을 진행하면 되므로 Blazor 기반 바이브코딩의 이점을 극대화할 수 있고, 디자인 협업에 있어서도 큰 강점이 됩니다.

무엇보다도 좋은 것은 이 전체 구성을 단일 R2R 패키지로도 내보낼 수 있어서, 애플리케이션의 크기가 다소 비대해질 수는 있지만 Electron을 래핑하는 것보다 훨씬 정교하고 완성도 높은 하이브리드 웹 앱을 구현할 수 있다는 점이 무척 매력적입니다.

UI에 관한 고민을 하고 계신 분들께 도움이 될 수 있을거라 생각하여 간단한 코드 구현 과정을 공유드렸습니다. :smiley:

관련 자료

12개의 좋아요

매우 좋은 시도인 것 같습니다.

다만, 플랫폼 확장성을 개선할 수 있도록 약간의 수정이 필요한 것 같습니다.

  1. 윈폼 참조 제거

MainForm 을 참조하는 코드 때문에 다른 플렛폼(WPF, MAUI, 서버, 웹어셈블리)에서 사용하지 못하거나, 앱이 무거워질 우려가 있습니다.

  1. Router 추가

페이지 요소 사이의 네비게이션을 가능하게 하는 </Router> 요소가 빠져 있습니다.
결과적으로 현재 앱은 단일 페이지 요소만 지원하므로, 멀티 페이지로 개발하는 블레이저와는 맞지 않는 것 같습니다.

  1. Razor Class Libarary

닷넷은 호스팅 방식에 따라, 세 가지 블레이저를 제공합니다.

  • 브라우저
    블레이저 웸어셈블리 스탠드얼론이 여기에 해당합니다.
 <script src="_framework/blazor.webassembly.js"></script>
  • 웹뷰
    MAUI, WPF, 윔폼이 여기에 해당합니다.
 <script src="_framework/blazor.webview.js"></script>
  • Asp Net Core (서버)
    블레이저 웹앱이 여기에 해당합니다.
 <script src="_framework/blazor.web.js"></script>

이는 각 호스팅 방식에 따라 위 스트립트 태그를 포함하는 index.html 을 앱(WPF, 윈폼 등) 프로젝트에 두고, <Router/> 를 포함한 레이저 컴포넌트와 css, js 들은 RCL 프로젝트에 두는 것이 좋습니다.
이렇게 분리하는 구조는 Photino 같은 제 3자 블레이저 기반 프레임워크에서도 사용할 수 있어, 확장성을 증대 시킵니다.

개인적으로 블레이저는 정말 싱글 코드 멀티 플랫폼을 구현한 유일의 production ready 한 UI 프레임워크인 것 같습니다.

특히, 고성능 렌더링이 필요한 경우, WPF 블레이저 하이브리드를 선택하여, 고성능 UI 창만 xaml 로 나머지는 블레이저를 선택하면, 아래의 목표는 좀 더 쉽게 달성될 수 있을 것 같습니다.

7개의 좋아요

맞습니다. 일단 기술적으로 어디까지 가능한지를 알아보려는 PoC가 주 목표였기 때문에 제안해주신 내용을 고려하진 않았습니다.

이제 여기서 고도화를 할 때 말씀주신 내용이 큰 도움이 될 것이라 생각합니다. :+1:

7개의 좋아요