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분
연관 글은, 대상 글의 본문이나 덧글에서 현재 글에 대한 링크를 가지고 있다면 "연관 글"로 보여주고 있습니다. 개인적으로, 가끔 특정 글을 찾고 싶을 때 편하게 하려고 넣어둔 것입니다. ^^;
정성태

... 16  17  18  19  20  21  22  23  24  25  26  27  28  29  [30]  ...
NoWriterDateCnt.TitleFile(s)
12870정성태12/9/20216063개발 환경 구성: 614. 파이썬 - PyPI 패키지 만들기 (4) package_data 옵션
12869정성태12/8/20218265개발 환경 구성: 613. git clone 실행 시 fingerprint 묻는 단계를 생략하는 방법
12868정성태12/7/20216836오류 유형: 770. twine 업로드 시 "HTTPError: 400 Bad Request ..." 오류 [1]
12867정성태12/7/20216544개발 환경 구성: 612. 파이썬 - PyPI 패키지 만들기 (3) entry_points 옵션
12866정성태12/7/202113917오류 유형: 769. "docker build ..." 시 "failed to solve with frontend dockerfile.v0: failed to read dockerfile ..." 오류
12865정성태12/6/20216612개발 환경 구성: 611. 파이썬 - PyPI 패키지 만들기 (2) long_description, cmdclass 옵션
12864정성태12/6/20215085Linux: 46. WSL 환경에서 find 명령을 사용해 파일을 찾는 방법
12863정성태12/4/20216980개발 환경 구성: 610. 파이썬 - PyPI 패키지 만들기
12862정성태12/3/20215723오류 유형: 768. Golang - 빌드 시 "cmd/go: unsupported GOOS/GOARCH pair linux /amd64" 오류
12861정성태12/3/20217947개발 환경 구성: 609. 파이썬 - "Windows embeddable package"로 개발 환경 구성하는 방법
12860정성태12/1/20216053오류 유형: 767. SQL Server - 127.0.0.1로 접속하는 경우 "Access is denied"가 발생한다면?
12859정성태12/1/202112201개발 환경 구성: 608. Hyper-V 가상 머신에 Console 모드로 로그인하는 방법
12858정성태11/30/20219455개발 환경 구성: 607. 로컬의 USB 장치를 원격 머신에 제공하는 방법 - usbip-win
12857정성태11/24/20216942개발 환경 구성: 606. WSL Ubuntu 20.04에서 파이썬을 위한 uwsgi 설치 방법
12856정성태11/23/20218721.NET Framework: 1121. C# - 동일한 IP:Port로 바인딩 가능한 서버 소켓 [2]
12855정성태11/13/20216117개발 환경 구성: 605. Azure App Service - Kudu SSH 환경에서 FTP를 이용한 파일 전송
12854정성태11/13/20217665개발 환경 구성: 604. Azure - 윈도우 VM에서 FTP 여는 방법
12853정성태11/10/20216044오류 유형: 766. Azure App Service - JBoss 호스팅 생성 시 "This region has quota of 0 PremiumV3 instances for your subscription. Try selecting different region or SKU."
12851정성태11/1/20217368스크립트: 34. 파이썬 - MySQLdb 기본 예제 코드
12850정성태10/27/20218514오류 유형: 765. 우분투에서 pip install mysqlclient 실행 시 "OSError: mysql_config not found" 오류
12849정성태10/17/20217686스크립트: 33. JavaScript와 C#의 시간 변환 [1]
12848정성태10/17/20218641스크립트: 32. 파이썬 - sqlite3 기본 예제 코드 [1]
12847정성태10/14/20218480스크립트: 31. 파이썬 gunicorn - WORKER TIMEOUT 오류 발생
12846정성태10/7/20218251스크립트: 30. 파이썬 __debug__ 플래그 변수에 따른 코드 실행 제어
12845정성태10/6/20218094.NET Framework: 1120. C# - BufferBlock<T> 사용 예제 [5]파일 다운로드1
12844정성태10/3/20216127오류 유형: 764. MSI 설치 시 "... is accessible and not read-only." 오류 메시지
... 16  17  18  19  20  21  22  23  24  25  26  27  28  29  [30]  ...