Services → Blog → Portfolio → About → Contact →
← 블로그 목록

게임 속 NPC가 동시에 살아 움직이는 비밀, 코루틴의 모든 것

마을의 무기상인은 아침에 문을 열고, 도둑은 밤에 움직이고, 경비병은 순찰을 돈다. 이 모든 NPC가 동시에 살아 움직이려면 어떻게 해야 할까? 코루틴이 게임 로직에서 어떤 문제를 해결하는지, FSM과는 어떻게 다른지, 실전에서 주의할 점은 무엇인지를 정리한다.

게임 속 NPC가 동시에 살아 움직이는 비밀, 코루틴의 모든 것

게임 속 NPC가 동시에 살아 움직이는 비밀, 코루틴의 모든 것

첫 번째 게임 프로젝트에서 마을을 만들 때였다. 무기상인은 아침 6시에 상점을 열고, 정오에 점심을 먹고, 저녁 5시에 가게를 닫고 술집으로 간다. 밤 10시에는 집으로 돌아가 잠든다. 이런 일상을 코드로 옮기려 했더니, 무기상인의 행동 하나를 처리하는 동안 마을의 다른 NPC가 전부 멈춰버렸다. 경비병은 순찰을 멈추고, 대장장이는 망치를 든 채 얼어붙었다. 문제는 내가 모든 NPC의 로직을 한 프레임 안에서 순차적으로 처리하고 있었다는 것이다. 그때 처음 코루틴이라는 개념을 접했고, 게임 로직을 바라보는 시선이 완전히 바뀌었다.


왜 코루틴이 필요한가: 게임 루프의 본질적 제약

게임은 매 프레임마다 Update 함수를 호출해 세상을 갱신한다. 문제는 이 Update가 메인 스레드에서 순차적으로 실행된다는 것이다. NPC 10명이 각각 길찾기를 수행하고, 상태를 판단하고, 애니메이션을 전환해야 한다면, 한 NPC의 로직이 오래 걸릴수록 나머지는 기다려야 한다.

멀티스레드로 해결하면 되지 않느냐고 생각할 수 있다. 실제로 C++이나 Java, C# 모두 멀티스레드를 지원한다. 하지만 게임에서 멀티스레드를 함부로 쓰면 더 큰 문제가 생긴다. Unity를 예로 들면, Transform, GameObject 같은 엔진 API는 메인 스레드에서만 접근 가능하다. 별도 스레드에서 NPC 위치를 바꾸려 하면 런타임 에러가 발생한다. 스레드 간 데이터 동기화 문제, 레이스 컨디션, 디버깅 난이도까지 고려하면 NPC 행동 로직에 스레드를 직접 쓰는 것은 현실적이지 않다.

코루틴은 이 딜레마를 우아하게 해결한다. 메인 스레드 안에서 실행되면서도, 함수 실행을 중간에 멈추고 다음 프레임에 이어서 실행할 수 있다. 한 NPC가 “나는 여기까지 했으니 다음 프레임에 계속할게”라고 양보하면, 그 사이에 다른 NPC의 로직이 돌아간다. 스레드 동기화 걱정 없이, 마치 여러 NPC가 동시에 움직이는 것처럼 보이게 만드는 것이다.


FSM으로 충분하지 않은가: 상태 머신의 한계

코루틴을 이야기하기 전에, 게임 AI의 전통적 접근법인 유한 상태 머신(FSM)을 먼저 짚어보자.

무기상인의 하루를 FSM으로 구현하면 이렇게 된다.

public class MerchantFSM : MonoBehaviour
{
    enum State { Sleeping, GoingToShop, Working, GoingToPub, Drinking, GoingHome }
    State currentState = State.Sleeping;

    void Update()
    {
        switch (currentState)
        {
            case State.Sleeping:
                if (GameTime.Hour >= 6) currentState = State.GoingToShop;
                break;
            case State.GoingToShop:
                MoveToward(shopPosition);
                if (ArrivedAt(shopPosition)) currentState = State.Working;
                break;
            case State.Working:
                if (GameTime.Hour >= 17) currentState = State.GoingToPub;
                break;
            case State.GoingToPub:
                MoveToward(pubPosition);
                if (ArrivedAt(pubPosition)) currentState = State.Drinking;
                break;
            case State.Drinking:
                if (GameTime.Hour >= 22) currentState = State.GoingHome;
                break;
            case State.GoingHome:
                MoveToward(homePosition);
                if (ArrivedAt(homePosition)) currentState = State.Sleeping;
                break;
        }
    }
}

