본문 바로가기

개발자/WPF(C#) UI

[WPF] UI Thread 연동을 위한 팁

반응형

이번 포스트 에서는 WPF에서 UI 변경 작업을 하기 위해 유용한 팁에 대해서 소개 하고자 한다. 

먼저 WinForm 이나 WPF나 첫 시작은 STA 라는걸 이해해야 한다. 
Main 메서드위에 [STAThread]라는  어트리뷰트를 본적이 있을것이다.
Single-Threaded Apartment 라는것으로 프로그램의 UI나 대부분의 메서드, 프로퍼티들이 활동하는 Thread 이다.
[* STA라는 용어는 COM에서 사용된 것인데, STA 객체는 하나의  Thread(보통 그 객체를 생성한 Thread) 에서만 엑세스 할 수 있도록 하는 규칙 과 제한을 뜻 한다. ]

일반적인 UI 관련 객체는 해당 UI 개체를 생성한 Thread 에서만 접근하도록 되어 있다. 
WinForm에서도 한 Thread 에서 생성한 컨트롤들은 그 Thread에서만 값을 바꾸거나 조작 할 수 있다.
다른 Thread 에서 컨트롤들을 조작하거나 값을 변경 하려 할때 "Cross-thread operation not valid" 나 "Invalid Operation Exception" 등을 마주치게 될 것이다. 

WPF는 Main Thread가 UI관련 작업을 모두 수행한다.
WPF의 Main Thread는 직접적으로 다른 Thread의 간섭을 받지 못한다. 또한 Main Thread는 WPF 객체들을 독립적으로 관리하는 Dispatcher라는 큐 관리자의 작업을 처리하며 해당 큐의 작업 Frame을 종료하기 전까지 UI를 그리지 않는다. 

여기서 발생하는 문제가 있다. 바로 특정 구문에서 직접적으로 UI를 반영해야 하는 경우, Main Thread가 큐 Frame을 종료하지 않은 채 UI 변경을 요구하는 구문을 통과하면 실제 객체가 가진 값은 변경되었지만 UI에 반영되지 않는 상황이 발생한다.

이러한 상황을 해결하기 위해 Dispatcher 와 DispatcherObject를 사용하게 된다.
- DispatcherObject
WPF에서는 이 STA를 System.Windows.Threading.DispatcherObject를 통해 실현 된다. 
DispatcherObject는 CheckAccess와 VerifyAccess 2개의 메서드를 가지고 있는데 DispatcherObject는 자신이 생성될 때 자신을 생성한 Thread를 기억해 두었다가 위의 2개의 메서드를 통해서 현재 자신을 엑세스하는 Thread가 자신을 생성한 Thread인지 검사한다.

따라서 이 DispatcherObject를 상속받는 클래스들은 외부로 노출되는 메서드나 프로퍼티에 대해서 항상 이 두 메서드를 사용하여 STA를 실현하게 되어있다. WPF의 UI 관련 요소들은 대부분 이 DispatcherObject를 상속 받는다.

- Dispatcher
WPF Threading의 또 다른 중요한 클래스 System.Windows.Threading.Dispatcher
Dispatcher는 WPF 어플리케이션의 Thread 마다 하나씩 생성된다.
[* Thread 생성 시 같이 생성되지 않고 Dispatcher를 처음 사용하려고 할때 생성]

Dispatcher는 자신이 속해 있는 Thread의 작업 수행을 조절, 분배한다고 볼수있다.
WPF에서 어떤 Thread를 통해서 메서드를 수행하려면 Dispatcher에 요청해서 실행되도록 해야 한다는 말이다.


Dispatcher에는 BeginInvoke와 Invoke 라는 메서드가 있는데, 이것이 이 Dispatcher가 속해있는 Thread 에서 작업이 이루어 지도록 요청하는 메서드 이다. 
Dispatcher가 요청 받은 작업을 Work Item이라고 부르는데, Dispatcher는 이 Work Item들을 Queueing 해서, 적절한 우선순위(Syste
m.Windows.Threading.DispatcherPriority)에 따라 차례로 실행시킨다.
[* DispatcherObject는 자신이 속한 Thread 의 Dispatcher 객체를 레퍼런스 하는 프로퍼티를 가지고 있다. DispatcherObject를 엑세스 하기 위한 메서드는 DispatcherObject의 Dispatcher에 실행을 요청하면 안전하게 수행할 수 있게 된다.]

그럼 Dispatcher가 Work Item을 Queueing 하고 실행시키는 메커니즘과 Dispatcher의 다른 메서드 PushFrame을 알아 보도록 하자. 

PushFrame은 Dispatcher의 메세지 펌프(Message Pump, 윈도우 어플리케이션의 GetMessage, PostMessage 등으로 구성) 루틴을 싱행하도록 만든다. 즉 PushFrame은 Block된다. (While 문으로 되어 있으므로)

Dispatcher의 Run 메서드는 내부적으로 PushFrame을 호출한다. 또한 System.Windows.Application Run() 도 내부적으로 현재 Thread의 Dispatcher의 Run()을 호출한다. 

비록 PushFrame에서 메소드가 Block이 되었지만, 그 내부에서는 메세지 펌프를 하면서 메세지에 따라 적절한 분기 실행을 하기 때문에 그 Thread가 멈춰있다고 할 수는 없는 상황이다.
정리하자면, 윈도우 기반 어플리케이션에서 필수적인 메세지 펌프 루틴은 Dispatcher가 구현하고 있다. 따라서 WPF의 윈도우들의 메세지 펌프와 메세지에 따른 실행분기를 Dispatcher가 수행한다. 

이쯤되면 Dispatcher의 Work Item의 실행 메커니즘도 이미 예상할 수 있을 것이다. 
Dispatcher의 BeginInvoke나 Invoke를 호출하면 Dispatcher는 그 Work Item을 Queueing 해 둔다. 그리고 자신의 Thread에 새로운 Work Item이 들어왔다는 메세지를 보낸다. 정확히 말하면 PostMessage 를 보낸다. 
이때 보내는 메세지는 Dispatcher가 생성될 때 RegisterWindowMessage 를 사용하여 "DispatcherProcessMessage" 라는 이름으로 등록한 윈도우 메세지를 사용한다.
 
그러면 그 메세지가 메세지 펌프에서 추출될 것이고, 그 메세지 처리부에서 적절한 우선순위에 따라 Work Item을 실행하도록 되어 있다.
따라서 Dispatcher는 자신이 속한 Thread에서 차례로 하나씩 work item을 처리할 수가 있는 것이다. 
(앞서도 말했지만, Dispatcher는 자신의 메세지 뿐만 아니라 WPF의 진짜 윈도우에 대한 메세지 펌프도 처리한다.)

사실 이런식의 구현 방법은 COM의 STA등에서 내부적으로 사용되는 방법이며
다양한 RPC 형태의 구현에서도 Thread Safe하지 않은 객체에 대한 엑세스의 직렬화 및 동기화를 위해 사용된다.

MSDN에서는 위와 같은 Dispatcher의 작동 방식이, Thread를 생성하지 않고 놀고 있는 UI Thread를 활용하는 방법이며, 또한 다른 Thread에서의 처리결과를 UI Thread로 마샬링(Marshaling)하는 방법이라고 설명되어 있다.

그렇지만 Dispatcher에 Invoke를 요청하는 메소드는 가능한 짧게 유지하는 것이 좋을 듯 하다.
요청한 작업이 놀고 있는 UI Thread를 잡아먹고 있는 동안 사용자가 UI 조작을 할지 모를 노릇이기 때문이다.(실제로 이런식의 오류가 발생한다. Dispatcher가 작업을 수행하는 동한 사용자가 UI에 다른 작업을 지시하는 경우가 있기 때문이다.)

특정 구문에서 직접적으로 UI를 반영해야하는 경우, MainThread가 큐 Frame을 종료하지 않은 채 UI 변경을 요구하는 구문을 통과하면 실제 객체가 가진 값은 변경되었지만 UI에 반영되지 않는 상황이 발생한다. 
좀더 쉽게 예를 들어보면 
*XAML
<Grid>
<Button Content="TEST" Height="37" HorizontalAlignment="Left" Margin="12,12,0,0" Name="btnTest"
VerticalAlignment="Top" Width="479" Click="btnTest_Click" />
<TextBox Height="244" HorizontalAlignment="Left" Margin="12,55,0,0" Name="txtResult"
VerticalAlignment="Top" Width="479" />
</Grid>
 

위와 같은 XAML이 있을때
Freezable Objects Overview.' data-guid="7b0bda3812e7619a60e99a0e8ccd9f87"> btnTest를 클릭하면 버튼의 IsEnabled 를 "false"로 1초간 지정했다가 다시 "true"로 변경해주는 코드를 짜보자.


*Code
private void btnTest_Click(object sender, RoutedEventArgs e)
{
this.txtResult.Text += string.Format("{0}{1}",DateTime.Now, Environment.NewLine);
this.txtResult.Text += string.Format("Button Clicked then disabled{0}", Environment.NewLine);
this.btnTest.IsEnabled = false //#1
 
System.Threading.Thread.Sleep(2000); //#2
 
this.btnTest.IsEnabled = true //#3
this.txtResult.Text += string.Format("Button pressed then enabled{0}{0}", Environment.NewLine);
} 
 
디버깅해보면 생각대로 진행되지 않는다. 버튼은 그냥 누른상태로 있고 (Disabled된 상태가 아니다) 
메세지는 메서드가 끝난뒤에야 출력된다. 당연히 Break Point를 걸고 버튼과 택스트 박스 내용을 확인해봐도 값은 변경되었지만, UI에 적용 되지 않는다는 것을 알수 있다.
이유는, Main Thread가 UI를 그리는 작업이 Dispatcher의 Frame 단위로 이루어지기 때문이다.
즉, Break Point로 한단계씩 위의 메서드를 통과 시키면 Main Thread가 해당 구문을 읽으면서 객체의 값을 바꾸긴 하지만 변경된 값을 다시 UI에 적용하는 시점은 Dispatcher의 큐 Frame이 끝나는 시점이라는 것이다. 


이것을 해결하기 위해 Dispatcher.BeginInvoke를 사용해 보기로 하자. 
BeginInvoke는 아래와 같은 오버로드 메서드를 가진다. 
public DispatcherOperation BeginInvoke(Delegate method, params object[] args);
public DispatcherOperation BeginInvoke(DispatcherPriority priority, Delegate method);
public DispatcherOperation BeginInvoke(Delegate method, DispatcherPriority priority, params object[] args);
public DispatcherOperation BeginInvoke(DispatcherPriority priority, Delegate method, object arg);
public DispatcherOperation BeginInvoke(DispatcherPriority priority, Delegate method, object arg, params object[] args);

방법1.  
this.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{ 
 // To Do;
})); 
   
