Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일

UI 요소의 접근은 반드시 그 UI를 만든 스레드에서!

의외군요... 이에 관해서 WPF로만 간단하게 언급하고 한 번도 제대로 설명하는 글을 쓴 적이 없었습니다. ^^ 어쨌든, 다음의 질문이 있었으니,

안녕하세요 성태님 도움으로 C# 네이버 카페 스팸글 작성되면 삭제되는 프로그램을 만들었는데요..여쭤볼게 하나 있습니다.
; https://www.sysnet.pe.kr/3/0/5000

이참에 정리를 하는 차원에서 글을 남깁니다. ^^




GUI 환경에서의 프로그램을 보면, 공통점이 하나 있습니다. 바로 "특정 UI 요소에 대한 접근은 반드시 그 UI 요소를 만든 스레드에서 해야 한다"는 것입니다. 윈도우만 알던 시절에는 이것이 윈도우 운영체제만의 제약인 줄 알았는데, iOS도 그렇고 Android도 그렇고 다른 운영체제들도 동일한 제약을 갖고 있었습니다.

지난 글에서 "작업자 스레드"와 "UI 스레드"간의 구분을 알아봤었는데요.

C# - 작업자 스레드와 UI 스레드
; https://www.sysnet.pe.kr/2/0/11287

따라서, UI 요소는 어떤 스레드에서든 만드는 것이 가능합니다. 단지, 별다른 일이 없는 한 최초 실행되는 Main 스레드에서 "윈도우" 창을 하나 만들고 시작하는 것이 일상적입니다. 게다가 UI 요소는 부모/자식 간의 관계로 연결되는 경우가 많기 때문에 윈도우 내에서 다시 생성되는 (TextBox나 CheckBox 등의) UI 요소들은 "윈도우"라는 UI 요소의 자식으로 등록해야 하기 때문에 그 생성을 또한 Main 스레드가 하게 됩니다. 그렇게 해서 결국 대부분의 UI를 Main 스레드가 담당하게 되는 것입니다.

자, 그럼 간단하게 코드를 볼까요? ^^

Windows Forms 프로젝트를 생성하면 다음과 같이 Main 메서드가 기본 처리를 담당합니다.

[STAThread]
static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);
    Application.Run(new Form1());
}

따라서 Form1 윈도우는 Main 스레드가 생성했으므로 Form1의 메서드나 속성을 호출하고 싶다면 반드시 Main 스레드를 경유해야 합니다. 가령, Title을 Form1.cs의 Window.OnLoad 이벤트에서 다음과 같이 할 수 있습니다.

using System;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private void Form1_Load(object sender, EventArgs e)
        {
            this.Text = "TEST WINDOW";
        }
    }
}

만약 다른 스레드에서 접근하려면 어떻게 될까요?

private void Form1_Load(object sender, EventArgs e)
{
    Task.Run(() =>
    {
        this.Text = "TEST WINDOW";
    });
}

위와 같이 코드를 바꾸고 F5 디버깅으로 실행하면 this.Text = "..."; 코드에서 다음과 같은 예외가 발생하는 것을 볼 수 있습니다.

System.InvalidOperationException
  HResult=0x80131509
  Message=Cross-thread operation not valid: Control 'Form1' accessed from a thread other than the thread it was created on.
  Source=System.Windows.Forms
  StackTrace:
   at System.Windows.Forms.Control.get_Handle()
   at System.Windows.Forms.Control.set_WindowText(String value)
   at System.Windows.Forms.Form.set_WindowText(String value)
   at System.Windows.Forms.Control.set_Text(String value)
   at System.Windows.Forms.Form.set_Text(String value)
   at WindowsFormsApp1.Form1.<Form1_Load>b__1_0() in F:\WindowsFormsApp1\Form1.cs:line 18
   at System.Threading.Tasks.Task.InnerInvoke()
   at System.Threading.Tasks.Task.Execute()

Form1을 생성하지 않은 스레드에서 접근하려고 했다고 자세하게 설명해 주고 있습니다. 그렇다면 UI를 생성한 스레드 이외에는 제목에 대한 설정조차도 할 수 없는 걸까요? 다행히 방법이 있습니다. 바로 UI를 생성한 스레드에게 실행할 코드를 던져 주는 것입니다. 이를 위해 다음과 같이 변경할 수 있습니다.

private void Form1_Load(object sender, EventArgs e)
{
    Task.Run(() =>
    {
        this.Invoke(
            (System.Action)( () =>
            {
                this.Text = "TEST WINDOW";
            }) );
    });
}

