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

... 31  32  33  34  35  36  37  38  [39]  40  41  42  43  44  45  ...
NoWriterDateCnt.TitleFile(s)
12646정성태5/15/20218251사물인터넷: 65. C# - Arduino IDE의 Serial Monitor 기능 구현파일 다운로드1
12645정성태5/14/20217953사물인터넷: 64. NodeMCU v1 ESP8266 - LittleFS를 이용한 와이파이 접속 정보 업데이트파일 다운로드1
12644정성태5/14/20219127오류 유형: 719. 윈도우 - 제어판의 "프로그램 및 기능" / "Windows 기능 켜기/끄기" 오류 0x800736B3
12643정성태5/14/20218276오류 유형: 718. 서버 유형의 COM+ 사용 시 0x80080005(Server execution failed) 오류 발생
12642정성태5/14/20219192오류 유형: 717. The 'Microsoft.ACE.OLEDB.12.0' provider is not registered on the local machine.
12641정성태5/13/20218918디버깅 기술: 179. 윈도우용 .NET Core 3 이상에서 Windbg의 sos 사용법
12640정성태5/13/202111832오류 유형: 716. RDP 연결 - Because of a protocol error (code: 0x112f), the remote session will be disconnected. [1]
12639정성태5/12/20218712오류 유형: 715. Arduino: Open Serial Monitor - The module '...\detection.node' was compiled against a different Node.js version using NODE_MODULE_VERSION
12638정성태5/12/20219611사물인터넷: 63. NodeMCU v1 ESP8266 - 펌웨어 내 파일 시스템(SPIFFS, LittleFS) 및 EEPROM 활용
12637정성태5/10/20219296사물인터넷: 62. NodeMCU v1 ESP8266 보드의 A0 핀에 다중 아날로그 센서 연결 [1]
12636정성태5/10/20219450사물인터넷: 61. NodeMCU v1 ESP8266 보드의 A0 핀 사용법 - FSR-402 아날로그 압력 센서 연동파일 다운로드1
12635정성태5/9/20218774기타: 81. OpenTabletDriver를 (관리자 권한으로 실행하지 않고도) 관리자 권한의 프로그램에서 동작하게 만드는 방법
12634정성태5/9/20217881개발 환경 구성: 572. .NET에서의 신뢰도 등급 조정 - 외부 Manifest 파일을 두는 방법파일 다운로드1
12633정성태5/7/20219337개발 환경 구성: 571. UAC - 관리자 권한 없이 UIPI 제약을 없애는 방법
12632정성태5/7/20219483기타: 80. (WACOM도 지원하는) Tablet 공통 디바이스 드라이버 - OpenTabletDriver
12631정성태5/5/20219418사물인터넷: 60. ThingSpeak 사물인터넷 플랫폼에 ESP8266 NodeMCU v1 + 조도 센서 장비 연동파일 다운로드1
12630정성태5/5/20219757사물인터넷: 59. NodeMCU v1 ESP8266 보드의 A0 핀 사용법 - CdS Cell(GL3526) 조도 센서 연동파일 다운로드1
12629정성태5/5/202111503.NET Framework: 1057. C# - CoAP 서버 및 클라이언트 제작 (UDP 소켓 통신) [1]파일 다운로드1
12628정성태5/4/20219459Linux: 39. Eclipse 원격 디버깅 - Cannot run program "gdb": Launching failed
12627정성태5/4/202110162Linux: 38. 라즈베리 파이 제로 용 프로그램 개발을 위한 Eclipse C/C++ 윈도우 환경 설정
12626정성태5/3/202110170.NET Framework: 1056. C# - Thread.Suspend 호출 시 응용 프로그램 hang 현상 (2)파일 다운로드1
12625정성태5/3/20219130오류 유형: 714. error CS5001: Program does not contain a static 'Main' method suitable for an entry point
12624정성태5/2/202112870.NET Framework: 1055. C# - struct/class가 스택/힙에 할당되는 사례 정리 [10]파일 다운로드1
12623정성태5/2/20219541.NET Framework: 1054. C# 9 최상위 문에 STAThread 사용 [1]파일 다운로드1
12622정성태5/2/20216359오류 유형: 713. XSD 파일을 포함한 프로젝트 - The type or namespace name 'TypedTableBase<>' does not exist in the namespace 'System.Data'
12621정성태5/1/20219799.NET Framework: 1053. C# - 특정 레지스트리 변경 시 알림을 받는 방법 [1]파일 다운로드1
... 31  32  33  34  35  36  37  38  [39]  40  41  42  43  44  45  ...