월세 500 호기심 천국
7주차: 남들보다 더 갓생 사는 Coroutine의 충격적인 정체 본문
0주차: 인트로 - 새롭고 신기한 것을 좋아하거나, 모르는 것을 알고 싶어 하는 마음
1주차: Unity 엔진은 왜 개발 언어로 C#을 선택했을까?
2주차: 의외로 세상 잡다한 Transform에 대한 모든 것
3주차: EulerAngle아, Quaternion아, 둘이 싸우지 말고 사이좋게 지내야 한다~ (1/2)
4주차: EulerAngle아, Quaternion아, 둘이 싸우지 말고 사이좋게 지내야 한다~ (2/2)
5주차: 헐크 아저씨도 깜짝 놀란 Unity Render Pipeline이 작동하는 과정
6주차: 매 프레임 나보다 갓생 사는 Unity의 Life Cycle
7주차: 남들보다 더 갓생 사는 Coroutine의 충격적인 정체
8주차: 야물(YAML)딱진 그 녀석의 은밀한 속마음 - Unity Scene 파일 (.unity)
9주차: 내 과자 포장지 누가 버렸어? - meta data 파일 (.meta) 파헤치기
10주차: 철가방 채로 배달온 Unity 빌드 파일. 난 단무지 시킨 적 없는데, 왜 있지?
11주차: 마무리 - 후기
보통 컴퓨터는 한 번에 하나의 일만 수행하는 일이 거의 없다.
티스토리에 글을 쓸 수 있도록 크롬창을 띄우고, 백그라운드에서 유튜브로 노래를 틀어주고,
카톡이 오면 사용자가 바로 볼 수 있도록 바로 알림을 띄워준다.
저 어디 안쪽에서는 백신 프로그램의 자동 검사도 돌아가고 있다.
짧은 시간 안에 많은 일이 주어지고 그것들을 동시에 수행해내야 하는 것은
비단 컴퓨터 운영체제만이 아닐 것이다.
우리가 개발하는 게임, 응용 프로그램들 역시 이것들을 해내야 한다.
하지만 어떻게?
우리가 유니티에게 'A랑 B 코드를 동시에 실행해줘!'
명령한다고 해서 말처럼 그게 바로 되는 건 아니다.
우선, 떠올릴 수 있는 가장 간단한 방법은
앞서 6주차에서 다뤘던 유니티의 Life Cycle 중 Update()를 활용하는 것이다.
MonoBehaviour를 포함한 모든 클래스에는 Update()가 기본적으로 주어지고,
이 함수는 코드가 작성된 순서대로, 직렬적으로 실행된다.
그리고 Update() 안의 모든 코드들은, 모두 한 프레임 안에서 동시에 실행될 것을 보장받는다.
즉, 동시에 실행하고 싶은 모든 내용들을 하나의 Update()안에 넣으면 된다.

