작성일 :


유니티 개발을 진행하다 보면 다양한 이벤트를 순차적으로 처리하거나 일정 시간 후에 동작을 수행하는 등의 기능을 구현해야 할 때가 많다.

이러한 것들을 간편하게 구현할 수 있는 유니티의 기능 중 하나가 바로 코루틴(Coroutine)이다.

유니티를 다루다보면 피할 수 없는, 필수적인 개념이기도 하다.


비동기(Asynchronous)와 병렬(Parallel)의 차이

코루틴을 이해하기 전에, 먼저 비동기병렬의 차이를 명확히 이해해야 한다.

이 두 개념은 자주 혼동되지만, 완전히 다른 의미를 가진다.

동기(Synchronous) vs 비동기(Asynchronous)

동기 처리는 작업이 완료될 때까지 다음 코드의 실행을 기다리는 방식이다.

비동기 처리는 작업이 완료될 때까지 기다리지 않고, 일단 제어권을 양보한 뒤 나중에 결과를 받는 방식이다.

1
2
3
4
5
6
7
8
// 동기 처리: 파일 읽기가 완료될 때까지 대기
string content = File.ReadAllText("file.txt");
Debug.Log("파일 읽기 완료");  // 파일 읽기가 끝나야 실행됨

// 비동기 처리: 파일 읽기를 요청하고 제어권 양보
var task = File.ReadAllTextAsync("file.txt");
Debug.Log("파일 읽기 요청함");  // 파일 읽기 완료 전에 실행됨
string content = await task;   // 여기서 결과를 기다림


단일 스레드 vs 병렬(멀티 스레드)

병렬 처리는 여러 작업을 동시에 여러 스레드(또는 CPU 코어)에서 실행하는 것이다.

중요한 점은, 비동기가 반드시 병렬을 의미하지는 않는다는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
비동기 + 단일 스레드:
┌─────────────────────────────────────────┐
│  메인 스레드                              │
│  ┌──────┐     ┌──────┐     ┌──────┐     │
│  │작업 A│ ──→ │작업 B│ ──→ │작업 A│     │
│  │시작  │     │실행  │     │재개  │     │
│  └──────┘     └──────┘     └──────┘     │
│      ↓ 제어권 양보    ↓ 제어권 양보        │
└─────────────────────────────────────────┘

병렬 (멀티 스레드):
┌─────────────────────────────────────────┐
│  스레드 1: ████████████ 작업 A           │
│  스레드 2: ████████████ 작업 B           │
│  스레드 3: ████████████ 작업 C           │
│  (동시에 실행)                            │
└─────────────────────────────────────────┘


이제 코루틴이 어디에 해당하는지 알 수 있다.

코루틴은 단일 스레드에서 동작하는 비동기 패턴이다.


코루틴(Coroutine)이란?

코루틴(Coroutine)은 실행을 일시 중단하고 나중에 재개할 수 있는 함수이다.

일반적인 함수는 호출되면 처음부터 끝까지 한 번에 실행되지만,

코루틴은 특정 지점에서 실행을 중단(yield)하고, 나중에 그 지점부터 다시 재개(resume)할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections;
using UnityEngine;

public class CoroutineExample : MonoBehaviour {
    private void Start() {
        StartCoroutine(WaitAndPrint());
    }

    private IEnumerator WaitAndPrint() {
        Debug.Log("1. 코루틴 시작");
        yield return new WaitForSeconds(2);  // 여기서 중단, 2초 후 재개
        Debug.Log("2. 2초 후 실행");
        yield return new WaitForSeconds(1);  // 여기서 중단, 1초 후 재개
        Debug.Log("3. 1초 더 후 실행");
    }
}


위 코드에서 yield return 을 만나면 코루틴은 실행을 중단하고 유니티 엔진에 제어권을 돌려준다.

그 사이에 유니티는 다른 게임 로직을 처리하고, 지정된 시간이 지나면 코루틴 실행을 재개한다.

이 모든 과정이 메인 스레드에서 일어난다.

따라서 코루틴은 게임을 멈추지 않으면서도 시간 기반 로직을 쉽게 구현할 수 있게 해주는 것이다.


코루틴이 병렬 처리가 아닌 이유

코루틴 내에서 무거운 연산을 수행하면 게임이 버벅거리게 된다.

왜냐하면 코루틴은 메인 스레드에서 실행되기 때문이다.

1
2
3
4
5
6
7
private IEnumerator HeavyWork() {
    // 이 루프가 실행되는 동안 게임이 멈춘다!
    for (int i = 0; i < 10000000; i++) {
        DoSomeCalculation(i);
    }
    yield return null;
}


