[WPF] 계층 구조를 갖는 메뉴 트리를 동적으로 구성하기

위 게시물에서 이어집니다.

메뉴를 View가 아닌 ViewModel에서 구성해야 할 때, 헤더, 계층 관계, 아이콘 등 ViewModel 내에 있을 필요가 없는 데이터를 ViewModel 내에서 설정하지 않고 외부에서 할당함으로써 계층 구조의 메뉴 트리를 유동적으로 구성하는 여러 가지 방법들에 대해 탐구해 보려고 합니다.

들어가기에 앞서
헤더, 계층 관계, 아이콘 등은 사용자에게 보여지는 것에 필요한 정보로, 제 생각에 MVVM 관점에서는 원칙적으로 ViewModel에 있을 이유가 없다고 생각합니다. 하지만 메뉴를 ItemsSource 바인딩으로 처리한다면 ViewModel assembly 내에서 메뉴 구성에 관한 정보를 할당하는 게 직관적이기도 하고, 메뉴 구성 정보를 별도로 분리할 방법이 마땅찮기도 합니다.

그러나 어쨌든 원칙적으로는 ViewModel과는 관계가 없는 정보일 뿐더러 메뉴 계층을 변경해야 할 일이 있는 경우, 같은 ViewModel을 다른 프로그램에 재사용할 일이 있는 경우 등 메뉴 구성이 변동될 가능성이 있다면 ViewModel 안에서 그러한 정보를 다루기에는 좀 찝찝합니다. (개인적으로는 별로 깔끔해 보이지도 않고요.)

따라서 동적으로 메뉴를 구성하는 방법을 마련해 둔다면 메뉴 구조 변동에 능동적으로 대처할 수 있습니다.

본 slog는 구현 과정에서 별도로 요구되는 경우가 아닌 한 다음의 프로젝트로 구성됩니다.

  • HierarchicalMenu.Common - 구현마다 재사용되거나 그럴 가능성이 있는 클래스가 포함되는 어셈블리
  • HierarchicalMenu.Views - View 어셈블리. 특이사항이 없는 한 모든 구현에 재사용할 예정입니다.
  • HierarchicalMenu.Common.ViewModels - ViewModel이 포함되는 어셈블리. 매 구현마다 변경됩니다.
  • HierarchicalMenu - 진입점 어셈블리. ViewModel이 변경될 때마다 변경됩니다.
4개의 좋아요

1. Attribute와 DI를 이용하는 방법

메뉴 구성 정보는 원칙적으로 로직과 관계가 없으며 ViewModel을 사용자에게 표현하는데 필요한 정보이므로, ViewModel을 설명하는 메타데이터로 간주할 수 있습니다. 따라서 이를 특성으로 분리한 다음 애플리케이션을 시작할 때 reflection으로 특성을 읽어와 메뉴를 구성하는 방식으로 구현을 진행해 보겠습니다.

이 방법에서 사용한 외부 라이브러리는 아래와 같으며, Nuget으로 패키지를 설치할 수 있습니다.

  1. CommunityToolkit.MVVM
  2. Microsoft.Extensions.DependencyInjection

먼저 아이콘 바인딩에 필요한 것들을 만들어 주도록 하겠습니다.

문자열을 리소스 키로 이용하기 위해 View 어셈블리 안에 MarkupExtension을 구현합니다. 이 내용은 stackoverflow의 동일 질문에 대한 답변을 이용했으므로, 해당 답변을 인용하는 것으로 소스코드를 대신합니다.

다만 해당 유저의 구현은 TypeConverter에 대한 처리가 들어있지 않아, TypeConverter를 사용할 경우 소스코드를 일부 수정해야 합니다. 본 구현에서는 enum을 Full Name으로 바인딩하기 위해 TypeConverter를 이용할 예정이므로 해당 답변의 ResourceKeyChanged 메서드를 다음과 같이 수정합니다.

var typeConverter = TypeDescriptor.GetConverter(newVal.Item1.GetType()); // 추가
var value = typeConverter?.ConvertTo(newVal.Item1, typeof(string)) ?? newVal.Item1; // 추가
target.SetResourceReference(dp, value);

이제 바인딩 타입의 TypeConverter가 정의되어 있다면 해당 타입컨버터의 컨버팅을 진행한 다음 바인딩하게 됩니다.

그 다음으로 ViewModel 또는 ViewModel이 참조할 어셈블리 안에 enum을 풀네임으로 변환하기 위한 TypeConverter와 Icon 바인딩을 위한 enum을 각각 만들어 줍니다.

public class EnumFullNameConverter<T> : EnumConverter where T : Enum
{
	public EnumFullNameConverter() : base(typeof(T))
	{

	}

	public override object? ConvertTo(ITypeDescriptorContext? context, CultureInfo? culture, object? value, Type destinationType) => $"{typeof(T).Name}.{value}";
}

[TypeConverter(typeof(EnumFullNameConverter<IconType>))]
public enum IconType
{
	None,
	Home,
	Desktop
}

View에서 ViewModel의 IconType을 가져와 리소스 사전 안에 해당 키가 정의되어 있다면 아이콘 바인딩에 해당 리소스를 이용하게 되는데, 만약 enum 안에 보편적으로 사용되는 단어가 들어가는 경우 View에서 리소스를 이용할 때 문제가 발생할 가능성이 있습니다.

