UniTask

JJW·2024년 11월 25일
0

Unity

목록 보기
3/34

오늘은 UniTask에 대해 설명해보는 시간을 가지겠습니다.


Task

  • .NET의 비동기 처리 클래스입니다.
  • .NET 프레임 워크에서 제공되며 C#의 기본 기능으로 지원되어 Unity 외의 다른 플랫폼에서도
    일정한 코드가 작성이 가능하며 타사 라이브러리와의 호환성이 좋습니다. (.NET 라이브러리)
  • 단점으로는 GC의 비용이 높으며 Unity와 같은 실시간 엔진에서는 부적합하며 Unity의 메인 스레드와의 호환성이 부족합니다. (별도의 추가 작업이 필요)

UniTask

  • Unity에서 Task의 단점을 보완하기 위해 만들어졌습니다.
  • GC 비용이 거의 발생하지 않도록 설계되었으며 Unity에서 만들어졌다보니 Unity 친화적입니다.
  • Unity API와 통합이 가능하며 단일 프레임 기반입니다.

Task 와 UniTask의 차이점

특징TaskUniTask
API 표준성.NET 표준 기반, Unity 외부에서도 사용 가능Unity 특화, Unity 외부 사용에는 부적합
성능 (GC 비용)생성 및 실행 시 GC 비용이 큼GC 비용 최소화
Unity 메인 스레드 지원메인 스레드 접근 시 추가 작업 필요Unity API와 자연스럽게 통합
프레임 제어Unity 프레임 제어가 복잡함Unity 프레임 기반 비동기 작업 지원
사용 난이도C# 기본 비동기 문법으로 사용 간편추가 학습이 필요, Unity 개발에 적합
멀티스레드 지원스레드 기반 작업 가능스레드 지원은 제한적
호환성다양한 .NET 라이브러리와 호환Unity 외 프로젝트와는 호환성 부족
  • Unity 내부 개발 작업에는 UniTask를 사용하는 것이 더 적합해 보입니다. (GC 최적화, Unity API 통합)
  • Unity 외부 라이브러리나 멀티스레드 작업이 요구될 때는 Task를 사용하는 것이 더 적합해 보입니다.

GC 테스트

public class TaskGCTest : MonoBehaviour
{
    private const int TaskCount = 100;

    private async void Start()
    {
        Debug.Log("Task 테스트 시작");

        int gcBefore = System.GC.CollectionCount(0);
        long memoryBefore = System.GC.GetTotalMemory(false);

        for (int i = 0; i < TaskCount; i++)
        {
            await Task.Run(() =>
            {
                int result = 0;
                for (int j = 0; j < 10; j++)
                {
                    result += j;
                }
            });
        }
        int gcAfter = System.GC.CollectionCount(0);

        long memoryAfter = System.GC.GetTotalMemory(false);
        Debug.Log($"Task: 메모리 할당량 = {memoryAfter - memoryBefore} bytes");
        Debug.Log($"GC Collection 횟수: {gcAfter - gcBefore}");
    }
}
  • UniTask 코드
public class UniTaskGCTest : MonoBehaviour
{
    private const int TaskCount = 100;

    private async void Start()
    {
        Debug.Log("UniTask 테스트 시작");

        int gcBefore = System.GC.CollectionCount(0);
        long memoryBefore = System.GC.GetTotalMemory(false);

        for (int i = 0; i < TaskCount; i++)
        {
            await UniTask.Create(async () =>
            {
                int result = 0;
                for (int j = 0; j < 10; j++)
                {
                    result += j;
                }
            });
        }
        int gcAfter = System.GC.CollectionCount(0);

        long memoryAfter = System.GC.GetTotalMemory(false);
        Debug.Log($"UniTask: 메모리 할당량 = {memoryAfter - memoryBefore} bytes");
        Debug.Log($"GC Collection 횟수: {gcAfter - gcBefore}");
    }
}

GC 비교

  • Task

  • UniTask

GC 비교 결론

  • UniTask를 사용하면 굉장히 많이 줄어든다.

  • 문제는 RunOnThreadPool 을 사용하여 테스트를 처음 구현하였는데 이 때는 UniTask가 더 GC 비용이 많이 나왔다...

  • 해당 문제 발생 원인은 Task의 경우 .NET ThreadPool을 사용하여
    이미 할당된 스레드를 재사용하므로 추가적인 메모리 할당이 최소화 되고
    오랜 시간 최적화 된 스레드 풀 관리 시스템을 가지고 있다고 하고
    UniTask의 경우에는 Unity 환경에 맞춘 ThreadPool을 사용한다고 합니다.
    이러면 가볍고 빠르다는 장점이 없어지는거 아닌가 싶긴합니다..
    일단 그래서 Create로 변경하여 사용하였습니다.

  • 결론적으로는 UnityAPI와 무관한 CPU 작업인 경우에는 Task
    연관된 작업이면 UniTask 사용이 권장된다고 합니다..


