WPF 갓 입문한 초보라 질문 하나 드립니다...

WPF로 개발 입문자라 적응하기가 좀 힘드네요 ㅠㅠ
MVVM 패턴으로 개발 작업하고 있는데, TreeListView ItemSource를 ObservableCollection 자료형 변수 A를 바인딩 하다보니 대량 데이터를 처리(추가/삭제 등)하게 되면 처리 속도가 엄청 걸리더라구요…
그래서 A 자료형을 List로 변경하니까 처리 속도는 올라가는데 데이터 처리(추가/삭제) 작업 시에 UI에 바로 업데이트가 안되는 바인딩 문제로 골머리를 썩히고 있는데 해결 방법 아시는 고수분 도움 좀 부탁드립니다…

1 Like

안녕하세요. 처음 오신 분이네요. 반갑습니다.

WPF는 WPF 카테고리가 별도로 있어서 글을 옮겨드립니다.

다음에는 카테고리에 맞게 질문 부탁드립니다.

2 Likes

그리고 자료형 List라고 하셨는데 List<T>는 원래 바인딩 시에 안 보여지는 게 정상입니다. INotifyPropertyChanged 인터페이스를 구현하고 있지 않기 때문입니다.

UI에 컬렉션형태로 추가될 때마다 어떤 보여지는 연출 효과를 보고 싶으시면 ObservableCollection<T>를 사용하셔야 합니다.

그리고 느린 것은, 비동기 작업 및 UI에 데이터가 바인딩되는 시점을 골라야 할 것 같아서 알려주신 부분만으로는 체크가 어려울 것 같습니다.

Github Gist나 Github Repository에 공유해주시면 더 많은 분들이 도와 주실 거 같습니다.

3 Likes

답변 감사합니다. 다음부터 카테고리에 맞게 질문 작성 하겠습니다.

코드를 올려서 질문하고 싶은데 상황이 되지 않아 설명으로 질문 추가드립니다.

ObservableCollection를 사용하는 경우 INotifyCollectionChanged, INotifyPropertyChanged 인터페이스가 구현되어 있어 UI에 바로 반영이 된다는 장점이 있는 자료형으로 알고 있는데,
단점으로 메모리 사용량이 List보다 많기 때문에 대량의 데이터를 다룰 경우에는 성능 저하가 일어난다는 것으로 알고 있습니다.

대량 데이터 처리하면서 처리 동작 시에는 UI 요소(TreeListView)에 즉시 반영하고 싶은데 이럴 때는 어떻게 작업을 하시나요?

ObservableCollection이 List보다 메모리 사용량이 많다는 것은 고려 대상이 아닙니다.
당연히…상대적으로 List보다 많이 쓰긴 쓰겠지요.
하지만 그게 눈에 띄게 인간이 체감이 가능한 수준이냐고 한다면 저는 절대로 그렇지 않다고 대답하겠습니다.
그게 실제로 메모리 사용량이 많이 먹는다고 하더라도 UI에 출력되는 컬렉션 개체로서는 필수적인 개체입니다. 꼭 써야만 하는 것이죠.

그게 싫다면 INotifyPropertyChanged를 구현하는 List를 상속받는 새로운 클래스를 정의하시면 됩니다. Microsoft가 만든 BCL인 ObservableCollection보다 잘 만들 자신이 있다면요…?

또한…Column의 수에 따라 물론 다르겠지만 DataGrid에 100만개의 Row를 10초 안에 띄우시는 분도 봤습니다. ObservableCollection으로요… 따라서 하기 나름입니다.

대량으로 데이터라고 하시는 게 어느정도 대량인지 감이 잘 안 옵니다…

초당 100만건씩 발생하는 데이터가 TreeView로 1초마다 갱신되면서 그러져야하는 그런 상황인가요?

상황에 대한 명시를 해주시면 좋을 거 같습니다.

통상적인 Windows Forms, MFC, WPF 같은 STA 형태의 Windows Desktop Application에서 UI Thread의 사용을 최소화하 방법(UI 응답성 개선)은 Worker Thread에서 모든 작업을 처리한 뒤에 UI에 반영될 때만 업데이트하는 것입니다.

WPF라면 Worker Thread에서 모든 작업을 처리한 뒤에 마지막에 추가할 때만 observablecollection에 반영하면 될 것 같습니다.

9 Likes

훌륭한 답변이라고 생각됩니다.

