프로그래밍/unity

UniTask 기본 사용법/생명주기 주의사항

Cheese Allergy Hamster 2021. 12. 5. 01:28

https://github.com/Cysharp/UniTask

 

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

Provides an efficient allocation free async/await integration for Unity. - GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

github.com

 

왜 UniTask를 사용해야 할까요?

 

 최근 UniTask를 사용하게 된 가장 큰 이유는 최근에 프로젝트에 비동기(스럽게) 관리하고 싶은 코드들이 많아졌습니다. 명확한 큰 이유는 아래와 같습니다.

 

1. 코루틴으로 관리하기에는 try-catch로 예외처리를 할 수가 없습니다.

2. 코루틴은 return 타입이 없어 실행 결과 리턴에 대한 부분을 콜백으로 처리 해야합니다. (콜백지옥의 유발)

3. async 에서의 Task 리턴 자체도 가비지에 부담이 되어 잦은 호출을 할 수 없습니다. 하지만 UniTask를 쓰면 해결됩니다.

4. 전체적으로 콜백 없이 코드를 작성하는경우 코드가 선형적으로 순서적으로 실행되기 때문에 코드가 훨씬 규칙성있고 깔끔합니다.

5. 사용자에따라 유니티의 Editor Tool 작성시에 EditorCoroutines등을 사용할때도 코드 관리가 유용함.

 

 개인적인 생각으로 UniTask는 이제는 선택 사항이 아닌 프로젝트에 필수 적용해서 적극적으로 사용하기를 권장되는 비동기 라이브러리라고 생각이 됩니다.

 

간단한 Web Request를 통한 사용 예시 

    IEnumerator GetUserLevel(string userName , System.Action<int> callback)
    {
        //try catch 예외 불가
        var req = UnityWebRequest.Get("https://.../user/" + userName);
        yield return req.SendWebRequest();
        callback(int.Parse(req.downloadHandler.text));
    }

    void Logic()
    {
        StartCoroutine(GetUserLevel("userName", (level) =>
        {
            Debug.Log(level);
        }));
    }

 

 위 코드는 설명을 위해 작성된 코드입니다. 웹서버와 통신하여 유저의 레벨을 가져오는 코드입니다. 보통은 이런 코드는 코루틴으로 작성합니다. 코루틴으로 작성하지 않으면 게임이 통신시간이 길어질수록 멈추기 때문입니다. 이 때, 코루틴으로는 리턴타입을 받지 못해서 생기는 콜백 사용이 발생합니다. 이런 구조가 꼬리를 물고 물면, 콜백지옥이 발생합니다. 특히 네트워크 통신을 하는 경우 이런 패턴을 흔히 볼 수 있습니다.

 

 하지만 UniTask를 사용하면 아래와같이 코드를 작성할 수 있습니다.

   async UniTask<int> GetUserLevelAsync(string userName)
    {
        try
        { 
            var req = UnityWebRequest.Get("https://.../user/" + userName); 
            var res = await req.SendWebRequest(); // Unity의 Async Operation 이라 await 가능하다.
            var responseString = res.downloadHandler.text;
            return int.Parse(responseString);
        }
        catch(Exception e)
        {
            Debug.LogError(e);
            return -1;
        }  
    } 

    async void Logic()
    {
        var level = await GetUserLevelAsync("user1");
        Debug.Log(level);
    }

 

 어떤가요? Logic() 함수에서 콜백을 제거해서 코드가 조금 더 직관적으로 변했습니다. try-catch를 통한 exception halding또한 쉽게 가능해졌습니다. 

 

 

 

