3초 뒤에 메시지를 띄우고 싶을 때, 함수 안에서 3초를 기다리면 게임 전체가 3초 멈춥니다. Unity 는 이 문제를 코루틴으로 풀고, 운영체제는 스레드로 풉니다. 이번 편에서는 같은 "기다렸다가 찍기" 를 두 방법으로 만들어 차이를 봅니다.
2024년 부산 콘텐츠 아카데미 퍼스널 트레이닝 수업에서 MyLesson Unity 실습 프로젝트의 Strategy_07_Coroutine.cs 로 가르친 내용입니다. 수업은 두 단계로 나뉘고, 단계마다 코드가 조금씩 바뀝니다.
기다리면 게임이 멈춘다
Unity 는 한 프레임 안에서 모든 스크립트의 Update 를 차례로 부르고, 그다음에 화면을 그립니다. 누군가 함수 안에서 3초를 기다리면, 그동안 다음 스크립트도 화면도 기다립니다.
코루틴: 멈췄다가 다음 프레임에 이어 가기
private IEnumerator DelayedPrint(float delay, string title, string someText)
{
float time = 0;
while (time < delay)
{
time += 1;
yield return new WaitForSeconds(1);
Program.WriteLine("{0}: {1}", title, time);
}
Program.WriteLine("{0}: {1}", title, someText);
yield return null;
}
public void Run()
{
Program.Get().StartCoroutine(DelayedPrint(3.0f, "첫번째 코루틴", "3초가 지났습니다."));
Program.Get().StartCoroutine(DelayedPrint(5.0f, "두번째 코루틴", "5초가 지났습니다."));
}6편의 yield 가 여기서 쓰입니다. DelayedPrint 는 IEnumerator 를 돌려주는 함수입니다. StartCoroutine 에 넘기면, Unity 가 매 프레임 이 IEnumerator 의 MoveNext 를 대신 불러 줍니다.
| yield return 값 | Unity 가 다음 MoveNext 를 부르는 때 |
|---|---|
null | 다음 프레임 |
new WaitForSeconds(1) | 1초가 지난 뒤의 프레임 |
new WaitUntil(() => 조건) | 조건이 참이 된 프레임 |
첫번째 코루틴: 1
두번째 코루틴: 1
첫번째 코루틴: 2
두번째 코루틴: 2
첫번째 코루틴: 3
첫번째 코루틴: 3초가 지났습니다.
두번째 코루틴: 3
두번째 코루틴: 4
두번째 코루틴: 5
두번째 코루틴: 5초가 지났습니다.두 코루틴이 번갈아 찍히니 동시에 도는 것처럼 보입니다. 하지만 둘 다 메인 스레드 하나에서 차례로 이어 갈 뿐입니다. 그래서 코루틴 안에서는 Program.WriteLine 처럼 Unity 화면을 고치는 코드를 마음껏 쓸 수 있습니다.
Strategy 는 MonoBehaviour 가 아니라 코루틴을 직접 시작할 수 없습니다. 그래서 씬에 있는 Program(MonoBehaviour)에게 Program.Get().StartCoroutine(…) 으로 부탁합니다.
스레드: 진짜로 따로 도는 흐름
private void DelayedPrint(float delay, string title, string someText)
{
double time = 0;
DateTime lastTime = DateTime.Now;
while (time < delay * 1000)
{
TimeSpan ts = DateTime.Now - lastTime;
time += ts.TotalMilliseconds;
Debug.LogFormat("{0}: {1}", title, Mathf.FloorToInt((float)time / 1000.0f));
lastTime = DateTime.Now;
Thread.Sleep(1000);
}
Debug.LogFormat("{0}: {1}", title, someText);
}
public void Run()
{
//Program.Get(); // 메인 쓰레드에서 미리 싱글톤 인스턴스를 찾아놓는다.
Thread firstThread = new Thread(() => DelayedPrint(3.0f, "첫번째 코루틴", "3초가 지났습니다."));
Thread secondThread = new Thread(new ThreadStart(
delegate
{
DelayedPrint(5.0f, "두번째 코루틴", "5초가 지났습니다.");
}
));
firstThread.Start();
secondThread.Start();
}2단계에서는 같은 일을 Thread 두 개로 합니다. 각 스레드는 운영체제가 따로 돌리는 흐름이라, 안에서 Thread.Sleep(1000) 으로 1초를 쉬어도 게임 화면은 멈추지 않습니다. 스레드를 만드는 두 가지 방법(람다를 바로 넘기기, ThreadStart 델리게이트로 감싸기)도 4편에서 본 문법 그대로입니다.
스레드 안에서는 Program.WriteLine 대신 Debug.LogFormat 을 썼습니다. Unity 의 오브젝트와 컴포넌트(글 상자 포함)는 메인 스레드에서만 만질 수 있기 때문입니다. 주석의 "메인 쓰레드에서 미리 싱글톤 인스턴스를 찾아놓는다" 도 같은 이유입니다. FindObjectOfType 같은 Unity API 는 다른 스레드에서 부르면 오류가 납니다.
두 스레드가 같은 목록을 만질 때
원본 소스의 2단계에는 두 스레드가 같은 리스트에 값을 넣고 읽는 코드가 더 있습니다.
private List<int> intarr = new List<int>();
private object lockArr = new object();lock (lockArr)
{ // Critical Section
intarr.Add(value);
value++;
foreach (var item in intarr)
{
Debug.Log(item.ToString());
}
}한 스레드가 리스트를 foreach 로 돌고 있는데 다른 스레드가 Add 를 하면, 리스트가 도중에 바뀌어 예외가 나거나 값이 꼬입니다. lock (lockArr) 은 "이 중괄호 안에는 한 번에 한 스레드만 들어온다" 는 표시입니다. 이렇게 보호하는 구간을 임계 구역(critical section)이라고 합니다.
코루틴
- 메인 스레드 하나에서 프레임마다 이어 간다
- Unity API 를 마음껏 쓴다
- lock 이 필요 없다
- 무거운 계산을 하면 그 프레임이 느려진다
스레드
- 운영체제가 따로 돌린다
- Unity API 를 쓸 수 없다
- 같은 데이터를 만지면 lock 이 필요하다
- 무거운 계산이나 네트워크 대기에 알맞다
돌아보며
이번 편에서 얻은 것
- 코루틴은 IEnumerator 를 Unity 가 프레임마다 이어 가 주는 것이다
- 스레드는 진짜로 따로 돌지만 Unity API 를 쓸 수 없다
- 여러 스레드가 같은 데이터를 쓰면 lock 으로 보호한다
아쉬운 점
- 스레드 결과를 화면에 띄우려면 메인 스레드로 넘기는 장치(큐 등)가 따로 필요한데, 이 수업에서는 다루지 않는다
정리
- 게임 안에서 기다릴 때는 코루틴입니다. yield return new WaitForSeconds(1) 은 1초 뒤 프레임에 이어 갑니다.
- 스레드는 메인 스레드 밖에서 돕니다. 화면을 멈추지 않지만 Unity 오브젝트를 만질 수 없습니다.
- 공유 데이터는 lock 으로 지킵니다. 한 번에 한 스레드만 들어오게 해서 목록이 도중에 바뀌지 않게 합니다.
다음 편에서는 var 와 초기화 문법, 그리고 프로퍼티로 선언을 짧고 안전하게 만드는 법을 봅니다.