ObservableCollection 초기화할 때 Notify가 몇번 발생할까

시작

문득 ObservableCollection에서

ObservableCollection 객체를 대충 할당해놓고, 사후에 반복문을 통해 CollectionItem을 초기화하는 게 나을지, 아니면 ObservableCollection(List list) 생성자를 통해 한번에 생성자에 이미 List 객체를 초기화 하는게 나은지 궁금했습니다.

뭐 성능이라는 것이 누적되어서 체감이 되어야 유의미한 성능이라고 불릴 수 있고, 체감도 안되는 미세한 차이는 성능이라고 말하기 애매한 부분이겠지만 정확하게 알고 싶었습니다.

먼저 ChatGPT-4에게 물어봤습니다.

ChatGPT 질의응답

Q. 그렇다면 ObservableCollection() 생성자를 생성하고 하나씩 등록하는 게 좋을까, 아니면 ObservableCollection(new List()) 형태로 등록하는게 좋을까?

어느 쪽이 Notify가 덜 발생해?


A. ObservableCollection<T> 생성자를 사용하여 컬렉션을 생성한 후 항목을 하나씩 추가하는 방법과 ObservableCollection<T>(new List<T>()) 형태로 한 번에 등록하는 방법 중 후자가 CollectionChanged 이벤트 알림이 덜 발생합니다.

하나씩 추가하는 경우, 항목이 추가될 때마다 CollectionChanged 이벤트 알림이 발생합니다. 반면, ObservableCollection<T>(new List<T>()) 형태로 등록하는 경우, 컬렉션에 대한 초기 설정이 완료된 후에만 CollectionChanged 이벤트가 한 번 발생합니다.

따라서 초기 설정 시 이벤트 알림이 최소화되는 것이 선호되는 경우, ObservableCollection<T>(new List<T>()) 형태로 등록하는 것이 더 좋습니다.

예를 들어 인코딩 목록을 ObservableCollection으로 변환하는 경우 다음과 같이 할 수 있습니다:

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text;

public class EncodingList
{
    public static ObservableCollection<Encoding> GetEncodings()
    {
        return new ObservableCollection<Encoding>(new List<Encoding>
        {
            Encoding.UTF8,
            Encoding.UTF7,
            Encoding.UTF32,
            Encoding.Unicode,
            Encoding.ASCII,
            Encoding.BigEndianUnicode,
            // 여기에 추가로 사용할 인코딩을 넣을 수 있습니다.
        });
    }
}

이렇게 하면 인코딩 목록이 한 번에 등록되고 CollectionChanged 이벤트 알림이 최소화됩니다.

확인

ChatGPT-4에게 낚시를 많이 당해봤기 때문에 지식에 대한 검증이 필요했습니다.
정말 그런지.

이 밑의 소스는 모두 .NET Reference Source에 있습니다.

우리의 ObservableCollection는 아래와 같이 생겼습니다.

public class ObservableCollection<T> : Collection<T>, INotifyCollectionChanged, INotifyPropertyChanged

그리고 아래와 같은 생성자 목록을 갖고 있습니다.

public ObservableCollection() : base() { }

public ObservableCollection(List<T> list) : base((list != null) ? new List<T>(list. Count) : list)
{
    CopyFrom(list);
}

public ObservableCollection(IEnumerable<T> collection)
{
    if (collection == null)
        throw new ArgumentNullException("collection");
 
    CopyFrom(collection);
}

private void CopyFrom(IEnumerable<T> collection)
{
    IList<T> items = Items;
    if (collection != null && items != null)
    {
        using (IEnumerator<T> enumerator = collection.GetEnumerator())
        {
            while (enumerator.MoveNext())
            {
                items.Add(enumerator.Current);
            }
        }
    }
}

CopyFrom 메서드를 보면

IList<T> items = Items;

라는 코드가 있고 밑에서

while (enumerator.MoveNext())
{
    items.Add(enumerator. Current);
}

를 하고 있었습니다.

여기서 메서드 내의 지역변수인 items 말고 Items는 상속받고 있는 Class인 Collection의 Property 였습니다.

