WinForms 로 작업하다 보면 한 번은 꼭 만나는 예외가 있습니다. 백그라운드 스레드에서 라벨 글자 하나 바꿨을 뿐인데 InvalidOperationException 이 튀어나오고, 메시지는 "크로스 스레드 작업이 잘못되었습니다". 소켓 비동기 수신, 타이머 콜백, Thread 로 돌리는 긴 작업 등 UI 와 분리된 곳에서 화면을 갱신하려는 순간 터집니다.

원인은 단순합니다. WinForms 컨트롤은 자기를 만든 스레드(보통 폼을 띄운 UI 스레드)에서만 다루도록 되어 있습니다. 다른 스레드가 직접 속성을 바꾸면 디버그 모드에서 이 검사에 걸려 예외가 납니다. 그래서 해결 방향도 하나입니다. "바꾸는 일 자체를 UI 스레드에게 시킨다."

광고

예외가 나는 코드부터

버튼을 누르면 새 스레드를 띄우고, 그 스레드에서 button1.Text 를 바꾸는 코드입니다. 일부러 예외를 확인하려고 try ~ catch 로 감싸 메시지박스로 띄웠습니다.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
namespace CrossThreadSafetyAccess {
    public partial class MainForm : Form {
        public MainForm() {
            InitializeComponent();
        }

        private void Button1Click(object sender, EventArgs e) {
            Thread t = new Thread(CrossThreadNonSafetyAccess);
            t.Start();
        }
        private void CrossThreadNonSafetyAccess() {
            try {
                button1.Text = "Not safety code";
            } catch (Exception ex) {
                MessageBox.Show(ex.Message, ex.GetType().ToString(), MessageBoxButtons.OK, MessageBoxIcon.Exclamation);
            }
        }
    }
}

실행 후 버튼을 누르면 이런 창이 뜹니다.

크로스 스레드 작업이 잘못되었습니다 InvalidOperationException 메시지박스

직접 Thread 를 만들지 않아도 똑같습니다. BeginReceive 같은 비동기 소켓 콜백, System.Threading.Timer 콜백 안에서 컨트롤을 만지면 그 코드는 이미 UI 스레드 바깥에서 돌고 있으니 같은 예외를 만납니다.

대리자 + InvokeRequired 로 감싸기

방법은 바꾸려는 동작을 메서드로 빼고, 그 메서드가 스스로 "지금 내가 UI 스레드인가"를 확인하게 만드는 것입니다. 먼저 호출할 메서드 모양과 같은 대리자를 선언합니다. 여기서는 컨트롤과 문자열을 받아 텍스트를 바꾸는 용도라 매개변수가 두 개입니다.

delegate void CrossThreadSafetySetText(Control ctl, String text);

그리고 실제로 텍스트를 바꾸는 메서드입니다.

private void CSafeSetText(Control ctl, String text) {

    /*
     * InvokeRequired 속성 (Control.InvokeRequired, MSDN)
     *   호출한 스레드가 이 컨트롤을 만든 스레드와 달라서
     *   Invoke 를 거쳐야 하는지를 알려 주는 속성입니다.
     *
     * 값이 참이면 Invoke 로 컨트롤의 스레드에 작업을 넘겨 텍스트를 바꾸고,
     * 거짓이면 지금 스레드에서 바로 바꿔도 문제가 없으므로 그대로 대입합니다.
     */
    if ( ctl.InvokeRequired )
        ctl.Invoke(new CrossThreadSafetySetText(CSafeSetText), ctl, text);
    else
        ctl.Text = text;
}

흐름을 따라가 보면 이렇습니다. 백그라운드 스레드에서 CSafeSetText 를 부르면 InvokeRequired 가 true 이므로 자기 자신을 대리자로 감싸 Invoke 에 넘깁니다. Invoke 는 그 대리자를 UI 스레드의 메시지 루프에서 실행하고, 끝날 때까지 호출한 쪽을 기다리게 합니다. UI 스레드에서 다시 들어온 CSafeSetText 는 이번엔 InvokeRequired 가 false 라서 ctl.Text = text 로 곧장 내려갑니다. 처음부터 UI 스레드에서 불렀다면 분기 없이 바로 대입되고요. 새 스레드가 생기는 게 아니라, 작업을 UI 스레드로 "넘겨서" 실행한다는 점을 기억해 두면 헷갈리지 않습니다.