하지만 이렇게 리소스 사전에서 각 리소스 키를 IconType.Home 등 풀네임으로 지정하고 enum을 풀네임으로 변환해 바인딩하면 문제가 될 가능성을 줄일 수 있습니다.

그 다음으로 메뉴 구성에 필요한 정보를 Attribute로 분리합니다. 저는 헤더, 부모 아이템, 부모 아이템 안에서의 순서, 아이콘 타입을 Attribute로 분리해 아래와 같이 만들었습니다.

  • 헤더: Header - string
  • 부모 아이템: Parent - Type(부모 뷰모델의 타입)
  • 순서: Order - ushort
  • 아이콘: IconType - IconType(enum)
[AttributeUsage(AttributeTargets.Class)]
public sealed class HeaderAttribute : Attribute
{
	public string? Header { get; set; }

	public HeaderAttribute(string? header) => Header = header;
}

그 다음으로는 이러한 특성을 가지고 메뉴를 표현하는데 이용될 ModulePresentationItem 클래스를 만들어 줍니다.

public partial class ModulePresentationItem : ObservableRecipient

이 클래스는 다음과 같이 구성됩니다.

- 앞서 Attribute로 분리한 메뉴 구성 정보를 저장하는 프로퍼티
일단 메뉴가 구성되고 나면 변경될 일이 없으므로 변경 통보를 구현하지 않고 setter도 init으로 설정합니다. 만약 런타임에 변경될 필요가 있다면 변경 통보를 구현해야 합니다.

public string? Header { get; init; }

public Type? Parent { get; init; }

public ushort Order { get; init; }

public IconType? IconType { get; init; }

- 하위 아이템 및 하위 아이템 관리에 필요한 메서드

private readonly ObservableCollection<ModulePresentationItem> _child = new();
public IEnumerable<ModulePresentationItem> Child
{
	get => this._child;
	set
	{
		this._child.Clear();
		foreach (var item in value)
		{
			AddChild(item);
		}
		OnPropertyChanged(nameof(Child));
	}
}

internal void AddChild(ModulePresentationItem child)
{
	this._child.Add(child);
}

- 연결되는 모듈의 ViewModel

public IModuleViewModel ViewModel { get; init; }

- 메뉴 선택 시 메인 ViewModel에 메뉴 변경을 통보하는 프로퍼티 및 Command

[ObservableProperty]
private bool _isSelected;

private RelayCommand? _changeMenuCommand;
public ICommand ChangeMenuCommand => this._changeMenuCommand ??= new RelayCommand(OnSelected);

protected virtual void OnSelected()
{
	Messenger.Send(new ChangeModulePresentationItemMessage(this));
}

- ViewModel의 attribute를 읽어와 클래스로 파싱해주는 메서드

public static (bool IsValid, ModulePresentationItem? Value) TryParse(IModuleViewModel viewModel)
{
	var viewModelType= viewModel.GetType();
	// Header가 존재하지 않는다면 메뉴를 갖는 ViewModel이 아님
	if (viewModelType.GetCustomAttribute(typeof(HeaderAttribute), false) is not HeaderAttribute header)
		return (false, null);

	var parent = viewModelType.GetCustomAttribute(typeof(ParentAttribute), false) as ParentAttribute;
	var order = viewModelType.GetCustomAttribute(typeof(OrderAttribute), false) as OrderAttribute;
	var iconType = viewModelType.GetCustomAttribute(typeof(IconTypeAttribute), false) as IconTypeAttribute;

	var item = new ModulePresentationItem(viewModel)
	{
		Header = header?.Header,
		Parent = parent?.Parent,
		Order = order?.Order ?? ushort.MaxValue,
		IconType = iconType?.IconType
	};

	return (true, item);
}

메뉴 아이템과 연결되는 뷰모델의 타입을 추상화한 ModuleViewModel은 다음과 같이 만듭니다.

public interface IModuleViewModel
{
	bool IsVisible { get; set; }
}

우선 본 구현에서는 View의 Visibility에 바인딩할 프로퍼티만 있으면 되니 bool 타입으로 정의해 주었습니다.

ModulePresentationItem에서 메인 ViewModel에 메뉴 변경을 통보할 때 사용할 Message는 아래와 같이 ValueChangedMessage를 상속해 간단히 만들어 줍니다.

public class ChangeModulePresentationItemMessage : ValueChangedMessage<ModulePresentationItem>
{
	public ChangeModulePresentationItemMessage(ModulePresentationItem newItem) : base(newItem)
	{
	}
}

그 다음으로는 뷰모델을 추상화한 IModuleViewModel과 뷰모델의 메뉴 구성 정보를 갖는 ModulePresentationItem을 연결해 주고 이를 관리할 클래스가 필요합니다. 이 클래스를 추상화한 IModuleViewModelFactory를 다음과 같이 만듭니다.

public interface IModuleViewModelFactory
{
	// 계층적 메뉴 트리
	IReadOnlyList<ModulePresentationItem> ModulePresentationHierarchy { get; }

	// ModulePresentationItem으로부터 해당되는 IModuleViewModel을 반환
	IModuleViewModel GetModuleViewModel(ModulePresentationItem presentationItem);
}

그 다음으로 IModuleViewModelFactory을 구현하는 ModuleViewModelFactory 클래스를 만듭니다. 이 클래스는 다음과 같이 구성됩니다.