3 Likes

ObservableCollectionOC

OCAddRange() 는 없고 Add() 만 있긴 하지만, 같은 Add() 를 할 떄 List보다 크게 느릴 이유는 없다고 생각하구요. 아마도 OC를 뷰에 바인딩 시켜놓고 Add() 중이시기 때문에 변동 시마다 뷰를 고치고 있어 느려지고 있을 겁니다.

특히 트리뷰라면, 가상화가 적용된 상태가 아니라면 추가된 아이템이 많을수록 아이템에 대응하는 자식 뷰를 많이 만들게 될 것 같은데요. 그러면 숫자가 많아질수록 부하가 커지고 있을 것 같네요.

반드시 대량의 데이터를 Add 해야 하고 Add하는 과정이 보여야 한다면 아래 내용을 시도해보세요.

  • 트리뷰를 가상화시킬 방법을 찾아보세요.
  • 백그라운드스레드에서 실제 작업을 하시고 Add()는 Dispatcher.InvokeAsync 를 통해 알아서 지연되어 처리되도록 시도해 보세요.
  • 기타 MVVM 라이브러리들에는 OC의 개선된 버전들이 있습니다. 그것들을 사용해보세요.

만약 하나하나 Add() 과정이 보일 필요 없다면

  • List로 바인딩하시고, 갱신이 필요한 시점에 바인딩 프로퍼티를 null을 주시고 다시 실제 LIst를 set 해 보세요.
2 Likes

어쨌든 tree view 에 아이템을 넣었다 뺐다 할 때 뭔가 업데이트가 일어나야하는 거 잖아요?
그럼 그 업데이트 자체의 부하는 어떻게 할 수 없는 게 당연하겠죠?
그게 ObservableCollection 이든 List 이든 업데이트가 발생한다면 업데이트 부하는 피할 수 없어요.

따라서 ObservableCollection 의 성능과 메모리가 안 좋은 게 아니라
ObservableCollection 에 의해 업데이트가 발생할 때 업데이트 부하가 발생하는 게 그렇게 보이는 거여요.
(ObservableCollection 도 단순히 컬렉션 기능만 수행하면 List 와 별 다를바 없어요.)

따라서 ObservableCollection 를 List 로 바꾼다고 한들
여전히 아이템 요소의 변화에 따라 업데이트가 발생해야한다면
업데이트에 따른 부하로 인해 성능 감소처럼 보이는 현상은 피할 수 없어요.

다만 최적화는 좀 다른 얘기일 수 있어요.

ObservableCollection 은 기본적으로 아이템 업데이트를 위해 여러가지 동작들이 포함되어 있는데
(물론 거의 대부분은 필요한 요소들입니다.)

요것들 중에 안 해도 되겠다 싶은 것이 있다면
그 부분을 제외한 나머지만 구현한 별도의 컬렉션을 만들어서 사용하면 됩니다.
물론 저는 그럴 자신이 없…

그리고 대부분은 반대의 경우가 더 많을 거예요.
@favdra 님이 언급하신 AddRange() 의 부재 등등이 필요해서 ObservableCollection 를 상속받아 추가 구현하는 경우들이지요.

또한 ObservableCollection 의 item add / remove 와 관계없이
화면에 표시되는 요소만 로드하는 가상화 기능을 구현하는 게 중요하기도 해요.
가상화 처리를 하고 안 하고에 따라 아이템 개수가 많아질 수록 성능 차이가 어머어마하게 벌어지거든요.

그 가상화를 최적화 하기 위해 ObservableCollection 이 아닌
ObservableCollection 을 ItemsSource 로 사용하는 control 을(예를 들면 TreeView 나 ListBox 따위?)
custom control 로 만들어서 원하는 형태의 가상화 기능을 만들어 사용하기도 한답니다.
…라고 쓰고 삽질이라고 읽…

여기까지 발을 담그신다면… 빼기 힘드실 거예요… =ㅁ=;;;

2 Likes

ObservableCollection 은 기본적으로 아이템 하나의 변경만 처리하도록 구현되어 있습니다.
기본 구현을 그대로 쓸 경우, CollectionChanged 이벤트는 아이템 하나 당 발생합니다.

