본문 바로가기

Unity/Articles

Unity: 비동기와 코루틴을 혼동하지 않기

본론에 앞서

유니티에서의 대부분 로직은 동기 방식으로 작성되며, 흐름(Flow, Stream)을 관리하거나 시간을 조절해 시행해야하는 작업은 코루틴(Coroutine)이라는 기능을 활용하여 구현됩니다. 특히 유니티는 기본적으로 메인스레드가 아닌 곳에서의 네이티브 API 호출을 허용하고 있지 않습니다. 비동기 방식으로 로직을 작성하더라도 유니티 API를 사용해야 하는 끝단에서는 메인스레드에서 함수 Call 작업을 마무리 해 주어야 합니다. 이 때 일반적으로 사용되는 방식이 액션을 큐에 추가해 유니티 Update 루프에서 처리해주는 디스패처입니다. 이 방법론이 필요한 이유와 한계, 장단점을 알고 계시다면 아래 내용은 스킵해도 됩니다. 

 

왜 게임 개발에서는 비동기 사용이 적을까요?

상용 엔진에서는 async/await를 사용해야만 하는 경우가 극히 드뭅니다. 일반적으로 그 외적인 방법으로도 컨트롤 할 수 있는 영역 내에서 스크립팅이 진행되고, Scalable한 비동기 API를 제공한다고 해도 그 효과를 적극적으로 누릴 수 있는 분야는 다소 제한적이기 때문입니다. 렌더링을 비롯한 게임 로직을 비동기로 동시에 사용할 수 있게 제공하더라도 그 효율성보다 그 준비단계에서의 부담이 더 크므로 그 필요성을 느끼지 못하는 것입니다. 

(사용자의 입장에서 async/await를 사용하고 싶을 수도 있지만, 엔진 개발 및 제공자의 입장에서 매력적인 옵션은 아닙니다.)

 

그렇다면 어떤 경우에 활용할 수 있나요?

네트워크 통신과 파일시스템 접근에는 다른 파트보다 비교적 효과를 볼 수 있습니다. 

소켓 등의 방식으로 데이터를 주고 받으며 비동기로 작업하다가 패킷이 완성되면 메인스레드로 넘겨 처리하거나, 파일 시스템에서 필요한 리소스를 비동기로 읽어들여 파싱하다가 데이터 구조가 작성될 때 마다 작업하는 식으로 처리할 수 있습니다. 여러 스레드에 작업을 할당하여 로딩 속도에서 이점을 볼 수 있는 것이죠. 

 

이처럼 어떤 작업이 언제 마무리될지 확신할 수 없고, 그 시간이 길어 로직이 멈춘(blocking) 상태로 유지되어서는 안 되는 작업에서 활용됩니다.


코루틴과 비동기에 대하여

비동기요? 코루틴을 사용하면 되는데 뭐하러 굳이?

비동기 로직 설계에 대한 얘기를 하면서 가장 많이 들었던 문구 중의 하나입니다. 이미 유니티는 작업을 나누어 처리할 수 있는 코루틴이라는 멋진 기능이 있는데 왜 '어렵고' ,'생소한', '버그가 생기기 쉬운' 비동기 로직을 사용하냐는 논지로요. 이 시점에서 코루틴과 비동기 로직의 차이를 이해하고 그 활용도에 대해 고민해보아야 합니다.

 

코루틴의 한계

가장 염두에 두어야 할 점은 유니티에서의 코루틴은 근본적으로 동기처리라는 점입니다. 만약, 1회의 코루틴 루프에서 아주 무거운 작업을 처리해야한다면 어떤 일이 발생할까요? 극단적인 예시로 코루틴 루프 로직에 탈출 불가능한 while 문을 작성해 볼 수 있습니다. 그 애플리케이션은 그 while문의 굴레를 벗어나지 못한 채 영원히 끝나기를 기다리게 될 것입니다.

 

너무 극단적인 예시였으므로, 조금 순한맛의 작업을 요청해보겠습니다. 

public class CoroutineTest : MonoBehaviour
{
	public int count = 1000;
	
	private void Awake()
	{
		StartCoroutine(Coroutine());
	}

	IEnumerator Coroutine()
	{
		double value = 0d;
		while (true)
		{
			for (int i = 0; i < count; i++)
			{
				for (int j = 0; j < count; j++)
				{
					value += Random.Range(-1.0f, 1.0f);
				}
			}

			yield return null;
		}
	}
}

위 코드는 매 프레임마다 count ^ 2 횟수만큼 반복하며 -1.0에서 1.0 사이의 Random한 수치를 value에 더합니다. yield return null; 라인에 도달해서야 그 프레임을 넘길 수 있습니다. 넉넉잡아서 이 게임이 200FPS를 유지한다고 치면, 이 루프는 최소 5ms 이내에 모두 처리되어야만합니다. 한 프레임에는 이 코루틴 외에도 처리해야 할 작업이 많으니 실제로 이 코루틴에게 허용되는 시간은 그것보다 훨씬 촉박할 겁니다. 코루틴의 로직은 메인 스레드의 진행을 block 하고 있으며, 이는 렌더링 프레임 수치에 직접적인 영향력을 행사합니다.

 