[Serializable]
[System.Runtime.InteropServices.ComVisible(false)]
[DebuggerTypeProxy(typeof(Mscorlib_CollectionDebugView<>))]
[DebuggerDisplay("Count = {Count}")]    
public class Collection<T>: IList<T>, IList, IReadOnlyList<T>
{
    IList<T> items;
    protected IList<T> Items {
        get { return items; }
    }
}

또한 Add 메서드도 Collection 안에 있는 메서드 였습니다.

public void Add(T item) {
    if( items.IsReadOnly) {
        ThrowHelper.ThrowNotSupportedException(ExceptionResource.NotSupported_ReadOnlyCollection);
    }
    
    int index = items. Count;
    InsertItem(index, item);
}

우리가 ObservableCollection클래스에서 Add 메서드를 호출하는 것은 ObservableCollection 클래스의 메서드가 아닌 Collection 클래스의 메서드 였습니다.

그리고 그 Collection 클래스의 Add 메서드에는 아래 메서드를 추가로 호출하고 있습니다.

InsertItem(index, item);

이 메서드를 따라가 보겠습니다.

protected virtual void InsertItem(int index, T item) {
    items.Insert(index, item);
}

InsertItem은 virtual 메서드였네요.
그렇다는 것은 재정의가 가능하다는 뜻이죠.
ObservableCollection 클래스에도 InsertItem이 있는지 확인해보겠습니다.

protected override void InsertItem(int index, T item)
{
    CheckReentrancy();
    base.InsertItem(index, item);
 
    OnPropertyChanged(CountString);
    OnPropertyChanged(IndexerName);
    OnCollectionChanged(NotifyCollectionChangedAction.Add, item, index);
}

역시 있었네요. base.InsertItem(index, item)을 통해 기존 Collection 클래스의 Items에 개체를 추가하고 ObservableCollection의 책임을 다하기 위한 Notify를 여기서 하고 있었네요.


결론

Notify는 InsertItem 메서드에서 하고 있었고,
InsertItem 메서드는 protected 메서드라서 사용자가 직접 호출이 불가하고,
Collection 클래스의 Add 메서드를 통해 재정의된 InsertItem 메서드가 호출되고 있었고,
ObservableCollection<T>(List<T> list) 생성자의 CopyForm 메서드에서는 Collection 클래스의 Add 메서드를 호출하고 있었으므로,

아무튼 ObservableCollection 클래스에서 Add 메서드를 호출하면 ObservableCollectionn의 InsertItem 메서드를 타게 됩니다.

즉,

public ObservableCollection<T> TestABC = new (new List<T>
{
    new T(),
    new T(),
    new T(),
    new T(),
});

public ObservableCollection<T> TestABC = new ();
TestABC.Add(new T());
TestABC.Add(new T());
TestABC.Add(new T());
TestABC.Add(new T());

Notify가 같은 횟수로 발생하며 ChatGPT가 거짓말을 했다는 결론이 나옵니다.

image

8개의 좋아요

결국 Notify를 최소화하려면 INCC를 구현하는 컬렉션을 새로 만들거나, 아니면 ObservableCollection을 상속한 후 Collection.Items에 직접 접근해 값을 추가한 다음 Notify하는 메서드를 정의해야 한다는 결론이 나오는군요 ㅋㅅㅋ

3개의 좋아요

네 ㅋㅋ

INCC가 뭔지 딱 보고는 몰랐는데, INotifyPropertyCollectionChanged 인가요?

실제로 그렇게 불리고 있을까요 ㅋㅋ

4개의 좋아요

일단 막무가내로 써서 너도나도 그렇게 쓰게 만들면 실제로 쓰이는 단어가 됩니당:blush:

4개의 좋아요

image

한번 쯤은 거짓말하지마 라고 해주는 것도 좋은 거 같습니다.

6개의 좋아요

자주 추궁해줘야합니다. :rofl:

4개의 좋아요

@Vincent 님이 목욕값을 안 주신게 분명합니다.

5개의 좋아요

거짓말하지말라고 하니까 바로 태세전환해서 차이가 없다고 해버리고
개인 취향과 상황에 따라 알아서 결정하라고 해버리네요…ㄷㄷ

5개의 좋아요

이럴수가…20$말고 더 줘야하나보군요;

앞으로는 API형태로 쏴보는 것으로…

