프로그래밍/Unity, MAUI, Mono

[Unity] 멀티 스레드 사용 시 UI가 업데이트되지 않는 오류 잡기

당근천국 2022. 4. 1. 15:30

유니티뿐만 아니라 UI/UX관련 작업을 하다 보면 UI쓰레드가 아닌 곳에서 작업하다가 문제가 생기는 경우가 종종 있습니다.

 

얼마 전 서버에서 온 데이터를 UI 뿌리는 테스트를 하고 있었는데 UI가 갱신되지 않는 현상이 일어났습니다.

 

* 테스트에 사용된 버전 : Unity 2020.3.25f1

 

연관글 영역

 

 

1. 다른 쓰레드

이런 경우 원래는 에러가 나야 하지만 유니티도 그렇고 다른 플랫폼도 그렇고.....

에러가 안 나는 경우가 있긴 합니다. ㅎㅎㅎㅎ

 

결국 올 게 왔구나 하면서 검색을 하는데....

뭐지?

증상을 격은 사람들은 많은데 해결에 대한 내용이 없지;;;;

 

C#은 쓰레드에 액션만 던져주면 간단하게 해결이 가능합니다.

참고 :

[WPF] 다른 스레드에서 UI쓰레드 접근하기 - Dispatcher.Invoke

[.Net] 크로스 스레드(Cross Thread) 오류 해결을 위한 인보크(Invoke)

[WPF] 'Dispatcher.Invoke'와 'Dispatcher.BeginInvoke'

 

 

유니티는 다르다!!

검색하는데도 명확한 답이 없었는데...

정확하게는 해결 방법을 써놓은 글을 못 찾았습니다.

어떤 식으로 해결하면 된다는 글만 봤지....

 

해결 방법은

Update가 호출될 때 필요한 액션을 취하는 방식으로 구현해야 한다는 것입니다....

그러니까 큐에 액션들을 쌓아놨다가 메인 쓰레드에서 처리하면 된다는 것이죠.

 

이것은 유니티가 게임엔진이라는 태생 때문에 프레임 단위로 작업해야 하고 그 프레임마다 호출되는 메인 쓰레드가 Update() 함수이니 이렇게  구현을 하면 되는데..........

 

는 개뿔이고

유니티 회사가 라이브러리를 안 만들어놔서 그렇지 뭘 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ

 

 

2. 에러를 내보자

이제 테스트를 해보기 위해 UI를 만들어 봅시다.

 

UI에 Text 4개와 Button 1개를 올려 둡니다.

 

 

 

UI 개체는 재활용해야 되니 변수로 선언하고

/// <summary>
/// 동기 UI
/// </summary>
public Text txtNormal;
/// <summary>
/// 비동기 UI
/// </summary>
public Text txtAsync;
 
/// <summary>
/// 비동기 매인 쓰래드 UI
/// </summary>
public Text txtAsyncMain;
 
/// <summary>
/// UnityMainThreadDispatcher 이용
/// </summary>
public Text txtUnityMainThreadDispatcher;
 
 
/// <summary>
/// 동작용 버튼
/// </summary>
public Button btnGo;

 

C# 스크립트를 생성하고 Start() 함수는 아래와 같이 작성하여 사용할 UI를 찾아 넣어줍니다.

void Start()
{
    this.txtNormal
        = GameObject.Find("txtNormal")
            .GetComponent<Text>();
 
    this.txtAsync
        = GameObject.Find("txtAsync")
            .GetComponent<Text>();
 
    this.txtAsyncMain
        = GameObject.Find("txtAsyncMain")
            .GetComponent<Text>();
 
    this.txtUnityMainThreadDispatcher
        = GameObject.Find("txtUnityMainThreadDispatcher")
            .GetComponent<Text>();
 
    this.btnGo
        = GameObject.Find("btnGo")
            .GetComponent<Button>();
 
}

 

그리고 테스트용 함수를 아래와 같이 만들어 줍니다.

public void GoCall()
{
    //일반적인 할당
    this.txtNormal.text = "txtNormal change! 101";
 
 
    //메인 쓰레드 아닌 에러를 위한 쓰레드
    Thread thread = new Thread(()=> 
    {
 
        this.txtAsyncMain.text = "txtAsyncMain change! 103";
        //메인 쓰래아니라고 에러
        this.txtAsync.text = "txtAsync change! 102";
        this.txtUnityMainThreadDispatcher.text 
            = "txtUnityMainThreadDispatcher change! 104";
    });
    thread.Start();
}

 

 

'btnGo'버튼 클릭 이벤트에 이 스크립트가 들어 있는 오브젝트를 등록하고 연결된 함수를 'GoCall()'로 하고 테스트해 봅시다.

