Unity의 비동기 프로그래밍: 코루틴, 스레드, 그리고 최신 대안들
들어가며
얼마 전, 모 회사에서 "Unity Engine의 코루틴과 스레드의 차이점이 무엇인가?"에 대한 질문을 받았고, 제대로 대답하지 못하여 부끄러웠던 경험이 있었다. 때문에, 이 글을 바탕으로 이 둘의 공통점과 차이점 그리고 최신 대안들까지에 대한 것들을 정리해보고자 한다.
Unity Engine은 비동기 프로그래밍을 위한 여러 접근 방식을 제공하며, 각각은 고유한 기술적 구현과 성능 특성을 가진다. 코루틴은 협력적 멀티태스킹을 사용하여 메인 스레드에서 실행되는 반면[^1], 스레드는 진정한 병렬성을 제공하지만 Unity API에 접근할 수 없다[^2]. 최신 Job System과 Burst 컴파일러는 두 방식의 장점을 결합하여 기존 접근법보다 10배에서 100배의 성능 향상을 달성하는 안전한 멀티스레딩을 제공한다[^3]. Unity 2023.1 릴리스는 Awaitable 클래스를 통한 네이티브 async/await 지원을 도입하여 Unity 특화 최적화를 유지하면서 비동기 패턴을 현대화했다[^4].
코루틴과 스레드를 쉽게 이해하기
게임 개발을 요리에 비유해보자. 코루틴은 혼자서 여러 요리를 번갈아가며 만드는 요리사와 같다. 파스타 면을 삶는 동안(yield) 소스를 만들고, 소스가 끓는 동안(yield) 샐러드를 준비한다. 모든 일을 혼자 하지만, 시간을 효율적으로 사용한다.
반면 스레드는 여러 요리사가 동시에 일하는 주방과 같다. 한 명은 파스타를, 다른 한 명은 소스를, 또 다른 한 명은 샐러드를 동시에 만든다. 더 빠르지만, 서로 부딪치지 않도록 조심해야 한다.
코루틴은 진정한 스레딩이 아닌 C#의 반복자 패턴으로 작동한다
Unity 코루틴은 스레드와 근본적으로 다르다. 병렬로 실행되는 대신 메인 스레드에서 실행되며 자발적으로 실행을 일시 중지한다[^5]. C# 컴파일러는 코루틴 메서드를 Unity의 내부 스케줄러가 관리하는 상태 머신 클래스로 변환한다.
// 개발자가 작성하는 코드
IEnumerator MyCoroutine()
{
Debug.Log("시작");
yield return null; // 일시 중지 지점 - 다음 프레임에 재개
Debug.Log("다음 프레임");
}
// 실제로 컴파일러가 생성하는 코드 (간소화 버전)
private sealed class MyCoroutine_StateMachine : IEnumerator
{
private int state = 0; // 현재 실행 위치
private object current; // 현재 yield 값
public bool MoveNext()
{
switch (state)
{
case 0: // 첫 번째 실행
Debug.Log("시작");
current = null;
state = 1;
return true; // 아직 끝나지 않음
case 1: // 두 번째 실행
Debug.Log("다음 프레임");
return false; // 완료
}
return false;
}
public object Current => current;
public void Reset() { }
}
이 State Machine 패턴은 마치 책갈피를 끼워놓고 나중에 이어서 읽는 것과 같다. Unity는 매 프레임마다 "이 코루틴 이어서 실행할 차례인가?"를 확인하고, 조건이 맞으면 다음 부분을 실행한다[^6].
YieldInstruction의 다양한 종류와 실행 타이밍
Unity는 다양한 YieldInstruction을 제공하여 코루틴의 재개 시점을 제어한다.
IEnumerator YieldInstructionExamples()
{
// 1. 다음 Update() 호출 후 재개
yield return null;
// 2. 모든 Update() 호출이 끝난 후 재개
yield return new WaitForEndOfFrame();
// 3. 다음 FixedUpdate() 호출 후 재개
yield return new WaitForFixedUpdate();
// 4. 지정된 시간(초) 후 재개
yield return new WaitForSeconds(2.0f);
// 5. 실제 시간 기준으로 대기 (Time.timeScale 영향 없음)
yield return new WaitForSecondsRealtime(2.0f);
// 6. 조건이 true가 될 때까지 대기
yield return new WaitUntil(() => isReady);
// 7. 조건이 false가 될 때까지 대기
yield return new WaitWhile(() => isLoading);
// 8. 다른 코루틴이 완료될 때까지 대기
yield return StartCoroutine(OtherCoroutine());
// 9. 비동기 작업이 완료될 때까지 대기
yield return asyncOperation;
}
모든 코루틴은 힙 할당을 생성하여 성능에 영향을 미친다[^7]. 상태 머신 객체는 호이스팅된 지역 변수, 실행 상태, 매개변수를 포함한다. 성능 연구에 따르면 1,000개의 코루틴을 시작하면 6ms의 시작 오버헤드가 발생하고 프레임 속도가 650 FPS에서 90 FPS로 떨어진다[^8]. 각 yield 문은 약 37바이트를 할당하여 코루틴을 많이 사용하는 애플리케이션에서 가비지 컬렉션 압력을 일으킨다.
Unity의 스레딩 아키텍처는 메인 스레드 안전성을 중심으로 한다
Unity는 근본적인 제약 하에 작동한다. 오직 메인 스레드만이 Unity 객체와 대부분의 API 메서드에 안전하게 접근할 수 있다[^9]. 이는 마치 운전대는 운전석에서만 조작할 수 있는 것과 같다. 다른 좌석의 사람이 운전대를 잡으려 하면 사고가 날 수 있다.
협력적 멀티태스킹 vs 선점형 멀티태스킹
협력적 멀티태스킹 (코루틴)은 학급에서 발표 순서를 정해놓고 차례대로 하는 것과 같다. 한 학생이 "저는 여기까지 하고 다음 친구에게 넘기겠습니다"라고 자발적으로 양보한다.
선점형 멀티태스킹 (스레드)은 선생님이 타이머를 맞춰놓고 "5분씩만 발표하세요"라고 강제로 순서를 바꾸는 것과 같다. 발표자가 원하든 원하지 않든 시간이 되면 다음 사람 차례가 된다[^10].
스레드 안전성과 동기화
워커 스레드와 메인 스레드 간의 안전한 통신을 위해서는 특별한 패턴이 필요하다.
public class ThreadSafeExample : MonoBehaviour
{
// Thread-safe한 큐 사용
private ConcurrentQueue<Action> mainThreadActions = new ConcurrentQueue<Action>();
private Thread workerThread;
void Start()
{
workerThread = new Thread(BackgroundWork);
workerThread.Start();
}
void BackgroundWork()
{
// 무거운 계산 수행
int result = ComplexCalculation();
// 메인 스레드에서 실행할 작업을 큐에 추가
mainThreadActions.Enqueue(() => {
// 이 부분은 메인 스레드에서 실행됨
GetComponent<Text>().text = $"결과: {result}";
});
}
void Update()
{
// 메인 스레드에서 큐의 작업들을 처리
while (mainThreadActions.TryDequeue(out Action action))
{
action.Invoke();
}
}
}
스레드 안전 Unity 타입에는 수학적 구조체들이 포함된다[^11]. Vector3, Quaternion, Color 같은 구조체와 Mathf 클래스가 이에 해당한다. 이들은 값 타입(Value Type)이므로 복사본을 만들어 사용하기 때문에 안전하다.
메모리 사용 패턴의 차이
코루틴과 스레드는 메모리를 사용하는 방식이 완전히 다르다.
코루틴의 힙(Heap) 메모리 사용
코루틴은 마치 박스에 물건을 담아 창고에 보관하는 것과 같다. State Machine 객체가 힙 메모리에 생성되어 필요한 정보를 저장한다. 이 박스는 나중에 가비지 컬렉터가 정리한다[^12].
스레드의 스택(Stack) 메모리 사용
스레드는 마치 각자의 책상을 가지는 것과 같다. 각 스레드는 기본적으로 1MB의 스택 공간을 할당받는다. 이는 코루틴보다 훨씬 많은 메모리지만, 스레드가 끝나면 자동으로 정리된다[^13].
// 메모리 사용량 비교 예시
public class MemoryComparison : MonoBehaviour
{
// 코루틴: 작은 힙 할당 (수십~수백 바이트)
IEnumerator CoroutineExample()
{
int localVar = 10; // State Machine에 저장됨
yield return null;
Debug.Log(localVar); // 다음 프레임에도 값 유지
}
// 스레드: 큰 스택 할당 (기본 1MB)
void ThreadExample()
{
Thread thread = new Thread(() => {
int localVar = 10; // 스택에 저장됨
// 스레드 종료 시 자동 정리
});
thread.Start();
}
}
성능 벤치마크는 명확한 사용 사례 경계를 보여준다
종합적인 성능 연구는 각 접근법의 명확한 성능 특성을 보여준다. Jackson Dunstan의 코루틴 벤치마크에 따르면 10,000개의 동시 코루틴은 75ms의 시작 오버헤드를 생성하고 121 FPS로 실행되며, 100,000개의 코루틴은 814ms의 시작 시간과 함께 8.2 FPS로 애플리케이션을 거의 정지시킨다[^14].
실제 게임 시나리오별 선택 가이드
시나리오 | 추천 방식 | 이유 |
---|---|---|
UI 페이드 효과 | Coroutine | Unity API 직접 접근 필요 |
대규모 경로 찾기 | Thread/Job System | CPU 집약적 계산 |
네트워크 요청 대기 | Coroutine | 간단한 비동기 대기 |
이미지 압축/해제 | Thread | Unity API 불필요한 무거운 작업 |
파티클 위치 업데이트 | Job System + Burst | 대량 데이터 병렬 처리 |
Thread Pool을 활용한 효율적인 스레드 관리
매번 새 스레드를 생성하는 대신 Thread Pool을 사용하면 성능을 크게 향상시킬 수 있다.
public class ThreadPoolExample : MonoBehaviour
{
void Start()
{
// 방법 1: ThreadPool 직접 사용
ThreadPool.QueueUserWorkItem(BackgroundWork);
// 방법 2: Task 사용 (내부적으로 ThreadPool 활용)
Task.Run(() => {
// 백그라운드 작업
int result = HeavyCalculation();
// 메인 스레드로 결과 전달
UnityMainThreadDispatcher.Instance.Enqueue(() => {
Debug.Log($"결과: {result}");
});
});
}
void BackgroundWork(object state)
{
// ThreadPool의 스레드에서 실행됨
Debug.Log($"스레드 ID: {Thread.CurrentThread.ManagedThreadId}");
}
}
Thread Pool은 마치 택시 승강장과 같다. 새 택시(스레드)를 매번 부르는 대신, 이미 대기 중인 택시를 바로 탈 수 있어 효율적이다[^15].
동기화 메커니즘의 구체적 사용법
여러 스레드가 동시에 같은 데이터에 접근할 때는 동기화가 필수다.
public class SynchronizationExample : MonoBehaviour
{
private int sharedCounter = 0;
private readonly object lockObject = new object();
private Mutex mutex = new Mutex();
private Semaphore semaphore = new Semaphore(3, 3); // 최대 3개 스레드 동시 접근
// 1. lock 사용 (가장 간단)
void IncrementWithLock()
{
lock (lockObject)
{
sharedCounter++; // 한 번에 하나의 스레드만 실행
}
}
// 2. Mutex 사용 (프로세스 간 동기화 가능)
void IncrementWithMutex()
{
mutex.WaitOne();
try
{
sharedCounter++;
}
finally
{
mutex.ReleaseMutex();
}
}
// 3. Semaphore 사용 (동시 접근 스레드 수 제한)
void AccessLimitedResource()
{
semaphore.WaitOne();
try
{
// 최대 3개 스레드만 동시에 이 영역 실행 가능
PerformLimitedWork();
}
finally
{
semaphore.Release();
}
}
}
동기화는 마치 화장실 문 잠금장치와 같다. lock은 일반적인 잠금장치, Mutex는 여러 층에서 공유하는 화장실의 잠금장치, Semaphore는 여러 칸이 있는 공중화장실의 입장 제한 시스템과 같다[^16].
최신 Unity는 전통적인 패턴에 대한 강력한 대안을 제공한다
2018.1에 도입된 Unity의 Job System은 최소한의 오버헤드로 안전한 병렬성을 제공하여 멀티스레드 프로그래밍에 혁명을 일으켰다[^17]. Job은 Unity가 관리하는 워커 스레드에서 실행되며, 자동 종속성 추적이 경쟁 조건을 방지한다.
// Job System을 사용한 대량 데이터 처리
[BurstCompile]
public struct WaveUpdateJob : IJobParallelFor
{
[ReadOnly] public float time;
public NativeArray<float3> vertices;
public float waveHeight;
public float waveFrequency;
public void Execute(int index)
{
float3 vertex = vertices[index];
vertex.y = math.sin(vertex.x * waveFrequency + time) * waveHeight;
vertices[index] = vertex;
}
}
public class WaveController : MonoBehaviour
{
void Update()
{
var job = new WaveUpdateJob
{
time = Time.time,
vertices = vertexArray,
waveHeight = 2f,
waveFrequency = 0.5f
};
// 100개씩 배치로 처리
JobHandle handle = job.Schedule(vertexArray.Length, 100);
handle.Complete();
}
}
Burst 컴파일러는 이 C# 코드를 LLVM을 사용하여 고도로 최적화된 네이티브 머신 코드로 변환한다. SIMD 벡터화와 캐시 친화적인 데이터 레이아웃을 통해 10배에서 100배의 성능 향상을 달성한다[^18].
Unity 2023.1은 Unity의 실행 모델을 위해 특별히 설계된 Awaitable 클래스를 통해 네이티브 async/await 지원을 도입했다.
public class ModernAsyncExample : MonoBehaviour
{
async Awaitable LoadAndProcessSceneAsync()
{
// 씬 로드를 비동기로 시작
var loadOperation = SceneManager.LoadSceneAsync("GameScene");
// 로드 완료 대기
await loadOperation;
// 한 프레임 대기
await Awaitable.NextFrameAsync();
// 백그라운드 스레드로 전환
await Awaitable.BackgroundThreadAsync();
// CPU 집약적 작업 수행
var processedData = ProcessLargeDataset();
// 메인 스레드로 복귀
await Awaitable.MainThreadAsync();
// Unity API 사용
ApplyProcessedData(processedData);
}
}
표준 .NET Task와 달리, Unity의 Awaitable은 항상 메인 스레드에서 완료되며 효율성을 위해 풀링된 인스턴스를 사용한다[^19].
실제 구현은 사용 사례에 맞는 기법을 요구한다
다양한 시나리오는 기술적 요구사항과 성능 필요에 따라 특정 접근법을 요구한다.
프레임 분할 처리 패턴
대량의 오브젝트를 처리할 때 코루틴으로 프레임에 걸쳐 작업을 분산시킬 수 있다.
public class FrameDistributedProcessor : MonoBehaviour
{
[SerializeField] private int itemsPerFrame = 100;
private List<GameObject> objectsToProcess = new List<GameObject>();
IEnumerator ProcessObjectsOverFrames()
{
int totalObjects = objectsToProcess.Count;
int currentIndex = 0;
while (currentIndex < totalObjects)
{
// 이번 프레임에 처리할 개수 계산
int endIndex = Mathf.Min(currentIndex + itemsPerFrame, totalObjects);
// 지정된 개수만큼 처리
for (int i = currentIndex; i < endIndex; i++)
{
ProcessSingleObject(objectsToProcess[i]);
}
currentIndex = endIndex;
// 진행률 표시
float progress = (float)currentIndex / totalObjects;
UpdateProgressBar(progress);
// 다음 프레임까지 대기
yield return null;
}
Debug.Log("모든 오브젝트 처리 완료!");
}
}
이 패턴은 마치 큰 숙제를 여러 날에 걸쳐 조금씩 하는 것과 같다. 한 번에 다 하면 힘들지만, 나눠서 하면 부담이 적다[^20].
베스트 프랙티스는 일반적인 함정을 피하면서 성능을 극대화한다
코루틴 최적화 체크리스트
- YieldInstruction 캐싱
// 나쁜 예 - 매번 새 객체 생성 IEnumerator BadExample() { while (true) { yield return new WaitForSeconds(1.0f); // 매번 37바이트 할당! } } // 좋은 예 - 캐싱된 객체 재사용 private readonly WaitForSeconds oneSecond = new WaitForSeconds(1.0f); IEnumerator GoodExample() { while (true) { yield return oneSecond; // 할당 없음! } }
- 코루틴 풀링 패턴
public class CoroutinePool : MonoBehaviour { private Queue<IEnumerator> coroutinePool = new Queue<IEnumerator>(); public void StartPooledCoroutine(System.Action action, float delay) { IEnumerator coroutine; if (coroutinePool.Count > 0) { coroutine = coroutinePool.Dequeue(); } else { coroutine = PooledCoroutine(action, delay); } StartCoroutine(coroutine); } IEnumerator PooledCoroutine(System.Action action, float delay) { yield return new WaitForSeconds(delay); action?.Invoke(); coroutinePool.Enqueue(this); // 재사용을 위해 풀에 반환 } }
스레드 안전성 체크리스트
피해야 할 일반적인 안티패턴
- ❌ 매 프레임 코루틴 시작 (GC 압력 유발)
- ❌ 스레드에서 Unity 객체 접근 (예외 발생)
- ❌ CPU 코어보다 많은 스레드 생성 (성능 저하)
- ❌ 무한 코루틴 중지 잊어버리기 (메모리 누수)
Unity Profiler의 Deep Profiling 모드는 "DelayedCallManager" 섹션에서 코루틴 오버헤드를 보여주고 Timeline 뷰에서 스레드 경합을 표시하여 이러한 문제를 식별하는 데 도움이 된다[^21].
실무에서의 선택 기준 정리
간단한 결정 플로우차트
작업이 Unity API를 사용하나요?
├─ 예 → 프레임 기반 타이밍이 필요한가요?
│ ├─ 예 → Coroutine 사용
│ └─ 아니오 → async/await (Unity 2023.1+) 사용
└─ 아니오 → 대량의 데이터 병렬 처리인가요?
├─ 예 → Job System + Burst 사용
└─ 아니오 → Thread 또는 Task 사용
마치며
Unity의 비동기 프로그래밍 환경은 다양한 시나리오를 위한 특화된 도구를 제공한다. 코루틴은 Unity의 프레임 루프와 통합된 읽기 쉽고 라이프사이클을 인식하는 타이밍 작업을 제공하지만 메모리 할당 오버헤드로 고통받는다. 전통적인 스레딩은 CPU 집약적 작업을 위한 진정한 병렬성을 가능하게 하지만 Unity API로부터의 신중한 격리가 필요하다. Burst 컴파일러를 사용한 Job System은 안전성을 유지하면서 데이터 병렬 작업에 탁월한 성능을 제공한다. 새로운 async/await 지원은 Unity 특화 최적화와 함께 비동기 패턴을 현대화한다.
1,000개 미만의 동시 작업을 포함하는 간단한 프레임 기반 로직에는 코루틴을 선택하라. 성능이 중요한 병렬 처리에는 Job System을 사용하라. 현대적인 비동기 I/O와 복잡한 작업 체인에는 async/await를 구현하라. 이러한 시스템의 기술적 구현과 제약을 이해하면 개발자는 각 특정 사용 사례에 최적의 접근법을 선택할 수 있으며, 높은 성능과 유지 관리 가능한 코드 아키텍처를 모두 달성할 수 있다.
마치 요리사가 상황에 따라 다른 도구를 사용하듯, Unity 개발자도 각 도구의 특성을 이해하고 적재적소에 활용해야 한다. 코루틴은 간단하고 안전한 만능 칼, 스레드는 강력하지만 조심해야 하는 전기톱, Job System은 최신식 푸드 프로세서와 같다. 각 도구를 올바르게 사용하면 훌륭한 게임을 만들 수 있다.
참고 문헌
[^1]: Unity - Manual: Splitting tasks across frames
[^2]: Using threads in Unity Engine | University of Games
[^3]: Unity Job System and Burst Compiler: Getting Started | Kodeco
[^4]: Unity - Manual: Await support
[^5]: Unity: What is a Coroutine and why is there an IEnumerator
[^6]: Unity - Manual: Coroutines
[^7]: JacksonDunstan.com | Unity Coroutine Performance
[^8]: Unity Coroutine Performance Benchmarks
[^9]: Unity - Manual: What is multithreading?
[^10]: Difference between a "coroutine" and a "thread"?
[^11]: Why should I use Threads instead of Coroutines? – Unity
[^12]: Unity - Manual: Coroutines
[^13]: Thread safety in Unity
[^14]: JacksonDunstan.com | Unity Coroutine Performance
[^15]: Unity Discussions: Jobs vs Tasks vs Threading vs Co-Routines
[^16]: Medium: Unity Coroutines — Don't Thread on Me
[^17]: Unity - Manual: Job system overview
[^18]: Unity Job System and Burst Compiler: Getting Started | Kodeco
[^19]: Unity - Manual: Await support
[^20]: Mastering Coroutine Execution: Yielding, Flow, and Practical Use Cases in Unity
[^21]: Unity - Manual: Analyzing coroutines