예를 들어, OC에 1000개의 아이템이 추가된다면, CollectionChanged 이벤트가 1000번 발생합니다.
그에 따라, 이벤트 구독 객체의 이벤트 핸들러도 1000번 호출됩니다.
만약, 구독 객체가 뷰 객체라면, 이벤트 핸들링은 렌더링으로 직결됩니다.

렌더링은 자원의 소모가 크기에, 이벤트 호출이 많아 질 수록 시스템이 느려지는 것입니다.

그런데, 이 이벤트의 형식은 아래와 같습니다.

public delegate void NotifyCollectionChangedEventHandler(object? sender, NotifyCollectionChangedEventArgs e);

이 대리자의 시그니쳐를 통해, EventArgs 형식이 NotifyCollectionChangedEventArgs 라는 것을 알 수 있습니다.

image

이 객체가 가진 속성을 통해 이 객체가 전달하는 이벤트 정보는 어떤 것이 있는지 알 수 있습니다.

Action (enum) 은 컬렉션의 변경을 유발한 행위는 무엇인지, (추가, 삭제, 삽입, 변경, 리셋)
그러한 행위로 인해 변경된 요소들과 그들의 시작 인덱스는 무엇인지 등 말이죠.

이것이 의미하는 바는 CollectionChanged 이벤트는 단일 요소 변경 뿐 아니라 복수 요소 변경에 대해서도 대응이 가능하도록 설계되어 있다는 의미입니다. 단지, 기본 구현이 단일 요소에 한정된 것 뿐입니다.

복수 아이템의 변경을 하나의 이벤트로 전파하고자 하는 경우,

class MyObservableCollection<T> : ObservableCollection<T>
{
    public void AddRange(T[] collection, int eventInterval)
    {
        var skip = 0;

        while(collection.Length > skip)
        {
            AddRange(collection, skip, eventInterval);
            skip += eventInterval;
        }        
    }

    protected void AddRange(IEnumerable<T> items, int skip, int eventInterval)
    {
        var newItemsStartingIndex = Items.Count;

        var itemRange = items.Skip(skip).Take(eventInterval).ToList();

        foreach (var item in itemRange)
        {
            Items.Add(item);
        }

        var eventArgs = new NotifyCollectionChangedEventArgs(
            NotifyCollectionChangedAction.Add, 
            itemRange, 
            newItemsStartingIndex);

        OnCollectionChanged(eventArgs);
    }    
}
  • 사용코드
ObservableCollection<int> oc = [1, 2, 3, ];
MyObservableCollection<int> myOC = [1, 2, 3,];

oc.CollectionChanged += CollectionChangedEventHandler;
myOC.CollectionChanged += CollectionChangedEventHandler;

Console.WriteLine("기본 OC에 4 ~ 6을 하나 씩 추가");
for (int i = 4; i <= 6; i++)
{
    Console.WriteLine($"{i} 추가:");
    oc.Add(i);
}

Console.WriteLine();

Console.WriteLine("커스텀 OC에 4 ~ 6을 하나 씩 추가");
for (int i = 4; i <= 6; i++)
{
    Console.WriteLine($"{i} 추가:");
    myOC.Add(i);
}

Console.WriteLine();
Console.WriteLine("커스텀 OC에 1 ~ 47 을 한꺼번에 추가하고, 매 10번째 아이템 마다 이벤트 호출");
int[] nums = Enumerable.Range(1, 47).ToArray();

myOC.AddRange(nums, 10);

void CollectionChangedEventHandler(object? sender, NotifyCollectionChangedEventArgs e)
    => Console.WriteLine($"   이벤트 핸들러: 행위: {e.Action}, 영항받은 아이템: {e.OldItems?.Count ?? e.NewItems?.Count ?? 0} 개");
  • 결과

기본 OC에 4 ~ 6을 하나 씩 추가
4 추가:
이벤트 핸들러: 행위: Add, 영항받은 아이템: 1 개
5 추가:
이벤트 핸들러: 행위: Add, 영항받은 아이템: 1 개
6 추가:
이벤트 핸들러: 행위: Add, 영항받은 아이템: 1 개

커스텀 OC에 4 ~ 6을 하나 씩 추가
4 추가:
이벤트 핸들러: 행위: Add, 영항받은 아이템: 1 개
5 추가:
이벤트 핸들러: 행위: Add, 영항받은 아이템: 1 개
6 추가:
이벤트 핸들러: 행위: Add, 영항받은 아이템: 1 개