- IModuleViewModel을 캐시해 저장하는 Dictionary

private readonly Dictionary<ModulePresentationItem, IModuleViewModel> _moduleViewModelCache = new();

- IModuleViewModelFactory에서 정의된 프로퍼티 및 메서드

private readonly IReadOnlyList<ModulePresentationItem> _modulePresentationHierarchy;
public IReadOnlyList<ModulePresentationItem> ModulePresentationHierarchy => this._modulePresentationHierarchy;

public IModuleViewModel GetModuleViewModel(ModulePresentationItem item)
{
	if (!this._moduleViewModelCache.ContainsKey(item))
	{
		this._moduleViewModelCache.Add(item, item.ViewModel);
	}

	return this._moduleViewModelCache[item];
}

ModulePresentationItem을 인자로 받아 캐시에 해당 아이템과 연결되는 모듈의 ViewModel이 존재하는지 확인한 후 존재하면 캐시된 ViewModel을 반환하고, 존재하지 않으면 캐시한 후 반환합니다.

- 생성자

public ModuleViewModelFactory(IEnumerable<IModuleViewModel> viewModels)
=> this._modulePresentationHierarchy = BuildPresentationHierarchy(viewModels);

- 계층적 메뉴 트리를 구성하는 메서드

private List<ModulePresentationItem> BuildPresentationHierarchy(IEnumerable<IModuleViewModel> viewModels)
{
	var ret = new List<ModulePresentationItem>();

	var presentationItems = viewModels.Select(viewModels=> ModulePresentationItem.TryParse(viewModels))
									  .Where(item => item.IsValid)
									  .Select(item => item.Value!)
									  .ToList();

	foreach (var item in presentationItems)
	{
		var parentType = item.Parent;
		if (parentType is null)
		{
			ret.Add(item);
		}
		else
		{
			var parentItem = presentationItems.SingleOrDefault(x => x.ViewModel.GetType().Equals(parentType)) ?? throw new InvalidOperationException();
			parentItem.AddChild(item);
		}
	}

	return SortByOrder(ret);
}

private List<ModulePresentationItem> SortByOrder(IEnumerable<ModulePresentationItem> items)
{
	items = items.OrderBy(x => x.Order);
	foreach (var item in items)
	{
		if (item.Child.Any())
		{
			item.Child = SortByOrder(item.Child);
		}
	}

	return items.ToList();
}

BuildPresentationHierarchy 메서드는 IModuleViewModel 타입의 열거형을 받아 ModulePresentationItem으로 파싱할 수 있는지, 즉 메뉴 구성에 필요한 Attribute가 존재하는지 확인합니다. 이후 파싱한 각 아이템에 대해 메뉴 트리상 부모 아이템이 존재하는지, 존재한다면 현재 보유한 메뉴 아이템 중 부모 아이템의 타입이 존재하는지 확인합니다. 만약 존재한다면 해당 메뉴 아이템의 자식 아이템에 추가하고, 부모 아이템이 존재하지 않는다면 부모 아이템이 존재하지 않으므로 예외를 드랍합니다.

그 다음 Order에 맞게 순서를 정렬한 후 리스트로 반환하게 됩니다.

이제 각 모듈의 뷰모델에 다음과 같이 메뉴 구성 정보를 Attribute로 입력할 수 있습니다.

// Root Menu Item
[Header("Module A")]
[Order(0)]
[IconType(IconType.Desktop)]
public partial class ModuleAViewModel : ModuleViewModelBase
{
}

// Child Menu Item
[Header("Module AA")]
[Parent(typeof(ModuleAViewModel))]
[Order(0)]
[IconType(IconType.None)]
public partial class ModuleAAViewModel : ModuleViewModelBase
{
}

다음으로 메뉴 컬렉션 및 메인 모듈을 갖는 메인 ViewModel을 만들 차례입니다.

public partial class ApplicationViewModel : ObservableRecipient, IRecipient<ChangeModulePresentationItemMessage>
{
	private readonly IModuleViewModelFactory _moduleViewModelFactory;

	public ApplicationViewModel(IModuleViewModelFactory moduleViewModelFactory)
	{
		this._moduleViewModelFactory = moduleViewModelFactory;
		this._menuItems = this._moduleViewModelFactory.ModulePresentationHierarchy.ToObservable();
		Messenger.Register(this);
	}

	[ObservableProperty]
	private ObservableCollection<ModulePresentationItem> _menuItems = new();

	[ObservableProperty]
	private ObservableCollection<IModuleViewModel> _modules = new();

	public void Receive(ChangeModulePresentationItemMessage message) => ChangeModulePresentationItem(message.Value);

	private void ChangeModulePresentationItem(ModulePresentationItem newToolItem)
	{
		this._modules.Where(x => x.IsVisible)
					 .Foreach(x => x.IsVisible = false);

		this._menuItems.Flatten()
					   .Where(x => x.IsSelected)
					   .Foreach(x => x.IsSelected = false);

		var viewModel = this._moduleViewModelFactory.GetModuleViewModel(newToolItem);
		if (!this._modules.Contains(viewModel))
			this._modules.Add(viewModel);

		viewModel.IsVisible = true;
		newToolItem.IsSelected = true;
	}
}

