템플릿으로는 나와있지 않다보니 간과하기 쉬운 주제가 하나 있는데, 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에 관한 고민을 하고 계신 분들께 도움이 될 수 있을거라 생각하여 간단한 코드 구현 과정을 공유드렸습니다.