WinUI 3 데스크톱 앱의 아키텍처 패턴

이 문서에서는 Windows 앱 SDK 사용하여 빌드된 WinUI 3 데스크톱 앱에 검증된 아키텍처 패턴을 적용하는 방법을 보여 줍니다. 엔터프라이즈 기간 업무(LOB) 시나리오에 맞게 종속성 주입을 설정하고, 구성을 관리하며, 코드를 구조화하는 방법을 알아봅니다.

사전 요구 사항

  • Windows 앱 SDK 1.5 이상
  • .NET 8 이상
  • .NET 데스크톱 개발Windows 애플리케이션 개발 워크로드가 포함된 Visual Studio 2022 버전 17.10 이상

종속성 주입

WinUI 3 데스크톱 앱은 ASP.NET Core 것과 같은 기본 제공 DI(종속성 주입) 컨테이너를 포함하지 않지만 동일한 Microsoft.Extensions.DependencyInjection NuGet 패키지를 사용하여 추가할 수 있습니다. DI를 사용하면 코드를 테스트할 수 있고 느슨하게 결합되며 유지 관리가 더 쉬워집니다.

DI 컨테이너 설정

NuGet 패키지를 설치합니다.

dotnet add package Microsoft.Extensions.DependencyInjection
dotnet add package Microsoft.Extensions.Hosting

App.xaml.cs에서 호스트와 서비스를 구성하세요:

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.UI.Xaml;

public partial class App : Application
{
    public IHost Host { get; }

    public static T GetService<T>() where T : class
    {
        if ((App.Current as App)!.Host.Services.GetService(typeof(T)) is not T service)
        {
            throw new ArgumentException(
                $"{typeof(T)} needs to be registered in ConfigureServices.");
        }
        return service;
    }

    public App()
    {
        InitializeComponent();

        Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
            .UseContentRoot(AppContext.BaseDirectory)
            .ConfigureServices((context, services) =>
            {
                // Services
                services.AddSingleton<INavigationService, NavigationService>();
                services.AddSingleton<IDataService, DataService>();
                services.AddTransient<IDialogService, DialogService>();

                // ViewModels
                services.AddTransient<MainViewModel>();
                services.AddTransient<SettingsViewModel>();

                // Views
                services.AddTransient<MainPage>();
                services.AddTransient<SettingsPage>();
            })
            .Build();
    }
}

비고

서비스를 등록하지 않으면 위의 도우미는 누락된 형식 이름이 포함된 GetService<T>()ArgumentException를 런타임에 발생시킵니다. 앱을 실행하고 개발 중에 각 페이지로 이동하여 모든 등록이 올바른지 확인합니다.

ViewModels에 종속성 삽입

컨테이너가 구성된 상태에서 ViewModels는 생성자 주입을 통해 종속성을 받습니다.

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

public partial class MainViewModel : ObservableObject
{
    private readonly IDataService _dataService;
    private readonly INavigationService _navigationService;

    public MainViewModel(IDataService dataService, INavigationService navigationService)
    {
        _dataService = dataService;
        _navigationService = navigationService;
    }

    [ObservableProperty]
    private string _statusMessage = string.Empty;

    [RelayCommand]
    private async Task LoadDataAsync()
    {
        StatusMessage = "Loading...";
        var items = await _dataService.GetItemsAsync();
        StatusMessage = $"Loaded {items.Count} items";
    }
}

서비스 수명

서비스를 등록할 때 적절한 수명을 선택합니다.

수명 Method 사용 목적
싱글톤 AddSingleton<T>() 탐색, 앱 전체 상태, 캐시
범위 AddScoped<T>() 창별 또는 대화 상자별 컨텍스트
Transient AddTransient<T>() ViewModels, 상태 비저장 서비스

팁 (조언)

각 탐색에서 새 인스턴스를 만들 수 있도록 ViewModels를 임시 로 등록합니다. 앱 전체 상태를 유지하는 서비스를 Singleton으로 등록합니다.

구성 관리

ASP.NET Core 사용되는 것과 동일한 패턴인 데스크톱 앱에서 앱 설정을 관리하는 데 사용합니다Microsoft.Extensions.Configuration.

구성 지원 추가

필요한 패키지를 설치합니다.

dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Options

appsettings.json 프로젝트 루트에 파일을 만듭니다. 솔루션 탐색기에서 파일을 마우스 오른쪽 단추로 클릭하고 속성을 선택한 다음, 출력 디렉터리에 복사새 버전인 경우 복사로 설정합니다. 또는 .csproj의 파일 항목에 <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>를 추가하세요.

{
  "AppSettings": {
    "ApiBaseUrl": "https://api.contoso.com/v2",
    "MaxRetryCount": 3,
    "EnableTelemetry": true
  },
  "Logging": {
    "LogLevel": "Information"
  }
}