코루틴은 “시간을 분할해서 작업을 나눠서 실행”하는 것이지, “다른 스레드에서 동시에 실행”하는 것이 아니다.

1
2
3
4
5
6
7
8
9
10
11
// 올바른 사용: 작업을 여러 프레임에 분산
private IEnumerator HeavyWorkDistributed() {
    for (int i = 0; i < 10000000; i++) {
        DoSomeCalculation(i);

        // 100번 연산마다 한 프레임 양보
        if (i % 100 == 0) {
            yield return null;
        }
    }
}



코루틴과 yield return, 그리고 IEnumerator

Unity 에서 코루틴은 IEnumerator 인터페이스를 반환하는 메서드를 통해 정의하고, StartCoroutine 메서드를 호출함으로써 시작된다.

이 때, 코루틴의 작동 원리를 이해하기 위해서는 먼저, yield return 구문과 IEnumerator 인터페이스에 대해 알아야 한다.

yield return

yield return 구문은 코루틴의 핵심이라고도 할 수 있다.

코루틴 내에서 해당 구문을 만나면, 코루틴은 그 시점에서 실행을 중지하고 유니티 엔진에 제어권을 넘겨주는데,

이 때 코루틴의 상태와 지역변수들은 모두 유지된다.

제어권을 받은 유니티 엔진은 조건이 충족되면 IEnumeratorMoveNext() 메서드를 호출하여 코루틴 실행을 재개한다.

이러한 방식이 반복됨으로써 코루틴을 통한 비동기 처리가 가능해지는 것이다.


또한, yield return 뒤에 올 수 있는 값에 따라서 코루틴의 흐름을 다양하게 제어할 수 있게 된다.

  • yield return null : 다음 프레임까지 코루틴을 중지하고 대기
  • yield return new WaitForSeconds(float seconds) : 지정된 초 만큼 코루틴을 중지하고 대기
  • yield return new WaitForFixedUpdate() : 다음 FixedUpdate 까지 코루틴을 중지하고 대기
  • yield return new WaitForSecondsRealtime(float seconds) : 실제 시간으로 지정된 초 만큼 코루틴을 중지하고 대기
    (즉, 유니티의 TimeScale0 인 상태에서도 대기 시간이 흐른다.)
  • yield return new WaitUntil(Func<bool> predicate) : 특정 조건이 참이 될 때까지 코루틴을 중지하고 대기
  • yield return new WaitWhile(Func<bool> predicate) : 특정 조건이 거짓이 될 때까지 코루틴을 중지하고 대기
  • yield return StartCoroutine(IEnumerator routine) : 다른 코루틴이 완료될 때까지 해당 코루틴을 중지하고 대기
  • yield return new WaitForEndOfFrame() : 현재 프레임의 렌더링이 완료될 때까지 대기
  • yield break : 코루틴을 즉시 종료


IEnumerator

IEnumerator 인터페이스는 C#.NET Framework 에서 제공하는 인터페이스로 System.Collections 네임스페이스 안에 다음과 같이 정의되어 있다.

1
2
3
4
5
public interface IEnumerator {
    object Current { get; }
    bool MoveNext();
    void Reset();
}
  • Current : 현재 yield 된 값을 반환한다.
  • MoveNext() : 다음 yield 지점까지 코드를 실행한다. 더 실행할 코드가 있으면 true 를, 그렇지 않으면 false 를 반환한다.
  • Reset() : 유니티의 코루틴에서는 사용되지 않는다.

유니티는 코루틴의 진행에 해당 인터페이스를 사용한다.

코루틴이 시작되면, 유니티 엔진은 MoveNext() 메서드를 호출하여 첫 번째 yield return 까지 실행하고,

yield return 구문을 만나면 반환된 값을 Current 프로퍼티에 저장한 다음 코루틴을 중지한다.

유니티는 Current 값을 확인하여 언제 다시 MoveNext() 를 호출할지 결정한다.

1
2
3
4
5
6
7
코루틴 실행 흐름:
1. StartCoroutine(MyCoroutine()) 호출
2. 유니티가 MoveNext() 호출 → 첫 yield return까지 실행
3. Current 값 확인 (예: WaitForSeconds(2))
4. 2초 대기...
5. 유니티가 MoveNext() 호출 → 다음 yield return까지 실행
6. MoveNext()가 false 반환할 때까지 반복



코루틴의 제어

코루틴 시작하기

코루틴을 시작하기 위해서는 StartCoroutine() 메서드를 사용한다.

이 메서드는 IEnumerator 객체 또는 코루틴 함수의 이름(string)을 인수로 받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections;
using UnityEngine;

public class CoroutineExample : MonoBehaviour {
    private Coroutine runningCoroutine;

