C#/게임 제작 + TIL

유니티 비동기 작업 (Unitask, CancellationToken)

Toa_ 2025. 1. 27. 20:58

몬스터의 스킬 비동기 시전

 

앞서 제작한 몬스터의 스킬을 Scriptable Object 로 구현할 때, 몬스터의 각 스킬을 정해진 확률에 맞춰 Queue 순서대로 등록시킨 뒤, 해당 스킬을 사용하기 위한 Cost가 충분해졌을 때 자동으로 시전 하게 만들고자 하였다.

 

동일한 테스트 몬스터 2마리

 

이때 2마리 이상의 동일한 몬스터가 존재하면, 동시에 스킬을 사용할 가능성이 있고, 이는 플레이어가 예상하지 못하게 갑작스러운 데미지가 들어올 수 있기 때문에 자연스러운 전투의 모습이 아닌 것 같다고 생각하였다.

따라서 Cost에 영향을 주지 않는 선에서 몬스터 오브젝트 자체에 스킬 시전 >> 실제 사용 간에 0.2 ~ 1.2초간의 랜덤한 딜레이를 주고자 하였다.

 

 

이때

 

이렇게 개별적으로 시간을 부여하여 동작을 제어하는 방식은, 유니티 자체의 LifeCycle과는 별개의 "비동기 작업" 으로 이루어지며, 기존의 방식과는 다르게 구현해야 한다.

 

일반적으로 코루틴을 사용하나, 유니티의 경우 UniTask 라는 자체적인 기능이 존재한다.

UniTask는 객체를 힙 메모리에 할당하는 코루틴과는 다르게, 스택에 할당하며 GC의 부하가 줄어들어 유니티 내부에서 구현하는 경우 최적화 적인 이점을 가져갈 수 있는 기능이다.

 

따라서 여기서 사용할 0.2 ~ 1.2초간의 랜덤 한 딜레이는

float delay = UnityEngine.Random.Range(0.2f, 1.2f);
await UniTask.Delay(TimeSpan.FromSeconds(delay));

 

이러한 식으로 구현하였고 여기까지는 일반적인 @@초뒤 실행의 간단한 기능으로 볼 수 있다.

 

 

 

여기서 중요한 점은

 

@@초뒤 실행을 걸어 둔 객체가 갑자기 사라져 버리는 경우, 비동기 지연을 설정한 객체의 속성이나 메서드를 호출하는 과정을 진행할 때 NullRefenceException과 같은 오류가 발생할 수 있다.

 

그렇기 때문에 이러한 예외 사항에 대한 처리를 위해서 비동기 작업과 관련한 여러 기능들을 찾아보았고.

비동기 작업에 취소 요청을 보낼 수 있는 CancellationTokenSource 에 대해서 배우게 되었다.

 

 

+ CancellationToken

은 비동기 작업을 실행 시 해당 작업의 취소를 관리하는 CancellationTokenSource 에서 생성된 토큰으로, 취소 상태를 확인하는 데 사용되며, 해당 토큰을 통해 비동기 작업 중 취소 여부를 체킹 하여 변경 사항을 적용할 때 쓰인다.

 

 

해당 기능들의 실제 적용 방법은 아래와 같다.

 

<실제 코드>

public MonsterData monsterData;
public MonsterSkillData[] skillQue;//다음에 사용할 스킬을 미리 저장해두는 큐.
private CancellationTokenSource skillCts;


private void SkillUse()//스킬 큐에 있는 가장 앞의 "스킬을 사용" + 뒤의 스킬을 당겨주며 채워준다.
{
    //스킬 사용 토큰을 생성했고 취소하지 않았다면, return.
    if(skillCts != null && !skillCts.Token.IsCancellationRequested)
        return;

    //return이 안됫다면, 새로운 토큰 생성.
    skillCts = new CancellationTokenSource();

    //스킬 에너지 사용.
    energy -= skillQue[0].cost;

    //비동기로 스킬 사용.
    Skill(skillCts.Token).Forget();
}

async UniTask Skill(CancellationToken token)
{
    try
    {
        // 스킬 사용 전 랜덤 딜레이
        float delay = UnityEngine.Random.Range(0.2f, 1.2f);
        await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token);

        if (token.IsCancellationRequested) return; // 토큰이 취소되었으면 실행 중단

        // 스킬 발동 (실제 사용,애니메이션, SFX 등)
        Debug.Log($"{gameObject.name} uses {skillQue[0].skillName}");
        skillQue[0].UseSkill();

        // 스킬 큐를 한 칸씩 당기고 새로운 스킬 추가
        MonsterSkillData[] nextQue = { skillQue[1], skillQue[2], null };
        float random = UnityEngine.Random.Range(0, 100);
        float sum = 0;

        foreach (var skills in monsterData.monsterSkillList)
        {
            sum += skills.skillChance;
            if (random < sum)
            {
                nextQue[2] = skills.monsterSkillData;
                skillQue = nextQue;
                break;
            }
        }
    }
    catch (OperationCanceledException)//스킬 사용 토큰이 취소되었을때.
    {
        Debug.Log("Skill execution was canceled.");
    }
    finally
    {
        // 작업 완료 후 토큰 정리
        skillCts?.Dispose();
        skillCts = null;
    }
}

 

