소스 생성기 기반 CommunityToolkit.Mvvm으로 .NET MAUI ViewModel 보일러플레이트를 제거하는 실전 가이드. ObservableProperty, RelayCommand, Messenger, ObservableValidator, NativeAOT 호환까지 코드 예제와 함께 정리했습니다.
.NET MAUI에서 CommunityToolkit.Mvvm은 소스 생성기를 써서 INotifyPropertyChanged 보일러플레이트 없이 MVVM 패턴을 구현하도록 돕는 Microsoft 공식 라이브러리입니다. [ObservableProperty] 한 줄로 알림 속성을, [RelayCommand] 한 줄로 명령을 만들 수 있어 ViewModel 코드가 체감상 절반 이하로 줄어듭니다. 2026년 기준 안정 버전은 8.4.x이며 .NET 8/9/10 MAUI 프로젝트에서 동일한 코드로 동작합니다.
솔직히 말해서, Xamarin.Forms 시절에 직접 SetProperty를 수백 번 호출해 본 입장에서는 처음 이 라이브러리를 도입했을 때 "왜 진작 안 썼지" 싶었습니다. 이 글에서는 설치부터 실전 패턴, 의존성 주입, Messenger, 검증, 디버깅 함정까지 한 번에 정리합니다. 제가 최근 프로젝트에서 직접 부딪힌 함정도 함께 적어 두었으니, 같은 곳에서 시간 낭비하지 마시길 바랍니다.
CommunityToolkit.Mvvm 8.4는 소스 생성기 기반이라 런타임 리플렉션이 없어 NativeAOT와 호환됩니다.
ObservableObject 상속 + [ObservableProperty] 어트리뷰트로 OnPropertyChanged 호출을 자동 생성합니다.
[RelayCommand]는 동기·비동기 메서드를 ICommand로 변환하며 CanExecute, 동시 실행 방지, 예외 전파를 지원합니다.
MAUI MauiProgram.cs에서 ViewModel과 서비스를 builder.Services에 등록한 뒤 페이지 생성자에서 주입해 사용합니다.
WeakReferenceMessenger로 화면 간 메모리 누수 없이 약한 참조 기반 메시지 전달이 가능합니다.
ObservableValidator를 사용하면 DataAnnotations 어트리뷰트만으로 입력 검증과 오류 메시지 바인딩을 처리할 수 있습니다.
CommunityToolkit.Mvvm이란 무엇이며 왜 사용해야 할까?
CommunityToolkit.Mvvm은 Microsoft가 유지보수하는 오픈소스 MVVM 라이브러리로, 이전 명칭은 Microsoft.Toolkit.Mvvm이었습니다. WPF, UWP, WinUI 3, Avalonia, 그리고 .NET MAUI를 모두 지원하며 핵심 차별점은 Roslyn 소스 생성기를 통해 컴파일 시점에 INotifyPropertyChanged 구현을 만들어 준다는 점입니다. 결과적으로 리플렉션, 런타임 코드 생성, Fody 후처리 도구 같은 외부 의존성이 사라집니다.
실제 ViewModel을 비교해 보면 차이가 꽤 극적입니다. 수동 구현은 백킹 필드, 프로퍼티 본문, SetField 호출, RaisePropertyChanged 호출까지 보통 8~10줄이 필요한데, CommunityToolkit.Mvvm은 같은 결과를 한 줄로 만듭니다. 거기에 [NotifyPropertyChangedFor], [NotifyCanExecuteChangedFor] 같은 어트리뷰트로 의존 속성 갱신과 명령 가용성 변경까지 선언적으로 처리하죠.
2026년 시점에서 또 다른 이유는 NativeAOT 호환성입니다. .NET 9부터 MAUI 일부 시나리오에서 AOT 게시가 가능해졌고, 리플렉션을 쓰지 않는 소스 생성기 기반 라이브러리만 안전하게 동작합니다. 그래서 ReactiveUI나 Prism의 자동 매직 기능과 비교했을 때 트림 친화성이 가장 뛰어납니다. MVVM 라이브러리 선택 기준은 아래 표에 정리했습니다.
다음으로 ViewModel을 담을 폴더를 만듭니다. 일반적인 구조는 /ViewModels, /Views, /Services, /Models 네 개의 폴더로 시작합니다. ViewModel 클래스는 반드시 partial로 선언해야 소스 생성기가 추가 코드를 같은 클래스에 합쳐 줍니다. 부분 클래스 키워드를 빠뜨리면 컴파일러는 MVVMTK0032 진단 코드를 발생시킵니다.
ObservableProperty와 ObservableObject 사용법
가장 먼저 만나는 두 타입은 ObservableObject와 [ObservableProperty] 어트리뷰트입니다. ObservableObject는 INotifyPropertyChanged와 INotifyPropertyChanging을 미리 구현한 기반 클래스이고, [ObservableProperty]는 백킹 필드를 자동으로 알림 속성으로 승격시킵니다. 아래는 제가 사이드 프로젝트에서 실제로 쓰던 상품 상세 화면 ViewModel입니다.
using CommunityToolkit.Mvvm.ComponentModel;
namespace ShopApp.ViewModels;
public partial class ProductDetailViewModel : ObservableObject
{
[ObservableProperty]
private string _name = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FormattedPrice))]
private decimal _price;
[ObservableProperty]
private bool _isFavorite;
public string FormattedPrice => $"{_price:N0}원";
}
컴파일 후 생성기는 같은 이름의 부분 클래스에 public string Name { get; set; } 형태의 공개 속성과 OnNameChanging, OnNameChanged 부분 메서드 훅을 추가합니다. 만약 가격이 음수가 되는 것을 막고 싶다면 다음 부분 메서드를 같은 클래스에 직접 정의하면 됩니다.
partial void OnPriceChanging(decimal value)
{
if (value < 0)
throw new ArgumentOutOfRangeException(nameof(value), "가격은 0 이상이어야 합니다.");
}
XAML에서는 별도 변환 없이 바로 바인딩됩니다. FormattedPrice는 Price가 바뀌면 [NotifyPropertyChangedFor] 덕분에 자동으로 갱신됩니다.
대용량 목록 화면에서 자주 발생하는 성능 문제를 줄이려면 .NET MAUI 10 성능 최적화 가이드에서 다룬 기법과 함께 사용하세요. 특히 ObservableCollection 갱신을 묶어서 일괄 처리하면 UI 스레드 부하가 크게 줄어듭니다.
RelayCommand로 명령 처리하기
[RelayCommand]는 메서드 위에 붙여 ICommand 구현체를 자동 생성합니다. 동기, 비동기, 매개변수 받기, 취소 토큰, CanExecute 평가까지 모두 어트리뷰트로 제어할 수 있어서 개인적으로 이 라이브러리에서 가장 자주 쓰는 기능입니다. 비동기 명령은 AsyncRelayCommand로 변환되고, 기본적으로 동시에 두 번 실행되지 않도록 보호됩니다(중복 탭으로 인한 이중 결제 같은 사고를 막아 줍니다).
XAML에서는 명령 이름 뒤에 자동으로 Command가 붙은 속성을 바인딩합니다. LoadMoreAsync는 LoadMoreCommand가 되고, ToggleFavorite은 ToggleFavoriteCommand가 됩니다. CommandParameter도 자연스럽게 동작합니다.
CancellationToken을 매개변수로 받으면 생성기는 자동으로 보조 취소 명령을 만들어 주지는 않지만, 화면이 사라질 때 LoadMoreCommand.Cancel()로 안전하게 중단할 수 있습니다. 비동기 명령에서 발생한 예외가 조용히 사라지는 걸 막으려면 아래 옵션을 추가하세요. 저는 출시 직전에 이 옵션을 빠뜨려서 한참 동안 "왜 에러 다이얼로그가 안 뜨지" 하고 헤맨 적이 있습니다.
.NET MAUI는 MauiAppBuilder에 내장된 Microsoft.Extensions.DependencyInjection 컨테이너를 사용합니다. CommunityToolkit.Mvvm은 자체 IoC 컨테이너(Ioc.Default)도 제공하지만, MAUI에서는 생성자 주입이 훨씬 권장됩니다. 사실상 ASP.NET Core와 거의 똑같은 API라서 백엔드 경험이 있다면 적응이 빠릅니다. MauiProgram.cs에서는 이렇게 등록합니다.
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts => fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular"));
// 서비스
builder.Services.AddSingleton<IProductService, ProductService>();
builder.Services.AddSingleton<HttpClient>();
// ViewModel
builder.Services.AddTransient<ProductListViewModel>();
builder.Services.AddTransient<ProductDetailViewModel>();
// 페이지
builder.Services.AddTransient<ProductListPage>();
builder.Services.AddTransient<ProductDetailPage>();
return builder.Build();
}
}
페이지 생성자에서 ViewModel을 주입받아 BindingContext에 할당합니다.
public partial class ProductListPage : ContentPage
{
public ProductListPage(ProductListViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
}
이렇게 하면 Shell 라우팅이나 Application.Current.MainPage 생성 시 ViewModel이 자동으로 해결됩니다. Xamarin.Forms에서 마이그레이션 중이라면 정적 Ioc.Default.GetService 호출을 모두 생성자 주입으로 바꾸는 것을 권장합니다. 자세한 마이그레이션 단계는 Xamarin.Forms에서 .NET MAUI 10으로 마이그레이션 가이드를 참고하세요.
Messenger로 ViewModel 간 통신하기
화면 A에서 발생한 이벤트를 화면 B의 ViewModel에 전달해야 할 때, 직접 참조 대신 WeakReferenceMessenger를 사용하면 메모리 누수 없이 약한 참조 기반 메시지 전달이 가능합니다. 메시지는 단순한 record로 정의합니다.
using CommunityToolkit.Mvvm.Messaging;
public sealed record ProductFavoritedMessage(int ProductId, bool IsFavorite);
public partial class ProductDetailViewModel : ObservableObject
{
[RelayCommand]
private void ToggleFavorite()
{
IsFavorite = !IsFavorite;
WeakReferenceMessenger.Default.Send(new ProductFavoritedMessage(ProductId, IsFavorite));
}
}
public partial class ProductListViewModel : ObservableObject,
IRecipient<ProductFavoritedMessage>
{
public ProductListViewModel()
{
WeakReferenceMessenger.Default.Register(this);
}
public void Receive(ProductFavoritedMessage message)
{
var item = Items.FirstOrDefault(p => p.Id == message.ProductId);
if (item is not null) item.IsFavorite = message.IsFavorite;
}
}
약한 참조이므로 ViewModel이 가비지 수집되면 자동으로 등록이 해제됩니다. 그래도 화면이 명확히 종료될 때는 WeakReferenceMessenger.Default.UnregisterAll(this)를 호출해 두는 게 안전합니다. Xamarin.Forms 시절의 MessagingCenter와 사용 경험이 거의 같아서 마이그레이션도 어렵지 않습니다.
ObservableValidator로 입력 검증하기
로그인이나 회원가입처럼 입력 검증이 필요한 화면에서는 ObservableValidator를 상속받습니다. 이 클래스는 INotifyDataErrorInfo를 구현하므로 MAUI Entry와 결합해 실시간 오류 메시지를 표시할 수 있습니다.
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class SignUpViewModel : ObservableValidator
{
[ObservableProperty]
[Required(ErrorMessage = "이메일을 입력하세요.")]
[EmailAddress(ErrorMessage = "올바른 이메일 형식이 아닙니다.")]
[NotifyDataErrorInfo]
private string _email = string.Empty;
[ObservableProperty]
[Required]
[MinLength(8, ErrorMessage = "비밀번호는 8자 이상이어야 합니다.")]
[NotifyDataErrorInfo]
private string _password = string.Empty;
[RelayCommand]
private async Task SignUpAsync()
{
ValidateAllProperties();
if (HasErrors) return;
// 회원가입 API 호출
}
}
XAML에서는 GetErrors를 호출하는 작은 변환기를 만들어 첫 번째 오류 메시지를 표시합니다. .NET 9부터는 ObservableValidator가 트림 모드에서도 잘 동작하도록 개선되었습니다. 더 자세한 어노테이션 목록은 System.ComponentModel.DataAnnotations 공식 레퍼런스를 참고하세요.
자주 발생하는 함정과 해결 방법
실무에서 부딪힌(혹은 동료 개발자들이 종종 묻는) 대표적인 함정을 다섯 가지 정리했습니다. 각 항목은 MVVMTK 진단 코드 또는 런타임 증상을 기준으로 빠르게 진단할 수 있습니다.
partial 키워드 누락(MVVMTK0032): 어트리뷰트는 붙였는데 속성이 보이지 않는다면 클래스 선언에 partial이 빠졌을 확률이 90%입니다.
필드 이름과 속성 이름 충돌:private bool _isBusy 옆에 직접 public bool IsBusy { get; set; }를 또 쓰면 모호한 참조 오류가 납니다. 둘 중 하나만 유지하세요.
UI 스레드 위반: 비동기 명령에서 ObservableCollection을 수정할 때 백그라운드 스레드라면 MainThread.BeginInvokeOnMainThread로 감싸야 합니다. 또는 .NET 9의 Dispatcher.DispatchAsync를 사용합니다.
Messenger 등록 누수:StrongReferenceMessenger를 잘못 선택하면 ViewModel이 사라지지 않습니다. 페이지 전환 후 메모리가 줄지 않는다면 가장 먼저 확인할 부분입니다.
AOT 게시 시 트리밍 경고: 명령 메서드가 리플렉션으로만 호출된다고 판단되면 트리머가 제거할 수 있습니다. DynamicallyAccessedMembers 어트리뷰트 또는 TrimmerRootDescriptor를 사용해 보존하세요.
마지막으로, 진단 코드를 IDE에서 그냥 무시하지 마세요. CommunityToolkit.Mvvm은 MVVMTK0001부터 MVVMTK0045까지 명확한 진단 메시지를 제공하니까, 빨간 줄을 만나면 메시지 코드를 검색해 공식 문서의 해결 가이드로 바로 이동하는 습관이 가장 빠른 길입니다.
ViewModel 단위 테스트 작성하기
ObservableObject를 상속한 ViewModel은 UI 의존성이 없는 평범한 .NET 클래스이므로 xUnit, NUnit, MSTest 어느 프레임워크와도 자연스럽게 어울립니다. PropertyChanged 이벤트가 올바르게 발생하는지, 명령의 CanExecute가 상태 변화에 맞춰 토글되는지, 비동기 명령 완료 후 컬렉션이 갱신되는지 같은 동작을 모두 단위 테스트로 확인할 수 있습니다. 다음은 xUnit 예시입니다.
using Xunit;
using FluentAssertions;
using NSubstitute;
public class ProductListViewModelTests
{
[Fact]
public async Task LoadMore_PopulatesItemsAndTogglesIsBusy()
{
var service = Substitute.For<IProductService>();
service.GetNextPageAsync(Arg.Any<CancellationToken>())
.Returns(new[] { new Product(1, "사과"), new Product(2, "배") });
var vm = new ProductListViewModel(service);
var busyStates = new List<bool>();
vm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(vm.IsBusy)) busyStates.Add(vm.IsBusy);
};
await vm.LoadMoreCommand.ExecuteAsync(null);
vm.Items.Should().HaveCount(2);
busyStates.Should().Equal(true, false);
vm.LoadMoreCommand.CanExecute(null).Should().BeTrue();
}
}
핵심은 ViewModel을 직접 인스턴스화하고 의존성을 가짜 객체로 주입한다는 점입니다. WeakReferenceMessenger를 사용하는 ViewModel을 테스트할 때는 테스트마다 WeakReferenceMessenger.Default.Reset()을 호출해 다른 테스트가 등록한 수신자의 잔재가 새 테스트를 오염시키지 않도록 합니다. ObservableValidator 기반 ViewModel은 ValidateAllProperties()를 호출한 후 GetErrors(nameof(Email)) 결과를 단언해 검증 규칙을 회귀 없이 보호할 수 있습니다.
CI 파이프라인에 ViewModel 테스트만이라도 추가해 두면 UI 자동화보다 훨씬 빠르고 안정적으로 회귀를 잡을 수 있습니다. GitHub Actions의 dotnet test 단계 하나만 넣어도 효과가 꽤 큽니다. 코드 커버리지는 coverlet으로 수집하고, Codecov 또는 GitHub의 PR 코멘트로 가시화하면 팀 전체의 테스트 문화가 자연스럽게 자리 잡습니다. 자세한 공식 명세는 ObservableProperty 생성기 레퍼런스에서도 추가로 확인할 수 있습니다.
자주 묻는 질문
CommunityToolkit.Mvvm과 MVVM Light는 어떻게 다른가요?
MVVM Light는 2021년 이후 비활성 상태이며 .NET MAUI를 공식 지원하지 않습니다. CommunityToolkit.Mvvm은 Microsoft가 직접 유지보수하고 소스 생성기를 사용하기 때문에 코드가 더 짧고 NativeAOT 환경에서도 안전하게 동작합니다.
ObservableProperty가 컴파일은 되는데 속성이 인식되지 않습니다.
ViewModel 클래스에 partial 키워드가 빠졌을 가능성이 가장 큽니다. 또한 IDE 캐시가 오래된 경우 Visual Studio 또는 Rider에서 솔루션을 닫고 obj, bin 폴더를 삭제한 뒤 다시 빌드하면 생성된 코드가 인식됩니다.
RelayCommand에서 비동기 메서드를 호출하면 예외가 사라지는 이유는 무엇인가요?
기본적으로 AsyncRelayCommand는 예외를 Task에 보관만 하고 다시 던지지 않습니다. [RelayCommand(FlowExceptionsToTaskScheduler = true)]로 설정하거나 메서드 내부에서 try/catch로 직접 처리하고 사용자에게 메시지를 표시하세요.
WeakReferenceMessenger 대신 이벤트를 써도 되나요?
가능하지만 모바일에서는 페이지가 빈번하게 생성·파괴되므로 일반 C# 이벤트는 구독 해제 누락으로 인한 메모리 누수의 주요 원인이 됩니다. WeakReferenceMessenger는 약한 참조로 등록되므로 ViewModel이 사라지면 자동으로 해제되어 더 안전합니다.
CommunityToolkit.Mvvm은 NativeAOT와 호환되나요?
네. 8.0 버전부터 모든 핵심 API가 트리머 친화적으로 설계되었고 8.4에서는 추가적인 어노테이션이 보강되었습니다. 다만 DataAnnotations 기반 검증을 사용할 때는 검증 어트리뷰트가 트리밍되지 않도록 TrimmerRootAssembly 설정을 확인하세요.
.NET MAUI 10 기준 실전 성능 최적화 가이드. iOS NativeAOT, XAML 컴파일, CollectionView 2, Handler 패턴, 시작 시간 단축, 메모리 누수 추적, 렌더링 60fps 유지부터 dotnet-trace·dotnet-gcdump 측정까지 한 번에 정리합니다.
Xamarin.Forms 지원 종료 이후 2026년 현재 .NET MAUI 10으로 옮기는 실전 전략. .NET Upgrade Assistant 활용, Custom Renderer를 Handler로 전환, 자주 발생하는 10대 오류 해결, CollectionView 2·NativeAOT iOS 적용, 실제 일정 추정까지 한 번에 정리했습니다.