동작은 하지만, 몇 가지 불편함이 있다.

첫째, 행동의 흐름이 한눈에 보이지 않는다. “아침에 일어나서 상점에 가고, 일하고, 술집에 가고, 집에 돌아간다”라는 단순한 이야기가 switch-case 안에 흩어져 있다. 상태가 10개, 20개로 늘어나면 전체 흐름을 파악하기가 점점 어려워진다.

둘째, 상태 전환 조건이 복잡해질수록 코드가 기하급수적으로 늘어난다. 무기상인이 비 오는 날에는 상점을 일찍 닫는다거나, 특정 이벤트가 발생하면 행동 패턴이 바뀐다거나 하면, 전환 조건이 중첩되면서 관리하기 힘든 코드가 된다.

셋째, 시간 기반 동작을 자연스럽게 표현하기 어렵다. “3초 동안 기다렸다가 다음 행동을 한다”는 것을 FSM에서 구현하려면 타이머 변수를 따로 만들고 매 프레임 감소시키는 보일러플레이트가 필요하다.


같은 로직을 코루틴으로 다시 쓰면

코루틴으로 무기상인의 하루를 구현하면 코드가 이야기처럼 읽힌다.

public class MerchantRoutine : MonoBehaviour
{
    void Start()
    {
        StartCoroutine(DailyRoutine());
    }

    IEnumerator DailyRoutine()
    {
        while (true)
        {
            // 아침까지 잠을 잔다
            yield return new WaitUntil(() => GameTime.Hour >= 6);

            // 상점으로 이동한다
            yield return StartCoroutine(MoveTo(shopPosition));

            // 상점을 연다
            OpenShop();

            // 저녁까지 일한다
            yield return new WaitUntil(() => GameTime.Hour >= 17);

            // 상점을 닫고 술집으로 간다
            CloseShop();
            yield return StartCoroutine(MoveTo(pubPosition));

            // 밤까지 술을 마신다
            yield return new WaitUntil(() => GameTime.Hour >= 22);

            // 집으로 돌아간다
            yield return StartCoroutine(MoveTo(homePosition));
        }
    }

    IEnumerator MoveTo(Vector3 destination)
    {
        while (Vector3.Distance(transform.position, destination) > 0.1f)
        {
            transform.position = Vector3.MoveTowards(
                transform.position, destination, speed * Time.deltaTime);
            yield return null; // 다음 프레임까지 양보
        }
    }
}

코드의 위에서 아래까지가 무기상인의 하루 그 자체다. “일어나서 → 상점으로 가서 → 일하고 → 술집 가서 → 집에 간다”가 그대로 보인다. 상태 전환을 위한 enum도, switch-case도, 타이머 변수도 필요 없다.

핵심은 yield return이다. 이 키워드를 만나면 코루틴은 실행을 멈추고 제어권을 Unity 엔진에 돌려준다. 엔진은 그 프레임에 다른 코루틴이나 Update 로직을 처리한다. 조건이 충족되면 멈춘 지점부터 다시 이어서 실행된다. 이것이 코루틴의 본질인 협력적 멀티태스킹이다.


yield return의 종류와 실행 타이밍

코루틴에서 yield return 뒤에 무엇을 넘기느냐에 따라 재개 시점이 달라진다. 이 차이를 정확히 알아야 의도한 타이밍에 로직을 실행할 수 있다.

yield return null은 다음 프레임의 Update 이후에 재개된다. 매 프레임 반복해야 하는 이동이나 애니메이션 보간에 적합하다.

yield return new WaitForSeconds(n)은 n초가 경과한 후 재개된다. 공격 쿨다운이나 대기 시간 구현에 쓴다.

yield return new WaitForFixedUpdate()는 다음 물리 시뮬레이션 단계(FixedUpdate) 이후에 재개된다. 물리 연산과 동기화가 필요한 경우에 쓴다.

yield return new WaitForEndOfFrame()은 해당 프레임의 렌더링이 끝난 후 재개된다. 스크린샷 캡처처럼 렌더링 결과가 필요한 경우에 쓴다. yield return null과는 프레임 내 실행 순서가 다르며(렌더링 이후 vs Update 이후), 성능상의 우열은 없지만 실행 타이밍이 다르므로 용도에 맞게 선택해야 한다.

yield return new WaitUntil(() => condition)은 조건이 true가 될 때까지 매 프레임 검사하며 기다린다. 게임 내 시간 조건이나 이벤트 대기에 유용하다.