5개의 좋아요

정답은 “아무 의미 없다” 입니다. ^^

소스 프로퍼티는 뷰모델 객체가 생성될 때 초기화 되고, 바인딩은 그 다음에 설정됩니다.

이는 바인딩되기 전에는 소스 프로퍼티의 set 에서 트리거한 이벤트를 구독하는 객체가 없는 상태라는 의미입니다.

PropertyChanged?.Invoke.. // ?. 연산자로 인해 invoke가 호출되지 않음.

Notify 가 안 되도 타겟이 소스의 값과 동일한 이유는, 바인딩이 성립되면 타겟은 소스를 무조건 한 번 Read 하도록 구현이 되어 있기 때문입니다.

5개의 좋아요

소스 프로퍼티는 어떤 걸 의미하신 걸까요?

아무 의미가 없다는 말씀도 잘 이해가 안됩니다.

어차피 PropertyChanged가 null일테니까 제가 소스를 통해 비교한 내용이 전부 틀렸다는 말씀이실까요?

4개의 좋아요

물론 컬렉션을 뷰모델의 생성자 안에서 초기화하면 그렇게 동작하겠지만, 바인딩이 완료된 후 다른 메서드에서 초기화하는 경우라면 원문대로 동작합니다. 이미 뷰모델의 PropertyChanged 이벤트를 구독하는 수신자가 있으니까요…

wpf에서 IEnumerable 계열 컬렉션을 바인딩하는 경우 내부적으로 CollectionView를 생성하는데, 여기서 이미 INotifyCollectionChanged의 CollectionChanged를 구독하는 과정을 거칩니다. 링크

INotifyCollectionChanged incc = collection as INotifyCollectionChanged;
if (incc != null)
{
    IBindingList ibl;
    if (!(this is BindingListCollectionView) ||
        ((ibl = collection as IBindingList) != null && !ibl.SupportsChangeNotification))
    {
        incc.CollectionChanged += new NotifyCollectionChangedEventHandler(OnCollectionChanged);
    }
    SetFlag(CollectionViewFlags.IsDynamic, true);
}

또한 ObservableCollection을 상속받는 파생 클래스를 정의한 후 OnCollectionChanged를 override 한 후 BP를 찍어보면 생성자에서 컬렉션을 넘겨주든, 생성과 함께 초기화하든, 생성 후 Add를 하든 모두 OnCollectionChanged 이벤트가 발생하는 것을 확인할 수 있습니다.

즉 CollectionChanged 이벤트는 생성 이후 아이템 변동 1건마다 1회 발생하며 이미 바인딩이 완료된 이후에는 이벤트 발생마다 UI 업데이트 역시 1회 발생한다는 뜻이져.


최신 소스에서는 ObservableCollection(List) 생성자 안에서 CopyFrom 하는 부분이 삭제된 것 같네요. 닷넷 7 기준 List를 이용해 컬렉션을 초기화할 경우엔 CollectionChanged 이벤트가 발생하지 않습니다.

4개의 좋아요

최신 소스에서는 ObservableCollection(List) 생성자 안에서 CopyFrom 하는 부분이 삭제된 것 같네요. 닷넷 7 기준 List를 이용해 컬렉션을 초기화할 경우엔 CollectionChanged 이벤트가 발생하지 않습니다.

그러네요;;

ObservableCollection.cs (dot.net)

확인했습니다.

정정해주셔서 감사합니다~!

2개의 좋아요

아… 틀렸다는 의미가 아니라,

속성을 "초기화"하는 경우라면, Notify가 되지 않기 때문에, Notify가 몇번 발생하는 지 알아보는 것은 의미없다는 뜻입니다…

바인딩에서 보통 뷰를 "바인딩 타겟"이라고 하고, 뷰모델을 "바인딩 소스"라고 합니다.

바인딩은 타겟의 속성(타겟 프로퍼티)과 소스의 속성(소스 프로퍼티) 사이에 이뤄지죠.

그런데, 필드를 초기화(Initialization)한다는 말은 생성된 객체의 필드가 가져야 하는 값을 미리 설정하는 것을 의미하므로,

소스 생성자 진입 => 필드 초기화 => 생성자 완료 == 객체 생성