UniTask 주요 메서드 정리 및 사용 방법

메서드설명사용 사례
UniTask.Delay일정 시간 동안 대기비동기 대기 처리 (Coroutine의 WaitForSecond..)
UniTask.DelayFrame특정 프레임 수만큼 대기Unity의 프레임 기반 대기 처리
UniTask.Yield다음 프레임까지 대기간단한 한 프레임 대기 처리
UniTask.WhenAll여러 비동기 작업을 병렬로 실행하고 모든 작업이 완료될 때까지의 대기다중 비동기 작업의 병렬 처리
UniTask.WhenAny여러 비동기 작업 중 하나라도 완료될 때까지 대기첫번째 완료되는 비동기 작업 처리
UniTask.RunOnTheradPool백그라운드 스레드 풀에서 작업 실행CPU 작업 또는 Unity 메인 스레드와 무관한 작업 처리
UniTask.Run비동기 작업 실행 (이제 사용 안함 -> RunOnThreadPool 사용)간단한 비 동기 작업 실행
UniTask.Create새로운 비동기 작업을 생성간단한 비동기 작업 작성
UniTask.SwitchToMainThread작업을 Unity 메인 스레드로 전환Unity API 호출이 필요한 작업 처리
UniTask.SwitchToThreadPool작업을 스레드 풀로 전환CPU 처리

1. UniTask.Delay

    private async void Start()
    {
        Debug.Log("3초 대기 시작...");
        await UniTask.Delay(3000);
        Debug.Log("3초 대기 완료!");
    }

2. UniTask.DelayFrame

    private async void Start()
    {
        Debug.Log("5 프레임 대기 시작...");
        await UniTask.DelayFrame(5);
        Debug.Log("5 프레임 대기 완료!");
    }

3. UniTask.Yield

    private async void Start()
    {
        Debug.Log("다음 프레임까지 대기 시작...");
        await UniTask.Yield();
        Debug.Log("다음 프레임으로 이동 완료!");
    }

4. UniTask.WhenAll

    private async void Start()
    {
        Debug.Log("병렬 작업 시작...");
        await UniTask.WhenAll(TaskA(), TaskB());
        Debug.Log("모든 작업 완료!");
    }

    private async UniTask TaskA()
    {
        await UniTask.Delay(2000);
        Debug.Log("작업 A 완료!");
    }

    private async UniTask TaskB()
    {
        await UniTask.Delay(3000);
        Debug.Log("작업 B 완료!");
    }

5. UniTask.WhenAny

    private async void Start()
    {
        Debug.Log("최초 완료 작업 대기...");
        await UniTask.WhenAny(TaskA(), TaskB());
        Debug.Log("최초 작업 완료!");
    }

    private async UniTask TaskA()
    {
        await UniTask.Delay(2000);
        Debug.Log("작업 A 완료!");
    }

    private async UniTask TaskB()
    {
        await UniTask.Delay(3000);
        Debug.Log("작업 B 완료!");
    }

6. UniTask.RunOnThreadPool

    private async void Start()
    {
        Debug.Log("백그라운드 작업 시작...");
        await UniTask.RunOnThreadPool(() =>
        {
            int result = 0;
            for (int i = 0; i < 1000000; i++) result += i;
            Debug.Log($"계산 결과: {result}");
        });
        Debug.Log("백그라운드 작업 완료!");
    }

7. UniTask.SwitchToMainThread

    private async void Start()
    {
        await UniTask.RunOnThreadPool(() =>
        {
            Debug.Log("백그라운드에서 작업 수행 중...");
        });
        await UniTask.SwitchToMainThread();
        Debug.Log("메인 스레드로 전환 완료!");
    }

8. UniTask.SwitchToThreadPool

    private async void Start()
    {
        Debug.Log("메인 스레드 작업 중...");

        // 백그라운드 스레드로 전환
        await UniTask.SwitchToThreadPool();
        Debug.Log("백그라운드 스레드 작업 수행 중...");
    }

9. UniTask.Create

    private async void Start()
    {
        await UniTask.Create(async () =>
        {
            Debug.Log("사용자 정의 작업 실행 중...");
            await UniTask.Delay(1000);
            Debug.Log("사용자 정의 작업 완료!");
        });
    }

Unity API로 UniTask를 사용한 값 넘기기

  • 이 부분은 평소 생각하던 이미지를 비동기로 불러오는 부분입니다.
    (다른 모바일 게임에서 사용한걸로 추측되는 부분입니다.
    기본으로 사용자에게 보여 줄 이미지를 세팅해 놓은 다음에 비동기로 필요한 정보를 불러와 다시 세팅해주는 부분입니다.
    네트워크가 끊겼을 때는 기본 이미지가 보이고 네트워크가 연결되어 제대로 실행이 된다면 보여줘야하는 이미지를 보여주는 부분입니다.)