UnityException: get_isActiveAndEnabled can only be called from the main thread. Constructors and field initializers will be executed from the loading thread when loading a scene. Don't use this function in the constructor or field initializers, instead move initialization code to the Awake or Start function. UnityEngine.EventSystems.UIBehaviour.IsActive () (at Library/PackageCache/com.unity.ugui@1.0.0/Runtime/EventSystem/UIBehaviour.cs:28) UnityEngine.UI.Graphic.SetVerticesDirty () (at Library/PackageCache/com.unity.ugui@1.0.0/Runtime/UI/Core/Graphic.cs:286) UnityEngine.UI.Text.set_text (System.String value) (at Library/PackageCache/com.unity.ugui@1.0.0/Runtime/UI/Core/Text.cs:214) AsyncUiUpdateTest.<GoCall>b__8_0 () (at Assets/AsyncUiUpdateTest.cs:102) System.Threading.ThreadHelper.ThreadStart_Context (System.Object state) (at <695d1cc93cca45069c528c15c9fdd749>:0) System.Threading.ExecutionContext.RunInternal (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state, System.Boolean preserveSyncCtx) (at <695d1cc93cca45069c528c15c9fdd749>:0) System.Threading.ExecutionContext.Run (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state, System.Boolean preserveSyncCtx) (at <695d1cc93cca45069c528c15c9fdd749>:0) System.Threading.ExecutionContext.Run (System.Threading.ExecutionContext executionContext, System.Threading.ContextCallback callback, System.Object state) (at <695d1cc93cca45069c528c15c9fdd749>:0) System.Threading.ThreadHelper.ThreadStart () (at <695d1cc93cca45069c528c15c9fdd749>:0) UnityEngine.UI.Text:set_text(String) AsyncUiUpdateTest:<GoCall>b__8_0() (at Assets/AsyncUiUpdateTest.cs:102) System.Threading.ThreadHelper:ThreadStart()

 

에러 나는 게 정상입니다.

그런데 상황에 따라선 UI는 갱신되지 않지만 에러는 나지 않는 겨우가 있습니다.

그럴때도 해결방법은 같습니다.

 

 

3. 해결해 보자

해결 방법은 큐를 만들고 액션을 큐에 넣어놨다가 Update()에서 큐에 담긴 액션들을 실행시켜주는 것입니다.

 

아래와 같이 큐를 선언하고

/// <summary>
/// 액션을 담아둘 큐
/// </summary>
private Queue<Action> m_queueAction = new Queue<Action>();

 

Update() 함수에서 아래와 같이 큐에 담긴 액션을 실행시킵니다.

void Update()
{
    //큐에 액션이 쌓여있으면 동작 시킨다.
    while (m_queueAction.Count > 0)
    {
        m_queueAction.Dequeue().Invoke();
    }
}

 

이제 기존에 있던 코드를 수정해 봅시다.

public void GoCall()
{
    //일반적인 할당
    this.txtNormal.text = "txtNormal change! 101";
 
 
    //메인 쓰레드 아닌 에러를 위한 쓰레드
    Thread thread = new Thread(()=> 
    {
        //큐에 액션을 넣는다.
        m_queueAction.Enqueue(() => 
        {
            this.txtAsyncMain.text = "txtAsyncMain change! 103";
        });
 
        this.txtUnityMainThreadDispatcher.text 
            = "txtUnityMainThreadDispatcher change! 104";
        //메인 쓰래아니라고 에러
        this.txtAsync.text = "txtAsync change! 102";
    });
    thread.Start();
}

 

실행해 보면 'txtAsyncMain'까지는 잘 수정되는 것을 볼 수 있습니다.

 

 

4. 'UnityMainThreadDispatcher' 사용하기

위에 방법으로 써도 되긴 하지만 예외 처리가 안돼 있어도 너무 안 돼있기 때문에 그냥 쓰기 찝찝합니다.

이럴 때는 그냥 'UnityMainThreadDispatcher'를 사용하면 됩니다.

 

깃허브에서 파일들을 다운받고

github - PimDeWitte/UnityMainThreadDispatcher 

프롭은 인스팩터에 넣어줍니다.

 

그리고 아래와 같이 수행을 액션을 만들어 넣어줍니다.

UnityMainThreadDispatcher.Instance().Enqueue(() => 
{
    this.txtUnityMainThreadDispatcher.text 
        = "txtUnityMainThreadDispatcher change! 104";
});

 

이렇게 완성된 'GoCall()' 전체 코드입니다.

public void GoCall()
{
    //일반적인 할당
    this.txtNormal.text = "txtNormal change! 101";
 
 
    //메인 쓰레드 아닌 에러를 위한 쓰레드
    Thread thread = new Thread(()=> 
    {
        //큐에 액션을 넣는다.
        m_queueAction.Enqueue(() => 
        {
            this.txtAsyncMain.text = "txtAsyncMain change! 103";
        });
 
        UnityMainThreadDispatcher.Instance().Enqueue(() => 
        {
            this.txtUnityMainThreadDispatcher.text 
                = "txtUnityMainThreadDispatcher change! 104";
        });
 
        //메인 쓰래아니라고 에러
        this.txtAsync.text = "txtAsync change! 102";
    });
    thread.Start();
}

 

이제 전체를 테스트해보면

 

일부로 에러를 내기 위한 개체 빼고는 잘 업데이트 되는 것을 볼 수 있습니다.

 

 

마무리

셈플 프로젝트 : github - dang-gun/UnitySamples/AsyncUiUpdate

 

이거 분명히 기본 라이브러리에 넣어달라고 요청이 많았을 겁니다.

윈폼을 비롯한 다른 C# 계열 UI쪽은 기본기능으로 들어가 있는 기능인데 유니티만 없는데 사람들이 요청 안 했을리가 ㅋㅋㅋㅋ

 

아직까지도 이게 기본기능에 없다는 건 회사가 주요 기능들에 대해 어떤 철학을 가지고 있는지 알 수 있는 대목입니다.

이것보다 더 큰 사안인 닷넷 프레임웍 버전 올리는것조차 MS의 전폭적인 지원을 받으면서도 오래 걸리는 거 보면....