필드 초기화 과정에 ObservableCollection 객체를 초기화하는 것이 포함된다면, 아래의 코드도 생성 과정 중에 실행됩니다.

public ObservableCollection<T> TestABC = new (new List<T>
{
    new T(),
    new T(),
    new T(),
    new T(),
});

생성이 완료될 때까지는 이 객체의 어떤 PropertyChanged 이벤트도 구독자가 없는 상태라서, PropertyChanged 이벤트는 트리거가 안되겠죠.

그 다음, 이렇게 생성된 소스를 타겟과 바인딩 시켜 주는 과정을 통해, 타겟을 소스의 구독자로 등록(양방향일 경우, 소스를 타겟의 구독자로 등록하는 것도 포함)하는 것입니다.

DataContext = myViewModel;

바인딩이 완료된 후에는, 말씀 하신 것처럼 소스와 타겟의 속성값이 변경 (보통 이 때는 초기화라는 말 대신, 설정(set)이라고 표현하죠)되면, Notify 가 발생합니다.

Notify가 발생하는 시점에서는 ObservableCollection 속성 값을 변경할 때, 한꺼번에 하는 게 나은지 하나씩 하는게 나은지 살펴보는 것은 의미가 있습니다.

3개의 좋아요

아 그런 의미셨군요. 부연 설명 감사드립니다!

위에 @루나시아 님께서 하신 말씀과 같은 맥락으로 이해했습니다.

물론 컬렉션을 뷰모델의 생성자 안에서 초기화하면 그렇게 동작하겠지만, 바인딩이 완료된 후 다른 메서드에서 초기화하는 경우라면 원문대로 동작합니다.

제가 글 제목을 단순하게 ObservableCollection을 초기화 하는 경우만 써놔서 저 코드를 생성자에서 썼는지, 아니면 이미 ViewModel이 바인딩이 되었다는 가정하에 사후에 ObservableCollection에 Item을 추가하는지에 따라 다른지를 안써놓은 반쪽짜리 실험글이었군요…

그리고 소스 프로퍼티라고 하시는게 제 코드나, 제가 링크 했던 .NET Framework Reference Source에 ObservableCollection<T>이던, Collection<T> 에 Source 라는 프로퍼티가 있는 것으로 이해 했었습니다 ㅎㅎ;;;

말씀하신 게 맞다고 생각합니다.

정정 감사합니다~

3개의 좋아요

여기에서 초기화란 필드 변수에 "객체를 할당하는 것"으로 이해하겠습니다.

저는 컬렉션 필드를 초기화하지 않고 사용해 본 적이 없었기 때문에, 바인딩이 완료된 후, 다른 메서드에서 초기화할 때 제대로 동작하는 지 실험을 해봤습니다.

예제를 ObservableCollection에 한정하기 위해, 윈도우 객체에 속성을 추가하는 방식을 사용했습니다.

  1. 생성 시 필드 초기화를 하지 않은 경우 => Notify 안됨
public partial class MainWindow : Window
{
    public MainWindow() =>  InitializeComponent();

    // 필드 초기화 하지 않음.
    public ObservableCollection<string> EndingWords { get; set; }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        // Notify 안됨.
        EndingWords = new(new string[] { "I ", "am ", "ievitable" });
    }
}
  1. 생성 시 필드 초기화를 하고, 나중에 다시 초기화함(새로운 객체를 할당함). => 초기값은 반영되지만, Notify 안됨
// 생략
    public MainWindow()
    {
        // 초기값은 뷰에서 보여짐.
        EndingWords = new(new string[] { "I ", "am ", "inevitable" });
        // ...
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        // Notify 안됨
        EndingWords = new(new string[] { "I ", "am ", "iron man" });
    }
  1. 생성 시 초기화를 하고, 요소만 변경함 => Notify 동작함
    public MainWindow()
    {
        EndingWords = new(new string[] { "I ", "am ", "inevitable" });
        //...;
    }

    private void Button_Click(object sender, RoutedEventArgs e)
    {
        // Notify 됨.
        EndingWords[2] = "iron man";
    }

.xaml