그래도 보완 가능하지 않나요?

가능합니다. 

 

코루틴에서도 허용된 시간을 초과하면 yield return null으로 프레임을 넘겨 주는 식으로 나머지 작업은 다음 프레임에 처리하도록 로직을 작성해야 합니다. 그러나 결코 권장하는 방법은 아니며, 다른 방법이 있다면 그쪽을 택하는 것을 먼저 고려해보는 것이 현명한 처사일 수 있습니다. 코루틴에 작업을 태울 때에는 해당 프로세스의 복잡도가 얼마나 높은지, 최대로 걸릴 수 있는 시간이 어느정도인지 충분히 인지하고 로직을 작성해야합니다. 

 

예제가 없다면 긴가민가할 수 있으니, 예시를 보도록 하겠습니다.

이번 프레임이 3ms 이상 소요되었으면 스킵
이번 프레임이 10ms 이상 소요되었으면 스킵

public class CoroutineTest : MonoBehaviour
{
	public int count = 1000;
	public int ms = 3;
	
	private void Awake()
	{
		StartCoroutine(Coroutine());
	}

	IEnumerator Coroutine()
	{
		Stopwatch sw = new Stopwatch();
		sw.Start();
		
		double value = 0d;
		
		while (true)
		{
			for (int i = 0; i < count; i++)
			{
				for (int j = 0; j < count; j++)
				{
					value += Random.Range(-1.0f, 1.0f);

					if (sw.ElapsedMilliseconds > ms)
					{
						yield return null;
						sw.Restart();
					}
				}
			}

			yield return null;
		}
	}
}

 

기존 예제와 유사하지만 매 작업 수행시마다 시간을 체크하여 yield return null으로 프레임의 흐름을 넘겨줍니다. 원하는 만큼의 프레임 당 ms를 얻을 수 있게됩니다. 그러나 위 코드는 출시될 게임에 넣을 정도로 안정적인 코드가 아니므로 이런 방식을 사용하는 것은 위험합니다.

 

Async 써야할 때는 과감하게 쓰자

서버와 소켓 통신을 하거나, 방대한 양의 데이터를 처리해야 할 때 사용을 고려할 수 있습니다. 상용 프로젝트에서 사용했던 코드를 일부 발췌해 간략화한 예제를 소개하겠습니다.

 

class StaticDataUtil {
    // 기술 편의상 static 메서드로 수정했습니다.
    public static async void Decrypt(StaticDataContext context, Action<StaticData[]> onComplete) {
        int size = context.size;
        var datas = new StaticData[size];
        
        Task task = Task.Run(() => {
            byte[] bytes = null;
            string json = null;
            for (int i = 0; i < size; i++) {
                bytes = context.datas[i];
                json = GZipCompressor.Unzip(bytes);
                json = GZipCompressor.XOR(json, "N.EFGame.BD3");
                
                datas[i] = JsonConvert.DeserializeObject<StaticData>(json);
            }
        });
        
        await task;
        onComplete?.Invoke(datas);
    }
}

이 코드를 모바일 기기에서 코루틴을 이용하여 구현을 한다고 가정해보겠습니다.

 

json = GzipCompressor.Unzip(bytes); <-- 0.48s // 2FPS

json = GzipCompressor.XOR(json, "N.EFGame.BD3"); <-- 0.11s // 9FPS

datas[i] = JsonConvert.DeserializeObject<StaticData>(json); <-- 0.02s // 50FPS

 

단일 함수를 하나 진행시킬 때 마다 yield return null을 해 주더라도 30FPS를 방어할 수 없습니다. 게임이 멈춘 로딩 상태일때는 이렇게 동작하도록 구현해도 되겠지만, 이 과정을 데이터 수 만큼 반복해야 하므로 시간이 꽤 걸릴 것이고 현재 게임이 로딩작업중임을 유저에게 확실하게 고지해야 합니다. 그렇지 않으면 오작동으로 뻗었거나 랙이 걸린다고 생각하고 이탈할 가능성도 생기기 때문입니다.

 

더 치명적인 점은, 위와 같은 경우 메인스레드가 0.1s~0.5s 정도 선에서 block 되는 상태가 유지되므로 '부드러운' 로딩 바 애니메이션과 트윈이 불가능합니다. 다른 컴포넌트의 Update나 코루틴에 Lerp를 이용해 부드럽게 차오르도록 준비해두었더라도 5 프레임 내외의 상황이 몇 초간 유지될테니 유저는 말 그대로 '뚝뚝' 끊기는 로딩화면을 보게 될것입니다. 이것이 비동기와 코루틴의 적절한 활용이 필요한 이유 중 하나입니다.

 

유니티에서는 어떻게?

