Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
(연관된 글이 9개 있습니다.)
(시리즈 글이 9개 있습니다.)
.NET Framework: 612. UWP(유니버설 윈도우 플랫폼) 앱에서 콜백 함수 내에서의 UI 요소 접근 방법
; https://www.sysnet.pe.kr/2/0/11071

.NET Framework: 680. C# - 작업자(Worker) 스레드와 UI 스레드
; https://www.sysnet.pe.kr/2/0/11287

.NET Framework: 777. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서!
; https://www.sysnet.pe.kr/2/0/11561

.NET Framework: 805. 두 개의 윈도우를 각각 실행하는 방법(Windows Forms, WPF)
; https://www.sysnet.pe.kr/2/0/11802

.NET Framework: 886. C# - Console 응용 프로그램에서 UI 스레드 구현 방법
; https://www.sysnet.pe.kr/2/0/12139

.NET Framework: 911. Console/Service Application을 위한 SynchronizationContext - AsyncContext
; https://www.sysnet.pe.kr/2/0/12231

.NET Framework: 1022. UI 요소의 접근은 반드시 그 UI를 만든 스레드에서! - 두 번째 이야기
; https://www.sysnet.pe.kr/2/0/12537

.NET Framework: 2076. C# - SynchronizationContext 기본 사용법
; https://www.sysnet.pe.kr/2/0/13190

.NET Framework: 2077. C# - 직접 만들어 보는 SynchronizationContext
; https://www.sysnet.pe.kr/2/0/13191




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




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

[연관 글]






[최초 등록일: ]
[최종 수정일: 12/10/2022]

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

비밀번호

댓글 작성자
 



2018-06-26 01시02분
[레몬] 좋은 글 감사합니다. 도움 되었습니다.
[guest]
2018-06-27 06시35분
[spowner] 화면이 복잡해지면 Windows Forms던 WPF던 컨트롤들을 구성하기 위해 시간이 소비가 되고 그 사이에는 화면이 멈짓 합니다. 화면이 아닌 다른 작업은 비동기 처리로 사용자가 어색하지 않는 대기화면이나 다른 처리들을 할 수 있는데 화면구성만큼은 어떻게 안되더군요. 실제로 ERP 수준의 복잡한 화면은 화면을 구성하기 위해 1~2초 랙이 걸리기도 하는데, 이럴 때 어떻게 처리들 하시는지 궁금하네요
[guest]
2018-06-27 07시09분
어쩔 수 없습니다. 그럴 때는 별도 스레드에서 윈도우를 생성하고 그 안에 progress bar를 놓아 진행 중임을 알려야 합니다.
정성태
2021-02-05 10시53분
[캬옹] 감사합니다.
안드로이드도 메인 쓰레드에서만 UI 접근 가능해서 고생했는데
덕분에 쉽게 접근 할 수 있네요!
[guest]
2021-02-22 05시19분
[guest] 좋은글 감사합니다.
애초에 사용하게 편하도록 BeginInvoke를 Embeded 시켰으면 어땠을가 생각해 봤습니다.
[guest]
2022-09-10 10시48분
[한예지] 명글 감사합니다!
[guest]
2022-09-10 10시53분
[한예지] 선생님 연관 글은 어떤 로직으로 추천하는 것인가요?
선생님이 게시글 작성할 때 별도의 카테고리 그룹 선택하면 그룹에 속한 글들을
연관 글로 보여주는 것인가요?
생각보다 연관 글이 유용해서 질문 드려봅니다...
[guest]
2022-09-10 11시18분
연관 글은, 대상 글의 본문이나 덧글에서 현재 글에 대한 링크를 가지고 있다면 "연관 글"로 보여주고 있습니다. 개인적으로, 가끔 특정 글을 찾고 싶을 때 편하게 하려고 넣어둔 것입니다. ^^;
정성태