안전한 호출과 위험한 호출을 한 폼에

버튼 두 개로 차이를 비교해 볼 수 있게 묶은 전체 코드입니다. button1 은 CSafeSetText 를 거치고, button2 는 새 스레드에서 직접 대입합니다.

using System;
using System.Collections.Generic;
using System.Drawing;
using System.Threading;
using System.Windows.Forms;
namespace CrossThreadSafetyAccess {
    public partial class MainForm : Form {
        public MainForm() {
            InitializeComponent();
        }

        delegate void CrossThreadSafetySetText(Control ctl, String text);

        private void CSafeSetText(Control ctl, String text) {

            /*
             * InvokeRequired 속성 (Control.InvokeRequired, MSDN)
             *   호출한 스레드가 이 컨트롤을 만든 스레드와 달라서
             *   Invoke 를 거쳐야 하는지를 알려 주는 속성입니다.
             *
             * 값이 참이면 Invoke 로 컨트롤의 스레드에 작업을 넘겨 텍스트를 바꾸고,
             * 거짓이면 지금 스레드에서 바로 바꿔도 문제가 없으므로 그대로 대입합니다.
             */
            if ( ctl.InvokeRequired )
                ctl.Invoke(new CrossThreadSafetySetText(CSafeSetText), ctl, text);
            else
                ctl.Text = text;
        }

        private void Button1Click(object sender, EventArgs e) {
            CSafeSetText((Control)sender, "Cross Thread Safety Code");
        }

        private void Button2Click(object sender, EventArgs e) {
            Thread t = new Thread(CNotSafetySetText);
            t.Start();
        }
        private void CNotSafetySetText() {
            button2.Text = "Cross Thread Not Safety Code";
        }
    }
}

button1 은 예외 없이 글자가 바뀌고, button2 는 예외가 납니다. 사실 Button1Click 은 이벤트 핸들러라 원래 UI 스레드에서 도는 코드라서 InvokeRequired 는 false 쪽으로 갑니다. 진짜 효과를 보려면 Button2Click 처럼 새 스레드 안에서 CSafeSetText(button2, "...") 를 불러 보면 됩니다. 그러면 예외 없이 글자가 바뀌는 걸 확인할 수 있습니다.

원 글에는 예제 프로젝트 압축 파일도 첨부되어 있었습니다.

CrossThreadSafetyAccess.zip 첨부파일 아이콘

쓰면서 주의할 점

Invoke 는 동기 호출입니다. 백그라운드 스레드가 UI 스레드의 처리가 끝날 때까지 멈춰 있으니, UI 스레드가 반대로 그 백그라운드 스레드를 Join 으로 기다리고 있으면 서로 기다리다 멈춰 버립니다. 진행률 표시처럼 결과를 기다릴 필요가 없는 갱신이라면 BeginInvoke 로 던져 놓는 편이 안전합니다.

또 CheckForIllegalCrossThreadCalls = false 로 검사를 꺼 버리는 방법이 검색에 많이 나오는데, 예외만 안 보일 뿐 문제는 그대로입니다. 간헐적으로 화면이 깨지거나 멈추는 원인이 되니 권하지 않습니다.

텍스트만이 아니라 위치, 배경색, 리스트 항목 추가도 같은 틀로 처리합니다. 바꾸려는 동작에 맞춰 대리자 시그니처만 바꾸면 되고, 요즘 버전에서는 별도 대리자를 선언하지 않고 ctl.Invoke((MethodInvoker)delegate { ctl.Text = text; }); 처럼 익명 메서드로 줄여 쓰는 경우도 많습니다.

참고: 꿈꾸는 프로그래머 - '크로스 스레드 작업이 잘못되었습니다'