* Invoke를 사용해도 동일 한 결과를 얻을수 있다.(Invoke는 동기식이고 BeginInvoke는 비동기 식)
* 익명 메서드를 편리하게 사용하기 위해 Action() 을 사용했다.



방법2. (MSDN 예문)
[SecurityPermissionAttribute(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
public void DoEvents()
{
DispatcherFrame frame = new DispatcherFrame();
Dispatcher.CurrentDispatcher.BeginInvoke(DispatcherPriority.Background,
new DispatcherOperationCallback(ExitFrame), frame);
Dispatcher.PushFrame(frame);
}
 
public object ExitFrame(object f)
{
((DispatcherFrame)f).Continue = false
return null
}

동작원리를 살펴보자. 
DispatcherFrame  Continue 속성을 false로 두고 Dispatcher Frame에 밀어넣어 준다는 것.  
그러면 Push Frame은 우선 순위를 판단해서 현재의 작업 Frame을 끊고 큐에 로드되어있던 UI를 적용한다.  조금 간략화 하면  


[SecurityPermissionAttribute(SecurityAction.Demand, Flags = SecurityPermissionFlag.UnmanagedCode)]
public void DoEvents()
{
DispatcherFrame frame = new DispatcherFrame();
this.Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(()=>
{
frame.Continue = false
}));
Dispatcher.PushFrame(frame);
} 
이렇게 되겠다.

작성자 : 김일환
출처 : http://blog.naver.com/PostView.nhn?blogId=ecaface&logNo=140152822078

반응형