그렇다면 이걸로 해결된걸까?
아래의 상황을 보자.
people은 일정 시간(3초)마다 Eat() 함수를 통해 밥을 먹어야 한다.
쿨타임 변수와 Time.deltaTime을 활용해 people에게 꾸준히 밥을 먹일 수 있다.
코드 상에는 문제가 없다.
float eatMaxCoolTime = 3;
float eatCurCoolTime = 0;
void Update()
{
if (eatCurCoolTime < 0)
{
people.Eat();
eatCurCoolTime = eatMaxCoolTime;
}
eatCurCoolTime -= Time.deltaTime;
}
그리고 사람이 다섯 명으로 늘었다. 다행히 우리에게는 반복문이 있기 때문에, 이 정도는 아직 할만하다.
float eatMaxCoolTime = 3;
float[] eatCurCoolTimes = new float[5];
void Update()
{
for (int i = 0; i < people.Length; i++)
{
if (eatCurCoolTimes[i] < 0)
{
people[i].Eat();
eatCurCoolTimes[i] = eatMaxCoolTime;
}
eatCurCoolTime[i] -= Time.deltaTime;
}
}
사람이 밥만 먹고 살지는 않는다. 먹고, 마시고, 놀고, 공부하고, 자는 과정을 추가한다.
반복문의 사용에도 한계가 있다.
float eatMaxCoolTime = 3;
float[] eatCurCoolTimes = new float[5];
float drinkMaxCoolTime = 2;
float[] drinkCurCoolTimes = new float[5];
float playMaxCoolTime = 5;
float[] playCurCoolTimes = new float[5];
float studyMaxCoolTime = 1;
float[] studyCurCoolTimes = new float[5];
float sleepMaxCoolTime = 10;
float[] sleepCurCoolTimes = new float[5];
void Update()
{
for (int i = 0; i < people.Length; i++)
{
if (eatCurCoolTimes[i] < 0)
{
people[i].Eat();
eatCurCoolTimes[i] = eatMaxCoolTime;
}
if (drinkCurCoolTimes[i] < 0)
{
people[i].Drink();
drinkCurCoolTimes[i] = drinkMaxCoolTime;
}
if (playCurCoolTimes[i] < 0)
{
people[i].Play();
playCurCoolTimes[i] = playMaxCoolTime;
}
if (studyCurCoolTimes[i] < 0)
{
people[i].Study();
studyCurCoolTimes[i] = studyMaxCoolTime;
}
if (sleepCurCoolTimes[i] < 0)
{
people[i].Sleep();
sleepCurCoolTimes[i] = sleepMaxCoolTime;
}
}
eatCurCoolTime[i] -= Time.deltaTime;
drinkCurCoolTimes[i] -= Time.deltaTime;
playCurCoolTimes[i] -= Time.deltaTime;
studyCurCoolTimes[i] -= Time.deltaTime;
sleepCurCoolTimes[i] -= Time.deltaTime;
}
게다가 사람은 밥을 한 프레임이 지나가는 시간 안에 절대 다 먹을 수 없다.
밥 한 술 떠야하고, 메추리알 장조림 하나 집어야 하고, 목 메이니까 가슴 콩콩 두드려야 하고...
연근 조림 한 번 쓱 보고, 다시 메추리알로 시선 돌리고...
밥 한 공기 다 먹는데에 최소 5초는 걸린다.
또 하루 일과가 밥 먹는거 하나만 있는 것도 아니고...

밥 먹는 데에 걸리는 시간(실제 함수 실행 시간)이랑
이것까지 다 고려해보면...

.
.
.
.
.

Debug.Log("박명수: 안해 이쒸");

한 Update() 안에 담겨야 하는 내용이 많아질수록,
그리고 그 작업들 간의 연관성이 줄어들수록,
코드가 눈에 띄게 복잡해지고 가독성 역시 떨어짐을 알 수 있다.
그렇다면 클래스의 개수를 늘리고, 여러 개의 Update()로 나눠 작성하면 일이 해결될까?
가독성은 개선되겠지만,
Unity는 Update() 뿐만 아니라 모든 코드를 하나의 단일 스레드로만 실행한다.
그러기에 발생하는 문제가 하나 더 있다.
for (int i = 0; i < people.Length; i++)
{
if (eatCurCoolTimes[i] < 0)
{
people[i].Eat();
eatCurCoolTimes[i] = eatMaxCoolTime;
}
}
사람 한 명이 밥을 먹게 되기까지 쿨타임이 10초, (= eatMaxCoolTime이 10초)
방금 전 상황처럼 밥 한 그릇을 다 먹는 데에 소요되는 시간이 5초라고 해보자. (= Eat()의 실행시간)
반복문을 돌며 people[0]과 people[1]이 쿨타임 10초를 다 채웠고 둘 모두 식사할 준비가 되어있음에도,
people[0]이 5초 동안 밥을 다 먹기 전까지는 (즉, people[0]의 Eat() 작업이 끝나기 전까지는)
people[1]은 밥을 먹을 수 없다.
유니티는 모든 소스 코드를 하나의 스레드로 실행하기 때문에
people[0]의 Eat() 함수로 들어가 return을 만나기 전까지는 그 다음 코드를 실행할 수 없고,
유니티는 5초간 Eat() 외의 다른 작업은 하나도 수행하지 못하며,
Update() 역시 5초간 종료될 수 없기에
그동안 프레임 자체가 정지될 것이다. (= 극심한 오버헤드)
나름 반복문과 쿨타임, Time.deltaTime을 통해 이들의 일정을 병렬적으로 해결했다고 생각했지만,
전혀 아니었던 것이다.
여러 개의 클래스를 통해 여러 개의 Update()를 만든다 해도 마찬가지이다.
클래스 A의 Update()를 모두 수행하기 전까지 클래스 B의 Update()는 실행되지 않는다.
.
.
.
때문에 우리는, 코루틴을 사용해야 한다.
코루틴이란?
코루틴은 서브 루틴을 일시 정지하고 재개할 수 있는 구성 요소를 말한다.
쉽게 말해 필요에 따라 일시 정지할 수 있는 함수를 말한다.
- "나무위키"

