.NET MAUI 앱을 처음 만들 때 거의 모두가 같은 벽에 부딪칩니다. 바로 "어떻게 하면 ViewModel을 깔끔하게 유지할 수 있을까?"라는 질문이죠. 솔직히 말해서, INotifyPropertyChanged를 직접 구현해본 분이라면 아실 겁니다 — 프로퍼티 하나에 5~10줄씩 들러붙는 그 보일러플레이트 코드, 정말 금방 지쳐요.
그래서 2026년 현재, .NET MAUI 10과 함께 쓰이는 사실상의 표준 해결책이 바로 Microsoft가 직접 관리하는 CommunityToolkit.Mvvm 패키지입니다.
이 가이드는 단순히 "[ObservableProperty]를 붙이세요"에서 멈추지 않습니다. 소스 생성기가 컴파일 타임에 정확히 무엇을 만들어내는지, RelayCommand와 AsyncRelayCommand를 언제 어떻게 골라 써야 하는지, 그리고 의존성 주입(DI)과 함께 ViewModel을 어떻게 등록해야 메모리 누수와 stale state 같은 골치 아픈 문제를 피할 수 있는지까지 — .NET MAUI 10 환경에서 실제로 동작하는 코드와 함께 정리합니다.
1. CommunityToolkit.Mvvm이 해결하는 문제
자, 일단 전통적인 MVVM 코드부터 한번 보시죠.
public class MainViewModel : INotifyPropertyChanged
{
private string _name;
public string Name
{
get => _name;
set
{
if (_name == value) return;
_name = value;
OnPropertyChanged(nameof(Name));
}
}
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged(string name)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
프로퍼티 5개만 추가해도 50줄을 훌쩍 넘기게 됩니다. 저도 예전 Xamarin.Forms 시절에 이렇게 짜다가 손목이 시큰거렸던 기억이 나네요. CommunityToolkit.Mvvm은 Roslyn 소스 생성기를 활용해 컴파일 타임에 이 모든 코드를 자동으로 생성합니다. 결과적으로 ViewModel 본문은 다음과 같이 단순해져요.
public partial class MainViewModel : ObservableObject
{
[ObservableProperty]
private string _name;
}
핵심은 partial class 선언입니다. 소스 생성기가 동일한 이름의 partial class를 또 하나 만들고, 그곳에 Name public 프로퍼티와 변경 알림 로직을 채워넣기 때문이죠.
2. 패키지 설치와 초기 설정
.NET MAUI 10 프로젝트의 루트 디렉터리에서 다음 명령으로 패키지를 추가하면 됩니다.
dotnet add package CommunityToolkit.Mvvm
또는 .csproj에 직접 명시할 수도 있습니다.
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
CommunityToolkit.Mvvm은 .NET Standard 2.0과 .NET 6 이상을 지원하므로 .NET MAUI 10(.NET 10 기반)에서 별도 설정 없이 즉시 사용할 수 있습니다. 한 가지 큰 매력은, UI 프레임워크에 종속되지 않는다는 점이에요. 덕분에 ViewModel을 다른 .NET 프로젝트(WPF, WinUI, Avalonia 등)에서 그대로 재사용할 수 있죠.
3. ObservableProperty 심층 가이드
3.1. 기본 사용법과 명명 규칙
[ObservableProperty]는 private 필드에만 적용할 수 있고, 같은 이름의 public 프로퍼티가 자동 생성됩니다. 필드 이름은 다음 세 가지 규칙 중 어느 것이든 사용할 수 있습니다.
name→Name_name→Namem_name→Name
개인적으로는 _name 스타일을 추천합니다. 한국어 코드베이스에서도 가장 널리 쓰이고, 디버깅 시 필드와 프로퍼티가 시각적으로 깔끔하게 구분되거든요.
3.2. 변경 콜백: OnXxxChanging / OnXxxChanged
프로퍼티 값이 바뀌기 직전과 직후에 동작을 끼워넣고 싶다면? partial 메서드를 그냥 선언하기만 하면 됩니다. 별다른 설정 없이도 소스 생성기가 자동으로 호출 지점을 삽입해줘요.
public partial class UserProfileViewModel : ObservableObject
{
[ObservableProperty]
private string _email;
partial void OnEmailChanging(string value)
{
// 변경 직전: 검증 또는 로깅
Debug.WriteLine($"이메일이 {value}(으)로 바뀌려 합니다.");
}
partial void OnEmailChanged(string value)
{
// 변경 직후: 부수 효과
IsEmailValid = !string.IsNullOrWhiteSpace(value) && value.Contains('@');
}
[ObservableProperty]
private bool _isEmailValid;
}
이 패턴은 검증 로직을 ViewModel 안에 자연스럽게 캡슐화할 수 있어서 정말 편리합니다. 별도의 Behavior나 Converter 없이도 즉각적인 UI 피드백을 구현할 수 있죠.
3.3. 다른 프로퍼티에 변경 알림 전파: NotifyPropertyChangedFor
한 프로퍼티가 바뀔 때 계산된 다른 프로퍼티도 함께 갱신해야 한다면 [NotifyPropertyChangedFor]를 사용합니다.
public partial class CartViewModel : ObservableObject
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
[NotifyPropertyChangedFor(nameof(IsCheckoutEnabled))]
private int _quantity;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TotalPrice))]
private decimal _unitPrice;
public decimal TotalPrice => Quantity * UnitPrice;
public bool IsCheckoutEnabled => Quantity > 0;
}
이렇게 하면 Quantity가 바뀔 때 TotalPrice와 IsCheckoutEnabled가 모두 자동으로 PropertyChanged 이벤트를 발생시킵니다. 깔끔하죠?
3.4. 생성된 프로퍼티에 어트리뷰트 전달하기
JSON 직렬화 어트리뷰트처럼 자동 생성된 프로퍼티 쪽에 어트리뷰트를 붙여야 할 때가 있습니다 (예: System.Text.Json 사용 시). 이때는 [property: ...] 타겟을 활용하면 돼요.
[ObservableProperty]
[property: JsonPropertyName("user_name")]
[property: JsonRequired]
private string? _username;
4. RelayCommand로 ICommand 보일러플레이트 제거
4.1. 동기 vs 비동기 커맨드
[RelayCommand]는 메서드에 적용합니다. 메서드가 void를 반환하면 IRelayCommand가, Task를 반환하면 IAsyncRelayCommand가 자동으로 생성되죠.
public partial class LoginViewModel : ObservableObject
{
private readonly IAuthService _authService;
public LoginViewModel(IAuthService authService)
{
_authService = authService;
}
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
private string _email = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
private string _password = string.Empty;
[ObservableProperty]
private bool _isBusy;
[RelayCommand(CanExecute = nameof(CanLogin))]
private async Task LoginAsync()
{
IsBusy = true;
try
{
await _authService.SignInAsync(Email, Password);
await Shell.Current.GoToAsync("//home");
}
finally
{
IsBusy = false;
}
}
private bool CanLogin() =>
!string.IsNullOrWhiteSpace(Email) &&
!string.IsNullOrWhiteSpace(Password) &&
!IsBusy;
}
여기서 한 가지 주목할 점. 메서드 이름이 LoginAsync인데 생성된 커맨드는 LoginCommand입니다. 소스 생성기가 Async 접미사를 자동으로 떼어준다는 거죠. 처음 봤을 땐 살짝 헷갈릴 수 있지만, 익숙해지면 오히려 자연스럽게 느껴집니다.
4.2. AsyncRelayCommand의 동시 실행 방지
모바일 앱에서 가장 흔한 버그 중 하나가 뭔지 아세요? 사용자가 버튼을 너무 빨리 두 번 탭해서 동일한 네트워크 요청이 두 번 날아가는 상황입니다. 결제 화면에서 이게 터지면… 정말 식은땀 나죠.
다행히 AsyncRelayCommand는 기본적으로 동시 실행을 차단합니다. 작업이 진행 중인 동안 자동으로 CanExecuteChanged를 발생시키므로, XAML의 Button도 자동으로 비활성화돼요.
동시 실행을 명시적으로 허용하려면 다음과 같이 옵션을 주면 됩니다.
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task RefreshAsync() { /* ... */ }
다만 — 대부분의 경우 기본값을 그대로 두는 것이 안전합니다. 특히 결제·로그인 같은 민감한 흐름이라면 더더욱이요.
4.3. CommandParameter와 인자 전달
[RelayCommand]
private async Task DeleteItemAsync(TodoItem item)
{
if (item is null) return;
Items.Remove(item);
await _repository.DeleteAsync(item.Id);
}
<CollectionView ItemsSource="{Binding Items}">
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:TodoItem">
<SwipeView>
<SwipeView.RightItems>
<SwipeItem Text="삭제"
Command="{Binding Source={RelativeSource AncestorType={x:Type vm:TodoListViewModel}},
Path=DeleteItemCommand}"
CommandParameter="{Binding .}" />
</SwipeView.RightItems>
</SwipeView>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
4.4. 취소 토큰 자동 주입
이건 정말 유용한 기능입니다. 비동기 메서드의 마지막 매개변수가 CancellationToken이면, RelayCommand는 자동으로 IAsyncRelayCommand.Cancel() 메서드를 노출시키고 토큰을 주입해줘요. 페이지 이탈 시 진행 중인 작업을 깔끔하게 취소할 수 있다는 뜻입니다.
[RelayCommand(IncludeCancelCommand = true)]
private async Task LoadDataAsync(CancellationToken token)
{
var items = await _api.GetItemsAsync(token);
Items = new ObservableCollection<Item>(items);
}
그러면 LoadDataCommand와 함께 LoadDataCancelCommand가 자동 생성됩니다. 한 줄 어트리뷰트로 이만큼 얻는 건 정말 가성비가 좋죠.
5. 의존성 주입과 함께 ViewModel 등록하기
.NET MAUI 10의 MauiProgram.cs는 ASP.NET Core와 동일한 IServiceCollection 기반 DI 컨테이너를 제공합니다. ViewModel과 Page를 모두 컨테이너에 등록하는 것이 표준 패턴이에요.
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<IAuthService, AuthService>();
builder.Services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
builder.Services.AddSingleton<HttpClient>();
// ViewModel과 Page (트랜지언트)
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<TodoListViewModel>();
builder.Services.AddTransient<TodoListPage>();
return builder.Build();
}
}
5.1. 라이프타임 선택 가이드
| 대상 | 라이프타임 | 이유 |
|---|---|---|
| HttpClient, DB Connection, Settings | Singleton | 전역 상태 공유, 비싼 생성 비용 |
| 인증·연결성·푸시 알림 서비스 | Singleton | 앱 전체에서 단일 인스턴스 필요 |
| Page, ViewModel | Transient | 네비게이션마다 fresh state 보장, 메모리 누수 방지 |
| Scoped | 거의 사용 X | MAUI에는 빌트인 스코프가 없음 |
5.2. Page에서 ViewModel 주입받기
public partial class LoginPage : ContentPage
{
public LoginPage(LoginViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
Shell 라우팅을 사용하는 경우, Routing.RegisterRoute()로 등록한 페이지도 DI 컨테이너에서 자동으로 해석됩니다.
5.3. 동일 인터페이스의 여러 구현: Keyed Services
.NET 8부터 도입된 Keyed Services는 .NET MAUI 10에서도 그대로 쓸 수 있습니다.
builder.Services.AddKeyedSingleton<IStorage, LocalStorage>("local");
builder.Services.AddKeyedSingleton<IStorage, CloudStorage>("cloud");
// ViewModel에서 사용
public SettingsViewModel(
[FromKeyedServices("local")] IStorage local,
[FromKeyedServices("cloud")] IStorage cloud)
{
_local = local;
_cloud = cloud;
}
6. WeakReferenceMessenger로 ViewModel 간 통신
잠깐, 옛날 얘기 좀 하자면 — Xamarin.Forms 시절의 MessagingCenter는 메모리 누수 문제로 악명이 높았습니다. 저도 한 번 이걸로 며칠을 헤맨 적이 있는데, 정말 디버깅하기 까다로웠죠.
다행히 CommunityToolkit.Mvvm의 WeakReferenceMessenger는 약한 참조(weak reference)를 사용하므로 자동으로 가비지 컬렉션됩니다. 한결 안전해요.
// 메시지 정의
public sealed record CartUpdatedMessage(int ItemCount);
// 발행자 (Cart 페이지 ViewModel)
public partial class CartViewModel : ObservableObject
{
[RelayCommand]
private void AddItem(Product product)
{
Items.Add(product);
WeakReferenceMessenger.Default.Send(new CartUpdatedMessage(Items.Count));
}
}
// 구독자 (Header ViewModel)
public partial class HeaderViewModel : ObservableObject, IRecipient<CartUpdatedMessage>
{
[ObservableProperty]
private int _badgeCount;
public HeaderViewModel()
{
WeakReferenceMessenger.Default.Register(this);
}
public void Receive(CartUpdatedMessage message)
{
BadgeCount = message.ItemCount;
}
}
StrongReferenceMessenger는 성능이 더 좋긴 하지만 수동 Unregister가 필요합니다. 그러니까 처음에는 WeakReferenceMessenger로 시작하시길 권합니다 — 정말 성능 병목이 확인된 다음에 옮겨도 늦지 않아요.
7. 실전 폴더 구조
중간 규모 이상의 .NET MAUI 10 프로젝트에서 실전으로 검증된 폴더 구조 예시입니다.
MyApp/
├── Models/
│ ├── TodoItem.cs
│ └── User.cs
├── Services/
│ ├── IAuthService.cs
│ ├── AuthService.cs
│ ├── ITodoRepository.cs
│ └── SqliteTodoRepository.cs
├── ViewModels/
│ ├── Base/
│ │ └── ViewModelBase.cs
│ ├── LoginViewModel.cs
│ └── TodoListViewModel.cs
├── Views/
│ ├── LoginPage.xaml
│ └── TodoListPage.xaml
├── Messages/
│ └── CartUpdatedMessage.cs
├── Resources/
└── MauiProgram.cs
핵심 원칙은 단 하나. ViewModel은 View를 절대 참조하지 않는다는 점입니다. 네비게이션도 Shell.Current.GoToAsync() 또는 INavigationService 추상화를 통해 ViewModel에서 호출하세요. 이 규칙만 지켜도 단위 테스트가 훨씬 수월해집니다.
8. 자주 빠지는 함정과 해결책
8.1. partial 키워드 누락
가장 흔한 실수예요. [ObservableProperty]를 붙였는데 프로퍼티가 안 만들어진다? 99%는 partial class 선언이 빠진 경우입니다. 제일 먼저 여기를 확인하세요.
8.2. ObservableObject가 아닌 베이스 클래스 상속
이미 INotifyPropertyChanged를 직접 구현한 베이스 클래스를 상속하면 소스 생성기가 충돌합니다. 이런 경우엔 [ObservableObject] 어트리뷰트를 클래스에 적용하면 베이스 클래스를 바꾸지 않고도 같은 효과를 얻을 수 있어요.
[ObservableObject]
public partial class CustomViewModel : MyExistingBase { }
8.3. ViewModel을 Singleton으로 등록
편의상 ViewModel을 Singleton으로 등록하면 어떻게 될까요? 페이지가 닫혀도 상태가 그대로 남아 있어서, 다음에 다시 진입했을 때 stale 데이터가 떡하니 보입니다. 사용자 입장에선 정말 짜증 나죠. ViewModel은 항상 Transient로 등록하세요. 예외는 거의 없습니다.
8.4. 생성된 코드 확인 방법
소스 생성기가 무엇을 만들었는지 직접 보고 싶다면 — 솔루션 탐색기에서 프로젝트 → Dependencies → net10.0-android → Analyzers → CommunityToolkit.Mvvm.SourceGenerators를 펼치면 생성된 .cs 파일을 볼 수 있습니다. 디버깅하다 막힐 때 정말 유용하니, 한 번쯤은 꼭 들여다보시길 추천합니다.
9. 마이그레이션 체크리스트
기존 수동 INotifyPropertyChanged ViewModel을 CommunityToolkit.Mvvm으로 옮길 때 따라야 할 순서입니다.
CommunityToolkit.MvvmNuGet 패키지 추가- 클래스를
partial로 변경하고ObservableObject상속으로 교체 - private 백킹 필드만 남기고 public 프로퍼티 본문 삭제 →
[ObservableProperty]부착 ICommand프로퍼티와RelayCommand인스턴스 생성 코드 삭제 → 메서드에[RelayCommand]부착RaisePropertyChanged호출 위치를NotifyPropertyChangedFor로 대체MessagingCenter호출을WeakReferenceMessenger로 교체MauiProgram.cs에서 ViewModel과 Page를 DI 컨테이너에 등록- 유닛 테스트로 동작 검증
한 번에 다 옮기려고 하지 마세요. 한 화면씩 점진적으로 전환하는 게 훨씬 안전합니다.
FAQ
CommunityToolkit.Mvvm은 .NET MAUI 10 외에 어디에서 쓸 수 있나요?
WPF, WinUI 3, Uno Platform, Avalonia, 콘솔 앱 등 .NET이 동작하는 거의 모든 환경에서 사용할 수 있습니다. UI 프레임워크에 종속되지 않기 때문에, ViewModel을 다른 플랫폼으로 그대로 옮길 수 있다는 점이 정말 큰 장점이에요.
ObservableProperty가 자동으로 만들어주는 프로퍼티는 virtual로 선언할 수 있나요?
아쉽게도 기본 생성 결과는 virtual이 아닙니다. 상속이 필요하다면 직접 프로퍼티를 작성해야 하는데, 사실 대부분의 경우엔 컴포지션이나 인터페이스 분리로 같은 목표를 달성할 수 있어서 그렇게까지 권장하지는 않아요.
RelayCommand와 .NET MAUI 빌트인 Command의 차이점은 무엇인가요?
가장 큰 차이는 의존성 분리입니다. .NET MAUI의 Command 클래스는 Microsoft.Maui.Controls 어셈블리에 속하므로, 이걸 쓰는 순간 ViewModel이 UI 프레임워크에 묶이게 돼요. 반면 RelayCommand는 UI 프레임워크와 완전히 무관해서, ViewModel을 순수 .NET 라이브러리로 분리해 단위 테스트하기가 훨씬 쉽습니다.
ObservableProperty와 직접 INotifyPropertyChanged 구현 중 성능 차이가 있나요?
실질적으로 거의 없습니다. 소스 생성기는 컴파일 타임에 동작하므로 런타임 리플렉션 비용이 0이고, 생성된 코드도 손으로 작성한 것과 거의 동일해요. 오히려 EqualityComparer<T>.Default를 사용해 동일 값일 때의 불필요한 알림을 막아주기 때문에, 미세하게 더 효율적이라고 볼 수도 있습니다.
ViewModel에서 페이지 네비게이션은 어떻게 하나요?
가장 권장되는 방법은 두 가지입니다. Shell.Current.GoToAsync("//route")를 직접 호출하거나, INavigationService 인터페이스를 만들어 DI로 주입받는 것이죠. 후자가 단위 테스트에서 네비게이션을 모킹할 수 있어서 훨씬 깔끔합니다 — 프로젝트 규모가 좀 있다면 처음부터 후자로 가시는 걸 추천드려요.
마무리
솔직히 말하면, .NET MAUI 10에서 CommunityToolkit.Mvvm은 더 이상 "선택지" 중 하나가 아닙니다. 사실상의 표준이에요. [ObservableProperty]와 [RelayCommand] 두 어트리뷰트만 익혀도 ViewModel 코드가 절반 이하로 줄어들고, 여기에 DI 컨테이너 등록 패턴과 WeakReferenceMessenger까지 더하면 유지보수성과 테스트 용이성이 한 단계 도약합니다.
새 프로젝트라면 첫 ViewModel부터, 기존 프로젝트라면 가장 손이 자주 가는 화면부터 점진적으로 도입해보세요. 한두 화면만 옮겨봐도 "왜 진작 이걸 안 썼지"라는 생각이 드실 겁니다.