<Window x:Class="ObservableCollectionTests.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:ObservableCollectionTests"
        mc:Ignorable="d"
        x:Name="_self"
        Title="MainWindow" Height="450" Width="800" >

   <StackPanel>
        <ListView DataContext="{Binding EndingWords, ElementName=_self}" ItemsSource="{Binding}"/>

        <Button Content="End this game" Click="Button_Click"/>        
    </StackPanel>
</Window>

참고로, 저는 Xamarin.Forms를 주로 사용했고, WPF는 잘 사용하지 않았기 때문에, 콘트롤 사용법에 오류가 있을 수도 있습니다. 만약, 그런 게 보이신다면 정정 부탁드립니다.

오류가 없다는 가정 하에, 내릴 수 있는 결론은 아래와 같을 것입니다.

  • 바인딩은 PropertyChanged 이벤트에 구독하는 것을 Binding 객체가 대신 해주는 것이다.

  • 바인딩 시점에 구독할 이벤트(를 보유한 개체)가 없으면, 바인딩 설정이 안된다.

  • 바인딩 시점에 구독한 이벤트를 보유한 객체가, 나중에 다른 객체로 대체되면, 기존에 구독한 이벤트도 사라지기 때문에 바인딩도 사라진다.

바인딩 이후의 초기화를 반영하려면, 아래와 같이, MainWindow 자체를 INPC를 구현시키서 새로운 객체가 할당될 때 Notify 하는 장치를 추가해야 합니다. (.xaml도 이를 반영하도록 수정해야함)

public partial class MainWindow : Window, INotifyPropertyChanged
{
        private ObservableCollection<string> _endingWords;
        public event PropertyChangedEventHandler? PropertyChanged;
        public ObservableCollection<string> EndingWords
        {
            get => _endingWords;
            set 
            {
                _endingWords = value;
                PropertyChanged?.Invoke(this, new(nameof(EndingWords)));
            }
        }
...
3개의 좋아요

저는 원문을 바인딩 여부와 관계없이, 인스턴스를 초기화하는 두 가지 방법 모두 CollectionChanged 이벤트가 여러 번 발생하므로 성능에 영향을 주는 요소가 될 수 있다로 이해했습니다. 앞서 작성한 댓글의 요지도 그것에 초점을 맞춘 것이구요.


  1. ObservableCollection는 내부 컬렉션이 변동될 때마다 CollectionChanged 이벤트가 발생한다.
  2. WPF의 CollectionView는 BindingSource가 INCC를 구현하기만 하면 CollectionChanged 이벤트를 구독하기 때문에, CollectionChanged 이벤트의 발생 횟수에 대한 성능 체크는 바인딩 성능 측면에도 충분히 유의미하다.(컬렉션에 여러 값을 추가하는 경우, 모든 값 추가 완료 후 데이터 바인딩 관련 동작을 할 수 있음에도 값이 1회 추가될 때마다 1회씩 동작하기 때문)

뷰모델 객체의 생성 시점에 컬렉션 변경 알림이 의미가 있냐 없냐는 부차적으로 발생한 토론일 뿐, 제가 원문 주제에 관해 제시한 핵심은 이 두 개였던 거죠. (부차적으로 발생한 토론에 대해서도 INPC를 기본적으로 구현한다는 전제 하의 답변입니다.)

뷰모델 생성자 안에서는 아직 바인딩 되는 시점이 아니므로, 바인딩이 동작하지 않는 것은 맞습니다.

서로 원문에서 다루고자 하는 바를 다르게 이해했을 뿐 다른 관점을 갖고 있는 것은 아닌 듯합니다.

3개의 좋아요

맞습니다.

다만, @루나시아 님의 댓글을 보고 나중에 초기화해도 동작하는 지가 문득 궁금해졌고, 되는지 안되는 지 실험을 해봤습니다.

참고로, Xamarin.Forms의 CollectionView 콘트롤(ListView 와 비슷)은 별 다른 장치가 없어도, 새롭게 할당된 컬렉션을 인식했던 것으로 기억합니다.

지금 생각해보면, 당시에 사용했던 ObservableCollection는 Xamarin.Forms의 네임스페이스에서 제공했던 것인데, 새로운 할당을 인식하도록 내부 구현이 되었던 것 같습니다.

3개의 좋아요