기존의 함수는 일단 한 번 호출하면 return을 만나거나 코드가 끝나지 않는 한 종료될 수 없었다.
우리의 경우는 보통 함수 실행시간 자체가 길지 않기 때문에 크게 걱정할 일이 없지만,
로딩, 입출력, 인터넷 연결과 같이 작업에 긴 시간이 소요되는 코드들이 일반 함수에 들어있을 경우
유니티 생명 주기를 딜레이시키는 주요 원인이 된다.
반면 코루틴은 작업의 중단이 자유롭다.
실행하던 작업을 잠시 멈추고, 자신을 호출했던 코드로 돌아가 그 뒤를 마저 수행할 수 있다.
단일 스레드만을 사용하는 유니티 특성상
코드 실행의 물리적인 병렬성을 구현할 수는 없지만,
코루틴을 통해 논리적인 병행성을 확보할 수 있는 것이다.

이는 꽤나 큰 이점이 되는데,
위에서 언급한 입출력, 인터넷 연결 등
외부적인 요인으로 시간 지연이 예상되는 작업은 요청만 해둔 채
코루틴을 중지하고 원래 수행 중이던 코드로 돌아가면 많은 시간을 절약할 수 있다.
운영체제에서 배웠던 스케쥴링의 중요성이 생각나기도 하고...
위에서 사람 다섯 명의 일과를 코루틴을 사용해 나타내면
아래와 같이 더욱 깔끔하게 표현할 수 있다.
Coroutine[] eatingCor = new Coroutine[5];
Coroutine[] drinkingCor = new Coroutine[5];
Coroutine[] playingCor = new Coroutine[5];
Coroutine[] studyingCor = new Coroutine[5];
Coroutine[] sleepingCor = new Coroutine[5];
void Update()
{
for (int i = 0; i < people.Length; i++)
{
if (eatingCor[i] == null)
eatingCor[i] = StartCoroutine(Eat(i, 5, 3));
if (drinkingCor[i] == null)
drinkingCor[i] = StartCoroutine(Drink(i, 2, 2));
if (playingCor[i] == null)
playingCor[i] = StartCoroutine(Play(i, 5, 7));
if (studyingCor[i] == null)
studyingCor[i] = StartCoroutine(Study(i, 1, 1));
if (sleepingCor[i] == null)
sleepingCor[i] = StartCoroutine(Sleep(i, 10, 10));
}
}
IEnumerator Eat(int peopleIdx, float spendTime, float coolTime)
{
yield return new WaitForSeconds(spendTime);
yield return StartCoroutine(WaitForCooltime(coolTime));
eatingCor[peopleIdx] = null;
}
IEnumerator Drink(int peopleIdx, float spendTime, float coolTime) { ... }
IEnumerator Play(int peopleIdx, float spendTime, float coolTime) { ... }
IEnumerator Study(int peopleIdx, float spendTime, float coolTime) { ... }
IEnumerator Sleep(int peopleIdx, float spendTime, float coolTime) { ... }
IEnumerator WaitForCooltime(float coolTime)
{
yield return new WaitForSeconds(coolTime);
}
코루틴의 핵심, yield
사실 저번 주차 Life Cycle에서도 살짝 얼굴을 비췄던 녀석이다.
도로 위 표지판에도 간간히 보이는 yield는, 코루틴에서도 비슷한 의미로 사용된다

보통 yield + return + @ 세트로 묶여 사용되며,
해당 문구에 도달하면 스레드는 코루틴 실행을 중지하여 다른 작업으로 관심을 돌린다.
코루틴이 다른 곳에 잠깐 동안 작업을 양보하는 개념인 것이다.
@값에 따라 코루틴이 호출자에게 양보하는 양상이 달라지며.
Update()와 싱크를 맞추고 싶을 경우 yield return + null,
FixedUpdate()와 싱크를 맞추고 싶을 경우 null 대신 WaitForFixedUpdate()를 붙여 사용할 수 있고,
WaitForSeconds(float) or WaitForSecondsRealtime(float)을 통해 일정 시간 만큼 코루틴을 중단시킬 수 있다. (WaitForSecondsRealtime은 timescale의 영향을 받지 않음)
또한 WaitWhile과 WaitUntil등으로 조건부 대기를 걸 수도 있다.
심지어 yield return StartCoroutine(코루틴명())을 통해 새로운 코루틴을 호출하고,
호출한 코루틴의 작업이 종료되기 전까지 지금의 코루틴을 중지시킬 수 있다.
이에 더해 yield + return + @가 아닌 yield break로 코루틴을 즉시 종료할 수 있다.
코루틴의 실행과 중지
코루틴은 일반 함수와는 실행 방식이 조금 다르다.
구현 부분에서도 차이가 있다.
void Start()
{
ThisIsFunc();
Coroutine cor = StartCoroutine(ThisIsCor());
}
void ThisIsFunc()
{
Debug.Log("This is Function");
return;
}
IEnumerator ThisIsCor()
{
Debug.Log("This is Coroutine");
yield break;
}
함수를 구현할 때는 함수 이름 앞에 return값의 자료형이 들어간다.
반면 코루틴 이름 앞에는 자료형 대신 열거자 인터페이스라는 의미의 IEnumerator가 들어간다.
함수는 반드시 자료형에 맞는 return을 내놓아야 하지만(void 함수 제외),
코루틴은 기존의 return을 요구하지 않는 대신 yield return + @, 혹은 yield break가 반드시 있어야 한다.
호출할 때 역시도 함수는 함수명과 인자를 적어 호출하는 반면,
코루틴은 StartCoroutine 안에 코루틴명과 인자를 적고,
필요에 따라 Coroutine 변수에 실행 중인 코루틴을 담아 둔 다음
StopCoroutine을 통해 해당 코루틴을 중단시킬 수 있다.
천천히 함께 가는 코루틴