메뉴 트리와 Module 컬렉션을 가지고 있다가, 메뉴 선택 이벤트가 발생하는 경우 ChangeModulePresentationItem 메서드에서 선택된 메뉴 및 연결된 뷰모델의 선택 상태를 활성화하는 간단한 기능만 구현했습니다. ModulePresentationItem의 확장 메서드 Flatten은 계층 구조에서 일반적인 linq로는 자식 아이템의 선택 상태를 비활성화할 수 없으므로 메뉴 트리 내의 모든 메뉴 아이템을 열거형으로 가져오는 기능을 하고, ToObservable과 Foreach는 각각 ObservableCollection 변환 및 열거형에 반복문을 적용하는 기능을 합니다.

internal static IEnumerable<ModulePresentationItem> Flatten(this IEnumerable<ModulePresentationItem> e) => e.SelectMany(s => s.Child.Flatten().Prepend(s));

public static ObservableCollection<T> ToObservable<T>(this IEnumerable<T> list) => new(list);

public static void Foreach<T>(this IEnumerable<T> items, Action<T> action)
{
	if (action is null)
		throw new ArgumentNullException(nameof(action));

	var list = items.ToList();
	for (int i = 0; i < list.Count; i++)
	{
		action(list[i]);
	}
}

이제 애플리케이션 진입점에서 각 뷰모델 및 Factory를 서비스로 등록해 DI로 넣어줄 수 있게만 만들어주면 됩니다. 이를 위한 클래스 Bootstrapper를 다음과 같이 정의합니다.

internal static class Bootstrapper
{
	internal static void Initialize()
	{
		var services = ConfigureSerivces();
		Ioc.Default.ConfigureServices(services);
	}

	private static IServiceProvider ConfigureSerivces()
	{
		var services = new ServiceCollection();

		// Register Services //
		services.AddSingleton<IModuleViewModelFactory, ModuleViewModelFactory>();

		// Register Module ViewModels //
		AppDomain.CurrentDomain.GetAssemblies()
							   .SelectMany(c => c.GetTypes())
							   .Where(x => typeof(IModuleViewModel).IsAssignableFrom(x) && !x.IsAbstract)
							   .Foreach(type => services.AddTransient(typeof(IModuleViewModel), type));

		// Register Application ViewModel //
		services.AddTransient<ApplicationViewModel>();

		return services.BuildServiceProvider();
	}
}

IModuleViewModel 인터페이스를 구현하는 모든 클래스를 가져와, IModuleViewModel 타입과 함께 서비스로 등록하는 것 외에 따로 특이사항은 없습니다.

구현은 모두 끝났습니다. 이제 테스트를 진행해 보겠습니다.

2개의 좋아요

테스트가 목적이므로, 구현상 편의를 위해 각 모듈의 View 및 ViewModel의 이름은 다음 규칙에 따라 명명하도록 하겠습니다.

  • Module + 이름(알파벳 순서) + View/ViewModel (예: ModuleAViewModel)
  • 자식 메뉴의 경우 이름 앞에 부모 메뉴의 이름을 붙임
    ex. Module A의 두 번째 자식 메뉴=ModuleABViewModel

Menu 컨트롤은 다음과 같이 만듭니다. 본격적으로 사용하기 위해서는 아이콘과 IsChecked를 함께 사용해야 하는 경우를 고려하는 등 Template을 수정해야 합니다.

<Menu ItemsSource="{Binding MenuItems}"
	  VerticalAlignment="Top">

	<Menu.Resources>

		<Path x:Key="MenuIcon"
			  x:Shared="False"
			  Stretch="Fill"
			  Fill="Black"
			  Data="{uicommon:ResourceBinding IconType, FallbackValue={x:Null}}"/>
				
	</Menu.Resources>

	<Menu.ItemContainerStyle>
				
		<Style TargetType="{x:Type MenuItem}" BasedOn="{StaticResource {x:Type MenuItem}}">
			<Setter Property="VerticalAlignment" Value="Center"/>
			<Setter Property="IsChecked" Value="{Binding IsSelected}"/>
			<Setter Property="Command" Value="{Binding ChangeMenuCommand}"/>
			<Setter Property="Icon" Value="{StaticResource MenuIcon}"/>
			<EventSetter Event="PreviewMouseLeftButtonDown" Handler="OnMouseDown"/>
		</Style>
				
	</Menu.ItemContainerStyle>
			
	<Menu.ItemTemplate>
				
		<HierarchicalDataTemplate ItemsSource="{Binding Path=Child}">
			<TextBlock Text="{Binding Header}"
					   VerticalAlignment="Center"/>
		</HierarchicalDataTemplate>
				
	</Menu.ItemTemplate>
</Menu>

PreviewMouseLeftButtonDown 이벤트는 자식 메뉴가 있는 메뉴 아이템에 Command를 발동하기 위한 것으로, code behind에 다음과 같이 핸들러를 만들어 줍니다.

private void OnMouseDown(object sender, MouseButtonEventArgs e)
{
	if (sender is not MenuItem menuItem || !menuItem.HasItems)
		return;

	menuItem.Command?.Execute(menuItem.CommandParameter);
}

ApplicationViewModel의 Modules에 바인딩해 Module의 View를 보여줄 ItemsControl은 다음과 같이 만듭니다. 이 부분은 다른 분의 블로그 게시물을 참고했으므로 블로그 게시물을 함께 인용합니다.

