리스트 가상화와 대용량 데이터 표시
TDS 시스템에서 사용자가 키워드에 /a 매개변수를 추가하면 모든 파일 목록이 표시됩니다. 이때 수백만 개의 항목이 표시될 수 있습니다. 이러한 데이터를 부드럽게 처리하고 표시하기 위해 리스트 가상화 기술을 사용해야 합니다.
리스트 가상화는 대량의 데이터를 처리할 때 성능과 사용자 경험을 향상시키기 위한 최적화 기술입니다. 실시간 계산을 통해 대용량 데이터의 표시를 시뮬레이션하며, 이때의 성능 부드러움은 데이터 크기와 관계없이 실시간 계산에 필요한 실행 시간에만 관련됩니다. 핵심 개념은 요청 시 로드와 요청 시 렌더링입니다.
사용자가 리스트를 스크롤할 때 가상화 기술은 자동으로 대량의 데이터를 여러 작은 블록(또는 페이지)으로 분할하고, 현재 보기 범위 내의 데이터 블록만 로드하고 렌더링합니다. 이 과정은 이벤트 기반으로, 사용자가 리스트를 스크롤할 때 이러한 이벤트가 애플리케이션에 새 데이터 블록을 로드하고 렌더링하도록 알립니다. 백그라운드에서 가상화 관리 모듈은 곧 보기 범위에 들어올 데이터 항목을 미리 캐시하여 빠른 액세스를 가능하게 합니다. 이는 데이터 소스에 대한 빈번한 액세스를 줄여 성능을 향상시킵니다.
주요 원리는 다음과 같습니다:
- 뷰포트 렌더링: 리스트 가상화 기술의 핵심은 사용자가 현재 볼 수 있는 영역 내의 요소만 렌더링하는 것입니다. 사용자가 스크롤할 때 동적으로 요소를 로드하고 언로드합니다.
- 플레이스홀더 요소: 리스트의 스크롤바 높이와 레이아웃을 올바르게 유지하기 위해 가상화 리스트는 렌더링되지 않은 부분의 높이를 나타내는 플레이스홀더 요소를 사용합니다.
- 요소 재사용: 가상화 리스트는 일반적으로 동일한 구성 요소 인스턴스(데이터 또는 UI 요소)를 재사용하여 다른 데이터 항목을 렌더링하며, 캐싱을 통해 오버헤드를 줄입니다.
Winform과 Avalonia의 리스트 가상화 기술 구현은 서로 다릅니다. Winform은 실시간 이벤트를 수동으로 처리해야 하는 반면, Avalonia는 내장된 반응형 모드 바인딩을 자동으로 완료할 수 있습니다. 각각의 구현 방식을 자세히 살펴보겠습니다.
Winform과 Avalonia 구현 비교
2.1 Winform 가상 리스트: ListView 예시
Winform의 가상 리스트를 활성화하려면 컨트롤 ListView의 VirtualMode 속성을 true로 설정해야 합니다. 가상화 과정에서 사용자가 리스트를 스크롤할 때, ListView는 두 가지 주요 이벤트를 트리거하며, 이를 구현해야 합니다:
ListView.CacheVirtualItems이벤트: 사용자가 리스트를 스크롤할 때 이 이벤트가 트리거됩니다. 이는 보기 범위에 들어올 항목을 알려주며, 이러한 항목을 캐시할 수 있습니다. 예를 들어, 이러한 곧 표시될 항목을 저장하기 위해 배열을 사용할 수 있습니다. 이는 데이터 소스에 대한 빈번한 액세스를 줄여 성능을 향상시킵니다.ListView.RetrieveVirtualItem이벤트:ListView가 특정 항목을 UI에 렌더링해야 할 때 이 이벤트가 트리거됩니다. 캐시에서 해당 항목을 읽어 UI에 반환하여 구현할 수 있습니다. 캐시에서 해당 항목을 찾지 못하면 동적으로 생성할 수도 있습니다.CacheVirtualItems는 때로는 구현하지 않아도 되며,RetrieveVirtualItem은 동적으로ListViewItem을 생성하여 실시간으로 처리할 수도 있습니다.
아래는 간소화된 코드 구현입니다. 자세한 구현은 프로젝트 소스 코드를 참조하십시오.
private ListViewItem[] dataCache; // 캐시를 저장할 배열
private int cacheStartIndex; // 캐시의 시작 인덱스
private bool cacheNeedsRefresh = false; // 캐시를 다시 로드해야 하는지 표시
// UI에 객체를 동적으로 제공
private void listView_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
{
// 캐시에 해당 항목이 있으면 캐시에서 직접 가져옴
if (dataCache != null && e.ItemIndex >= cacheStartIndex && e.ItemIndex < cacheStartIndex + dataCache.Length)
{
e.Item = dataCache[e.ItemIndex - cacheStartIndex];
}
else
{
// 캐시에 해당 항목이 없으면 동적으로 생성
e.Item = CreateListViewItem(e.ItemIndex);
}
// 동적 생성에 실패하면 예외를 방지하기 위해 빈 객체 반환
if (e.Item == null)
{
e.Item = new ListViewItem(new string[] { "로드 실패", "", "" });
}
}
// 캐시 생성
private void listView_CacheVirtualItems(object sender, CacheVirtualItemsEventArgs e)
{
// 캐시할 범위가 이미 캐시에 있으면 바로 반환
if (e.StartIndex >= cacheStartIndex && e.EndIndex <= cacheStartIndex + dataCache.Length)
{
return;
}
// 캐시의 시작 인덱스와 길이 업데이트
cacheStartIndex = e.StartIndex;
int cacheLength = e.EndIndex - e.StartIndex + 1;
// 캐시 재생성
dataCache = new ListViewItem[cacheLength];
for (int i = 0; i < cacheLength; i++)
{
// 데이터 소스에서 해당 항목을 가져와 캐시에 저장
dataCache[i] = CreateListViewItem(cacheStartIndex + i);
}
// 캐시가 업데이트되었음을 표시
cacheNeedsRefresh = false;
// 내용에 맞게 열 너비 자동 조정
listView.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent);
}
// 인덱스에 따라 ListViewtem 생성
private ListViewItem CreateListViewItem(int index)
{
// 실제 데이터 소스에 따라 ListViewtem을 생성할 수 있습니다
// 예시: 리스트에서 데이터를 가져옴
if (index < totalItemsCount)
{
FileData file = dataSource[index];
return new ListViewItem(new string[] { file.FileName, file.FilePath, file.FileSize.ToString() });
}
return null;
}
실제 사용에서 ListViewtem 가상화의 캐시는 수동으로 데이터를 배열이나 List에 연결하는 것입니다. 데이터가 변경될 때, 각 인덱스의 객체 값을 자동으로 새로 고침하는 것 외에도 길이를 제어해야 합니다.
캐시에 100개의 요소가 있고 ListView의 VirtualListSize 속성을 설정하여 표시할 요소 개수를 변경할 수 있습니다. 예를 들어 처음 10개만 표시하면 데이터가 작아질 때 배열/리스트 객체를 다시 생성하는 것을 방지할 수 있습니다.
2.2 Avalonia 가상 패널: VirtualizingStackPanel 예시
Winform과 달리 Avalonia의 ListBox 등 컨트롤에는 VirtualMode 속성이 없으며, VirtualizingStackPanel과 같은 방식을 통해 활성화해야 합니다. XML 코드는 다음과 같습니다.
<ListBox x:Name="fileListBox" ItemsSource="{Binding DisplayedItems, Mode=OneWay}" >
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel /> <!-- 가상화 패널 -->
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<!-- 사용자 정의 구현 -->
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
바인딩의 핵심 아이디어는 실제 데이터의 참조를 저장하고, ListBox는 IEnumerable<T> 객체 인터페이스에 바인딩된다는 것입니다. 실제 데이터가 업데이트될 때, IEnumerable<T>의 업데이트를 제어합니다.
IEnumerable<T>는 지연 실행(yield return)이므로 추가 배열 오버헤드가 발생하지 않습니다. Take(DisplayCount) 메서드를 사용하여 표시되는 데이터의 길이를 동적으로 제어할 수 있으며, 이는 Winform의 VirtualListSize와 유사하여 성능을 효과적으로 향상시킬 수 있습니다. 특히 대량의 데이터를 처리하고 데이터 길이가 자주 변경되는 시나리오에서 유용합니다.
아래 코드에서는 ViewModel의 예시 구현을 제공합니다. Avalonia.ReactiveUI 라이브러리가 필요합니다(이 라이브러리는 Nuget에서 별도로 다운로드해야 함).
public class FileViewModel : ReactiveObject
{
private IList<FileData> _allFiles = [];
private IEnumerable<FileData> _displayedItems = [];
private int _visibleItemCount = 100;
public IEnumerable<FileData> DisplayedItems
{
get => _displayedItems;
private set => this.RaiseAndSetIfChanged(ref _displayedItems, value);
}
public int VisibleItemCount
{
get => _visibleItemCount;
private set
{
_visibleItemCount = value;
}
}
public FileViewModel()
{
}
public void InitializeData(IList<FileData> files)
{
if (this._allFiles != files)
{
// 테스트 데이터 생성 (실제로는 파일이나 데이터베이스에서 로드할 수 있음)
this._allFiles = files;
UpdateDisplayedItems();
}
}
public void UpdateDisplayedItems()
{
// LINQ의 Take() 사용, 이는 게으른 평가이므로 성능이 좋음
DisplayedItems = _allFiles.Take(VisibleItemCount);
}
// 다른 수량으로 빠르게 전환
public void SetVisibleItemCount(int count)
{
VisibleItemCount = count;
}
}
위 코드에서 DisplayedItems 속성은 RaiseAndSetIfChanged 메서드를 통해 속성 값을 업데이트하고 알림을 보내, 해당 속성에 바인딩된 UI 요소가 데이터 변경에 즉시 반응할 수 있도록 합니다. VisibleItemCount 속성이 업데이트될 때 UpdateDisplayedItems() 메서드를 호출하여 DisplayedItems의 내용이 항상 VisibleItemCount와 일치하도록 보장합니다.
또 다른 문제는 Avalonia의 VirtualizingStackPanel에서 가상화 후 데이터가 병목이 되지 않더라도 UI 표시가 지연될 수 있다는 것입니다. 특히 가상화를 적용할 때 실시간으로 복잡한 레이아웃을 렌더링하는 경우입니다. 따라서 VirtualizingStackPanel.CacheLength 속성을 설정할 수 있습니다.
이 속성은 double 값으로, 뷰포트 위쪽과 아래쪽(또는 왼쪽과 오른쪽)에 얼마나 많은 추가 공간을 유지할지 결정합니다. 값이 0.5이면 시스템은 각 측면(상하 또는 좌우)에서 뷰포트 크기의 절반을 버퍼링하며, 이때 더 많은 UI 요소가 인스턴스화됩니다. 메모리를 더 많이 사용하지만 Measure-Arrange 루프의 횟수를 크게 줄여 GC 부하를 줄입니다( measure: 컨트롤의 최소 너비와 높이를 결정; arrange: 부모 컨트롤 내에 컨트롤을 배치하고 최종 위치와 크기를 결정).