테스트 코드

using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.UI;


public class AsyncImageLoader : MonoBehaviour
{
    public Image targetSpriteRenderer; 
    public Sprite defaultSprite;
    public string imageName = "22";  // 어드레서블을 사용하는 경우에는 어드레서블 경로를 사용하면 좋을 듯 합니다
    public int delay = 0;
    public bool bNetworkSuccess;

    private async void Start()
    {
        SetDefaultImage();

        await LoadImageBasedOnNetworkStatus();
    }

    private void SetDefaultImage()
    {
        if (targetSpriteRenderer != null && defaultSprite != null)
        {
            targetSpriteRenderer.sprite = defaultSprite;
            Debug.Log("기본 이미지가 설정되었습니다.");
        }
        else
        {
            Debug.LogError("SpriteRenderer 또는 기본 이미지가 설정되지 않았습니다.");
        }
    }

    private async UniTask LoadImageBasedOnNetworkStatus()
    {
        try
        {
            // 네트워크 상태 확인 (현재는 그냥 변수로 테스트)
            bool isNetworkConnected = await CheckNetworkConnectionAsync();

            if (!isNetworkConnected)
            {
                Debug.LogWarning("네트워크 연결이 끊어져 있습니다. 기본 이미지로 유지됩니다.");
                return;
            }

            // 비동기로 이미지 로드
            Sprite loadedSprite = await LoadImageFromResourcesAsync(imageName);

            // Unity 메인 스레드에서 SpriteRenderer에 이미지 설정
            await UniTask.SwitchToMainThread();
            if (targetSpriteRenderer != null && loadedSprite != null)
            {
                targetSpriteRenderer.sprite = loadedSprite;
                Debug.Log("네트워크 이미지를 성공적으로 로드하고 설정했습니다.");
            }
        }
        catch (System.Exception ex)
        {
            Debug.LogError($"이미지 로드 실패: {ex.Message}");
        }
    }

    private async UniTask<bool> CheckNetworkConnectionAsync()
    {
        await UniTask.Delay(delay);

        #region  네트워크 HTTP 요청
        // 네트워크 연결 상태를 확인하기 위해 HTTP 요청
        //try
        //{
        //    using (UnityWebRequest request = UnityWebRequest.Get(testUrl))
        //    {
        //        request.timeout = 5;
        //        await request.SendWebRequest();

        //        if (request.result == UnityWebRequest.Result.Success)
        //        {
        //            Debug.Log("네트워크 연결 확인 성공");
        //            return true; // 연결 성공
        //        }
        //        else
        //        {
        //            Debug.LogWarning($"네트워크 연결 확인 실패: {request.error}");
        //            return false; // 연결 실패
        //        }
        //    }
        //}
        //catch (System.Exception ex)
        //{
        //    Debug.LogError($"네트워크 확인 중 예외 발생: {ex.Message}");
        //    return false;
        //}
        #endregion

        // 지금은 그냥 변수로 체크
        return bNetworkSuccess;
    }

    private async UniTask<Sprite> LoadImageFromResourcesAsync(string resourceName)
    {
        // 메인 스레드로 전환 -> Resource.Load 접근을 위한
        await UniTask.SwitchToMainThread();
        Debug.Log($"Resources에서 이미지 로드 중... ({resourceName})");

        // 메인 스레드에서 `Resources.Load` 호출
        Sprite sprite = Resources.Load<Sprite>(resourceName);
        if (sprite == null)
        {
            throw new System.Exception($"Resources에서 '{resourceName}'를 찾을 수 없습니다.");
        }
        return sprite;
    }
}

컴포넌트 세팅


테스트 결과

이미지 1과 3은 bool 값이 true라 성공적으로 이미지가 변동된 모습이고
이미지 2는 bool 값이 false라 기본 이미지로 보입니다.


느낀 점

UniTask가 생각보다 Task보다 그렇게 좋은건 아니라는 걸 느꼈고 지금은 겉핥기 식으로 해서 사용법이 쉬웠지만 좀 더 복잡한 작업을 해야할 때에는 연동이 어려울 것으로 느껴졌습니다. 이를 위해 UniTask도 공부를 많이 해놔야 될 것 같은 필요성을 느낍니다..

  • 제가 조사한 내용이 맞지 않거나 잘못 된 경우에 댓글로 잘못된 점 지적해주시면 감사합니다 ! (´._.`)
profile
Unity 게임 개발자를 준비하는 취업준비생입니다..

0개의 댓글