좀 더 쉽게 다음과 같이 풀어볼 수도 있겠죠!

private void Form1_Load(object sender, EventArgs e)
{
    Task.Run(() =>
    {
        SetTitle("TEST WINDOW");
    });
}

private void SetTitle(string title)
{
    this.Invoke(
        (System.Action)(() =>
        {
            this.Text = title;
        }));
}

표면상으로는 UI 접근을 하는 코드를 (Form 객체인) this의 Invoke 메서드를 전달하고 있습니다. (참고로, Invoke 메서드는 Control 타입에서 제공되며 Form은 Control을 상속받은 타입입니다.) 하지만, Invoke 내부에서는 전달받은 메서드를 Main 스레드에 전달해 주는 역할을 합니다. 그러니까, Windows Forms 세계에서 개별 Control은 자신을 만든 스레드를 알 수 있고 그 스레드에 코드를 전달할 수 있는 Invoke 메서드가 제공되는 것입니다.

여기서 Invoke 메서드는 동기적으로 실행됩니다. 즉, 위의 Task.Run 스레드는 this.Invoke로 전달한 메서드가 Main 스레드에 의해 실행이 완료되기까지 대기하게 됩니다. 만약, 이런 대기를 원치 않는다면 Invoke의 비동기 버전인 BeginInvoke를 불러도 됩니다.

this.BeginInvoke(
    (System.Action)(() =>
    {
        this.Text = title;
    }));

어떤 차이가 있는지 다음과 같은 변경을 하면 금방 알 수 있습니다.

private void SetTitle(string title)
{
    Debug.WriteLine(DateTime.Now);
    this.Invoke(
        (System.Action)(() =>
        {
            this.Text = title;
            Thread.Sleep(1000);
        }));
    Debug.WriteLine(DateTime.Now);

    Debug.WriteLine(DateTime.Now);
    this.BeginInvoke(
        (System.Action)(() =>
        {
            this.Text = title;
            Thread.Sleep(1000);
        }));
    Debug.WriteLine(DateTime.Now);
}

디버그 모드로 실행하면, 다음과 같은 식으로 출력이 됩니다.

2018-06-26 오후 7:23:44
2018-06-26 오후 7:23:45
2018-06-26 오후 7:23:45
2018-06-26 오후 7:23:45




SetTitle 메서드를 다음과 같이 구현했을 때,

private void SetTitle(string title)
{
    this.BeginInvoke(
        (System.Action)(() =>
        {
            this.Text = title;
        }));
}

사실 이 메서드를 무심코 Main 스레드 및 다른 스레드에서도 호출할 수 있습니다. 하지만, Main 스레드인 경우에는 굳이 BeginInvoke를 거칠 필요 없이 곧바로 this.Text에 접근할 수 있으므로 성능을 위해 할 수만 있다면 그렇게 하는 것이 좋습니다. 그리고 바로 이때 사용할 수 있는 플래그 값이 "InvokeRequired" 속성입니다.

private void SetTitle(string title)
{
    if (this.InvokeRequired) // 현재 스레드가 this(Form1) 요소를 만든 스레드를 경유해야 하는지 확인
    {
        this.BeginInvoke(
            (System.Action)(() =>
            {
                this.Text = title;
            }));
    }
    else
    {
        this.Text = title;
    }
}




좀 더 실질적인 예를 들어 볼까요?

2초에 한 번씩 REST API를 호출한다고 가정해 보겠습니다. 그리고 그 호출이 서버로부터 응답받는데 보통 1초가 걸리고 그 응답을 화면에 뿌려줘야 하는 경우를 보겠습니다.

만약 이때, 2초에 한 번씩 실행하는 코드를 System.Windows.Forms.Timer를 쓰게 되면 어떻게 될까요?

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        System.Windows.Forms.Timer _timer;

        public Form1()
        {
            InitializeComponent();

            _timer = new System.Windows.Forms.Timer();
            _timer.Interval = 2000;
            _timer.Tick += timer_Tick;
            _timer.Start();
        }

        private void timer_Tick(object sender, EventArgs e)
        {
            string result = CallNetworkResult();
            this.textBox1.Text = result;
        }

        private string CallNetworkResult()
        {
            Thread.Sleep(1000); // API 호출에 1초 걸린다고 가정
            return DateTime.Now.ToString(); // API가 반환한 결과라고 가정
        }
    }
}

