Unity Coroutine

Unity에서 특정 작업을 일정 시간 뒤에 실행하거나 여러 프레임에 걸쳐 순차적으로 처리해야 하는 경우가 있다.

대표적인 방법이 Update()에서 시간을 직접 계산하는 것이다.

private float timer;

private void Update()
{
    timer += Time.deltaTime;

    if (timer >= 5f)
    {
        timer = 0f;
        ActivateSkill();
    }
}

동작 자체는 문제가 없다.

하지만 단순히 “5초 기다린 뒤 실행한다”라는 로직을 표현하기에는 코드가 불필요하게 복잡해진다.

이럴 때 사용하는 것이 Coroutine이다.

Coroutine -> 실행 -> yield -> 일시 정지 -> 조건 충족 -> 재개 -> 종료

Coroutine이란

Coroutine은 일반적인 함수처럼 한 번 호출해서 끝까지 실행하는 방식과 다르게, 실행을 중간에 중단했다가 나중에 중단된 지점부터 다시 실행할 수 있는 구조다.

일반적인 함수는 다음과 같이 동작한다.

함수 호출 -> 처음부터 실행 -> return -> 종료

Coroutine은 다음과 같이 동작한다.

Coroutine 호출 -> 실행 -> yield -> 일시 정지 -> 재개 -> 실행 -> yield -> ... -> 종료

핵심은 yield다.

yield를 만나면 현재 실행 위치를 보존한 상태에서 Coroutine의 실행을 잠시 중단하고 Unity에게 제어권을 넘긴다.

이후 조건이 충족되면 이전에 멈췄던 위치부터 다시 실행한다.

IEnumerator와 yield

Unity Coroutine은 일반적으로 IEnumerator를 반환하는 메서드로 작성한다.

private IEnumerator TestCoroutine()
{
    Debug.Log("Start");

    yield return new WaitForSeconds(1f);

    Debug.Log("End");
}

여기서 중요한 것은 IEnumeratoryield의 역할이다.

IEnumerator는 C#에서 열거 가능한 데이터를 순차적으로 접근하기 위한 인터페이스다.

Coroutine에서는 이 구조를 이용해 Unity가 실행을 중단하고 다시 이어갈 수 있는 형태를 만든다.

yield return은 Coroutine의 실행을 중단하고 Unity에게 제어를 넘기는 역할을 한다.

Coroutine -> 코드 실행 -> yield return -> Unity에 제어권 전달 -> 조건 충족 -> yield 다음 코드 실행

예를 들어 다음 코드는

private IEnumerator TestCoroutine()
{
    Debug.Log("A");

    yield return new WaitForSeconds(2f);

    Debug.Log("B");
}

다음과 같이 동작한다.

TestCoroutine() -> A 출력 -> 2초 대기 -> B 출력 -> 종료

중요한 점은 WaitForSeconds 동안 Coroutine 코드가 계속 실행되는 것이 아니라는 것이다.

해당 Coroutine의 실행이 일시적으로 중단되고, Unity가 시간이 경과했는지를 관리한다.

왜 Coroutine을 사용하는가

가장 대표적인 이유는 시간이나 특정 조건을 기준으로 여러 프레임에 걸쳐 실행되는 로직을 간결하게 표현할 수 있기 때문이다.

예를 들어 스킬의 쿨타임을 구현한다고 생각해보자.

Update()를 사용하면 매 프레임마다 시간을 검사해야 한다.

private float cooldown;

private void Update()
{
    cooldown -= Time.deltaTime;

    if (cooldown <= 0f)
    {
        ActivateSkill();
    }
}

반면 Coroutine을 사용하면 시간의 흐름을 코드 자체로 표현할 수 있다.

private IEnumerator Cooldown()
{
    yield return new WaitForSeconds(5f);

    ActivateSkill();
}

코드의 의미가 명확하다.

Coroutine 실행 -> 5초 대기 -> 스킬 활성화

다만 이것을 “Coroutine은 매 프레임 검사하지 않는다”라고 이해하면 안 된다.

WaitForSeconds 역시 Unity의 실행 루프와 연동되어 조건을 확인하고 Coroutine을 재개한다.

Coroutine은 CPU 작업 자체를 없애는 최적화 기능이 아니다.

시간 기반 또는 프레임 기반의 실행 흐름을 표현하기 편하게 만드는 실행 모델에 가깝다.

Coroutine 시작하기

Unity에서는 StartCoroutine()을 사용해 Coroutine을 실행한다.

private void Start()
{
    StartCoroutine(CoroutineA());
}

Coroutine 메서드는 다음과 같이 작성한다.

private IEnumerator CoroutineA()
{
    Debug.Log("Start");

    yield return new WaitForSeconds(1f);

    Debug.Log("End");
}

실행 흐름은 다음과 같다.

Start() -> StartCoroutine() -> CoroutineA() -> yield -> 1초 대기 -> 재개 -> End 출력

Coroutine을 중지하기

Coroutine은 StopCoroutine()을 이용해 중지할 수 있다.

가장 관리하기 좋은 방식 중 하나는 Coroutine 핸들을 저장하는 것이다.

private Coroutine coroutine;

private void Start()
{
    coroutine = StartCoroutine(TestCoroutine());
}

private void Stop()
{
    StopCoroutine(coroutine);
}