https://blog.naver.com/vactorman/221014167271


<ItemsControl Grid.Row="1"
			  ItemsSource="{Binding Modules}">
			
	<ItemsControl.ItemsPanel>
		<ItemsPanelTemplate>
			<Grid/>
		</ItemsPanelTemplate>
	</ItemsControl.ItemsPanel>

	<ItemsControl.ItemTemplate>
		<DataTemplate>
			<ContentControl Content="{Binding}"/>
		</DataTemplate>
	</ItemsControl.ItemTemplate>

	<ItemsControl.ItemContainerStyle>
				
		<Style>
			<Style.Triggers>
				<DataTrigger Binding="{Binding IsVisible}" Value="False">
					<Setter Property="FrameworkElement.Visibility" Value="Collapsed"/>
				</DataTrigger>
			</Style.Triggers>
		</Style>
				
	</ItemsControl.ItemContainerStyle>
</ItemsControl>

리소스 사전에 IconType.Home과 IconType.Desktop에 해당하는 벡터 그래픽을 PathGeometry로 정의해 주고, App.xaml에 merge합니다.

다음으로는 ViewModel-First 방식으로 ViewModel을 바인딩하면 연결되는 View를 표현할 수 있게 아래와 같이 DataTemplate을 만들어주고 App.xaml에 merge합니다.

<DataTemplate DataType="{x:Type modules:ModuleAViewModel}">
	<views:ModuleA/>
</DataTemplate>
(이하 생략)

이제 빌드한 후 프로그램을 실행시키면 다음과 같이 메뉴가 계층적으로 구성되는 것을 확인할 수 있습니다.
캡처

[Header("Module ABA")]
[Parent(typeof(ModuleABViewModel))]
[Order(1)]
[IconType(IconType.Home)]
public partial class ModuleABAViewModel : ModuleViewModelBase
{
}

[Header("Module ABB")]
[Parent(typeof(ModuleABViewModel))]
[Order(0)]
public partial class ModuleABBViewModel : ModuleViewModelBase
{
}

Module ABA와 Module ABB의 Order를 반대로 설정했는데, 캡처 이미지에서 볼 수 있듯 정상적으로 순서가 변경되어 구성됩니다.

모듈을 비활성화해야 할 경우 자식 아이템이 없다면 필수 항목인 Header Attribute를 제거하거나 주석 처리하는 것만으로 간단히 메뉴 트리에서 제거할 수 있습니다.

source code:

5개의 좋아요

2. Attribute와 MEF를 이용하는 방법
똑같이 특성을 이용하는 방법이지만, MEF(Managed Extensibility Framework)를 이용하면 더 간단하게 메뉴 트리를 구성할 수 있습니다. DI를 이용하는 1번 방식에서는 메타데이터 파싱을 직접 해줘야 했지만 MEF에서는 개체와 메타데이터 특성을 연결 및 주입하는 기능을 프레임워크 차원에서 지원합니다.

이 방법에서 사용한 외부 라이브러리는 아래와 같습니다.

  1. CommunityToolkit.MVVM
  2. Microsoft.VisualStudio.Composition

전체적인 틀은 1번과 거의 유사하지만, MEF에서 제공하는 메타데이터 특성을 이용하기 위해 일부 수정해야 할 것들이 있습니다.

먼저, 메타데이터로 이용할 IModuleViewModelMetadata 인터페이스를 정의합니다.

public interface IModuleViewModelMetadata
{
	string? Header { get; }

	[DefaultValue(null)]
	Type? Parent { get; }

	[DefaultValue(int.MaxValue)]
	int Order { get; }

	[DefaultValue(Common.IconType.None)]
	IconType? IconType { get; }
}

메타데이터를 담을 인터페이스에는 프로퍼티만 정의되어야 합니다. 메뉴 트리를 구성할 때 필요한 정보들을 위와 같이 프로퍼티로 정의해 줍니다.

DefaultValue 특성은 할당되지 않은 프로퍼티에 대해 기본값을 지정해 주는 특성입니다. 기본적으로 메타데이터로 사용할 인터페이스에 포함된 모든 속성은 필수 프로퍼티로, Export 시 인터페이스 내의 모든 프로퍼티를 포함하지 않으면 일치하지 않는 것으로 간주되어 메타데이터가 Export되지 않습니다. DefaultValue 특성은 프로퍼티에 대해 기본값을 지정해 선택적 프로퍼티로 만듦으로써 값이 할당되지 않더라도 메타데이터가 Export 되도록 만듭니다.

필수 프로퍼티로 지정할 Header를 제외하고 모두 DefaultValue 특성을 정의해 선택적 프로퍼티로 지정해 줍니다.

이제 메타데이터를 가지고 메뉴 트리를 구성해 줄 ModulePresentationItem과 Factory 클래스를 수정할 차례입니다.

1) IModuleViewModel 인터페이스에서 정의한 모든 프로퍼티를 ModulePresentationItem에 똑같이 정의하고, 생성자 내에서 일대일로 할당하는 방법

ModulePresentationItem에서 TryParse 메서드를 제거하고, 생성자의 시그니처를 아래와 같이 변경합니다.

public ModulePresentationItem(IModuleViewModel viewModel, IModuleViewModelMetadata metadata)
{
	ViewModel = viewModel;
	Header = metadata.Header;
	Parent = metadata.Parent;
	Order = metadata.Order;
	IconType = metadata.IconType;
}