일단, timer_Tick 이벤트 처리기에서 this.textBox1 UI 요소를 접근하고 있는 것은 괜찮은 것일까요? System.Windows.Forms.Timer는 Tick 이벤트를 Timer를 생성한 스레드에 실어서 보내기 때문에 결국 그것은 Main 스레드가 되고 this.textBox1을 정상적으로 접근할 수 있습니다.

여기서 문제는, timer_Tick에서 실행되는 1초 지연의 네트워크 호출입니다. Main 스레드는 이 호출을 처리하느라 UI 요소의 Paint를 비롯해 관련 이벤트를 전혀 처리하지 못하게 됩니다. 따라서 UI가 1초씩 얼어버리는 부작용이 발생하게 됩니다.

이 문제를 해결하려면 CallNetworkResult 메서드를 어떤 식으로든 다른 스레드에서 실행하도록 해야 합니다. 그에 대해서는 여러 가지 우회 방법이 있겠지만 위에서는 단지 System.Threading.Timer를 쓰는 것으로 바꿔줘도 좋습니다.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        System.Threading.Timer _timer;

        public Form1()
        {
            InitializeComponent();

            _timer = new System.Threading.Timer(timer_Tick, null, 0, 2000);
        }

        private void timer_Tick(object state)
        {
            string result = CallNetworkResult();
            this.textBox1.Text = result;
        }

        private string CallNetworkResult()
        {
            Thread.Sleep(1000);
            return DateTime.Now.ToString();
        }

    }
}

System.Threading.Timer는, 전달된 timer_Tick 콜백 함수를 스레드 풀로부터 빌려온 자유 스레드를 통해 호출해 줍니다. 자, 그럼 여기서 새로운 문제가 하나 발생합니다. 그 자유 스레드는 당연히 this.textBox1 UI 요소를 생성한 것이 아니기 때문에 위와 같이 바꾸면 this.textBox1.Text = result; 코드에서 System.InvalidOperationException 예외가 발생하게 됩니다. 따라서 UI를 접근하는 코드만 별도로 분리해 다음과 같이 구성해야 합니다.

private void timer_Tick(object state)
{
    string result = CallNetworkResult(); // 네트워크 호출은 스레드 풀의 자유 스레드에서 실행되고,
    SetResult(result); // UI 접근은 BeginInvoke를 통해 Main 스레드에서 실행.
}

private void SetResult(string result)
{
    if (this.InvokeRequired == true)
    {
        this.BeginInvoke((System.Action)(
            () =>
            {
                this.textBox1.Text = result;
            }));
    }
    else
    {
        this.textBox1.Text = result;
    }
}

참고로, 위에서 Form1 객체인 this와 this.textBox1은 모두 Control 객체를 상속받았고 모두 동일한 스레드에서 생성되었기 때문에 다음과 같이 코드를 고쳐도 무방합니다.

private void SetResult(string result)
{
    if (this.textBox1.InvokeRequired == true)
    {
        this.textBox1.BeginInvoke((System.Action)(
            () =>
            {
                this.textBox1.Text = result;
            }));
    }
    else
    {
        this.textBox1.Text = result;
    }
}

(첨부 파일은 이 글의 예제 코드를 포함합니다.)




이 글에서는 Windows Forms를 기준으로 설명했지만, WPF에서도 (메서드만 다를 뿐) 처리 방식에는 변함이 없습니다.

UWP(유니버설 윈도우 플랫폼) 앱에서 콜백 함수 내에서의 UI 요소 접근 방법
; https://www.sysnet.pe.kr/2/0/11071




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]

[연관 글]


donaricano-btn



[최초 등록일: ]
[최종 수정일: 6/26/2018 ]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer at outlook.com

비밀번호

댓글 쓴 사람
 