yield return StartCoroutine(other)는 다른 코루틴이 완료될 때까지 기다린다. 코루틴을 순차적으로 연결할 때 쓴다.


코루틴의 실전 활용 패턴

패턴 1: 적 AI의 공격 사이클

IEnumerator AttackCycle()
{
    while (isAlive)
    {
        // 플레이어를 감지할 때까지 순찰
        yield return StartCoroutine(Patrol());

        // 플레이어 발견 시 접근
        yield return StartCoroutine(ChasePlayer());

        // 공격 실행
        PlayAttackAnimation();
        DealDamage();

        // 공격 후 쿨다운
        yield return new WaitForSeconds(attackCooldown);
    }
}

패턴 2: 연출 시퀀스

보스 등장 연출처럼 여러 단계가 순서대로 진행되어야 하는 경우, 코루틴이 가장 깔끔한 해법이다.

IEnumerator BossEntrance()
{
    // 카메라를 보스 쪽으로 이동
    yield return StartCoroutine(CameraPanTo(bossSpawnPoint));

    // 보스 등장 애니메이션 재생
    bossAnimator.Play("Entrance");
    yield return new WaitForSeconds(2.5f);

    // 보스 이름 UI 표시
    bossNameUI.SetActive(true);
    yield return new WaitForSeconds(1.5f);

    // 카메라를 플레이어에게 복귀
    yield return StartCoroutine(CameraPanTo(playerPosition));

    // 전투 시작
    StartBattle();
}

패턴 3: 비동기 리소스 로딩

IEnumerator LoadNextScene(string sceneName)
{
    loadingScreen.SetActive(true);

    AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);
    operation.allowSceneActivation = false;

    while (operation.progress < 0.9f)
    {
        progressBar.value = operation.progress;
        yield return null;
    }

    progressBar.value = 1f;
    yield return new WaitForSeconds(0.5f);

    operation.allowSceneActivation = true;
}

코루틴을 쓸 때 반드시 주의할 점

코루틴은 편리하지만, 잘못 쓰면 찾기 어려운 버그를 만든다.

yield 없는 무한 루프는 게임을 멈춘다

// 이렇게 하면 Unity가 영원히 응답하지 않는다
IEnumerator BadLoop()
{
    while (true)
    {
        DoSomething();
        // yield가 없으므로 제어권이 돌아가지 않는다
    }
}

무한 루프 안에는 반드시 yield return이 있어야 한다. yield return null 한 줄이면 매 프레임 제어권을 양보하므로 정상 동작한다.

오브젝트가 파괴되면 코루틴도 멈춘다

MonoBehaviour에서 실행된 코루틴은 해당 GameObject가 비활성화되거나 파괴되면 자동으로 중단된다. 이것을 모르면 “분명히 StartCoroutine을 호출했는데 왜 실행이 안 되지?”라는 상황에 빠진다.

// 적이 죽으면서 파괴될 때, 이 코루틴은 중간에 끊긴다
IEnumerator DropLootAfterDelay()
{
    yield return new WaitForSeconds(2f);
    // GameObject가 이미 Destroy되었다면 여기 도달하지 못한다
    Instantiate(lootPrefab, transform.position, Quaternion.identity);
}

해결 방법은 코루틴을 파괴되지 않는 매니저 오브젝트에서 실행하거나, 파괴 전에 필요한 처리를 먼저 완료하는 것이다.

코루틴은 메인 스레드에서 실행된다

코루틴은 병렬 처리처럼 보이지만, 실제로는 메인 스레드에서 시분할로 실행된다. 코루틴 안에서 무거운 연산(수천 개 오브젝트 탐색, 복잡한 길찾기 등)을 하면 그 프레임에서 프레임 드랍이 발생한다. 무거운 연산은 여러 프레임에 나눠서 처리하거나, Unity의 Job System과 Burst Compiler를 별도로 활용해야 한다.

// 무거운 작업을 여러 프레임에 걸쳐 분산 처리
IEnumerator ProcessLargeData(List<Data> dataList)
{
    int batchSize = 50;
    for (int i = 0; i < dataList.Count; i += batchSize)
    {
        int end = Mathf.Min(i + batchSize, dataList.Count);
        for (int j = i; j < end; j++)
        {
            ProcessSingle(dataList[j]);
        }
        yield return null; // 50개 처리 후 다음 프레임으로
    }
}

StopCoroutine 사용 시 참조 관리

코루틴을 중간에 멈추려면 StopCoroutine을 써야 하는데, 이때 문자열이 아닌 Coroutine 참조를 저장해두는 것이 안전하다.