여기서 만약 비동기 작업으로 실행되는 스킬을 시전 하는 몬스터 객체가 죽어서 스킬 사용을 취소해야 할 경우.

void MonsterDie()
{
    //몬스터 사망시 스킬 토큰 취소.
    skillCts?.Cancel();
    skillCts?.Dispose();
    skillCts = null;
    //끝나면 몬스터 오브젝트 비활성화.
    gameObject.SetActive(false);
}

 

아래와 같은 방법으로 .Cancel() 을 통해 취소를 해준다.

 

 

작동 방식 설명

먼저 해당 CancellationToken의 구현 방식은. 특정 비동기 작업을 실행하고자 할 때

SkillCts = new CancellationTokenSource();

 

CancellationTokenSource로 새로운 토큰을 생성해 준 뒤 해당 토큰을 기반으로 비동기 작업을 실행해 준다.

만약 해당 비동기 작업이 .Cancel()에 의해서 취소될 경우,

 

1. token.IsCancellationRequested 가 true로 설정됨.

2. OperationCanceledException 을 발생시킴.

 

이라는 두 가지 기능을 이용해서 try and catch로 스킬의 시전 취소를 관리한다.
만약 0.2 ~ 1.2 초의 딜레이 도중에 토큰이 취소가 되면 try 문의 return 또는 catch를 통해서  스킬의 실제 사용을 진행하지 않도록 한다.

try
    {
        float delay = UnityEngine.Random.Range(0.2f, 1.2f);
        await UniTask.Delay(TimeSpan.FromSeconds(delay), cancellationToken: token);

        if (token.IsCancellationRequested) return; // 토큰이 취소되었으면 실행 중단
    }
    catch (OperationCanceledException)//스킬 사용 토큰이 취소되었을때.
    {
        Debug.Log("Skill execution was canceled.");
    }

 

 

이렇게 몬스터의 사망으로 인해 토큰이 취소되면 CancellationTokenSource가 메모리상의 스택에서 사용하던 리소스를 동적으로 해제해 줘야 한다.

일반적으로 C#에서는 GC라는 가비지 컬렉션에 의해 자동으로 메모리의 정리를 진행하지만, 위와 같은 비동기 작업 또는 외부 자원에 대해서는 자동으로 정리되지 않기 때문이다.

 

이때 리소스를 동적으로 해제할 때 사용하는 것이 .Dispose() 이다.

.Dispose() 를 호출하여 앞서 skillCts = new CancellationTokenSource(); 을 통해 지정된 토큰의 메모리를 정리하고 메모리 누수를 방지할 수 있다.

 

<실제 코드>

void MonsterDie()
{
    //몬스터 사망시 스킬 토큰 취소.
    skillCts?.Cancel();
    skillCts?.Dispose();
    skillCts = null;
    //끝나면 몬스터 오브젝트 비활성화.
    gameObject.SetActive(false);
}

async UniTask Skill(CancellationToken token)
{
    try
    {
        //.....
    }
    catch (OperationCanceledException)//스킬 사용 토큰이 취소되었을때.
    {
        Debug.Log("Skill execution was canceled.");
    }
    finally
    {
        // 작업 완료 후 토큰 정리
        skillCts?.Dispose();
        skillCts = null;
    }
}

 

여기서는 각각

 

1. 몬스터가 죽어서 .Cancel()을 진행

2. 스킬 사용 도중 try 또는 catch에서 조건을 만족해서 진행된 finally 

 

에서 Dispose() 를 실행해 주며 비동기 작업이 끝이 난다.

 

 

 

 

 

 

 

잠재적인 문제

만약 앞서 설명한 코드대로 구현을 할 경우, 0.2 ~ 1.2초의 딜레이를 거치고 있는 CancellationTokenSource  가 딜레이 이후 사용되기 전에 또 다른 SkillUse() 메서드 실행으로 skillCts = new CancellationTokenSource();가 진행되어 버리면 앞서 등록한 스킬의 토큰이 사라지며 정상적인 동작을 하지 않을 수 있다.

이와 같은 경우 CancellationTokenSource를 array로 만들어 2개 이상의 토큰으로 관리하거나. 스킬의 시전 자체에 새로운 Queue <T>를 만들어 순차적으로 실행되도록 하는 방법 등이 있지만.

현재 개발 중인 게임의 경우 구조상 몬스터가 1.2초 보다 짧은 딜레이로 스킬을 사용하는 경우는 없도록 설계하기에 현재로서는 이대로 진행하여도 문제가 없을 것 같다.