이 방식은 어떤 Coroutine을 중지할 것인지 명확하다.

필요하다면 StopAllCoroutines()를 사용할 수도 있다.

StopAllCoroutines();

이 경우 해당 MonoBehaviour에서 실행 중인 모든 Coroutine이 중지된다.

따라서 특정 Coroutine만 중지해야 하는 상황에서 무분별하게 사용하면 안 된다.

문자열로 Coroutine을 실행하는 방식

Unity에는 문자열을 이용하는 방식도 존재한다.

StartCoroutine("CoroutineC");

중지는 다음과 같이 한다.

StopCoroutine("CoroutineC");

과거 Unity 코드에서는 이런 방식도 사용했지만, 일반적으로는 메서드 호출 방식이나 Coroutine 핸들을 사용하는 편이 명확하다.

특히 문자열 방식은 컴파일러가 메서드 이름을 직접 검증하지 못하기 때문에 리팩터링이나 오타에 취약하다.

따라서 특별한 이유가 없다면 다음과 같은 방식을 사용하는 것이 좋다.

StartCoroutine(TestCoroutine());

그리고 중지가 필요하다면 핸들을 저장한다.

private Coroutine coroutine;

private void Start()
{
    coroutine = StartCoroutine(TestCoroutine());
}

Coroutine과 비동기 처리

Coroutine을 처음 접하면 async/await나 멀티스레드와 비슷한 기능이라고 생각하기 쉽다.

하지만 동일한 개념은 아니다.

Coroutine은 기본적으로 Unity의 메인 스레드 실행 흐름 안에서 동작한다.

Coroutine != Thread
Coroutine != async/await
Coroutine != 병렬 실행

Coroutine은 작업을 여러 프레임에 나누거나 특정 조건까지 실행을 중단하는 방식이다.

예를 들어 다음 코드에서

private IEnumerator TestCoroutine()
{
    HeavyCalculation();

    yield return null;

    AnotherCalculation();
}

yield return null을 사용하면 다음 프레임까지 실행을 넘길 수 있다.

하지만 HeavyCalculation() 자체가 매우 무거운 작업이라면 Coroutine을 사용한다고 해서 해당 계산이 별도의 스레드에서 실행되는 것은 아니다.

따라서 무거운 CPU 작업을 병렬 처리하기 위한 수단으로 Coroutine을 사용하면 안 된다.

Coroutine에서 자주 사용하는 yield

Unity에서는 여러 종류의 yield return을 사용할 수 있다.

yield return null;

다음 프레임까지 실행을 넘기는 용도로 사용할 수 있다.

yield return new WaitForSeconds(1f);

지정된 시간 이후 Coroutine을 재개한다.

yield return new WaitUntil(() => condition);

조건이 true가 될 때까지 기다린다.

yield return new WaitWhile(() => condition);

조건이 true인 동안 기다린다.

따라서 Coroutine은 단순한 시간 지연뿐만 아니라 게임 플레이의 순차적인 흐름을 표현하는 데 사용할 수 있다.

게임 시작 -> 연출 실행 -> 대기 -> 애니메이션 재생 -> 대기 -> 플레이어 입력 활성화

이런 흐름을 Coroutine으로 작성하면 상태를 직접 관리하는 코드보다 읽기 쉬워지는 경우가 많다.

Coroutine을 사용할 때 주의할 점

Coroutine은 편리하지만 모든 로직을 Coroutine으로 작성하는 것은 좋은 설계가 아니다.

특히 Update()로 처리하는 것이 자연스러운 지속적인 상태 감시를 무조건 Coroutine으로 바꿀 필요는 없다.

또한 Coroutine은 해당 MonoBehaviour와 생명주기 관계가 있기 때문에 오브젝트가 비활성화되거나 파괴되는 상황을 고려해야 한다.

Coroutine을 여러 개 실행하는 경우에는 각각의 Coroutine이 언제 시작되고 언제 종료되는지도 관리해야 한다.

Coroutine 증가 -> 실행 흐름 증가 -> 상태 관리 증가 -> 디버깅 복잡도 증가

따라서 Coroutine은 “비동기 작업을 위한 만능 도구”가 아니라 시간과 프레임을 기준으로 실행 흐름을 나누기 위한 도구라고 이해하는 것이 정확하다.

정리

Coroutine의 핵심은 yield다.

Coroutine -> 실행 -> yield -> 실행 중단 -> 조건 충족 -> 이전 위치부터 재개

IEnumerator는 Coroutine의 실행 상태를 표현하는 기반이 되고, yield return은 Unity에게 실행을 잠시 넘기는 지점을 만든다.

대표적인 사용처는 다음과 같다.

시간 지연 / 연출 시퀀스 / 애니메이션 순차 처리 / 쿨타임 / 반복적인 프레임 분할 작업 / 조건 대기

그리고 가장 중요한 구분은 이것이다.

Coroutine -> 실행 흐름 제어
Thread -> 병렬 실행
async/await -> 비동기 작업 모델

Coroutine은 성능을 자동으로 개선해주는 기능이 아니다.

복잡한 시간 기반 로직을 읽기 쉽고 순차적인 코드로 표현할 수 있게 해주는 Unity의 실행 모델이라고 보는 것이 가장 정확하다.