UniTask 개발자는 UniTask를 이렇게 요약 설명 하고있습니다.

  • - UniTask<t>는 struct base입니다.  즉, heap에 할당되지 않으므로 zero allocation이라는 말이 됩니다.(C#의 기본 Task는 class로 되어있습니다. 개발자는, 기본 비동기 Task보다 UniTask가 더 가볍다고 설명합니다.)
  • - 유니티의 AsyncOperations (어드레서블 async같은) 작업이나 코루틴 작업을 await으로 대기할 수 있습니다. (이것은 제가 위의 사진 예시로 보여드렸습니다.)
  • - 유니티 메인 쓰레드(Player Loop) 기반으로 작업하여 코루틴을 대체할 수 있습니다. (UniTask.Yield, UniTask.Delay, UniTask.DelayFrame) 
  • - 모노비헤이어 메시지, 이벤트, ugui의 메세지 이벤트를 대기하거나 비동기 열거가 가능합니다.
  • - 유니티 메인 쓰레드(플레이어 루프)에서 실행되므로 WebGL/WASM 등에서도 사용할 수 있습니다.
  • - Asynchronous LINQ, with Channel and Async ReactiveProperty
  • - 메모리 누수를 방지하기 위한 task tracker를 제공합니다.
  • - 기존 C#의 Task/ValueTask/IValueTaskSource와 호환성이 뛰어납니다.

 

 

기본적인 사용방법 

 

먼저 작성되어있는 비동기 코드를 살펴봅시다.

    public async UniTaskVoid Function()
    {   
        Debug.Log("Hello");
        await UniTask.Delay(TimeSpan.FromSeconds(1));
        Debug.Log("World");
    }
    // return 타입이 있는경우
    public async UniTask<float> FunctionFloat()
    { 
        Debug.Log("I Will Return Float Value! Please Wait 5 Second");
        await UniTask.Delay(TimeSpan.FromSeconds(5));
        return 1.0f;
    }

    public async UniTask Start()
    {
        Function().Forget();
        var f = await FunctionFloat();
        Debug.Log("Value => " + f);
    }

 UniTask 사용법은 기존 async Task 사용법과 상당히 유사합니다.  결국 비동기 작업으로 처리하고 싶은 경우 async 키워드와 UniTask 리턴타입을 함께 사용해서 함수를 작성하면 됩니다.

 

 Start문의 Function().Forget()은, 어쩔 수 없이 await을 사용하지 못하는 경우나, 일부러 사용하지 않을 때 발생하는 IDE의 경고 메세지를 무시하기 위한것이므로 사실 작성하지 않아도 됩니다. Forget() 으로만 실행해도 되지만 IDE 메세지가 거슬리는 경우 사용해주세요. 

 

  코드에서는 async 키워드를 쓰되 void타입의 함수는 UniTaskVoid를 사용하고, return 타입이 있는경우 UniTask<T>를 쓰고있습니다. 위와 같이 코드를 작성한 후 실행을 해보면 비동기 메세지가 계획한 시간 스케줄에 따라 잘 실행 되었음을 확인할 수 있습니다. 

 

실행순서 : Hello 출력 -> 5초뒤 float value 리턴 메세지 출력 -> World 출력 -> return된 float value 

 

실행 시간을 확인해 보면 계획한 스케줄에 따라 호출 되었음을 알 수 있다.

 

어떻게 사용하는게 좋을까?

 

UI 연출 등 코루틴으로 하던 작업을 unitask로 대체 (아래 예시에는 DoTween을 사용)

 

위와같은 연출에 대한 코드는 아래와 같습니다.

    async UniTask StartTitleAnimation()
    { 
        // 초기화면 대기
        await UniTask.Delay(1500);  
        
        // 텍스트 상태 초기화
        titleText.rectTransform.anchoredPosition = new Vector2(0, titleText.rectTransform.sizeDelta.y/2);    
        titleText.text = "Loading...";
        titleText.gameObject.SetActive(true); 
 
        await titleText.rectTransform.DOAnchorPosY(-330f, 0.5f).SetEase(Ease.OutCirc);
        await UniTask.Delay(700);
        
        // 텍스트 상태 변경
        titleText.text = "The Black";
        titleText.fontSize = 100;
        titleText.transform.localScale = new Vector3(2, 2, 2);
        
        // 텍스트 커짐->작아짐 
        await titleText.transform.DOScale(1, 0.5f).SetEase(Ease.OutBack);


        // pressAnyKey는 대기하지 않고 싶기 때문에 await 없이 진행
        pressAnyKeyText.transform.localScale = new Vector3(2,2,2);
        pressAnyKeyText.gameObject.SetActive(true); 
        pressAnyKeyText.transform.DOScale(1, 0.35f).SetEase(Ease.OutBack);
        
        // 트윈 무한 반복
        await titleText.transform.DOScale(1.1f, 2).SetLoops(-1, LoopType.Yoyo).SetEase(Ease.InOutQuad);
    }

    void Start()
    {
        StartTitleAnimation().Forget();
    }

UniTask는 DoTween에 대한 await 기능또한 제공합니다. (opem upm으로 dg tween을 설치하고, UNITASK_DOTWEEN_SUPPORT 를 활성화 한 경우)

 

이와 같은 경우에 선택적으로 await을 통해 대기할 연출과, 아닌 연출을 설정할 수 있습니다. 예를 들면 pressAnyKeyText 에 대한 연출에는 await을 사용하지 않았기 때문에 다음 트윈 코드 실행에 영향을 주지 않습니다.

 

네트워크 통신에서의 사용

 

아래는 유니티의 web request를 간단하게 표현한 코드 조각입니다. OnClickGameStart 함수를 호출했을때 지정한 api 서버로 요청을 보낼 수 있으며, 역시 코루틴을 사용하지 않기때문에 코드가 더욱 깔끔하고 yield를 사용하지 않으므로 try-catch 또한 선택적으로 적용 가능합니다.

 

 

 public async UniTask<T> Get<T>(string url)
    {
        var request = UnityWebRequest.Get(url);
        await request.SendWebRequest();
        if (request.isNetworkError || request.isHttpError)
        {
            Debug.LogError(request.error);
            return default;
        }
        return JsonUtility.FromJson<T>(request.downloadHandler.text);
    }


    public async UniTaskVoid OnClickGameStart()
    {
        var result = await Get<string>("https://your-server.com/api/game/start");
        // ... processing
    }

 

그 외 유니티의 async operation 기능이나 코루틴 코드 대체목적으로 사용

 

두 같은 함수가 있지만, UniTask 버전은 호출과 내부 구현이 간단합니다. 반면, 코루틴 버전은 yield 키워드로 대기해야만하고, 대기해서 전달받은 결과를 한번 더 파싱해줘야 하며, 호출에도 StartCoroutine 함수가 필요합니다.

    public async UniTaskVoid LoadObject()
    {
        var obj = await Resources.LoadAsync<GameObject>("...") as GameObject;
        obj.transform.position = Vector3.zero;
    }

    IEnumerator LoadObject2()
    {
        var resReq  = Resources.LoadAsync<GameObject>("");
        yield return resReq;
        var obj = resReq.asset as GameObject; 
        obj.transform.position = Vector3.zero; 
    }

 

 

비동기의 Cancellation에 대한 처리방법 (주의사항)

 

코루틴을 사용할때에도, 코루틴을 언젠가 취소시켜야 하는 작업, 혹은 특정 상황에 취소해야하는 작업으로 생각하고 함수를 작성했다면 코루틴을 StopCoroutine을 통해 멈추거나 오브젝트를 삭제하는 방법으로 코루틴을 중단시킵니다.

 

그렇기 때문에 C#의 비동기 프로그래밍에서도 Cancellation 처리는 중요합니다. 코루틴과 같은경우 오브젝트가 삭제되거나 disable 되면 알아서 코루틴이 중단되지만 UniTask에서는 직접 Cancellation 관리를 해줘야하기 때문입니다.

 

이제는 while문을 돌려봅시다. 별로 어렵지 않은 스크립트입니다. 플레이어 루프안에서 특정 작업을 무한 반복시킬 수 있는 비동기 함수를 작성했습니다. 헷갈리는 분들을 위해 코루틴 버전의 예시도 포함되어 있습니다. 비동기 함수와 코루틴 함수는 같은 동작을 합니다. 

 

   public IEnumerator CoroutineVer()
    {
        while (true)
        {
            yield return null;
            Debug.Log(Time.realtimeSinceStartup);
        }
    }
    public async UniTaskVoid WhileTest()
    {
        while (true)
        { 
            await UniTask.Yield();
            Debug.Log(Time.realtimeSinceStartup);
        }
    } 
    
    public async UniTask Start()
    {
         WhileTest(); 
    }

코드를 보면 무슨 차이인지 모르실 수 있겠지만, 당연히 호출 방법에도 차이가 있고 (StartCoroutine), 위에서 계속 누차 말씀드렸듯, 비동기 방식에서는 try,catch, 그리고 대리자 없이 리턴또한 가능합니다. 단 코드에서 불필요하게 표현하지 않았 을 뿐입니다.

 

그리고 위 코드를 실행하면 아래처럼 유니티 로그를 확인할 수 있습니다. Time.realtimeSinceStartup의 값을 계속 출력하고 있습니다.

 여기서 주의해야 하는 점이 무엇일까요? 이 플레이어 루프에서 무한 반복되는 비동기 함수는 제 프로젝트에서 AsyncObject라는 오브젝트에 UniTaskTest라는 스크립트를 붙여서 작동하고 있습니다. 그런데 Aynsc Object를 삭제하면 어떻게 될까요? 과연 비동기 함수의 호출이 오브젝트와 함께 멈추게 될까요? 

 

 정답은 아닙니다. 계속 비동기 함수가 호출됩니다. 분명히 오브젝트를 삭제했는데 말이죠. 오브젝트가 삭제되어도 비동기 함수는 계속 실행되고 있으므로 CancellationToken으로 이 오브젝트를 멈춰주어야 합니다. (위에서 설명했듯 코루틴처럼 UniTask의 비동기 작업은 자동 관리 되지 않습니다.)

Async Object를 삭제해도..

  

&nbsp;계속 함수가 호출되고 있어요.&nbsp;

 

 UniTask Trakcer (Window 탭에서 찾을 수 있음) 를 열어서 확인을 해보면 비동기 함수가 끝나고 있지 않다는걸 확인할 수 있습니다. (여담으로, 이 도구를 사용하면 테스크가 종료되지 않아 생기는 메모리 누수등을 방지할 수 있습니다.)

 

 

이를 해결하려면 CancellationToken 으로 Cancel 해주어야합니다. UniTask는 CancellationToken 관리 하기가 매우 편리하게 되어있는데요, 기존 비동기로 이 작업을 수행하려면 일일히 취소 토큰을 생성하고 캐싱하고 관리해주어야 하지만 UniTask에서는 그럴필요가 없습니다. 단지 아래 코드처럼 UniTask 작업에 UniTask에서 제공하는 OnDestroy 시점에 Cancel이 호출되는 토큰을 넣기만 하면 됩니다. 아래 코드를 살펴볼까요?

    public async UniTaskVoid WhileTest()
    {
        while (true)
        { 
            await UniTask.Yield(cancellationToken:this.GetCancellationTokenOnDestroy());
            Debug.Log(Time.realtimeSinceStartup);
        }
    }

 아래 함수는 UniTask에서 Object의 Destroy 시점에 자동으로 Cancel이 호출되는 Cancellation 토큰을 가져오는 함수입니다. 이를 UniTask.Yield의 cancellation token에 적용했습니다. 이후에는 오브젝트를 삭제하면 정상적으로 작업이 멈춥니다.

this.GetCancellationTokenOnDestroy();

 

 

 

 그럼 아래처럼 코드를 바꿔서 다시 테스트 해보겠습니다.

    public async UniTaskVoid WhileTest()
    {
        while (true)
        { 
            await UniTask.Yield(cancellationToken:this.GetCancellationTokenOnDestroy());
            Debug.Log(Time.realtimeSinceStartup);
        }
    } 
     
    public async UniTask Start()
    {
         WhileTest(); 
    }

    private void OnDestroy()
    {
       Debug.Log("오브젝트를 삭제했습니다. 더이상 비동기가 실행되면 안됩니다.");
    }

 이후 오브젝트를 삭제하면 비동기 함수가 멈추게 됩니다. 이 작업이 중요한 이유는, destroy도 destroy지만 씬 변경시에도 비동기 함수가 살아있을 수 있습니다. 그래서 반드시 Cancellation 을 해줘야 하는 경우라면 이 작업을 해주셔야 합니다. 

 UniTask의 기본적인 사용법 포스트는 여기까지 입니다. 기회가 되면 사용하는 예시등을 예로들어 추가 글을 더 작성하도록 하겠습니다. 또한, Cancellation Token의 예시를 보면 알 수 있듯, 비동기 함수로 실행한 코드는 항상 Cancellation Token으로 생명 주기를 잘 관리해주어야 합니다. 

 

 즉, Cancellation Token은 진행중인 작업을 중단시키기 위한 용도로 사용됩니다. 네트워크 요청을 보냈지만, 요청을 중간에 취소하고 싶다거나, 오브젝트를 비동기로 로드하다가 중간에 씬이 전환되어 비동기로 로드를 하던 테스크를 중단시키고 싶은 등 케이스는 다양합니다. 

 

 무엇보다 UniTask의 API들을 잘 활용하기 위해서는 개발자의 깃허브를 참고하는게 가장 좋습니다. 영문이지만 글이 어렵지 않게 작성되어 있으므로 꼭 참고해보세요.  제 포스트는 한글로 간단하게 작성한 길라잡이라고 생각하시고 깃허브에서 advanced tip을 참고하시는게 좋습니다.

 

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

Provides an efficient allocation free async/await integration for Unity. - GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.

github.com

 

1 2 3 4 5 6 7 8 9 ··· 16