DI 설정에서 구성을 바인딩합니다.

.ConfigureServices((context, services) =>
{
    // Bind settings to a strongly-typed class
    services.Configure<AppSettings>(
        context.Configuration.GetSection("AppSettings"));

    // Inject IOptions<AppSettings> into services
    services.AddSingleton<IApiClient, ApiClient>();
})

서비스에서 구성 사용

using Microsoft.Extensions.Options;

public class ApiClient : IApiClient
{
    private readonly AppSettings _settings;
    private readonly HttpClient _httpClient;

    public ApiClient(IOptions<AppSettings> options)
    {
        _settings = options.Value;
        _httpClient = new HttpClient
        {
            BaseAddress = new Uri(_settings.ApiBaseUrl)
        };
    }
}

사용자 설정 지속성

앱 업데이트에서 유지되는 사용자별 설정의 경우(패키지된 앱) 또는 로컬 JSON 파일(패키지되지 않은 앱)을 사용합니다 Windows.Storage.ApplicationData .

public class UserSettingsService : IUserSettingsService
{
    private readonly string _settingsPath;

    public UserSettingsService()
    {
        var localAppData = Environment.GetFolderPath(
            Environment.SpecialFolder.LocalApplicationData);
        _settingsPath = Path.Combine(localAppData, "Contoso", "MyApp", "settings.json");
    }

    public async Task SaveAsync<T>(string key, T value)
    {
        var settings = await LoadAllAsync();
        settings[key] = JsonSerializer.Serialize(value);
        Directory.CreateDirectory(Path.GetDirectoryName(_settingsPath)!);
        await File.WriteAllTextAsync(
            _settingsPath, JsonSerializer.Serialize(settings));
    }
}

비고

패키지된 앱(MSIX)은 간단한 키-값 쌍에 대해 ApplicationData.Current.LocalSettings를 사용할 수 있습니다. 패키지되지 않은 앱은 자체 스토리지 위치를 관리해야 합니다.

기능 표시기

기능 플래그를 구현하여 재배포 없이 점진적 롤아웃 및 A/B 테스트를 사용하도록 설정합니다.

구성이 포함된 로컬 기능 플래그

public interface IFeatureFlagService
{
    bool IsEnabled(string featureName);
}

public class FeatureFlagService : IFeatureFlagService
{
    private readonly Dictionary<string, bool> _flags;

    public FeatureFlagService(IConfiguration configuration)
    {
        _flags = configuration.GetSection("FeatureFlags")
            .Get<Dictionary<string, bool>>() ?? new();
    }

    public bool IsEnabled(string featureName) =>
        _flags.TryGetValue(featureName, out var enabled) && enabled;
}

Azure App Configuration 통합

클라우드 관리 기능 플래그의 경우 Azure App Configuration 사용합니다.

dotnet add package Microsoft.Extensions.Configuration.AzureAppConfiguration
dotnet add package Microsoft.FeatureManagement
using Azure.Identity;

Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder()
    .ConfigureAppConfiguration((context, config) =>
    {
        config.AddAzureAppConfiguration(options =>
        {
            options.Connect(
                    new Uri("https://<your-store>.azconfig.io"),
                    new DefaultAzureCredential())
                .UseFeatureFlags(flagOptions =>
                {
                    flagOptions.CacheExpirationInterval = TimeSpan.FromMinutes(5);
                });
        });
    })
    .ConfigureServices((context, services) =>
    {
        services.AddFeatureManagement(context.Configuration);
    })
    .Build();

비고

로컬 개발의 경우 대신 연결 문자열 DefaultAzureCredential사용할 수 있습니다. 환경 변수 또는 Windows 자격 증명 관리자에 연결 문자열 저장합니다. 원본 제어에는 저장되지 않습니다.

options.Connect(Environment.GetEnvironmentVariable("APP_CONFIG_CONNECTION_STRING"))

팁 (조언)

패키지 수준에서 스토어 기반 점진적 출시는 점진적 패키지 출시를 참조하세요.

엔터프라이즈 및 LOB 패턴

기간계 앱에는 ID, 데이터 보호 및 장치 관리와 관련해 추가 요구 사항이 있습니다.

ID 및 조건부 액세스

엔터프라이즈 인증에 MSAL(Microsoft 인증 라이브러리)을 사용합니다.

services.AddSingleton<IAuthService>(sp =>
{
    var app = PublicClientApplicationBuilder
        .Create("your-client-id")
        .WithAuthority(AzureCloudInstance.AzurePublic, "your-tenant-id")
        .WithRedirectUri("http://localhost")
        .Build();
    return new AuthService(app);
});

중요합니다

