본문 바로가기

MAUI에서 ObservableCollection이 말 안 들을 때

@veedeeo 2025. 12. 9. 15:22

MAUI에서 ObservableCollection이 말 안 들을 때

체크박스와 CollectionView를 함께 쓰는 화면에서는 ObservableCollection이 제대로 동작하는 것처럼 보여도, 어느 순간 UI가 갱신되지 않거나 프리즈처럼 멈추거나, PropertyChanged가 호출되는데도 화면이 따라오지 않는 문제가 발생한다.

이 문제는 대체로 이벤트 흐름이 꼬이거나 스레드 컨텍스트가 무너질 때, 그리고 모델과 뷰 모델의 책임 경계가 흐려질 때 나타난다. 화면이 갱신되지 않는 상황, 프리즈나 무한 반복처럼 보이는 상황, PropertyChanged가 호출되는데도 UI가 따라오지 않는 상황을 하나의 프레임으로 묶어 정리한다.

MAUI에서 ObservableCollection이 말 안 들을 때

CollectionView와 ObservableCollection은 겉으로는 단순히 바인딩처럼 보이지만, 실제로는 CollectionChanged, PropertyChanged, UI 스레드 제약이 동시에 맞물리며 동작한다. 이 중 하나라도 흐름이 끊기면 UI는 갱신되지 않거나, 프리즈처럼 보이거나, 이벤트는 찍히는데 화면만 멈춘 상태가 된다.

핵심은 두 가지다. 첫째, 컬렉션 인스턴스를 바꿀 때는 반드시 속성 변경 통지를 보장해야 한다. 둘째, 컬렉션 수정과 UI 갱신은 반드시 MainThread에서 일어나야 한다. 이 원칙만 지켜도 대부분의 문제는 재현 자체가 사라진다.


1. ObservableCollection과 CollectionView 바인딩은 어떤 이벤트로 동작하나

CollectionView와 ObservableCollection 조합은 기본적으로 세 가지 이벤트 흐름으로 동작한다.

1.1 컬렉션 구조가 바뀔 때 CollectionChanged가 발생한다

항목이 추가되거나 삭제될 때 ObservableCollection이 INotifyCollectionChanged의 CollectionChanged 이벤트를 발생시키고, CollectionView는 이 이벤트를 구독해 셀을 새로 만들거나 재사용한다.

1.2 항목 속성이 바뀔 때 PropertyChanged가 발생한다

각 항목이 INotifyPropertyChanged를 구현하고 속성이 바뀔 때마다 PropertyChanged를 발생시키면, 셀의 BindingContext를 통해 CheckBox나 Label이 값을 다시 그린다.

1.3 UI 스레드 제약이 존재한다

CollectionChanged와 PropertyChanged를 받은 이후 실제 UI 요소를 추가하거나 제거하는 일은 UI 스레드에서 수행되어야 한다. 백그라운드 스레드에서 컬렉션을 건드리면 예외나 예측 불가능한 동작이 발생할 수 있다.


2. 화면이 갱신되지 않을 때 가장 흔한 원인은 무엇인가

2.1 컬렉션 인스턴스를 새로 할당했는데 속성 변경 통지가 없는 경우

가장 흔한 실수는 기존 컬렉션을 유지하지 않고 새 ObservableCollection을 할당하는 패턴이다.

public ObservableCollection<PlaylistItem> PlaylistItems { get; set; }

public void LoadItems(IEnumerable<PlaylistItem> items)
{
    PlaylistItems = new ObservableCollection<PlaylistItem>(items);
}

XAML에서 ItemsSource가 이미 바인딩된 상태라면, PlaylistItems 속성이 바뀌었다는 사실을 INotifyPropertyChanged로 알리지 않는 순간 CollectionView는 여전히 이전 컬렉션을 바라본다.

따라서 가능한 한 컬렉션 인스턴스는 유지하고 내용만 교체하는 방식이 안정적이다.

public class MainViewModel : INotifyPropertyChanged
{
    private ObservableCollection<PlaylistItem> _playlistItems =
        new ObservableCollection<PlaylistItem>();

    public ObservableCollection<PlaylistItem> PlaylistItems
    {
        get => _playlistItems;
        private set
        {
            if (_playlistItems == value)
                return;

            _playlistItems = value;
            OnPropertyChanged(nameof(PlaylistItems));
        }
    }

    public void LoadItems(IEnumerable<PlaylistItem> items)
    {
        PlaylistItems.Clear();
        foreach (var item in items)
            PlaylistItems.Add(item);
    }
}

3. 앱이 멈추거나 무한 반복처럼 보일 때 어떤 패턴을 의심해야 하나

3.1 모델 setter에서 컬렉션이나 전역 상태를 동시에 조작하는 경우

항목의 속성이 바뀌는 순간 컬렉션까지 동시에 조작하면, UI 갱신과 로직 갱신이 서로를 계속 자극하는 구조가 만들어질 수 있다.

public class PlaylistItem : INotifyPropertyChanged
{
    private bool _isSelected;

