본문 바로가기

C#

C# ObservableCollection<T>와 UI 데이터 바인딩

UI는 컬렉션의 추가/삭제를 즉시 반영해야 합니다. C#의 ObservableCollection<T>는 변경 알림(INotifyCollectionChanged)을 제공하여 WPF/WinUI/MAUI 바인딩에서 자동으로 UI를 갱신합니다. List<T>는 알림이 없어 바인딩에 적합하지 않습니다.

1. ObservableCollection이 필요한 이유

ObservableCollection은 Add/Remove/Move/Replace/Reset 등의 변경을 INotifyCollectionChanged로 알립니다. UI 프레임워크는 이 이벤트를 구독해 화면을 업데이트합니다.

2. WPF 기본 바인딩 예제

ViewModel에 ObservableCollection을 노출하고 XAML에서 ItemsSource로 바인딩합니다.

using System.Collections.ObjectModel;
using System.ComponentModel;

public class Person : INotifyPropertyChanged
{
    private string _name;
    public string Name
    {
        get => _name;
        set { if (_name != value) { _name = value; OnPropertyChanged(nameof(Name)); } }
    }

    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

public class MainViewModel
{
    public ObservableCollection<Person> People { get; } = new()
    {
        new Person { Name = "Alice" },
        new Person { Name = "Bob" }
    };

    public void AddPerson(string name) => People.Add(new Person { Name = name });
    public void RemovePerson(Person p)
    {
        if (p != null) People.Remove(p);
    }
}
<Window x:Class="Demo.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:Demo">
  <Window.DataContext>
    <local:MainViewModel />
  </Window.DataContext>
  <StackPanel Margin="12">
    <ListBox ItemsSource="{Binding People}" DisplayMemberPath="Name" x:Name="PeopleList" />
    <StackPanel Orientation="Horizontal" Margin="0,8,0,0">
      <TextBox x:Name="NameBox" Width="140" />
      <Button Content="추가" Margin="8,0,0,0" Click="Add_Click" />
    </StackPanel>
  </StackPanel>
</Window>
// code-behind
private void Add_Click(object sender, System.Windows.RoutedEventArgs e)
{
    var vm = (MainViewModel)DataContext;
    vm.AddPerson(NameBox.Text);
}

People에 Add/Remove를 호출하면 ListBox가 자동으로 갱신됩니다.

3. 아이템 속성 변경 반영

컬렉션 내부 아이템의 속성 변경은 INotifyPropertyChanged로 반영합니다. 위 Person처럼 속성 변경 시 OnPropertyChanged를 호출해야 UI가 갱신됩니다.

4. 스레드/비동기 업데이트

ObservableCollection은 UI 스레드에서 수정해야 합니다. 백그라운드 작업에서 추가하려면 Dispatcher를 사용합니다.

using System.Threading.Tasks;
using System.Windows;

Task.Run(() =>
{
    var item = new Person { Name = "Eve" };
    Application.Current.Dispatcher.Invoke(() =>
    {
        // UI 스레드에서 수정
        ((MainViewModel)Application.Current.MainWindow.DataContext)
            .People.Add(item);
    });
});

WPF .NET 4.5+에서는 EnableCollectionSynchronization로 스레드 간 동기화를 허용할 수 있습니다.

using System.Windows.Data;

public class MainViewModel
{
    private readonly object _lock = new();
    public ObservableCollection<Person> People { get; } = new();

    public MainViewModel()
    {
        BindingOperations.EnableCollectionSynchronization(People, _lock);
    }

    public void AddFromAnyThread(Person p)
    {
        lock (_lock)
        {
            People.Add(p);
        }
    }
}

WinUI/MAUI도 개념은 동일하며, 주로 메인 스레드 디스패처(Dispatcher/MainThread)를 사용합니다.

5. 대량 변경 최적화(AddRange)

많은 항목을 한 번에 추가하면 항목마다 이벤트가 발생해 느려집니다. Reset 한 번만 발생시키는 RangeObservableCollection을 사용합니다.

using System.Collections.Specialized;
using System.Collections.ObjectModel;
using System.ComponentModel;

public class RangeObservableCollection<T> : ObservableCollection<T>
{
    private bool _suppress;

    public void AddRange(IEnumerable<T> items)
    {
        if (items == null) return;
        _suppress = true;
        foreach (var item in items) Items.Add(item);
        _suppress = false;
        OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
        OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
        OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
    }

    protected override void OnCollectionChanged(NotifyCollectionChangedEventArgs e)
    {
        if (!_suppress) base.OnCollectionChanged(e);
    }
}

UI는 전체 리프레시를 수행하므로, 가상화가 있는 컨트롤을 쓰면 대량 변경에도 부하가 줄어듭니다.

6. 읽기 전용으로 노출

외부 수정 차단을 위해 내부 컬렉션을 감싸 ReadOnlyObservableCollection으로 노출합니다.

private readonly ObservableCollection<Person> _people = new();
public ReadOnlyObservableCollection<Person> People { get; }

public MainViewModel()
{
    People = new ReadOnlyObservableCollection<Person>(_people);
}

public void AddPerson(string name) => _people.Add(new Person { Name = name });

7. 정렬/필터링과 CollectionView

CollectionViewSource를 사용하면 UI 갱신은 유지하면서 정렬/필터를 적용할 수 있습니다.

using System.ComponentModel;
using System.Windows.Data;

var view = CollectionViewSource.GetDefaultView(viewModel.People);
using (view.DeferRefresh())
{
    view.SortDescriptions.Clear();
    view.SortDescriptions.Add(new SortDescription(nameof(Person.Name), ListSortDirection.Ascending));
    view.Filter = o => ((Person)o).Name.StartsWith("A", StringComparison.CurrentCultureIgnoreCase);
}

8. 체크리스트

- List<T> 대신 ObservableCollection<T>를 바인딩에 사용합니다.
- 아이템 클래스는 INotifyPropertyChanged를 구현합니다.
- 백그라운드 스레드에서 수정 시 Dispatcher 또는 EnableCollectionSynchronization을 사용합니다.
- 대량 변경은 AddRange(Reset) 패턴으로 최적화합니다.
- 외부에는 ReadOnlyObservableCollection으로 노출해 캡슐화를 지킵니다.
- 정렬/필터는 CollectionView로 처리합니다.

이 패턴을 지키면 UI 바인딩이 깔끔하고, 성능도 안정적으로 유지됩니다.