http://localhost 리디렉션 URI는 개발에 적합합니다. 프로덕션 데스크톱 앱의 경우 사용자의 Windows 계정과 강력한 토큰 보호를 SSO에 제공하는 WAM(Windows broker)을 대신 사용합니다.

Intune을 통해 배포된 엔터프라이즈 앱은 다음이 필요한 조건부 액세스 정책을 적용할 수 있습니다.

  • 디바이스 준수(암호화, PIN, OS 버전)
  • 다중 요소 인증
  • 네트워크 위치 제한

오프라인 데이터 및 캐싱

데스크톱 LOB 앱은 오프라인으로 작업해야 하는 경우가 많습니다. 로컬 캐싱을 사용하여 리포지토리 패턴을 구현합니다.

public class CachedRepository<T> : IRepository<T> where T : class, IEntity
{
    private readonly IApiClient _apiClient;
    private readonly ILocalDatabase _localDb;

    public async Task<IReadOnlyList<T>> GetAllAsync(bool forceRefresh = false)
    {
        if (!forceRefresh)
        {
            var cached = await _localDb.GetAllAsync<T>();
            if (cached.Any())
                return cached;
        }

        try
        {
            var items = await _apiClient.GetAsync<List<T>>();
            await _localDb.UpsertAllAsync(items);
            return items;
        }
        catch (HttpRequestException)
        {
            // Offline fallback
            return await _localDb.GetAllAsync<T>();
        }
    }
}

데이터 보호

중요한 로컬 데이터를 암호화하는 데 (패키지된 앱) 또는 .NET Windows.Security.Cryptography.DataProtection 사용합니다 DataProtectionProvider .

필요한 패키지를 설치합니다.

dotnet add package Microsoft.AspNetCore.DataProtection.Extensions

그런 다음, DI 컨테이너에 데이터 보호를 등록합니다.

using Microsoft.AspNetCore.DataProtection;

services.AddDataProtection()
    .SetApplicationName("Contoso.LOBApp")
    .ProtectKeysWithDpapi();

계층화된 아키텍처

종속성이 한 방향으로 계속 흐르도록 계층에서 WinUI 3 앱을 구성합니다.

┌─────────────────────────────┐
│   Views (XAML + code-behind)│  ← UI layer, no business logic
├─────────────────────────────┤
│   ViewModels (MVVM Toolkit) │  ← Presentation logic, commands
├─────────────────────────────┤
│   Services / Use Cases      │  ← Business rules, orchestration
├─────────────────────────────┤
│   Repositories / Data       │  ← Data access, API clients, caching
└─────────────────────────────┘

규칙:

  • 각 계층은 바로 아래 계층에만 의존합니다.
  • ViewModels는 UI 형식(Page, Window, ContentDialog)을 참조하지 않습니다.
  • 서비스는 인터페이스를 정의합니다. 구현은 데이터 계층에 있습니다.
  • DI 컨테이너에 모든 계층 간 종속성을 등록합니다.

이전 버전과의 호환성 및 버전 관리

새 버전의 앱을 릴리스하는 경우 다음을 고려합니다.

  • 데이터 마이그레이션: 로컬 데이터베이스 스키마의 버전을 지정합니다. 시작 시 마이그레이션 실행기를 사용하여 이전 스키마에서 현재 스키마로 업그레이드합니다.
  • 설정 마이그레이션: 설정 파일에 스키마 버전을 저장합니다. 로드 시 이전 형식에서 새 형식으로 변환을 적용합니다.
  • 병렬 설치: MSIX는 기본적으로 패키지를 업그레이드합니다. 여러 주 버전을 나란히 실행하려면 디자인 타임에 각 버전에 고유한 패키지 패밀리 이름을 할당합니다.
public class DatabaseMigrator
{
    public async Task MigrateAsync(SqliteConnection db)
    {
        var currentVersion = await GetSchemaVersionAsync(db);

        if (currentVersion < 2)
            await ApplyMigration_v2(db);
        if (currentVersion < 3)
            await ApplyMigration_v3(db);

        await SetSchemaVersionAsync(db, LatestVersion);
    }
}

설치 확인

앱을 실행하고 각 페이지로 이동하여 서비스가 올바르게 해결되는지 확인합니다. 서비스가 등록되지 않은 경우 런타임에 누락된 InvalidOperationException 형식 이름이 표시됩니다. 다음을 확인해주세요.

  • 구성 값은 appsettings.json에서 로드됩니다(디버거에서 바인딩된 속성을 확인하세요).
  • 기능 플래그는 예상대로 평가됩니다(플래그 토글 및 다시 시작).
  • 오프라인 캐싱은 네트워크를 사용할 수 없는 경우 데이터를 반환합니다.