1  [2]  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
13575정성태3/7/20241671닷넷: 2227. 최신 C# 문법을 .NET Framework 프로젝트에 쓸 수 있을까요?
13574정성태3/6/20241556닷넷: 2226. C# - "Docker Desktop for Windows" Container 환경에서의 IPv6 DualMode 소켓
13573정성태3/5/20241561닷넷: 2225. Windbg - dumasync로 분석하는 async/await 호출
13572정성태3/4/20241638닷넷: 2224. C# - WPF의 Dispatcher Queue로 알아보는 await 호출의 hang 현상파일 다운로드1
13571정성태3/1/20241599닷넷: 2223. C# - await 호출과 WPF의 Dispatcher Queue 동작 확인파일 다운로드1
13570정성태2/29/20241626닷넷: 2222. C# - WPF의 Dispatcher Queue 동작 확인파일 다운로드1
13569정성태2/28/20241539닷넷: 2221. C# - LoadContext, LoadFromContext 그리고 GAC파일 다운로드1
13568정성태2/27/20241602닷넷: 2220. C# - .NET Framework 프로세스의 LoaderOptimization 설정을 확인하는 방법파일 다운로드1
13567정성태2/27/20241613오류 유형: 898. .NET Framework 3.5 이하에서 mscoree.tlb 참조 시 System.BadImageFormatException파일 다운로드1
13566정성태2/27/20241626오류 유형: 897. Windows 7 SDK 설치 시 ".NET Development" 옵션이 비활성으로 선택이 안 되는 경우
13565정성태2/23/20241464닷넷: 2219. .NET CLR2 보안 모델에서의 개별 System.Security.Permissions 제어
13564정성태2/22/20241611Windows: 259. Hyper-V Generation 1 유형의 VM을 Generation 2 유형으로 바꾸는 방법
13563정성태2/21/20241625디버깅 기술: 196. windbg - async/await 비동기인 경우 메모리 덤프 분석의 어려움
13562정성태2/21/20241630오류 유형: 896. ASP.NET - .NET Framework 기본 예제에서 System.Web에 대한 System.IO.FileNotFoundException 예외 발생
13561정성태2/20/20241740닷넷: 2218. C# - (예를 들어, Socket) 비동기 I/O에 대한 await 호출 시 CancellationToken을 이용한 취소파일 다운로드1
13560정성태2/19/20241743디버깅 기술: 195. windbg 분석 사례 - Semaphore 잠금으로 인한 Hang 현상 (닷넷)
13559정성태2/19/20242622오류 유형: 895. ASP.NET - System.Security.SecurityException: 'Requested registry access is not allowed.'
13558정성태2/18/20241818닷넷: 2217. C# - 최댓값이 1인 SemaphoreSlim 보다 Mutex 또는 lock(obj)를 선택하는 것이 나은 이유
13557정성태2/18/20241569Windows: 258. Task Scheduler의 Author 속성 값을 변경하는 방법
13556정성태2/17/20241636Windows: 257. Windows - Symbolic (hard/soft) Link 및 Junction 차이점
13555정성태2/15/20241951닷넷: 2216. C# - SemaphoreSlim 사용 시 주의점
13554정성태2/15/20241706VS.NET IDE: 189. Visual Studio - 닷넷 소스코드 디컴파일 찾기가 안 될 때
13553정성태2/14/20241731닷넷: 2215. windbg - thin/fat lock 없이 동작하는 Monitor.Wait + Pulse
13552정성태2/13/20241682닷넷: 2214. windbg - Monitor.Enter의 thin lock과 fat lock
13551정성태2/12/20242015닷넷: 2213. ASP.NET/Core 웹 응용 프로그램 - 2차 스레드의 예외로 인한 비정상 종료
13550정성태2/11/20242104Windows: 256. C# - Server socket이 닫히면 Accept 시켰던 자식 소켓이 닫힐까요?
1  [2]  3  4  5  6  7  8  9  10  11  12  13  14  15  ...