지직전기
[C#/WPF] UI 업데이트 최적화 – Dispatcher.Invoke/BeginInvoke와 스레드 마샬링 본문
WPF에서 UI를 갱신할 때 Dispatcher를 어떻게 사용해야 할까?
WPF로 프로그램을 처음 개발하다보니 UI 스레드와 백그라운드 스레드의 역할 차이, 그리고 데이터를 전달하는 과정을 제대로 이해하지 못해 무분별하게 혼용 사용하여 리팩토링 간 고생했던 기억이 있습니다. 새로 만들던 프로그램의 요구사항이 한번에 지도에 50000+ 개가 넘는 항적을 UI로 표시할 수 있도록 만드는게 목표였기 때문에 성능 개선에 좀 더 관심을 갖기 위해 아래 내용을 찾아보고 정리해보았습니다.
처음에는 단순하게 백그라운드에서 데이터를 받으면 Dispatcher를 통해 UI를 바로 업데이트하면 되지 않을까 하며 무분별한 Invoke와 BeginInvoke를 남발했는데 그로 인해 성능 저하가 발생하고(매 프레임간 버벅거림 발생)
특히 네트워크, 센서, 타이머처럼 이벤트가 지속적으로 발생하는 환경에서는 UI를 어떻게 갱신하느냐에 따라 체감 성능이 크게 바뀐다는 것을 알게 되었습니다.
UI(메인)스레드와 작업(백그라운드) 스레드
- 백그라운드 스레드 - 데이터 수집, 생성
네트워크 수신, 파일 I/O, 연산 처리 담당 - UI 스레드 - UI 컨트롤
화면 렌더링, 사용자 입력, 컨트롤 업데이트 담당
일반적으로 WPF에서는 모든 UI 요소가 UI 스레드에 종속됩니다.
다른 스레드에서 UI를 직접 접근하면 InvalidOperationException이 발생합니다.
백그라운드 스레드에서는 Dispatcher를 통해 마샬링하여 UI 업데이트를 요청하거나 버퍼/큐에 담아 UI스레드에 데이터를 전달하여 UI 업데이트를 요청할 수 있습니다.
Dispatcher에서 Invoke / BeginInvoke의 동작
백그라운드 스레드에서 UI를 건드리려면 Dispatcher를 통해 작업을 넘겨야 합니다.
먼저 Invoke는 동기 호출입니다.
Invoke로 보낸 UI 업데이트 작업은 UI스레드가 즉시 수행하며 작업이 끝날때까지 호출한 스레드는 대기상태에 있습니다.(작업 순서 보장)
따라서 이로 인해 Invoke로 과도한 메세지를 보낼 경우 Deadlock 현상에 빠질 수 있습니다.
BeginInvoke는 비동기 호출입니다.
UI 스레드에 작업만 요청하고 즉시 리턴되며, Dispatcher 큐에 작업을 등록합니다.
UI스레드는 큐에 쌓인 작업을 순차적으로 처리합니다(호출은 빠르나 작업 순서 보장 X)
여기서 제가 오해했던 부분이 BeginInvoke는 큐에 넣고 빠지는 거라 스레드간 충돌도 없고 괜찮지 않을까? 였는데
실제로는 호출 스레드를 막지 않을 뿐 UI 스레드의 부담을 줄여주는 것은 아닌걸로 이해했습니다.
트러블 슈팅
문제점
개발 간 프로그램에 항적 리스트를 넣고 데이터가 들어오면 실시간으로 업데이트하는 기능을 만든 적이 있습니다.
항적 시뮬레이터에서는 초당 수백 건(700~800건) 이상의 메시지가 들어오는 구조였습니다.
처음 구현은 단순했습니다.
- 메시지 수신 시마다 Dispatcher.BeginInvoke 호출
- 항적리스트에 실시간으로 UI 갱신
코드는 단순했고 초기에는 정상적으로 동작했지만 몇 초 지나지 않아 문제가 발생했습니다.
- 리스트 스크롤이 밀림
- 클릭 반응 지연
- UI 전체가 버벅거리는 현상
디버깅 결과 Dispatcher 큐에 수천 개 이상의 delegate가 쌓여 있었습니다.
문제의 원인은
"이벤트 발생 횟수만큼 UI 작업을 쌓고 있었다."
왜 이런 문제가 발생할까
BeginInvoke는 호출 자체는 가볍지만 UI 스레드는 그 모든 작업을 결국 하나씩 처리해야 합니다.
- 이벤트 1000건 발생 → delegate 1000개 큐에 적재
- UI 스레드 → 1000번 UI 갱신 수행
여러 스레드에서 UI라는 하나의 자원을 향해 계속 작업을 밀어 넣는 일종의 병목 문제입니다.
해결 방법: UI 갱신 빈도를 줄이기(DispaterTimer)
“이벤트 발생 빈도와 UI 갱신 빈도를 분리해야 한다.”
백그라운드에서는 데이터를 계속 받아도 되지만,
UI는 일정 주기로만 업데이트하면 됩니다.
구현 방식
백그라운드에서는 UI를 건드리지 않고 큐에만 데이터를 쌓습니다.
public void OnDataReceived(string data)
{
_queue.Enqueue(data);
}
UI 스레드에서는 DispatcherTimer를 사용해 일정 주기로 한 번에 처리합니다.
public ObservableCollection<string> Items { get; } = new();
public MainViewModel()
{
_uiTimer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(100)
};
_uiTimer.Tick += (s, e) => FlushUiUpdates();
_uiTimer.Start();
}
private void FlushUiUpdates()
{
while (_queue.TryDequeue(out var item))
{
Items.Add(item);
}
}
이 방식의 특징은
- 이벤트는 계속 쌓이지만 UI는 100ms마다 한 번만 갱신됨
결과적으로
- 초당 수천 이벤트 → UI는 초당 10회 이하 갱신(UI 부하 급감 → 체감 성능 개선)
Invoke / BeginInvoke는 언제 써야 할까
모든 UI 갱신을 타이머로 처리할 수 있는 것은 아닙니다.
즉시 반영이 필요한 경우는 여전히 존재합니다.
예를 들어
- Dispatcher를 통해 업데이트 되는 값이 다음 코드에서 계산할 때 필요한 경우
{
TextBox.Text = "값 설정";
});
// 이 아래 코드가 TextBox.Text 값에 의존하는 경우
DoSomething(TextBox.Text);
예를 들어
- 경고 메시지 표시
- 버튼 상태 즉시 변경
- 사용자 인터랙션에 대한 즉각적인 피드백
{
StatusText = "긴급 알림 발생";
});
정리

WPF에서 UI는 반드시 UI 스레드에서만 접근해야 합니다.
이를 위해 Dispatcher를 통한 마샬링이 필요합니다.
하지만 이벤트마다 Invoke / BeginInvoke를 호출하는 구조는
UI 스레드에 과도한 부담을 주게 됩니다.
따라서 구조를 다음과 같이 나누는 것이 중요합니다.
- 백그라운드 스레드 → 데이터만 수집
- UI 스레드 → 타이머 기반으로 주기적 갱신
- 즉시 반영이 필요한 경우만 Dispatcher 사용
이 방식으로 구조를 바꾸면,
실시간 이벤트가 많은 환경에서도 UI가 안정적으로 유지됩니다.
'STUDY > C#' 카테고리의 다른 글
| [C#/WPF] MVVM + DDD패턴 적용하기 (0) | 2026.04.09 |
|---|