코루틴은 모든 작업을 한 프레임에 모두 끝내버리는 일반 함수와는 다르다.
시간차를 두고 천천히 진행하는 특성 덕분에 코드의 실행을 의도적으로 지연시키고,
오버헤드의 우려가 있는 작업을 여러 번에 나누어 처리할 수 있다.
또한 코루틴은 혼자만 앞서 나가지 않는다.
코루틴이 몇 개가 호출되든, Update()나 FixedUpdate(), 혹은 다른 코루틴들과 함께 발맞춰
그들과 병행하며 작업을 수행한다.
이런 식의 지연과 병행 작업이 필요한 상황이 아니라면 보통의 함수를 이용하는 편이 더 간단하고 편할 수 있겠다.
하지만 코루틴을 온전히 이해하고 목적에 맞게, 적재적소에 활용할 수 있다면,
그 잠재력은 무궁무진해질 것이다.
마치 리그 오브 레전드, 할로우나이트, 얼불춤, 오목, 영상 편집,
스타크래프트, 모의고사 풀이와 유니티 코딩을
앉은 자리에서 컴퓨터 한 대로 모두 해내는,
Faker처럼.
그럼 이번 주도 끝!!!
0주차: 인트로 - 새롭고 신기한 것을 좋아하거나, 모르는 것을 알고 싶어 하는 마음
1주차: Unity 엔진은 왜 개발 언어로 C#을 선택했을까?
2주차: 의외로 세상 잡다한 Transform에 대한 모든 것
3주차: EulerAngle아, Quaternion아, 둘이 싸우지 말고 사이좋게 지내야 한다~ (1/2)
4주차: EulerAngle아, Quaternion아, 둘이 싸우지 말고 사이좋게 지내야 한다~ (2/2)
5주차: 헐크 아저씨도 깜짝 놀란 Unity Render Pipeline이 작동하는 과정
6주차: 매 프레임 나보다 갓생 사는 Unity의 Life Cycle
7주차: 남들보다 더 갓생 사는 Coroutine의 충격적인 정체
8주차: 야물(YAML)딱진 그 녀석의 은밀한 속마음 - Unity Scene 파일 (.unity)
9주차: 내 과자 포장지 누가 버렸어? - meta data 파일 (.meta) 파헤치기
10주차: 철가방 채로 배달온 Unity 빌드 파일. 난 단무지 시킨 적 없는데, 왜 있지?
11주차: 마무리 - 후기
'내 맘대로 쓰는 Unity 심층분석 析深層分' 카테고리의 다른 글
| 9주차: 내 과자 포장지 누가 버렸어? - meta data 파일 (.meta) 파헤치기 (0) | 2024.02.24 |
|---|---|
| 8주차: 야물(YAML)딱진 그 녀석의 은밀한 속마음 - Unity Scene 파일 (.unity) (0) | 2024.02.14 |
| 6주차: 매 프레임 나보다 갓생 사는 Unity의 Life Cycle (1) | 2024.02.04 |
| 5주차: 헐크 아저씨도 깜짝 놀란 Unity Render Pipeline이 작동하는 과정 (3) | 2024.01.27 |
| 4주차: EulerAngle아, Quaternion아, 둘이 싸우지 말고 사이좋게 지내야 한다~ (2/2) (1) | 2024.01.18 |