Coroutine patrolCoroutine;

void StartPatrol()
{
    patrolCoroutine = StartCoroutine(Patrol());
}

void StopPatrol()
{
    if (patrolCoroutine != null)
    {
        StopCoroutine(patrolCoroutine);
        patrolCoroutine = null;
    }
}

FSM과 코루틴, 언제 무엇을 선택하나

FSM이 더 적합한 경우가 있고, 코루틴이 더 적합한 경우가 있다. 둘은 경쟁 관계가 아니라 보완 관계다.

FSM은 상태 간 전환이 비선형적일 때 강점을 발휘한다. 플레이어 캐릭터의 이동 상태(Idle, Run, Jump, Fall, Attack)처럼 어떤 상태에서든 다른 상태로 전환될 수 있는 경우, FSM의 명시적인 전환 규칙이 구조를 잡아준다.

코루틴은 순차적 흐름이 핵심인 로직에 적합하다. 위의 무기상인 일과처럼 “A 한 다음 B 하고 그다음 C 한다”는 시나리오는 코루틴으로 작성하면 코드가 곧 문서가 된다.

실전에서는 둘을 함께 쓰는 경우도 많다. FSM의 각 상태 안에서 코루틴을 실행하여 해당 상태의 세부 동작을 처리하는 방식이다.

// FSM 상태 전환은 명시적으로, 각 상태의 동작은 코루틴으로
void EnterState(State newState)
{
    if (activeCoroutine != null) StopCoroutine(activeCoroutine);

    currentState = newState;
    switch (newState)
    {
        case State.Patrol:
            activeCoroutine = StartCoroutine(PatrolBehavior());
            break;
        case State.Chase:
            activeCoroutine = StartCoroutine(ChaseBehavior());
            break;
        case State.Attack:
            activeCoroutine = StartCoroutine(AttackBehavior());
            break;
    }
}

Unity 이후의 코루틴: async/await의 등장

Unity는 전통적으로 IEnumerator 기반 코루틴을 써왔지만, C# 언어 자체가 async/await를 지원하면서 새로운 선택지가 열렸다. UniTask 같은 라이브러리를 사용하면 Unity 환경에서도 async/await를 자연스럽게 쓸 수 있다.

// UniTask를 사용한 async/await 방식
async UniTaskVoid DailyRoutineAsync()
{
    while (true)
    {
        await UniTask.WaitUntil(() => GameTime.Hour >= 6);
        await MoveToAsync(shopPosition);
        OpenShop();
        await UniTask.WaitUntil(() => GameTime.Hour >= 17);
        CloseShop();
        await MoveToAsync(pubPosition);
        await UniTask.WaitUntil(() => GameTime.Hour >= 22);
        await MoveToAsync(homePosition);
    }
}

async/await는 반환값 처리가 깔끔하고, 예외 처리를 try-catch로 할 수 있다는 장점이 있다. 기존 코루틴에서는 yield return 지점에서 스택 트레이스가 끊겨 예외의 원인을 추적하기 어려웠는데, 이 문제가 해결된다. 새 프로젝트를 시작한다면 UniTask 도입을 검토해볼 만하다.


핵심 정리

코루틴은 메인 스레드 안에서 함수 실행을 중단하고 재개할 수 있게 해주는 구조다. 멀티스레드의 복잡성 없이 여러 NPC가 동시에 움직이는 것처럼 보이게 만들어준다. FSM은 비선형 상태 전환에 강하고, 코루틴은 순차적 흐름에 강하며, 실전에서는 둘을 결합해 쓰는 것이 가장 효과적이다. yield return 뒤에 넘기는 객체에 따라 재개 시점이 달라지므로, 각 타입의 실행 타이밍을 정확히 이해해야 한다. 코루틴 안에서 무거운 연산은 프레임 드랍을 유발하므로 배치 분산 처리가 필요하고, 오브젝트 생명주기와의 관계도 반드시 고려해야 한다.


마치며

무기상인이 제시간에 상점을 열고, 경비병이 순찰을 돌고, 도둑이 밤에 움직이는 마을. 이런 마을을 만들기 위해 필요한 것은 복잡한 멀티스레드 아키텍처가 아니라, 각 캐릭터에게 “여기서 잠깐 쉬었다가 계속해”라고 말할 수 있는 구조다. 코루틴이 바로 그 구조이며, 게임 프로그래밍에서 시간과 흐름을 다루는 가장 기본적이면서도 강력한 도구다.

← 목록으로