    public bool IsSelected
    {
        get => _isSelected;
        set
        {
            if (_isSelected == value)
                return;

            _isSelected = value;
            OnPropertyChanged(nameof(IsSelected));

            if (_isSelected)
                GlobalSelectedItems.Add(this);
            else
                GlobalSelectedItems.Remove(this);
        }
    }
}

모델은 자기 자신의 상태만 관리하고, 선택 목록 같은 복합 로직은 뷰 모델로 올리는 편이 안전하다.

3.2 속성 간 상호 참조로 재귀 루프가 생긴 경우

IsSelected와 IsHighlighted 같은 속성이 서로를 다시 설정하는 구조가 있으면 바인딩을 타고 재귀 호출이 만들어져 프리즈처럼 보일 수 있다.

private bool _isSelected;
public bool IsSelected
{
    get => _isSelected;
    set
    {
        _isSelected = value;
        OnPropertyChanged(nameof(IsSelected));

        if (_isSelected)
            IsHighlighted = true;
    }
}

private bool _isHighlighted;
public bool IsHighlighted
{
    get => _isHighlighted;
    set
    {
        _isHighlighted = value;
        OnPropertyChanged(nameof(IsHighlighted));

        if (_isHighlighted)
            IsSelected = true;
    }
}

값 변경 여부를 먼저 검사하고, 의존 관계 방향을 한 방향으로만 유지하면 이런 루프를 크게 줄일 수 있다.

public bool IsSelected
{
    get => _isSelected;
    set
    {
        if (_isSelected == value)
            return;

        _isSelected = value;
        OnPropertyChanged(nameof(IsSelected));

        if (!_isSelected)
            IsHighlighted = false;
    }
}

public bool IsHighlighted
{
    get => _isHighlighted;
    set
    {
        if (_isHighlighted == value)
            return;

        _isHighlighted = value;
        OnPropertyChanged(nameof(IsHighlighted));
    }
}

4. PropertyChanged는 호출되는데 UI가 따라오지 않을 때 무엇을 봐야 하나

4.1 백그라운드 스레드에서 ObservableCollection을 수정하는 경우

비동기 작업 이후 UI 스레드로 복귀하지 못한 상태에서 PlaylistItems를 수정하면 디버그에서는 예외가 나고, 릴리즈에서는 일부만 그려지거나 빈 화면이 되는 문제가 생길 수 있다.

using Microsoft.Maui.ApplicationModel;

public async Task ProbePlaylistAsync()
{
    var list = await _ytdlpService.GetPlaylistAsync();

    await MainThread.InvokeOnMainThreadAsync(() =>
    {
        PlaylistItems.Clear();
        foreach (var item in list)
            PlaylistItems.Add(item);
    });
}

5. 문제가 생겼을 때 바로 적용할 수 있는 디버깅 루틴은 무엇인가

ObservableCollection 문제는 로그로 이벤트 폭주나 잘못된 타이밍을 먼저 확인하는 편이 빠르다.

5.1 CollectionChanged 로그로 리셋과 폭주를 확인한다

public MainViewModel()
{
    PlaylistItems.CollectionChanged += (s, e) =>
    {
        Debug.WriteLine($"CollectionChanged Action, {e.Action}");
    };
}

5.2 특정 속성의 PropertyChanged 호출 빈도를 확인한다

동일 속성이 짧은 시간에 과도하게 호출된다면 setter 내부에서 다른 속성을 건드리거나 상호 참조가 있는지 의심해야 한다.


6. 정리, ObservableCollection이 안정적으로 동작하는 최소 규칙

MAUI에서 ObservableCollection을 안전하게 쓰기 위한 최소 규칙은 다음과 같다.

  1. 컬렉션 인스턴스는 가능한 한 유지하고 Clear, Add, Remove로만 조작한다.
  2. 부득이하게 새로 할당해야 한다면 속성 수준에서 PropertyChanged를 반드시 발생시킨다.
  3. 모델 setter는 자기 상태만 관리하고, 컬렉션 조작과 복합 로직은 뷰 모델로 올린다.
  4. 모든 컬렉션 수정은 MainThread에서 수행한다.
  5. 속성 간 상호 참조 구조를 최소화하고 의존 관계를 한 방향으로만 유지한다.
  6. 문제가 생기면 CollectionChanged, PropertyChanged 로그로 이벤트 흐름을 먼저 확인한다.

ObservableCollection 문제는 체크박스 하나가 안 눌리는 문제처럼 보이지만, 실제로는 이벤트 흐름, 스레드 컨텍스트, 책임 분리 문제가 한 번에 섞인 경우가 많다. 한 번 구조를 정리해 두면 이후에는 같은 패턴을 재사용하는 것만으로 안정적인 목록 화면을 빠르게 확장할 수 있다.

MAUI에서 ObservableCollection이 말 안 들을 때MAUI에서 ObservableCollection이 말 안 들을 때MAUI에서 ObservableCollection이 말 안 들을 때

veedeeo
@veedeeo :: .net MAUI·Netlify·SEO·웹 최적화 기술 블로그

.net MAUI·Netlify·SEO·웹 최적화 기술 블로그

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차