유니티로 개발을 해본 경험이 있다면 코루틴을 들어본 적 있거나 조금 복잡한 게임을 만들어 보았다면 실제 사용해본 적이 있을 것이다. 이전에 다룬 Update와 같이 주기적으로 특정 작업을 반복하는 역할을 하는 Coroutine에 대해 알아보자.
Unity script lifecycle에서 Update
직후에 코루틴에 대한 처리가 이루어지는 것을 확인할 수 있다.
여기서 유니티의 프로세싱 원리에 대해 알고 넘어갈 필요가 있는데, 유니티는 단일 스레드 방식으로 작업을 수행하기 때문에 수많은 게임오브젝트들이 실행하는 스크립트들은 매 프레임(Update
, LateUpdate
) 또는 설정한 주기(FixedUpdate
)에 따라 실행되거나 특정 이벤트(On...
계열, delegate
등)가 발생했을 때마다 함수 제어권을 위임받아 작업을 수행한다.
이렇게 제어권을 받은 함수는 작업이 완료되어서야 다른 함수로 제어권을 옮기는데, 게임에선 매 프레임 수많은 오브젝트가 각자의 처리 명령을 가지기 때문에 한 함수가 제어권을 길게 가지고 있는 것이 문제가 된다.
예를 들어 오브젝트가 서서히 투명해지는 코드를 작성한다고 하자.
void Fade() {
for (float f = 1f; f >= 0; f -= 0.1f) {
Color c = renderer.material.color;
c.a = f;
renderer.material.color = c;
}
}
오브젝트의 알파(a
)값을 서서히 감소시키는데, 중요한 것은 유니티 엔진에선 프레임마다 렌더링을 하기 때문에 위 작업이 한 프레임 안에 끝난다면 서서히 감소하는 중간과정을 관찰할 수 없다는 것이다.
Update()
함수 안에서 매 프레임 알파값을 조정할 수도 있지만, Update()
이벤트 함수에선 보통 다른 다양한 함수들을 호출하므로 그 안에 특정 작업을 직접 구현해 구조를 복잡하게 하는 것은 좋지 못한 방법이다. 되도록 Update()
함수는 짧게 만드는 것이 좋다.
이때 적절한 해결책이 코루틴이다. 코루틴은 열거자 IEnumerator를 활용하여 필요한 만큼 함수를 실행한 후 원하는 간격만큼 제어권을 위임할 수 있다. 코루틴으로 위 Fade()
함수를 구현한다면 아래와 같다.
IEnumerator Fade() {
for (float f = 1f; f >= 0; f -= 0.1f) {
Color c = renderer.material.color;
c.a = f;
renderer.material.color = c;
yield return null;
}
}
yield
구문 뒤에 다양한 함수를 삽입하여 원하는 시간만큼 제어권을 위임할 수 있다. 그 종류로는 다음과 같다.
yield return null;
: 다음 프레임까지 대기yield return new WaitForSeconds(float time);
: time 만큼의 시간만큼 대기yield return new WaitFixedUpdate()
: 다음 FixedUpdate 까지 대기yield return new WaitForEndOfFrame()
: 다음 프레임의 모든 랜더링 작업이 끝날 때까지 대기yield return new WaitUntil(compare)
: compare에 해당하는 조건문을 만족할 때까지 대기yield return StartCoroutiune(string coroutine)
: coroutine이라는 이름의 코루틴이 끝날 때까지 대기yield return new www(string )
: 웹 통신 작업이 끝날 때까지 대기yield return new AsyncIoeration()
: 비동기 작업이 끝날 때까지 대기( 씬 로딩);KKIMSSI님의 [Unity] yield return 종류 포스팅을 참고하였다. null을 제외하곤 함수를 호출하는 것이기 때문에 new
키워드를 반드시 포함해야 한다. 이때 StartCoroutine
을 호출하는 반환문은 함수가 그 자체로 인스턴스를 생성하도록 구현되어 있어 new 키워드를 생략한다.
이러한 코루틴을 사용하면 함수를 원하는 만큼만 수행하고 일시중지했다가 이어서 수행하는 효과를 낼 수 있다. 이 함수를 원할 때 키고StartCoroutine()
, 원할 때 끌 수 있으며StopCoroutine()
, Update()
와 별개로 운영되므로 함수가 복잡해지는 것을 막을 수 있다.
하지만 이렇게 유용한 코루틴을 사용할 때 주의해야 할 점은, 가비지를 많이 생성해 GC의 오버헤드를 크게 향상시킬 수도 있다는 것이다. 코루틴이 가비지를 생성하는 경우는 크게 두 가지이다.
StartCoroutine()
을 호출하는 것은 함수의 인스턴스를 생성하는 행위이기 때문에 가비지를 생성한다.
yield return null
을 제외하고 함수를 호출하는 yield return new ~
형식의 모든 위임 방식은 위임할 때마다 new
키워드로 함수의 인스턴스를 생성한다. 이를 YieldInstruction이라 하며, 이때 가비지를 생성한다.
이때 캐싱을 통해 가비지 생성을 대폭 감소시킬 수 있다. 캐싱 방법의 출처는 EjongHyuck's dev blog의 유니티 코루틴 최적화 게시글이며, 코드는 아래와 같다.
static class YieldInstructionCache
{
class FloatComparer : IEqualityComparer<float>
{
bool IEqualityComparer<float>.Equals(float x, float y)
{
return x == y;
}
int IEqualityComparer<float>.GetHashCode(float obj)
{
return obj.GetHashCode();
}
}
private static readonly Dictionary<float, WaitForSeconds> _timeInterval = new Dictionary<float, WaitForSeconds>(new FloatComparer());
public static WaitForSeconds WaitForSeconds(float seconds)
{
WaitForSeconds wfs;
if (!_timeInterval.TryGetValue(seconds, out wfs))
_timeInterval.Add(seconds, wfs = new WaitForSeconds(seconds));
return wfs;
}
public static readonly WaitForEndOfFrame WaitForEndOfFrame = new WaitForEndOfFrame();
public static readonly WaitForFixedUpdate WaitForFixedUpdate = new WaitForFixedUpdate();
}
위와 같은 캐싱 기법으로
yield return WaitForEndOfFrame;
yield return WaitForEndOfFixedUpdate;
yield return WaitForSeconds(0.1f);
와 같이 new 키워드 없이 인스턴스를 생성하지 않을 수 있고, 객체나 딕셔너리에 존재하지 않는 조건의 인스턴스를 캐싱하기 위한 가비지가 생성될 수 있지만, 첫 실행 시에만 인스턴스를 생성하기 때문에 훨씬 적은 가비지를 생성하게 된다.
결과적으로 가비지를 최소화하기 위해 캐싱을 활용하며, 코루틴의 실행과 종료를 남발하지 말아야 한다. 프로그램 실행 전반에 걸쳐 실행될 코드라면 웬만해선 Update()
계열의 함수를 사용하고, 타이머와 같이 특정 타이밍에만 실행해야 하는 함수라면 최소한으로 코루틴을 호출해야 할 것이다.