유니티 API는 메인스레드에서만 호출해야 합니다. 그렇지 않으면 유니티는 예외를 뱉어내며 컴포넌트가 꺼져버릴것입니다. 이러한 동작을 의도한 이유는 여러가지가 있겠지만 그 중 하나로는 여러 스레드에서 공통된 메모리에 접근할 때 생길 수 있는 Thread-Safe 문제 때문일 것이며 명백하게 단일 스레드에서 처리하기를 권고하는 메시지일 것입니다.

 

사실 유니티 메인스레드가 아닌 곳에서 호출해도 치명적인 이슈가 발생하지 않는 경우가 대부분이지만 유니티는 의도적으로 이 호출을 금지하고 있습니다. 혹시 모를 부가 효과가 발생할 수도 있는데 디버깅이 쉽지 않고 이에 대한 지원 및 책임을 질 수 없기 때문일 것입니다. 이 사실은 유니티로 앱을 빌드하고 메인스레드가 아닌 스레드의 함수를 후킹한 다음 유니티 코어 로직을 호출해보면 알 수 있습니다. 대부분 무리 없이 작동합니다. 다만 일부 Thread-Safe 하지 않은 작업의 현상이 관측될 수 있습니다. 

 

Dispatcher를 사용하기

유니티를 사용하는 개발자 사이에서는 MainThreadDispatcher와 유사한 이름으로 불립니다. 메인 스레드가 아닌 곳에서 유니티 API 호출을 하고 싶을 때, MonoBehaviour에서 그 처리를 할 수 있도록 넘겨받아 다음 프레임의 Update 루프에서 작업해주는 것이 기본 개념입니다.

public class Dispatcher : MonoBehaviour
{
	private static Dispatcher main = null;
	private readonly Queue<Action> queued = new Queue<Action>();

	private void Awake()
	{
		Init();
	}

	private void Update()
	{
		lock (queued)
		{
			while (queued.Count > 0)
			{
				queued.Dequeue().Invoke();
			}
		}
	}

	public static void Init()
	{
		if (main == null)
		{
			main = FindObjectOfType<Dispatcher>();
			if (main == null)
			{
				main = new GameObject("[Dispatcher]").AddComponent<Dispatcher>();
			}
		}

		DontDestroyOnLoad(main.gameObject);
	}

	public static void Register(Action action)
	{
		main.queued.Enqueue(action);
	}

	public static void Register(IEnumerator action)
	{
		Register(() => main.StartCoroutine(action));
	}

	[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
	private static void ResetDomain()
	{
		main = null;
	}
}
간단한 싱글톤 패턴, Register 함수로 메인스레드에서 실행시키고 싶은 작업을 Action 형태로 넘겨주기, ResetDomain으로 Fast EnterPlayMode 대응.

 

유니티의 한 프레임이 대기하는 동안 Dispatcher의 queued 큐에는 2개 이상의 액션이 바인딩 될 수도 있습니다. 이런 경우 여러 이터레이션에서 예약된 함수들이 큐에 들어간 순서에 따라 하나의 프레임에서 처리됨을 명심해야합니다. 보통의 작업은 따로 고려하지 않아도 되지만, 시간에 관련된 작업인 경우에는 의도한 대로 코드가 작동할지 생각해 보는 과정이 있으면 좋습니다.


글을 마치며

코루틴과 비동기는 비슷하다고 느껴질 수 있으나 그 태생과 사용의 목적이 전혀 다릅니다. 또한 그 실행 결과 역시 상이합니다. 규칙없는 무분별한 비동기 로직의 사용은 난해한 코드 생태계를 구성하기 쉬워 남용은 자제해야겠으나, 무조건적으로 코루틴을 선택하는 일 역시 조심해야합니다. 

 

만약 어디에선가 비동기 대신 코루틴을 쓰라는 말을 들은 적이 있다면, 그것은 비동기를 절대 사용하지 말라는 뜻은 아니었을 것입니다. 코루틴으로 처리할 수 있는 부분은 코루틴으로 처리하고 불필요한 멀티스레드 환경을 만들지 말자는 의미였을 거라고 생각합시다. 

 

코루틴과 비동기에 대한 얘기를 할 때면 이전 모 유명 게임 개발 유튜버가 한 말이 떠오릅니다.

FixedUpdate는 자주 호출되지 않고, Update는 매프레임 호출되니 성능상의 이점을 위해 FixedUpdate 에서 Input을 받고 처리하는 것이 바람직합니다. (사실은 틀린 조언입니다!!!)

 

비슷한 맥락에서 '비동기보다 코루틴을 써라'는 부실한 근거의 개발 괴담이 퍼지지 않았나 생각이 듭니다. 누군가 "이것은 이렇게, 저것은 저렇게 해야만 해." 라고 얘기한다면, 충분히 의심하고 탐구해 볼 가치가 있습니다. 개발자는 그런 공부를 게을리 하지 않고 계속해나가야합니다. 비단 저의 글도 제외되지는 않습니다.

 

끊임 없이 의심하고 공부해 조금 더 나아진 내일을 맞을 수 있도록 합시다.