    private void Start() {
        // 방법 1: IEnumerator 직접 전달 (권장)
        runningCoroutine = StartCoroutine(WaitAndPrint());

        // 방법 2: 문자열로 전달 (비권장 - 오타 위험, 성능 저하)
        StartCoroutine("WaitAndPrint");
    }

    private IEnumerator WaitAndPrint() {
        yield return new WaitForSeconds(5);
        Debug.Log("5초 후 메시지 출력");
    }
}


StartCoroutine()Coroutine 객체를 반환하는데, 이를 저장해두면 나중에 해당 코루틴만 정확히 중지할 수 있다.

코루틴 멈추기

코루틴을 임의로 중지하기 위해서는 StopCoroutine() 메서드를 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using System.Collections;
using UnityEngine;

public class CoroutineExample : MonoBehaviour {
    private Coroutine runningCoroutine;

    private void Start() {
        runningCoroutine = StartCoroutine(WaitAndPrint());
        Invoke("StopExampleCoroutine", 2f);
    }

    private IEnumerator WaitAndPrint() {
        yield return new WaitForSeconds(5);
        Debug.Log("5초 후 메시지 출력");
    }

    private void StopExampleCoroutine() {
        // 방법 1: Coroutine 객체로 중지 (권장)
        if (runningCoroutine != null) {
            StopCoroutine(runningCoroutine);
        }

        // 방법 2: 문자열로 중지 (문자열로 시작한 경우에만)
        // StopCoroutine("WaitAndPrint");

        Debug.Log("코루틴 중지!");
    }
}


주의할 점은, IEnumerator 객체로 시작한 코루틴은 문자열로 중지할 수 없고, 그 반대도 마찬가지라는 것이다.

따라서 Coroutine 객체를 저장해두고 이를 통해 중지하는 것이 가장 확실한 방법이다.

모든 코루틴 멈추기

해당 MonoBehaviour 에서 실행 중인 모든 코루틴을 한 번에 중지하기 위해 StopAllCoroutines() 메서드를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System.Collections;
using UnityEngine;

public class CoroutineExample : MonoBehaviour {
    private void Start() {
        StartCoroutine(WaitAndPrint_1());
        StartCoroutine(WaitAndPrint_2());
        Invoke("StopAllExampleCoroutine", 2f);
    }

    private IEnumerator WaitAndPrint_1() {
        yield return new WaitForSeconds(5);
        Debug.Log("5초 후 메시지 출력");
    }

    private IEnumerator WaitAndPrint_2() {
        yield return new WaitForSeconds(10);
        Debug.Log("10초 후 메시지 출력");
    }

    private void StopAllExampleCoroutine() {
        StopAllCoroutines();
        Debug.Log("모든 코루틴 중지!");
    }
}


StopAllCoroutines() 는 해당 MonoBehaviour 컴포넌트에서 시작된 코루틴만 중지한다.

다른 게임 오브젝트나 다른 컴포넌트의 코루틴에는 영향을 주지 않는다.


코루틴과 async/await

코루틴과 async/await 는 모두 비동기 프로그래밍을 위한 도구이지만, 동작 방식에 차이가 있다.

async/await 는 스레드를 생성하지 않는다

흔히 오해하는 부분인데, async/await 는 자동으로 별도의 스레드를 생성하지 않는다.

async/await 는 비동기 프로그래밍 패턴이지, 멀티스레딩 도구가 아니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class AsyncExample : MonoBehaviour {
    async void Start() {
        Debug.Log($"1. 시작 - Thread: {Thread.CurrentThread.ManagedThreadId}");

        await Task.Delay(1000);  // 1초 대기

        // Unity에서는 여전히 메인 스레드!
        Debug.Log($"2. 1초 후 - Thread: {Thread.CurrentThread.ManagedThreadId}");

        // Unity API 사용 가능
        transform.position = Vector3.zero;
    }
}

// 출력:
// 1. 시작 - Thread: 1
// 2. 1초 후 - Thread: 1


Unity 에서 async/await 를 사용하면, await 이후에도 기본적으로 메인 스레드로 돌아온다.

이는 Unity 의 UnitySynchronizationContextawait 이후 메인 스레드에서 실행을 재개하도록 보장하기 때문이다.

별도 스레드를 사용하려면 명시적으로 요청해야 한다

