[WPF]MS DI, CommunityToolkit.Mvvm 활용 간단한 뷰/비즈니스로직 분리 구조

통상적으로 사용되던 MVVM 구현 방식은 View의 느슨한 결합을 달성하기 위해서 수많은 양의 보일러플레이트 코드가 발생하고,(Command, Converter 등등…) 러닝커브가 상당히 높고 직관성이 떨어지는 문제가 있다고 느꼈습니다. 그러다보니 복잡성을 줄이기 위해 타사 MVVM 라이브러리/프레임워크에 의존하는 경향도 큰거같구요.

블레이저로 개발하면서 아키텍처가 상당히 깔끔하다고 느꼈고, 이를 최대한 WPF에 녹여보고 싶었습니다.

프로젝트 구조(.NET 8)

image

Services.cs

namespace WpfSimpleLooseCoupling.Services;

public class ItemDataService
{
    readonly List<Item> data;
    public ItemDataService()
    {
        data = [
            new() { Content = "1" },
            new() { Content = "2" },
            new() { Content = "3" },
        ];
    }
    public void Insert(Item item)
    {
        data.Add(item);
    }

    public IEnumerable<Item> Get()
    {
        return data;
    }

    public void Delete(Item item)
    {
        data.Remove(item);
    }

    public void Update(Item item)
    {
        var index = data.FindIndex(i => i.Id == item.Id);
        if (index != -1)
        {
            data[index] = item;
        }
        else
        {
            Insert(item);
        }
    }
}

public class Item
{
    public string Id { get; set; } = Guid.NewGuid().ToString();
    public string? Content { get; set; }
}

public enum DataState
{
    Unchanged, New, Modify, Delete
}

public class ItemService
{
    readonly ItemDataService dataService;
    public Item[] Items { get; private set; } = [];
    public Dictionary<Item, DataState> ItemChanges { get; private set; } = [];
    public ItemService(ItemDataService dataService)
    {
        this.dataService = dataService;
    }
    public void Load()
    {
        Items = dataService.Get().ToArray();
        ItemChanges.Clear();
    }

    public void SaveChanges()
    {
        foreach (var (item, state) in ItemChanges)
        {
            switch (state)
            {
                case DataState.New:
                    dataService.Insert(item);
                    break;
                case DataState.Modify:
                    dataService.Update(item);
                    break;
                case DataState.Delete:
                    dataService.Delete(item);
                    break;
            }
        }
        Load();
    }

    public Item CreateNewItem() => new Item();

    public void Update(Item item)
    {
        if (Items.Contains(item))
        {
            ItemChanges[item] = DataState.Modify;
        }
        else
        {
            ItemChanges[item] = DataState.New;
        }
    }

    public void Remove(Item item)
    {
        if (ItemChanges.TryGetValue(item, out var state) && state == DataState.New)
        {
            ItemChanges.Remove(item);
        }
        else if (Items.Contains(item))
        {
            ItemChanges[item] = DataState.Delete;
        }
    }

    public void DiscardChanges()
    {
        ItemChanges.Clear();
    }
}

최대한 짧게 만드려다 보니 한 파일에 우겨넣었습니다. ㅎㅎ
데이터 서비스, 특정 비즈니스로직 실행을 위한 서비스 작성했습니다.

App.xaml.cs

using System.Windows;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using WpfSimpleLooseCoupling.Services;
namespace WpfSimpleLooseCoupling;
public partial class App : Application
{
    public IServiceProvider Services { get; }
    public IConfiguration Configuration { get; }
    public new static App Current => (App)Application.Current;

    public App()
    {
        var builder = Host.CreateApplicationBuilder();
        builder.Services.AddSingleton<ItemDataService>();
        builder.Services.AddTransient<ItemService>();
        var host = builder.Build();
        Services = host.Services;
        Configuration = host.Services.GetRequiredService<IConfiguration>();
        host.Start();
    }
}

서비스를 등록합니다.

MainWindow.xaml

상단 d:DataContext=“{Binding RelativeSource={RelativeSource Self}}”
디자인타임에 적용되는 DataContext 힌트입니다.(설정 안하면 바인딩할때 인텔리센스 도움을 못받습니다)

