게임 속 NPC가 동시에 살아 움직여 보이는 이유, Unity 코루틴을 다시 정리해보자

게임 속 논플레이어 캐릭터(NPC)가 제각기 다른 시간표대로 움직이는 장면을 만들고 싶을 때 많은 입문자가 비슷한 고민을 한다. “무기상인은 가게를 열고, 경비병은 순찰하고, 도둑은 밤에 움직이게 하려면 멀티스레드를 써야 하나?” Unity에서는 꼭 그렇지 않다. 대부분의 경우 코루틴(coroutine)으로도 충분하다.
다만 코루틴을 “가벼운 스레드”처럼 이해하면 금방 헷갈린다. Unity 공식 문서도 코루틴은 스레드가 아니며, 동기 함수처럼 보이는 흐름을 여러 프레임에 걸쳐 나눠 실행하는 방식이라고 설명한다. 즉, 코루틴은 동시에 여러 일을 진짜 병렬로 처리하는 구조가 아니라 메인 스레드 안에서 시간을 나눠 쓰는 구조에 가깝다.
코루틴이 해결하는 문제
게임 로직에는 이런 종류의 일이 많다.
- 몇 초 기다렸다가 문을 닫기
- 목적지에 도착할 때까지 이동하기
- 대사 한 줄이 끝나면 다음 연출 실행하기
- 조건이 만족될 때까지 대기하기
이런 흐름을 전부 Update() 안에서 상태 플래그와 타이머로만 처리하면 코드가 금방 흩어진다. 코루틴의 장점은 순차적인 이야기를 코드 순서 그대로 표현하기 쉽다는 데 있다.
IEnumerator DailyRoutine()
{
yield return new WaitUntil(() => GameTime.Hour >= 6);
yield return MoveTo(shopPosition);
OpenShop();
yield return new WaitUntil(() => GameTime.Hour >= 17);
CloseShop();
yield return MoveTo(homePosition);
}
이 코드는 “기다리고, 이동하고, 열고, 다시 기다리고, 닫고, 돌아간다”는 흐름을 그대로 보여준다. 이것이 코루틴의 가장 큰 장점이다.
코루틴은 무엇을 해결하지 못하나
코루틴이 있다고 해서 무거운 계산이 공짜로 빨라지는 것은 아니다. Unity 문서도 코루틴 코드가 CPU 시간상으로는 일반 코드와 다르지 않다고 설명한다. 즉, 코루틴 안에서 경로 탐색이나 큰 데이터 처리를 오래 돌리면 결국 같은 프레임 드랍이 발생한다.
정리하면 이렇다.
- 코루틴은
시간을 나눠 표현하는 데 강하다 - 코루틴은
무거운 계산을 병렬 처리하는 도구는 아니다
그래서 코루틴은 연출, 대기, 이동, 단계적 처리에는 잘 맞지만, 진짜 비동기 I/O나 병렬 계산이 필요한 문제는 다른 도구와 함께 봐야 한다.
yield return은 재개 시점을 정하는 장치다
코루틴에서 중요한 것은 yield return 뒤에 무엇을 두느냐다. Unity에서는 그것이 언제 다시 실행될지를 결정한다.
-
yield return null다음 프레임까지 기다린다. -
yield return new WaitForSeconds(2f)대략 2초 뒤 다시 실행한다. -
yield return new WaitUntil(() => condition)조건이 참이 될 때까지 매 프레임 검사하며 기다린다. -
yield return new WaitForFixedUpdate()다음 물리 업데이트 이후에 재개된다. -
yield return new WaitForEndOfFrame()현재 프레임의 렌더링이 끝난 뒤 재개된다.
즉, 코루틴은 “중간에 멈출 수 있는 함수”이기도 하지만, 더 정확히는 실행 타이밍을 표현하는 문법에 가깝다.
FSM과 코루틴은 경쟁 관계가 아니라 역할이 다르다
FSM은 상태 전환 규칙이 중요한 경우에 강하다. 예를 들어 캐릭터가 Idle, Run, Jump, Fall, Attack처럼 여러 상태를 오가고, 어느 상태에서든 다른 상태로 갈 수 있다면 FSM이 더 명시적이다.
반대로 코루틴은 이런 흐름에 잘 맞는다.
- A를 하고
- 끝나면 B를 하고
- 그다음 C를 한다
실무에서는 둘을 같이 쓰는 경우가 많다. 큰 상태 전환은 FSM으로 관리하고, 각 상태 안의 순차적 행동은 코루틴으로 처리하는 방식이다.
코루틴을 쓸 때 자주 놓치는 점
1. yield 없는 루프는 멈추지 않는다
코루틴 안에서도 while (true) 루프에 yield return이 없으면 메인 스레드를 계속 붙잡는다. 코루틴이라고 해서 자동으로 안전해지는 것은 아니다.
2. 생명주기와 함께 끊길 수 있다
Unity 문서에 따르면 코루틴은 GameObject.SetActive(false)로 오브젝트가 비활성화되거나, 해당 MonoBehaviour가 파괴되면 중단된다. 반면 MonoBehaviour.enabled = false만으로는 코루틴이 자동으로 멈추지 않는다. 이 차이를 모르면 왜 어떤 코루틴은 끊기고 어떤 코루틴은 계속 도는지 헷갈리기 쉽다.
3. 매 프레임 무거운 일을 넣으면 결국 느리다
코루틴은 타이밍을 나누는 구조이지 계산량을 없애 주는 구조가 아니다. 무거운 루프는 적절히 쪼개거나 다른 방식으로 처리해야 한다.
4. 무조건 코루틴이 좋은 것은 아니다
Unity의 성능 관련 글도 말하듯, 거의 매 프레임 계속 도는 단순 로직이라면 오히려 Update()가 더 읽기 쉬운 경우가 있다. “시간을 표현해야 하는가”가 코루틴 선택 기준에 더 가깝다.
실무에서 코루틴이 특히 편한 경우
- 보스 등장 연출
- 웨이브 기반 적 생성
- 대사 시퀀스
- 문 열림/닫힘 같은 순차 이벤트
- 일정 시간 간격으로 반복되는 행동
이런 상황에서는 상태 플래그 여러 개를 흩뿌리는 것보다 코루틴이 더 읽히는 경우가 많다.
핵심 정리
Unity 코루틴은 멀티스레드의 대체물이 아니다. 메인 스레드 안에서 순차적 흐름을 여러 프레임에 걸쳐 나눠 실행하게 해주는 구조다. 그래서 연출, 대기, 이동, 단계적 행동 같은 문제를 표현하는 데 강하다. 반면 무거운 계산을 병렬로 빠르게 돌리는 용도는 아니다.
FSM은 상태 전환 규칙에, 코루틴은 순차적 흐름 표현에 더 강하며, 실제로는 둘을 함께 쓰는 경우가 많다. 무엇보다 중요한 것은 yield return이 재개 타이밍을 정한다는 점과, 코루틴도 오브젝트 생명주기의 영향을 받는다는 점이다.
마치며
코루틴을 이해하면 게임 로직을 바라보는 시선이 조금 달라진다. NPC가 동시에 살아 움직이는 것처럼 보이는 장면도, 사실은 병렬 처리보다 시간을 잘게 나눈 표현일 때가 많다.
복잡한 시스템을 처음부터 멀티스레드로 풀려 하기 전에, 이 흐름이 정말 코루틴으로도 충분한 문제인지부터 먼저 보는 편이 더 실용적이다.
참고 자료
- Unity Manual, Splitting tasks across frames: https://docs.unity3d.com/Manual/Coroutines.html
- Unity Scripting API, MonoBehaviour.StartCoroutine: https://docs.unity3d.com/ScriptReference/MonoBehaviour.StartCoroutine.html
- Unity Scripting API, WaitForSeconds: https://docs.unity3d.com/ScriptReference/WaitForSeconds.html
- Unity Manual, Order of execution for event functions: https://docs.unity3d.com/Manual/ExecutionOrder.html