앞서 만든 TryParse 메서드와 비교하면 리플렉션으로 Attribute를 읽어와 메타데이터로 파싱하는 부분을 제거하고, 대신 메타데이터를 주입받아 각 프로퍼티에 할당함을 알 수 있습니다.

그 다음으로는 Factory 클래스의 정의 부분과 생성자 시그니처를 다음과 같이 변경합니다.

[Export(typeof(IModuleViewModelFactory))]
public sealed class ModuleViewModelFactory : IModuleViewModelFactory
[ImportingConstructor]
public ModuleViewModelFactory([ImportMany] IEnumerable<Lazy<IModuleViewModel, IModuleViewModelMetadata>> viewModels) => this._modulePresentationHierarchy = BuildPresentationHierarchy(viewModels);

Export(Type) 특성은 클래스를 해당 타입으로 Export함을 MEF에 알려주는 특성입니다. 이후 다른 곳에서 아래와 같이 IMoudleViewModelFactory 타입을 Import받는 부분이 있으면 MEF에서 이 클래스를 Export해 제공해 줍니다.

// 예시
[Import]
public IModuleViewModelFactory ModuleViewModelFactory

ImportMany 특성은 해당 타입으로 Export 선언된 다수의 개체를 열거형 타입으로 주입해 줍니다. Lazy<T, TMetadata> 타입으로 ImportMany를 선언하면 IModuleViewModel 타입으로 Export 된 개체 중 ModulePresentationItem의 필수 프로퍼티를 충족하는 개체에 한해 열거형 타입으로 주입합니다.

여기서는 IModuleViewModelMetadata 특성에서 필수 프로퍼티로 지정한 Header를 제공하는 개체에 한해 주입됩니다.

ImportingConstructor는 MEF에 해당 생성자를 사용함을 알리는 특성입니다. MEF는 기본적으로 의존성을 해결할 때 파라미터가 없는 생성자를 사용하는데, ImportingConstructor 특성을 붙이면 해당 생성자를 사용하고 이 과정에서 해당 생성자의 파라미터를 자동으로 가져옵니다.

다음으로는 앞서 메뉴 트리를 구성하기 위해 만든 BuildPresentationHierarchy 메서드를 다음과 같이 수정합니다.

private List<ModulePresentationItem> BuildPresentationHierarchy(IEnumerable<Lazy<IModuleViewModel, IModuleViewModelMetadata>> viewModels)
{
	var ret = new List<ModulePresentationItem>();

	var presentationItems = viewModels.Select(item => new ModulePresentationItem(item.Value, item.Metadata))
									  .ToList();

	// 이하 생략(이전과 동일)
}

메서드 시그니처와 PresentationItem의 리스트를 select하는 부분을 제외하고는 이전과 동일합니다. 메타데이터의 유효성을 판별해 유효한 메타데이터만 select하는 이전과 달리 MEF 차원에서 유효성을 만족하는 메타데이터만 주입받기 때문에 select하는 부분이 이전과 비교해 더 간단해졌습니다.

이제 각 ModuleViewModel의 특성을 다음과 같이 수정합니다.

[Export(typeof(IModuleViewModel))]
[ExportMetadata("Header", "Module ABA")]
[ExportMetadata("Parent", typeof(ModuleABViewModel))]
[ExportMetadata("Order", 0)]
[ExportMetadata("IconType", IconType.Desktop)]
public partial class ModuleABAViewModel : ModuleViewModelBase

먼저 IModuleViewModel 타입으로 주입받을 예정이므로 해당 타입으로 Export 해주고, 앞서 IModuleViewModel 인터페이스에서 정의한 각 프로퍼티를 ExportMetadata로 함께 내보내 줍니다. 이렇게 하면 MEF에서 IModuleViewModel 타입으로 Export된 개체를 찾을 때, ExportMetadata로 내보내진 메타데이터를 함께 읽어 해당 개체의 메타데이터로 제공하게 됩니다. 메타데이터를 Export할 때 메타데이터의 이름은 string 타입으로 앞서 IModuleViewModelMetadata에서 정의한 프로퍼티의 이름과 일치해야 합니다.

ApplicationViewModel에도 Export 특성과 ImportingConstructor 특성을 붙여줍니다.

[Export(typeof(ApplicationViewModel))]
public partial class ApplicationViewModel : ObservableRecipient, IRecipient<ChangeModulePresentationItemMessage>
[ImportingConstructor]
public ApplicationViewModel(IModuleViewModelFactory moduleViewModelFactory)

다음으로는 App.xaml.cs에서 MEF를 호스팅하는 코드를 작성해 주어야 합니다. 이 부분은 vs mef의 깃헙에 올라온 샘플을 이용했는데, 해당 샘플은 vs mef의 어셈블리만 가져오기 때문에 실행 중인 모든 어셈블리를 가져오도록 수정했습니다.
주소: https://github.com/microsoft/vs-mef/blob/main/doc/hosting.md

public partial class App : Application
{
	public ExportProvider? ExportProvider { get; private set; }

	protected override async void OnStartup(StartupEventArgs e)
	{
		base.OnStartup(e);
		ExportProvider = await HostingMefAsync();
		var win = new MainWindow { DataContext = ExportProvider.GetExportedValue<ApplicationViewModel>() };
		win.ShowDialog();
		Environment.Exit(0);
	}