<Window x:Class="WpfSimpleLooseCoupling.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfSimpleLooseCoupling"
	    d:DataContext="{Binding RelativeSource={RelativeSource Self}}"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800" Loaded="Window_Loaded">
	<Grid>
		<Grid.RowDefinitions>
			<RowDefinition Height="auto" />
			<RowDefinition />
			<RowDefinition Height="auto" />
		</Grid.RowDefinitions>
		<StackPanel Grid.Row="0" Orientation="Horizontal">
			<Button Click="SaveButton_Click">저장</Button>
			<Button Click="DiscardButton_Click">취소</Button>
		</StackPanel>
		<ListView ItemsSource="{Binding Items}" Grid.Row="1" SelectedItem="{Binding SelectedItem}">
			<ListView.View>
				<GridView>
					<GridViewColumn Width="auto">
						<GridViewColumnHeader>
							<Button Click="AddButton_Click">추가</Button>
						</GridViewColumnHeader>
						<GridViewColumn.CellTemplate>
							<DataTemplate></DataTemplate>
						</GridViewColumn.CellTemplate>
					</GridViewColumn>
					<GridViewColumn Header="Id" DisplayMemberBinding="{Binding Item.Id}" Width="500" />
					<GridViewColumn Header="내용" DisplayMemberBinding="{Binding Item.Content}" Width="100" />
					<GridViewColumn Header="상태" Width="100" >
						<GridViewColumn.CellTemplate>
							<DataTemplate>
								<TextBlock Text="{Binding DataStateView.State}" FontWeight="Bold" Foreground="{Binding DataStateView.CellColor}" />
							</DataTemplate>
						</GridViewColumn.CellTemplate>
					</GridViewColumn>
				</GridView>
			</ListView.View>
		</ListView>
		<GroupBox Header="에디터" Grid.Row="2" Visibility="{Binding EditorVisibility}">
			<Grid>
				<StackPanel>
					<Grid>
						<Grid.ColumnDefinitions>
							<ColumnDefinition Width="auto" />
							<ColumnDefinition />
						</Grid.ColumnDefinitions>
						<TextBlock Grid.Column="0" Text="Id" />
						<TextBox Grid.Column="1" Text="{Binding EditingItem.Item.Id}" />
					</Grid>
					<Grid>
						<Grid.ColumnDefinitions>
							<ColumnDefinition Width="auto" />
							<ColumnDefinition />
						</Grid.ColumnDefinitions>
						<TextBlock Grid.Column="0" Text="내용" />
						<TextBox Grid.Column="1" Text="{Binding EditingItem.Item.Content}" />
					</Grid>
					<StackPanel Orientation="Horizontal">
						<Button Click="ItemUpdateButton_Click">적용</Button>
						<Button Click="ItemDeleteButton_Click">삭제</Button>
						<Button Click="ItemEditCancelButton_Click">닫기</Button>
					</StackPanel>
				</StackPanel>
			</Grid>
		</GroupBox>
	</Grid>
</Window>

보시다시피 이벤트 그냥 코드비하인드에서 구현하고, 데이터만 바인딩합니다.

MainWindow.xaml.cs

using System.Windows;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.DependencyInjection;
using WpfSimpleLooseCoupling.Services;

namespace WpfSimpleLooseCoupling;
[INotifyPropertyChanged]
public partial class MainWindow : Window
{
    readonly ItemService itemService;
    public MainWindow()
    {
        itemService = App.Current.Services.GetRequiredService<ItemService>(); // 의존 서비스 요청

        InitializeComponent();
        DataContext = this; // 필수~
    }

    // 보여주기용 enumerable.
    public IEnumerable<ItemView> Items => itemService
        .Items.Where(item => !itemService.ItemChanges.ContainsKey(item))
        .Select(item => new ItemView(item, DataState.Unchanged))
        .Concat(itemService.ItemChanges.Select(kv => new ItemView(kv.Key, kv.Value)));

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(EditorVisibility))]
    ItemView? editingItem;

    [ObservableProperty]
    ItemView? selectedItem;
    partial void OnSelectedItemChanged(ItemView? value)
    {
        EditingItem = value;
    }

    public Visibility EditorVisibility => EditingItem != null 
        ? Visibility.Visible
        : Visibility.Collapsed;

    void Refresh()
    {
        itemService.Load();
        OnPropertyChanged(nameof(Items));
    }

    private void Window_Loaded(object sender, RoutedEventArgs e)
    {
        Refresh();
    }

    private void ItemUpdateButton_Click(object sender, RoutedEventArgs e)
    {
        if (EditingItem == null) return;

        itemService.Update(EditingItem.Item);
        OnPropertyChanged(nameof(Items));
        EditingItem = null;
    }

    private void ItemDeleteButton_Click(object sender, RoutedEventArgs e)
    {
        if (EditingItem == null) return;

        itemService.Remove(EditingItem.Item);
        OnPropertyChanged(nameof(Items));
        EditingItem = null;
    }

    private void AddButton_Click(object sender, RoutedEventArgs e)
    {
        EditingItem = new ItemView(itemService.CreateNewItem(), DataState.New);
    }

    private void ItemEditCancelButton_Click(object sender, RoutedEventArgs e)
    {
        EditingItem = null;
    }

    private void SaveButton_Click(object sender, RoutedEventArgs e)
    {
        itemService.SaveChanges();
        Refresh();
    }

    private void DiscardButton_Click(object sender, RoutedEventArgs e)
    {
        EditingItem = null;
        Refresh();
    }

    public class DataStateView
    {
        public DataState DataState { get; }

        public DataStateView(DataState dataState)
        {
            DataState = dataState;
        }

        public Brush CellColor => DataState switch
        {
            DataState.New => Brushes.Blue,
            DataState.Modify => Brushes.Green,
            DataState.Delete => Brushes.Red,
            _ => Brushes.Black
        };

        public string State => DataState switch
        {
            DataState.New => "신규",
            DataState.Modify => "변경",
            DataState.Delete => "삭제",
            _ => "변경없음"
        };
    }

    public class ItemView
    {
        public Item Item { get; }
        public DataStateView DataStateView { get; }
        public ItemView(Item item, DataState dataState)
        {
            Item = item;
            DataStateView = new DataStateView(dataState);
        }
    }
}

바인딩을 하기 위해 INotifyPropertyChanged를 구현해야 하는데,
간결성이 떨어지는 문제를 CommunityToolkit.Mvvm 라이브러리를 사용해 보완했습니다.

View의 코드비하인드는 의존 서비스의 기능을 단순 실행하고 보여주는 창구 역할에 충실하도록 구현하였습니다.

마치며

결국 관심사 분리가 제 1순위이며, 그것이 달성된다면 사실 코드비하인드를 사용하더라도 복잡해질수가 없다는 것이 결론인 것 같습니다.

의견 및 피드백 환영합니다!

12개의 좋아요