2018-06-26 01시02분
[레몬] 좋은 글 감사합니다. 도움 되었습니다.
[손님]
2018-06-27 06시35분
[spowner] 화면이 복잡해지면 Windows Forms던 WPF던 컨트롤들을 구성하기 위해 시간이 소비가 되고 그 사이에는 화면이 멈짓 합니다. 화면이 아닌 다른 작업은 비동기 처리로 사용자가 어색하지 않는 대기화면이나 다른 처리들을 할 수 있는데 화면구성만큼은 어떻게 안되더군요. 실제로 ERP 수준의 복잡한 화면은 화면을 구성하기 위해 1~2초 랙이 걸리기도 하는데, 이럴 때 어떻게 처리들 하시는지 궁금하네요
[손님]
2018-06-27 07시09분
어쩔 수 없습니다. 그럴 때는 별도 스레드에서 윈도우를 생성하고 그 안에 progress bar를 놓아 진행 중임을 알려야 합니다.
정성태
2021-02-05 10시53분
[캬옹] 감사합니다.
안드로이드도 메인 쓰레드에서만 UI 접근 가능해서 고생했는데
덕분에 쉽게 접근 할 수 있네요!
[손님]
2021-02-22 05시19분
[손님] 좋은글 감사합니다.
애초에 사용하게 편하도록 BeginInvoke를 Embeded 시켰으면 어땠을가 생각해 봤습니다.
[손님]

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12688정성태6/24/202138.NET Framework: 1073. C# - JSON 역/직렬화 시 리플렉션 손실을 없애는 JsonSrcGen파일 다운로드1
12687정성태6/22/202136오류 유형: 729. Invalid data: Invalid artifact, java se app service only supports .jar artifact
12686정성태6/21/202152Java: 22. Azure - 자바(Java)로 만드는 Web App Service
12685정성태6/21/202158Java: 21. Azure Web App Service에 배포된 Java 프로세스의 메모리 및 힙(Heap) 덤프 뜨는 방법
12684정성태6/19/202148오류 유형: 728. Visual Studio 2022부터 DTE.get_Properties 속성 접근 시 System.MissingMethodException 예외 발생
12683정성태6/18/202165VS.NET IDE: 166. Visual Studio 2022 - Windows Forms 프로젝트의 x86 DLL 컨트롤이 Designer에서 오류가 발생하는 문제파일 다운로드1
12682정성태6/18/202179VS.NET IDE: 165. Visual Studio 2022를 위한 Extension 마이그레이션
12681정성태6/18/202154오류 유형: 727. .NET 2.0 ~ 3.5 + x64 환경에서 System.EnterpriseServices 참조 시 CS8012 경고
12680정성태6/18/202140오류 유형: 726. python2.7.exe 실행 시 0xc000007b 오류
12679정성태6/18/202197COM 개체 관련: 23. CoInitializeSecurity의 전역 설정을 재정의하는 CoSetProxyBlanket 함수 사용법파일 다운로드1
12678정성태6/17/202182.NET Framework: 1072. C# - CoCreateInstance 관련 Inteop 오류 정리파일 다운로드1
12677정성태6/17/202176VC++: 144. 역공학을 통한 lxssmanager.dll의 ILxssSession 사용법 분석파일 다운로드1
12676정성태6/16/2021108VC++: 143. ionescu007/lxss github repo에 공개된 lxssmanager.dll의 CLSID_LxssUserSession/IID_ILxssSession 사용법파일 다운로드1
12675정성태6/16/202154Java: 20. maven package 명령어 결과물로 (war가 아닌) jar 생성 방법
12674정성태6/15/202194VC++: 142. DEFINE_GUID 사용법
12673정성태6/15/202157Java: 19. IntelliJ - 자바(Java)로 만드는 Web App을 Tomcat에서 실행하는 방법
12672정성태6/15/202148오류 유형: 725. IntelliJ에서 Java webapp 실행 시 "Address localhost:1099 is already in use" 오류
12671정성태6/15/202164오류 유형: 724. Tomcat 실행 시 Failed to initialize connector [Connector[HTTP/1.1-8080]] 오류
12670정성태6/13/202169.NET Framework: 1071. DLL Surrogate를 이용한 Out-of-process COM 개체에서의 CoInitializeSecurity 문제파일 다운로드1
12669정성태6/11/2021160.NET Framework: 1070. 사용자 정의 GetHashCode 메서드 구현은 C# 9.0의 record 또는 리팩터링에 맡기세요.
12668정성태6/11/2021175.NET Framework: 1069. C# - DLL Surrogate를 이용한 Out-of-process COM 개체 제작파일 다운로드2
12667정성태6/10/202199.NET Framework: 1068. COM+ 서버 응용 프로그램을 이용해 CoInitializeSecurity 제약 해결파일 다운로드1
12666정성태6/10/202178.NET Framework: 1067. 별도 DLL에 포함된 타입을 STAThread Main 메서드에서 사용하는 경우 CoInitializeSecurity 자동 호출파일 다운로드1
12665정성태6/9/2021146.NET Framework: 1066. Wslhub.Sdk 사용으로 알아보는 CoInitializeSecurity 사용 제약파일 다운로드1
12664정성태6/9/2021102오류 유형: 723. COM+ PIA 참조 시 "This operation failed because the QueryInterface call on the COM component" 오류
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...