	private async Task<ExportProvider> HostingMefAsync()
	{
		var discovery = PartDiscovery.Combine(
			new AttributedPartDiscovery(Resolver.DefaultInstance),
			new AttributedPartDiscoveryV1(Resolver.DefaultInstance));

		var assemblies = AppDomain.CurrentDomain.GetAssemblies();

		var catalog = ComposableCatalog.Create(Resolver.DefaultInstance)
			.AddParts(await discovery.CreatePartsAsync(assemblies))
			.WithCompositionService();

		var config = CompositionConfiguration.Create(catalog);

		var epf = config.CreateExportProviderFactory();

		return epf.CreateExportProvider();
	}
}

이렇게 한 다음 View와 연결해 실행하면, 1번과 마찬가지로 정상적으로 메뉴 트리가 구성되는 것을 확인할 수 있습니다.

2022-05-08

그런데 이 방법은 만약 이후에 메뉴 트리 구성에 사용할 메타데이터를 추가해야 되는 경우, ModulePresentationItem에서 프로퍼티를 정의하고 생성자 내에서 해당 프로퍼티에 값을 추가로 할당해 줘야 한다는 단점이 있습니다. 가령 메뉴에 색상 코드를 포함해야 하는 경우, IModuleViewModelMetadata 및 ModulePresentationItem를 다음과 같이 수정해야 합니다.

public interface IModuleViewModelMetadata
{
	string? Header { get; }

	[DefaultValue(null)]
	Type? Parent { get; }

	[DefaultValue(int.MaxValue)]
	int Order { get; }

	[DefaultValue(Common.IconType.None)]
	IconType? IconType { get; }

	[DefaultValue("#FFFFFF")]
	string? ColorCode { get; }
}

public ModulePresentationItem(IModuleViewModel viewModel, IModuleViewModelMetadata metadata)
{
	ViewModel = viewModel;
	Header = metadata.Header;
	Parent = metadata.Parent;
	Order = metadata.Order;
	IconType = metadata.IconType;
	ColorCode = metadata.ColorCode;
}

ModulePresentationItem에 프로퍼티를 추가해 주는 건 ModulePresentationItem에서 IModuleViewModelMetadata 인터페이스를 구현함으로써 어느 정도 상쇄할 수 있겠지만 프로퍼티를 일일히 재정의하는 건 번거로울 뿐더러 깜빡하고 할당하는 구문을 빼먹을 수도 있고, 생성자가 불필요하게 길어지는 결과를 낳게 됩니다. 물론 IModuleViewModelMetadata 객체 자체를 프로퍼티로 들고 있고 각 프로퍼티에서 IModuleViewModelMetadata의 프로퍼티를 getter로 가져오는 방법도 있겠지만 그 역시 프로퍼티의 getter에 해당 구문을 추가해 줘야 합니다.

이 부분은 Factory 클래스의 생성자에서 IModuleViewModelMetadata 대신 ModulePresentationItem 타입을 주입받는 것으로 해결할 수 있는데, 이 방법은 다음 댓글에서 설명하겠습니다.

3개의 좋아요

2) ModulePresentationItem을 메타데이터로 주입받는 방법

이 방법은 별도의 메타데이터 인터페이스를 이용하는 대신 ModulePresentationItem 객체 자체를 메타데이터로 활용하는 방법으로, 앞서 생성한 IModuleViewModelMetadata 인터페이스가 필요하지 않습니다. 대신 ModulePresentationItem 클래스의 생성자와 ViewModel 프로퍼티를 다음과 같이 수정합니다. 또한 앞서 IModuleViewModelMetadata 인터페이스에서 각 프로퍼티마다 붙여줬던 DefaultValue 특성을 마찬가지로 각 프로퍼티에 붙여줍니다.

public ModulePresentationItem()
{

}

public IModuleViewModel ViewModel { get; internal set; }

이전 방법과 비교했을 때 ViewModel 및 메타데이터를 받아와 프로퍼티에 할당하는 부분이 없어지고, 대신 ViewModel의 setter가 init에서 internal set으로 수정된 것을 확인할 수 있습니다. 메뉴 트리를 구성하는데 필요한 각 프로퍼티는 MEF에서 할당받고, 연결되는 모듈의 ViewModel은 Factory 클래스에서 할당하는 방법입니다.

Factory 클래스의 생성자 시그니처 및 BuildPresentationHierarchy 메서드를 다음과 같이 수정합니다.

public ModuleViewModelFactory([ImportMany] IEnumerable<Lazy<IModuleViewModel, ModulePresentationItem>> viewModels) => this._modulePresentationHierarchy = BuildPresentationHierarchy(viewModels);

