Microsoft MVP성태의 닷넷 이야기
글쓴 사람
홈페이지
첨부 파일

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




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

[연관 글]





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

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer@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를 놓아 진행 중임을 알려야 합니다.
정성태

... 16  17  18  19  20  21  22  23  24  25  [26]  27  28  29  30  ...
NoWriterDateCnt.TitleFile(s)
11593정성태7/8/20182402Math: 40. GeoGebra 기하 (17) - 각의 복사파일 다운로드1
11591정성태7/7/20182254Math: 39. GeoGebra 기하 (16) - 삼각형의 방심과 방접원파일 다운로드1
11590정성태7/7/20181933Math: 38. GeoGebra 기하 (15) - 삼각형의 수심파일 다운로드1
11589정성태7/7/20182669.NET Framework: 787. object로 형변환된 인스턴스를 원래의 타입 인자로 제네릭 메서드를 호출하는 방법 [2]파일 다운로드1
11588정성태12/19/20192393디버깅 기술: 116. windbg 분석 사례 - ASP.NET 웹 응용 프로그램의 CPU 100% 현상
11587정성태7/5/20182099.NET Framework: 786. ASP.NET - HttpCookieCollection을 다중 스레드에서 사용할 경우 무한 루프 현상
11586정성태7/5/20182590Math: 37. GeoGebra 기하 (14) - 삼각형의 무게 중심파일 다운로드1
11585정성태7/5/20182602Math: 36. GeoGebra 기하 (13) - 삼각형의 외심과 외접하는 원파일 다운로드1
11584정성태7/5/20183029Math: 35. GeoGebra 기하 (12) - 삼각형의 내심과 내접하는 원파일 다운로드1
11583정성태7/5/20181921.NET Framework: 785. public으로 노출되지 않은 다른 어셈블리의 delegate 인스턴스를 Reflection으로 생성하는 방법파일 다운로드1
11582정성태5/8/20193320.NET Framework: 784. C# - 제네릭 인자를 가진 타입을 생성하는 방법 [1]파일 다운로드1
11581정성태7/4/20182978Math: 34. GeoGebra 기하 (11) - 3대 작도 불능 문제의 하나인 임의 각의 3등분파일 다운로드1
11580정성태7/4/20182235Math: 33. GeoGebra 기하 (10) - 직각의 3등분파일 다운로드1
11579정성태7/4/20182414Math: 32. GeoGebra 기하 (9) - 임의의 선분을 한 변으로 갖는 정삼각형파일 다운로드1
11578정성태7/3/20182355Math: 31. GeoGebra 기하 (8) - 호(Arc)의 이등분파일 다운로드1
11577정성태7/3/20182653Math: 30. GeoGebra 기하 (7) - 각의 이등분파일 다운로드1
11576정성태7/3/20182423Math: 29. GeoGebra 기하 (6) - 대수의 4칙 연산파일 다운로드1
11575정성태7/2/20182834Math: 28. GeoGebra 기하 (5) - 선분을 n 등분하는 방법파일 다운로드1
11574정성태7/2/20182370Math: 27. GeoGebra 기하 (4) - 선분을 n 배 늘이는 방법파일 다운로드1
11573정성태7/2/20182315Math: 26. GeoGebra 기하 (3) - 평행선
11572정성태7/1/20182071.NET Framework: 783. C# 컴파일러가 허용하지 않는 (유효한) 코드를 컴파일해 테스트하는 방법
11571정성태7/1/20182042.NET Framework: 782. C# - JIRA에 등록된 Project의 Version 항목 추가하는 방법파일 다운로드1
11570정성태7/2/20183261Math: 25. GeoGebra 기하 (2) - 임의의 선분과 특정 점을 지나는 수직선파일 다운로드1
11569정성태7/1/20182758Math: 24. GeoGebra 기하 (1) - 수직 이등분선파일 다운로드1
11568정성태7/12/20184715Math: 23. GeoGebra 기하 - 컴퍼스와 자를 이용한 작도 프로그램 [1]
11567정성태6/28/20182535.NET Framework: 781. C# - OpenCvSharp 사용 시 포인터를 이용한 속도 향상파일 다운로드1
... 16  17  18  19  20  21  22  23  24  25  [26]  27  28  29  30  ...