커스텀 OC에 1 ~ 47 을 한꺼번에 추가하고, 매 10번째 아이템 마다 이벤트 호출
이벤트 핸들러: 행위: Add, 영항받은 아이템: 10 개
이벤트 핸들러: 행위: Add, 영항받은 아이템: 10 개
이벤트 핸들러: 행위: Add, 영항받은 아이템: 10 개
이벤트 핸들러: 행위: Add, 영항받은 아이템: 10 개
이벤트 핸들러: 행위: Add, 영항받은 아이템: 7 개

결과가 말해주듯, 커스텀 OC에 47개를 추가해도, 이벤트 호출 횟수는 5회 밖에 되지 않습니다.
횟수만 줄었을 뿐이지, 이벤트 핸들러는 변경된 요소들에 대한 정보를 모두 전달 받습니다.

만약, 이벤트 구독자가 뷰 객체라면, 리렌더링 횟수가 47번에서 5번으로 줄어들고, 47 개 아이템의 변화(추가)를 5번에 걸쳐서 시각화할 수 있음을 의미합니다.

그런데, 위 예제는 컬렉션에 추가만 했기 때문에 매우 간단히 구현할 수 있었습니다. 추가는 컬렉션의 제일 마지막에 덧붙이기 때문에, 추가된 아이템과 이들의 시작 인덱스를 알아 내는 게 무척 쉽습니다.
그런데, 만약 InsertRange 나, RemoveRange 를 구현하는 경우, 영향을 받은 아이템과 그들의 시작 인덱스를 알아 내는 게 단순하지는 않을 것입니다.

또한, 각 행위 마다 어떤 정보를 구독자에게 보낼 지도 결정해야 합니다.
다행스러운 것은 주도 면밀한 마소 놈들이 컬렉션에 일반적으로 행해질 수 있는 행위와 그 행위에 관한 정보가 무언인지를 NotifyCollectionChangedEventArgs 의 생성자를 통해 일목 요연하게 정리해놓은 점입니다.

NotifyCollectionChangedEventArgs Class (System.Collections.Specialized) | Microsoft Learn

각 생성자 유형을 잘 들여다 보면, InsertRange 나 RemoveRange 시에 어떤 데이터가 EventArgs 에 포함되어야 하는 지에 대한 힌트를 얻을 수 있습니다.

그러나,

다시 한번 말씀드리지만, TreeListView 가 EventArgs를 통해 복수의 아이템을 수신할 때, 그 것을 핸들링(렌더링)되도록 설계되어 있는 경우에만 위와 같은 파생이 의미 있습니다.

만약, 그렇지 않다면, 그렇게 대응하도록 TreeListVew 파생 객체를 정의해야 합니다.
또는, 이벤트 발행자가 아이템 하나 씩 전파하더라도, 이들을 일정한 주기 동안 모아서 렌더링하도록 파생할 수도 있습니다.

어느 쪽이든 쉽지 않은 선택입니다. (욕심의 무게를 견뎌야 합니다^^)

페이징

일반적으로 많은 데이터를 한 화면에 전부 표시하려는 노력은 의미가 없을 수도 있습니다.
예를 들어, 데이터 100만개를 한 화면에 전부 표시하려면, 점 외에는 마땅한 표현 수단이 없겠죠?

이런 경우, 널리 알려진 기법이 페이징입니다.
데이터가 아무리 많아도 (적정하고 우아하게) 표시될 만큼의 데이터만 처리하여 렌더링하는 것이죠.

예를 들어 한 화면에 30개가 적정하다면, 1000개를 추가해도 그 중 30개만 보여주면 됩니다. 사용자가 추가로 보고자 하는 경우, 그 다음 30개 씩을 보여주는 식입니다.

페이징 기법을 도입하면, 화면 객체에 바인딩될 OC는 30개를 초과하는 데이터를 가질 필요는 없어지게 됩니다.

참고로, 닷넷에서 페이징을 위해 많이 사용되는 도구가 IEnumerable, IQueryable 의 Skip, Take 메서드입니다.

6 Likes

favdra 님 말씀처럼, 가상화가 가장 처음에 시도할 접근법일 것입니다.

실제로 가상화를 진행하시면, 상당히 빨리지는 것을 경험하실 수 있을 것입니다.

WPF 소스 코드(ItemsControl) 살펴보시면 가상화에 대한 이해를 시작하실 수 있을 것입니다.

1 Like