private List<ModulePresentationItem> BuildPresentationHierarchy(IEnumerable<Lazy<IModuleViewModel, ModulePresentationItem>> viewModels)
{
	var ret = new List<ModulePresentationItem>();

	var presentationItems = viewModels.Select(item =>
	{
		var metadata = item.Metadata;
		metadata.ViewModel = item.Value;
		return metadata;
	}).ToList();

	// 이하 이전과 동일

IModuleViewModelMetadata 인터페이스를 이용해 ModulePresentationItem를 생성하는 대신 이미 ModulePresentationItem 객체에 MEF가 할당해준 메타데이터가 주입되어 있으므로, ViewModel만 해당하는 객체로 할당해 주면 됩니다. 이렇게만 수정하고 빌드하면 이전과 동일하게 정상적으로 메뉴 트리가 구성됩니다.

그러나 이 방법에도 문제가 있습니다. 별도의 메타데이터 인터페이스를 이용하는 방법에서는 Metadata Export 시 MEF에서 필수 프로퍼티의 유효성 검사를 진행해줬지만 ModulePresentationItem 클래스에 직접 메타데이터를 주입받는 방식에서는 프로퍼티 유효성 검사를 직접 구현해 줘야 합니다. 가령 앞서 필수 프로퍼티로 지정한 Header를 Export하지 않은 경우 1번 방식에서는 해당 뷰모델 및 메타데이터를 주입하지 않지만, 이 방식에서는 유효성 검사를 진행하지 않고 주입하므로 다음과 같이 표시됩니다.

[Export(typeof(IModuleViewModel))]
//[ExportMetadata("Header", "Module ABA")]
[ExportMetadata("Parent", typeof(ModuleABViewModel))]
[ExportMetadata("Order", 0)]
[ExportMetadata("IconType", IconType.Desktop)]
public partial class ModuleABAViewModel : ModuleViewModelBase

캡처
필수 프로퍼티인 Header를 주석처리 해 메뉴 트리에서 제외하려고 했던 게 본래 의도인데, Header가 할당되지 않은 채 메뉴 트리에 포함된 것을 확인할 수 있습니다.

이 부분은 메타데이터로 이용하는 ModulePresentationItem에 메타데이터용 프로퍼티뿐만 아니라 다른 프로퍼티나 메서드가 정의되어 있어서 그런지, 혹은 MEF의 동작 방식을 제대로 이해하지 못해서인지는 모르겠지만 어쨌든 제가 의도한 동작은 아니므로 유효성 검사가 필요합니다.

또한 ExportMetadata를 이용해 메타데이터를 내보내는 방법의 경우 프로퍼티의 이름을 string 타입으로 입력할 것을 요구하고 있기 때문에 프로퍼티 이름이 변경되는 경우 해당되는 ExportMetadata 특성을 일일히 수정해 줘야 한다는 단점이 있습니다.

이 문제점들은 Custom Metadata Attribute를 이용하는 것으로 해결할 수 있습니다.

3) Custom Metadata Attribute를 이용하는 방법
MEF에서는 사용자 정의 특성을 메타데이터로 이용하는 방법도 제공하고 있습니다. 사용자 정의 특성을 메타데이터로 이용하면 메타데이터 프로퍼티 변경에 조금이나마 능동적으로 대처할 수 있습니다.

ModulePresentationItem에 앞서 정의한 IModuleViewModelMetadata 인터페이스를 다시 구현시켜 줍니다. 메타데이터 주입에 필요한 것은 아니고, 메타데이터 등록에 이용할 attribute와 형식을 맞춰 인터페이스가 수정되는 경우 수정사항 반영을 강제하기 위한 용도입니다. 메타데이터 주입 역시 인터페이스가 아닌 ModulePresentationItem 클래스로 주입받을 것이므로, DefaultValue 특성을 IModuleViewModelMetadata 인터페이스가 아닌 ModulePresentationItem의 프로퍼티에 붙여줍니다.

그 다음으로 메타데이터 특성으로 사용할 attribute를 정의해 줍니다.

[MetadataAttribute]
[AttributeUsage(AttributeTargets.Class)]
public sealed class PresentationMetadataAttribute : Attribute, IModuleViewModelMetadata
{
	public string? Header { get; set; }

	public Type? Parent { get; set; }

	public int Order { get; set; }

	public IconType? IconType { get; set; }

	public PresentationMetadataAttribute(string? header, [Optional]Type? parent, [Optional]int order, [Optional]IconType iconType) => (Header, Parent, Order, IconType) = (header, parent, order, iconType);
}

MetadataAttribute 특성을 붙임으로써 해당 특성을 메타데이터로 활용할 것임을 MEF에 알려야 합니다. 이제 모듈의 ViewModel에 다음과 같은 방식으로 특성을 붙이면 됩니다.

[Export(typeof(IModuleViewModel))]
[PresentationMetadata(header: "Module ABA", parent: typeof(ModuleABViewModel), order: 0, iconType: IconType.Desktop)]
public partial class ModuleABAViewModel : ModuleViewModelBase
{
}

특성을 정의할 때 생성자 시그니처에서 필수 프로퍼티인 Header를 제외한 나머지를 선택적 인수로 만들었으므로 특성을 붙일 때 해당 프로퍼티를 제외할 수 있습니다. 또한 Header를 제외한 경우 빌드 시 오류가 발생하며, 특성 자체를 제거한다면 MEF에서 해당 뷰모델을 Factory 클래스에 제공하지 않으므로 2번의 단점인 필수 프로퍼티 유효성 검사가 어느 정도 해결됩니다.

MEF는 이름만 들었지 실제 구현에 사용한 것은 처음이라 기능 파악을 제대로 하지 못한 상태입니다. 제가 미처 알지 못하는 기능으로 더 깔끔하고 정확하게 구현하는 방법이 존재할 수 있으므로 참고 부탁드립니다.

source code:

4개의 좋아요