Task.Run() 을 사용하면 명시적으로 스레드 풀의 스레드에서 작업을 실행할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class ThreadExample : MonoBehaviour {
    async void Start() {
        Debug.Log($"1. 메인 스레드: {Thread.CurrentThread.ManagedThreadId}");

        int result = await Task.Run(() => {
            // 이 부분은 스레드 풀의 별도 스레드에서 실행
            Debug.Log($"2. 워커 스레드: {Thread.CurrentThread.ManagedThreadId}");
            return HeavyCalculation();
        });

        // Unity에서는 다시 메인 스레드로 돌아옴
        Debug.Log($"3. 다시 메인 스레드: {Thread.CurrentThread.ManagedThreadId}");

        // Unity API 사용 가능
        Debug.Log($"계산 결과: {result}");
    }

    private int HeavyCalculation() {
        // 무거운 연산...
        return 42;
    }
}

// 출력:
// 1. 메인 스레드: 1
// 2. 워커 스레드: 7  (다른 스레드!)
// 3. 다시 메인 스레드: 1


Task.Run() 내부에서는 Unity API 를 호출하면 안 된다.

Unity API 는 메인 스레드에서만 안전하게 호출할 수 있기 때문이다.

코루틴 vs async/await 비교

| 항목 | 코루틴 | async/await | |——|——–|————-| | 실행 스레드 | 항상 메인 스레드 | 기본적으로 메인 스레드 (Task.Run 사용 시 별도 스레드) | | Unity API 사용 | 항상 가능 | await 이후 가능 (Task.Run 내부에서는 불가) | | 프레임 기반 대기 | yield return null 등 직관적 | await Task.Yield() (권장되지 않음) | | 시간 기반 대기 | WaitForSeconds (TimeScale 영향) | Task.Delay (실제 시간) | | 반환값 | 불가 (IEnumerator) | 가능 (Task<T>) | | 예외 처리 | 어려움 (예외 시 중단) | try-catch 사용 가능 | | 취소 | StopCoroutine | CancellationToken | | GC 할당 | YieldInstruction 생성 시 발생 | Task 생성 시 발생 (UniTask 사용 시 최소화) |

UniTask: Unity 에 최적화된 async/await

표준 Task 는 Unity 에 최적화되어 있지 않아 GC 할당이 발생하고, WaitForSeconds 같은 Unity 전용 대기를 지원하지 않는다.

UniTask 는 이러한 문제를 해결한 Unity 전용 비동기 라이브러리이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using Cysharp.Threading.Tasks;

public class UniTaskExample : MonoBehaviour {
    async UniTaskVoid Start() {
        // Unity의 시간 시스템과 연동
        await UniTask.Delay(1000);  // 1초 대기

        // WaitForSeconds처럼 TimeScale 영향 받음
        await UniTask.Delay(TimeSpan.FromSeconds(1), DelayType.DeltaTime);

        // 프레임 대기
        await UniTask.Yield();
        await UniTask.NextFrame();

        // 조건 대기
        await UniTask.WaitUntil(() => someCondition);
    }
}


UniTask 는 코루틴의 편리함과 async/await 의 장점을 모두 제공하므로, UniTask 사용을 고려해볼 만 하다.


언제 무엇을 사용해야 하는가?

코루틴이 적합한 경우

  • 간단한 시간 기반 로직 (페이드 인/아웃, 연출 등)
  • Unity 의 프레임 사이클과 밀접한 작업
  • 기존 코드베이스가 코루틴 기반인 경우

async/await 가 적합한 경우

  • 반환값이 필요한 비동기 작업
  • 외부 API 호출, 파일 I/O 등 진정한 비동기 작업
  • 복잡한 예외 처리가 필요한 경우
  • 병렬 처리가 필요한 경우 (Task.Run 활용)

진정한 병렬 처리가 필요한 경우

CPU 집약적인 작업을 백그라운드에서 처리해야 한다면 Task.Run 을 사용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using System.Threading.Tasks;

async void ProcessLargeData() {
    // 무거운 연산을 백그라운드 스레드에서 실행
    var result = await Task.Run(() => {
        // 이 블록은 별도 스레드에서 실행됨
        // Unity API 호출 금지!
        return PerformHeavyCalculation();
    });

    // 메인 스레드로 돌아와서 결과 사용
    Debug.Log($"결과: {result}");
    UpdateUI(result);
}



결론

코루틴은 단일 스레드에서 동작하는 비동기 패턴이다.

병렬 처리가 아니므로 무거운 연산을 코루틴에서 실행하면 게임이 버벅거린다.

async/await 도 기본적으로는 메인 스레드에서 실행되며, Task.Run() 을 명시적으로 사용해야만 별도 스레드에서 작업이 실행된다.

각 도구의 특성을 정확히 이해하고, 상황에 맞게 적절히 사용하는 것이 중요하다.


참고 자료

Tags: C#, Coroutine, Unity, 게임 개발, 비동기, 코루틴

Categories: ,