유니티 입문2026년 02월 15일· 8 min read

1. Unity Coroutine 기본 구조: IEnumerator와 yield return이 프레임을 나누는 원

Unity Coroutine이 IEnumerator를 통해 어떻게 프레임을 나누고, yield return이 Player Loop에서 언제 재개되는지 C# 래퍼와 C++ 런타임 관점으로 설명한다. GC와 성능 함정까지 다룬다. 

1. Unity Coroutine 기본 구조: IEnumerator와 yield return이 프레임을 나누는 원

Unity Coroutine 기본 구조: IEnumerator와 yield return이 프레임을 나누는 원

게임 만들다 갑자기 ‘문이 1초 뒤에 열려야 하는데 즉시 열림’ 같은 사고가 터진다. Update에서 타이머를 굴리다 보면 플래그가 꼬이고, 씬 전환 중에는 오브젝트가 Destroy되면서 NullReferenceException이 튄다. 코루틴은 이 문제를 ‘프레임을 나눠 실행’하는 형태로 풀지만, 내부에서 언제 재개되는지 모르면 yield return null 하나로도 동작이 바뀐다. 이 글은 코루틴이 C# IEnumerator를 어떻게 엔진 스케줄러에 등록하고, Player Loop의 어느 지점에서 MoveNext가 호출되는지까지 연결해서 이해시키는 데 초점을 둔다.

핵심 개념

코루틴은 ‘비동기’가 아니라 ‘프레임 분할 실행’이다. StartCoroutine을 호출하면 Unity는 IEnumerator를 즉시 한 번 진행시키지 않고, 네이티브 런타임 쪽 코루틴 매니저에 등록한 뒤 특정 Player Loop 타이밍에 MoveNext를 호출해준다. 그래서 코루틴은 멀티스레드가 아니고, 메인 스레드에서 Update 계열과 같은 스레드에서 돈다.

IEnumerator는 C# 컴파일러가 만들어주는 상태 머신(state machine) 객체다. yield return을 만나면 현재 위치와 로컬 변수 값을 객체 필드에 저장해두고, 다음 MoveNext 호출 때 그 지점부터 이어서 실행한다. ‘왜 yield return이 필요한가’는 여기서 끝난다. yield가 없으면 상태를 저장할 지점이 없고, 프레임을 나눌 수 없다.

yield return null은 ‘다음 프레임까지 중단’이라는 의미로 Unity가 해석한다. null 자체가 특별한 타입이라서가 아니라, Unity 코루틴 스케줄러가 Current 값을 보고 “아무 대기 조건이 없으면 다음 Update 사이클에 다시 깨운다”는 규칙을 갖고 있기 때문이다. 같은 이유로 yield return new WaitForSeconds(1f)는 Current가 YieldInstruction 계열이어서 엔진이 시간을 체크하는 대기 노드로 등록한다.

용어를 엔진 관점으로 묶으면 깔끔해진다. 1) ‘상태 머신’은 C# 객체(관리 힙)이며, 2) ‘코루틴 핸들’은 Coroutine 타입(C# 래퍼)이고, 3) ‘스케줄링’은 C++ 런타임의 코루틴 리스트/큐에 들어가는 작업이며, 4) ‘재개 타이밍’은 Player Loop의 특정 단계(Update/LateUpdate/EndOfFrame/FixedUpdate 등)와 연결된다.

처음에 나도 코루틴이 StartCoroutine 호출 직후 바로 한 번 실행된다고 착각했다. 그래서 Awake에서 StartCoroutine을 걸고, 같은 프레임에 생성된 컴포넌트가 아직 초기화되지 않아 로그가 뒤죽박죽 나왔다. 디버그로 MoveNext 호출 타이밍을 찍어보니, ‘호출한 프레임’과 ‘재개되는 프레임’이 엇갈리는 구간이 존재했고, 그게 yield 종류에 따라 달라졌다.

CoroutineBasics.cs
1using System.Collections;
2using UnityEngine;
3
4public class CoroutineBasics : MonoBehaviour
5{
6    private IEnumerator Start()
7    {
8        Debug.Log($"Start() frame={Time.frameCount}");
9        yield return null;
10        Debug.Log($"After yield null frame={Time.frameCount}");
11        yield return new WaitForSeconds(1f);
12        Debug.Log($"After 1s frame={Time.frameCount} time={Time.time:F2}");
13    }
14}

이 스크립트를 빈 오브젝트에 붙이고 Play를 누르면 콘솔에 3줄이 찍힌다. 첫 로그는 Start가 호출된 프레임 번호, 두 번째 로그는 프레임 번호가 1 증가한 값, 세 번째 로그는 Time.time이 약 1초 증가한 값이다. 눈으로 확인되는 건 ‘코루틴이 프레임을 건너뛴다’인데, 엔진 관점에서 중요한 건 “MoveNext가 Player Loop의 어느 단계에서 다시 호출되었는가”다. 그 지점이 정확해야 애니메이션, 물리, UI 갱신과 엇갈리지 않는다.

엔진 관점에서의 내부 동작

C#에서 StartCoroutine(IEnumerator)를 호출하면, MonoBehaviour의 C# 메서드가 네이티브 바인딩을 통해 엔진 런타임으로 넘어간다. Unity의 C# API는 ‘얇은 래퍼’가 많고, 코루틴도 예외가 아니다. C# 레벨에서 실제로 하는 일은 IEnumerator 인스턴스(상태 머신)를 만들고, 그 참조를 엔진에 넘겨 등록하는 것이다.

엔진(C++) 쪽에는 코루틴을 관리하는 스케줄러가 있고, 각 MonoBehaviour 인스턴스(네이티브 오브젝트)에 연결된 코루틴 리스트가 붙는다. Coroutine 타입은 C#에서 보이는 핸들이지만, 내부적으로는 네이티브 쪽 코루틴 엔트리를 가리키는 포인터/ID 성격에 가깝다. 그래서 StopCoroutine(Coroutine)과 StopCoroutine(IEnumerator)의 동작이 미묘하게 다른데, 하나는 핸들 기반으로 바로 찾아 끊고, 다른 하나는 IEnumerator 참조 매칭을 추가로 한다.

Player Loop에서 코루틴 재개는 보통 ‘Update 단계 사이’에서 일어난다. Unity는 프레임마다 “재개 가능한 코루틴”을 모아 MoveNext를 호출한다. MoveNext가 true면 계속 살아 있고, false면 종료로 간주해 리스트에서 제거한다. yield return의 Current 값이 null이면 ‘다음 프레임 재개’로 분류되고, WaitForSeconds/WaitForFixedUpdate/WaitForEndOfFrame 같은 타입이면 해당 조건을 만족할 때까지 보류 큐로 이동한다.

메모리 관점에서 가장 먼저 생기는 할당은 IEnumerator 상태 머신 객체다. yield가 있는 메서드는 컴파일러가 클래스를 하나 만들고, 그 인스턴스가 힙에 할당된다. 코루틴 안에서 캡처되는 로컬 변수, foreach 열거자, 람다 캡처가 섞이면 추가 할당이 붙는다. Profiler에서 GC Alloc이 0이 아닌 코루틴을 보면 대부분 이 상태 머신 + 부가 객체가 원인이다.

왜 yield return null이 ‘필수처럼’ 느껴지는가. Update에서 한 프레임에 처리할 일을 여러 프레임으로 나누려면, 엔진이 다시 호출할 수 있는 재개 지점이 필요하다. 그 재개 지점이 yield다. yield가 없으면 코루틴은 그냥 일반 함수 호출과 같은 비용/흐름으로 한 번에 끝난다. 그래서 ‘코루틴인데 프레임이 안 나뉜다’는 버그는 yield가 조건문에 막혀 실행되지 않는 경우가 많다.

처음에 3시간 삽질했던 사례가 StopCoroutine("Foo")였다. 문자열로 끊는 방식은 리플렉션/이름 매칭이 들어가고, 메서드 시그니처가 바뀌거나 오버로드가 생기면 조용히 실패한다. 콘솔에는 에러가 안 나고 코루틴만 계속 돈다. 그때 Profiler Timeline에서 Scripts 쪽에 코루틴이 계속 남아 있는 걸 보고, 핸들 기반 StopCoroutine로 바꿔서 해결했다.

PlayerLoopProbe.cs
1using System.Collections;
2using UnityEngine;
3
4public class PlayerLoopProbe : MonoBehaviour
5{
6    private void Awake()
7    {
8        Debug.Log($"Awake frame={Time.frameCount}");
9    }
10
11    private IEnumerator Start()
12    {
13        Debug.Log($"Start frame={Time.frameCount}");
14        yield return null;
15        Debug.Log($"Coroutine resumed (after null) frame={Time.frameCount}");
16        yield return new WaitForFixedUpdate();
17        Debug.Log($"After FixedUpdate frame={Time.frameCount} fixedTime={Time.fixedTime:F2}");
18        yield return new WaitForEndOfFrame();
19        Debug.Log($"After EndOfFrame frame={Time.frameCount}");
20    }
21
22    private void Update()
23    {
24        Debug.Log($"Update frame={Time.frameCount}");
25    }
26
27    private void FixedUpdate()
28    {
29        Debug.Log($"FixedUpdate frame={Time.frameCount}");
30    }
31
32    private void LateUpdate()
33    {
34        Debug.Log($"LateUpdate frame={Time.frameCount}");
35    }
36}

이 코드를 실행하면 콘솔이 폭발하니, Console 우측 상단 ‘Collapse’를 켜고, 필요하면 Update 로그만 잠시 주석 처리한다. 관찰 포인트는 세 가지다. 1) yield return null 이후 재개 로그가 Update 사이클과 어떻게 섞이는지, 2) WaitForFixedUpdate 이후 로그가 FixedUpdate 이후에만 찍히는지, 3) WaitForEndOfFrame 로그가 LateUpdate 이후, 렌더링 직전에 찍히는지다. 이 순서가 ‘코루틴이 물리/렌더/스크립트 중 어디에 걸리는가’를 결정한다.

GcAllocInCoroutine.cs
1using System.Collections;
2using UnityEngine;
3
4public class GcAllocInCoroutine : MonoBehaviour
5{
6    private IEnumerator Start()
7    {
8        for (int i = 0; i < 300; i++)
9        {
10            string msg = "tick:" + i; // 문자열 결합은 할당을 만든다
11            Debug.Log(msg);
12            yield return null;
13        }
14    }
15}

Profiler( Window → Analysis → Profiler )를 열고, Play 후 CPU Usage와 GC Alloc 컬럼을 같이 본다. 이 예제는 코루틴 자체보다 문자열 할당이 더 크게 튄다. 코루틴이 ‘느리다’는 인상은 이런 부수 할당에서 시작되는 경우가 많다. 상태 머신 1회 할당은 프레임당이 아니지만, 코루틴 내부에서 매 프레임 새 객체를 만들면 프레임당 GC 압박으로 바뀐다.

한 문단 요약: 코루틴은 IEnumerator 상태 머신(관리 힙)을 네이티브 코루틴 스케줄러(C++)에 등록하고, Player Loop의 특정 단계에서 MoveNext를 호출받아 프레임을 나눠 실행한다. yield return의 타입/값이 재개 타이밍과 대기 조건을 결정하며, 성능 문제는 대개 코루틴 자체보다 코루틴 내부의 할당과 잘못된 재개 타이밍에서 터진다.

실습하기

1단계: 프로젝트 설정(버전/템플릿/Profiler 준비)

Unity Hub → Projects → New project에서 3D(Core) 템플릿을 선택한다. 에디터 버전은 2022.3 LTS 또는 2021.3 LTS를 권장한다(코루틴 동작은 동일하지만 Profiler UI가 안정적이다). 프로젝트 이름은 CoroutineLab 정도로 둔다.

Profiler를 먼저 열어 둔다. 메뉴 Window → Analysis → Profiler. 우측 상단 톱니바퀴에서 ‘Profile Editor’를 켜면 에디터 오버헤드가 커지니, 이 실습에서는 끈다. CPU Usage 모듈에서 Timeline/Hierarchy 전환 버튼 위치를 확인해 둔다.

SceneSetupHint.cs
1using System.Collections;
2using UnityEngine;
3
4public class SceneSetupHint : MonoBehaviour
5{
6    [Header("Inspector에서 seconds를 0.2~2로 바꿔가며 체감")]
7    [SerializeField] private float seconds = 1f;
8
9    private IEnumerator Start()
10    {
11        Debug.Log($"Start frame={Time.frameCount}");
12        yield return new WaitForSeconds(seconds);
13        Debug.Log($"Waited {seconds} seconds, time={Time.time:F2}");
14    }
15}

이 스크립트는 Inspector에서 숫자 하나만 바꿔가며 코루틴 대기 조건이 ‘시간 기반’임을 확인하는 용도다. 실행하면 Start 로그가 찍힌 뒤, seconds만큼 지난 후 두 번째 로그가 찍힌다. Time.time이 증가한 값을 같이 찍어서, 프레임 수가 아니라 시간 조건으로 풀린다는 걸 눈으로 확인한다.

2단계: 씬 구성(오브젝트 배치/컴포넌트/Inspector 입력)

Hierarchy 우클릭 → Create Empty로 빈 오브젝트를 만들고 이름을 CoroutineRunner로 바꾼다. Inspector에서 Transform은 기본값(0,0,0) 그대로 둔다.

CoroutineRunner에 방금 만든 SceneSetupHint를 Add Component로 추가한다. Inspector에서 seconds를 1로 두고 Play를 누른다. seconds를 0.2로 바꾸면 로그가 더 빨리 찍히고, 2로 바꾸면 늦게 찍힌다. 이때 프레임 드랍이 있어도 ‘초’ 기준으로 풀리기 때문에 체감이 흔들릴 수 있다.

YieldNullVsWait.cs
1using System.Collections;
2using UnityEngine;
3
4public class YieldNullVsWait : MonoBehaviour
5{
6    [SerializeField] private int frames = 5;
7
8    private IEnumerator Start()
9    {
10        Debug.Log($"A: frame={Time.frameCount}");
11        for (int i = 0; i < frames; i++)
12        {
13            yield return null;
14        }
15        Debug.Log($"B(after {frames} frames): frame={Time.frameCount}");
16
17        float t = Time.time;
18        yield return new WaitForSeconds(0.5f);
19        Debug.Log($"C(after 0.5s): dt={(Time.time - t):F2} frame={Time.frameCount}");
20    }
21}

이 스크립트를 같은 오브젝트에 추가하고, Inspector에서 frames를 1, 5, 60으로 바꿔가며 실행한다. B 로그는 프레임 기반으로 지연되고, C 로그는 시간 기반으로 지연된다. 같은 ‘대기’라도 엔진이 Current를 해석하는 방식이 다르기 때문에, 프레임/시간/물리/렌더 타이밍을 섞어 쓰면 체감이 달라진다.

3단계: 코드 작성 및 테스트(중단/재시작/Destroy 시나리오)

코루틴이 실무에서 터지는 지점은 ‘중단’과 ‘오브젝트 생명주기’다. Hierarchy 우클릭 → 3D Object → Cube로 큐브를 만들고, 이름을 TargetCube로 둔다. CoroutineRunner 오브젝트에 아래 스크립트를 추가한다.

Inspector에서 target에 TargetCube를 드래그해서 넣는다. playSeconds는 3, destroyAfter는 1.5로 둔다. Play를 누르면 큐브가 움직이다가 1.5초에 Destroy되고, 코루틴이 target을 만지는 순간 MissingReferenceException 류의 문제가 어떻게 생기는지 확인할 수 있다.

CoroutineLifecycleDemo.cs
1using System.Collections;
2using UnityEngine;
3
4public class CoroutineLifecycleDemo : MonoBehaviour
5{
6    [SerializeField] private Transform target;
7    [SerializeField] private float playSeconds = 3f;
8    [SerializeField] private float destroyAfter = 1.5f;
9
10    private Coroutine moveRoutine;
11
12    private void OnEnable()
13    {
14        moveRoutine = StartCoroutine(MoveTarget());
15        StartCoroutine(DestroyTargetLater());
16    }
17
18    private IEnumerator MoveTarget()
19    {
20        float start = Time.time;
21        while (Time.time - start < playSeconds)
22        {
23            if (target == null)
24            {
25                Debug.Log("target is null (destroyed). stop moving");
26                yield break;
27            }
28            target.position += Vector3.right * 0.5f;
29            yield return null;
30        }
31        Debug.Log("move finished");
32    }
33
34    private IEnumerator DestroyTargetLater()
35    {
36        yield return new WaitForSeconds(destroyAfter);
37        if (target != null)
38        {
39            Destroy(target.gameObject);
40            Debug.Log("target destroyed");
41        }
42    }
43
44    private void OnDisable()
45    {
46        if (moveRoutine != null)
47            StopCoroutine(moveRoutine);
48    }
49}

여기서 확인되는 포인트는 두 가지다. 1) target이 Destroy되면 C# 참조는 남아 있어도 UnityEngine.Object의 네이티브 쪽이 죽어서 ‘null처럼’ 동작한다(커스텀 null 오버로드). 2) OnDisable에서 StopCoroutine을 하지 않으면, 비활성화/씬 전환 중에도 코루틴이 남아 예상치 못한 시점에 재개될 수 있다. 코루틴은 ‘메서드 실행’이 아니라 ‘엔진에 등록된 작업’이라서 생명주기 관리가 필요하다.

심화 활용

패턴 1: 한 오브젝트당 코루틴 1개만 유지(중복 실행 방지)

UI 버튼을 연타하면 StartCoroutine이 중복으로 걸려 애니메이션이 가속되는 문제가 흔하다. 엔진은 같은 IEnumerator를 ‘자동으로 합쳐주지’ 않는다. 호출할 때마다 상태 머신 인스턴스가 새로 생기고, 네이티브 스케줄러 리스트에 엔트리가 추가된다. 그래서 중복 방지는 코드를 명시적으로 써야 한다.

SingleCoroutineGuard.cs
1using System.Collections;
2using UnityEngine;
3
4public class SingleCoroutineGuard : MonoBehaviour
5{
6    private Coroutine running;
7
8    public void PlayOnce()
9    {
10        if (running != null)
11            StopCoroutine(running);
12
13        running = StartCoroutine(DoPlay());
14    }
15
16    private IEnumerator DoPlay()
17    {
18        Debug.Log("play start");
19        float t = 0f;
20        while (t < 1f)
21        {
22            t += Time.deltaTime;
23            transform.localScale = Vector3.one * (1f + t);
24            yield return null;
25        }
26        Debug.Log("play end");
27        running = null;
28    }
29}

이 코드를 빈 오브젝트에 붙이고, Inspector에서 버튼 이벤트로 PlayOnce를 여러 번 호출하면(예: UI Button OnClick에 연결) 스케일 애니메이션이 ‘항상 1개만’ 돈다. StopCoroutine이 없으면 스케일이 여러 코루틴 합산으로 튀고, 마지막에 running이 null로 되돌아가는 시점도 꼬인다. 중복 실행은 성능 문제로도 이어진다. 코루틴 50개가 yield null로 매 프레임 재개되면, MoveNext 호출이 50번 늘어난다.

패턴 2: CustomYieldInstruction으로 ‘조건 대기’를 할당 없이 재사용

WaitUntil/WaitWhile도 편하지만, 람다 캡처가 들어가면 할당이 생긴다. 조건 대기를 자주 쓰는 시스템(튜토리얼, 컷신, 로딩 게이트)에서는 CustomYieldInstruction으로 명시적인 객체를 만들고 재사용하는 쪽이 예측 가능하다. 엔진은 keepWaiting 프로퍼티를 매 프레임 폴링하고, false가 되는 프레임에 코루틴을 재개한다.

CustomYieldDemo.cs
1using System.Collections;
2using UnityEngine;
3
4public class WaitForFlag : CustomYieldInstruction
5{
6    private readonly System.Func<bool> predicate;
7
8    public WaitForFlag(System.Func<bool> predicate)
9    {
10        this.predicate = predicate;
11    }
12
13    public override bool keepWaiting
14    {
15        get { return predicate != null && !predicate(); }
16    }
17}
18
19public class CustomYieldDemo : MonoBehaviour
20{
21    private bool ready;
22
23    private void Update()
24    {
25        if (Input.GetKeyDown(KeyCode.Space))
26            ready = true;
27    }
28
29    private IEnumerator Start()
30    {
31        Debug.Log("press Space");
32        yield return new WaitForFlag(() => ready);
33        Debug.Log($"ready at frame={Time.frameCount}");
34    }
35}

Play 후 Space를 누르면 ready 로그가 찍힌다. 여기서 중요한 건 ‘조건이 만족되는 프레임’에 즉시 재개된다는 점이다. keepWaiting이 false가 되는 순간이 Player Loop의 코루틴 체크 지점이고, 그 프레임에 MoveNext가 호출된다. 조건 대기 구조가 많아지면, keepWaiting 폴링 자체가 비용이 된다. 조건을 수천 개 폴링하면 Update에 준하는 비용으로 커진다.

내 흑역사 1: WaitForSeconds를 캐싱해서 쓰면 GC가 줄어든다고 믿고, static WaitForSeconds(1f)를 만들어 여러 코루틴에서 공유했다. 그런데 타이밍이 랜덤하게 어긋났다. 원인은 WaitForSeconds가 내부적으로 ‘시작 시점’을 들고 있는 구조라서(엔진 쪽 대기 노드에 등록되는 시점이 중요), 같은 인스턴스를 공유하면 서로의 대기 조건이 덮어쓰이거나 의도와 다른 시점에 풀린다. 그 뒤로 WaitForSeconds는 캐싱하지 않고, 필요하면 WaitForSecondsRealtime 같은 대안과 구조 변경을 고민한다.

내 흑역사 2: 씬 전환 중 Addressables 로딩 완료를 기다리는 코루틴이 있었다. 씬이 바뀌며 코루틴을 돌리던 오브젝트가 Destroy되었고, 다음 프레임에 재개되면서 MissingReferenceException: The object of type 'Text' has been destroyed but you are still trying to access it 라는 메시지가 터졌다. 해결은 두 갈래였다. 1) DontDestroyOnLoad로 생존시키거나, 2) 코루틴을 ‘로딩 매니저’ 같은 생명주기 안정적인 오브젝트로 옮기고, UI 참조는 매 프레임 null 체크 후 안전하게 끊는 방식으로 바꿨다.

자주 하는 실수

실수 1: yield return null을 ‘즉시 다음 줄 실행’로 오해

증상: 코루틴에서 값을 세팅하고 곧바로 다른 컴포넌트가 그 값을 읽을 거라 기대했는데, 한 프레임 늦게 반영되거나 UI가 깜빡인다. 콘솔 프레임 로그를 찍으면 frameCount가 1 증가한 뒤에야 다음 줄이 실행된다.

원인: yield return null은 엔진 스케줄러에게 ‘다음 프레임 재개’로 해석된다. C# 함수 호출 흐름이 아니라, Player Loop 재개 지점까지 제어권이 돌아간다. 같은 프레임에 처리하고 싶으면 yield를 두지 않거나, 로직을 StartCoroutine 호출 이전/이후로 분리해야 한다.

해결: 프레임 경계를 의도적으로 설계한다. UI 레이아웃 계산 이후를 기다리려면 WaitForEndOfFrame 또는 Canvas.ForceUpdateCanvases 같은 명시적 동기화 지점을 쓴다. ‘한 프레임 대기’가 필요한지, ‘렌더 직전 대기’가 필요한지 목적을 먼저 고른다.

실수 2: 코루틴이 멀티스레드라고 착각

증상: 코루틴에서 큰 for 루프를 돌리면 게임이 멈춘다. ‘코루틴이니까 백그라운드에서 돌겠지’라고 생각했는데, 실제로는 1초 동안 화면이 멈춘다.

원인: 코루틴은 메인 스레드에서 MoveNext가 호출된다. yield를 만나기 전까지는 일반 함수와 동일하게 한 번에 실행된다. 10만 번 루프를 yield 없이 돌리면 Update에서 10만 번 도는 것과 같다.

해결: 작업을 프레임으로 쪼갠다. 예를 들어 1000개 처리마다 yield return null을 넣어 프레임 budget을 지킨다. 진짜 백그라운드가 필요하면 Task/Thread를 쓰되, Unity API 접근은 메인 스레드로 돌아와야 한다(디스패처 패턴 필요).

실수 3: StopCoroutine("MethodName")로 끊다가 조용히 실패

증상: 코루틴을 끊었다고 생각했는데 계속 돈다. 콘솔에는 에러가 없고, 특정 상황(리팩터링 후)에서만 재현된다.

원인: 문자열 기반 중단은 메서드 이름 매칭에 의존한다. 오버로드/이름 변경/StartCoroutine 호출 방식 변화가 생기면 매칭이 깨진다. 엔진은 ‘해당 이름의 코루틴이 없으면’ 그냥 아무 것도 안 한다.

해결: Coroutine 핸들을 저장해서 StopCoroutine(handle)로 끊는다. 또는 IEnumerator 참조를 저장하고 StopCoroutine(enumerator)로 끊되, 동일 IEnumerator 인스턴스를 끊는다는 전제가 필요하다.

실수 4: Destroy된 UnityEngine.Object를 null 체크로만 믿기

증상: if (target != null)로 감쌌는데도 MissingReferenceException이 터지거나, 로그에는 null이 아닌 것처럼 보이는데 접근하면 죽는다. 특히 코루틴에서 자주 나온다.

원인: UnityEngine.Object는 네이티브 오브젝트 생존 여부를 오버로드된 == 연산자로 숨긴다. Destroy 이후에도 C# 참조는 남아 있고, 특정 타이밍에서는 ‘가짜 null’처럼 동작한다. 코루틴은 프레임 경계를 넘기 때문에 이 경계에서 Destroy가 끼어들 확률이 높다.

해결: 코루틴 루프마다 target == null을 먼저 검사하고, 필요하면 yield break로 종료한다. 씬 전환/비활성화 시 OnDisable에서 코루틴을 끊어 접근 자체를 차단한다.

실수 5: WaitForSeconds를 캐싱/공유

증상: 같은 1초 대기를 여러 곳에서 쓰는데, 어떤 코루틴은 너무 빨리 풀리거나 너무 늦게 풀린다. 재현이 불안정해서 더 괴롭다.

원인: WaitForSeconds는 단순한 값 객체처럼 보여도, 엔진이 등록/해석하는 과정에서 ‘시작 시점’이 중요하다. 같은 인스턴스를 여러 코루틴이 공유하면 대기 조건이 의도와 다르게 엮일 수 있다.

해결: WaitForSeconds는 필요할 때 새로 만든다. GC가 걱정되면 구조를 바꾼다. 예를 들어 한 코루틴에서 시간을 누적(Time.time 또는 deltaTime)하고, 다른 코루틴은 그 결과만 구독하게 하면 대기 객체 생성 자체를 줄일 수 있다.

ChunkedWorkCoroutine.cs
1using System.Collections;
2using UnityEngine;
3
4public class ChunkedWorkCoroutine : MonoBehaviour
5{
6    private IEnumerator Start()
7    {
8        int total = 200000;
9        int chunk = 2000;
10        int sum = 0;
11
12        for (int i = 0; i < total; i++)
13        {
14            sum += i;
15            if (i % chunk == 0)
16                yield return null;
17        }
18
19        Debug.Log($"done sum={sum} frame={Time.frameCount}");
20    }
21}

이 코드는 ‘코루틴이면 안 멈춘다’는 착각을 깨는 확인용이다. chunk를 200000으로 바꾸면 화면이 멈추고, 2000으로 두면 멈춤이 줄어든다. 프레임 budget을 지키는 핵심은 yield를 어디에 두느냐고, 그 위치가 Player Loop에 제어권을 돌려주는 지점이다.

성능 최적화 체크리스트

  • Profiler( Window → Analysis → Profiler )에서 CPU Usage + GC Alloc 컬럼을 동시에 켠다
  • 코루틴 시작 시점에 상태 머신 1회 할당이 생긴다는 전제로, ‘프레임당 할당’은 코루틴 내부 코드에서 제거한다(문자열 결합/ToString/linq)
  • yield return null을 남발하기 전, 재개 타이밍이 Update인지 EndOfFrame인지 FixedUpdate인지 먼저 고른다
  • WaitForSeconds를 캐싱/공유하지 않는다. 반복 대기는 누적 타이머 방식으로 바꾸는 쪽을 우선 검토한다
  • StartCoroutine 호출마다 중복 실행이 생긴다는 전제로, Coroutine 핸들을 저장하고 중복 방지 가드를 둔다
  • 씬 전환/비활성화가 있는 오브젝트는 OnDisable에서 StopCoroutine(handle)로 정리한다
  • Destroy될 수 있는 UnityEngine.Object 참조는 코루틴 루프마다 null(가짜 null 포함) 체크 후 yield break로 끊는다
  • WaitUntil/WaitWhile 사용 시 람다 캡처로 인한 GC Alloc을 Profiler로 확인하고, 필요하면 CustomYieldInstruction 또는 명시적 플래그로 교체한다
  • 코루틴에서 큰 작업은 chunk 단위로 쪼개고, 프레임당 처리량을 숫자로 고정한다(예: 2000개/프레임)
  • FixedUpdate와 섞는 코루틴은 WaitForFixedUpdate를 사용하고, 물리 상태를 Update에서 읽지 않도록 책임을 분리한다
  • StopCoroutine(string) 같은 문자열 기반 API는 리팩터링에 취약하니 금지 규칙으로 둔다
  • IL2CPP 빌드에서 Profiler Deep Profile 의존을 줄이고, Development Build + Autoconnect Profiler로 실제 디바이스 비용을 본다

자주 묻는 질문

Coroutine은 StartCoroutine을 호출한 프레임에 바로 실행되나?

StartCoroutine을 호출하면 IEnumerator 인스턴스(상태 머신)가 만들어지고 엔진 스케줄러에 등록된다. 다만 ‘언제 MoveNext가 호출되는가’는 yield에 의해 갈린다. Start 메서드 자체는 Player Loop에서 호출되고, 그 안에서 yield를 만나기 전까지는 같은 프레임에 실행된다. yield return null을 만나는 순간 제어권이 엔진으로 돌아가며, 다음 프레임의 코루틴 처리 지점에서 MoveNext가 다시 호출된다. 확인 키워드는 Time.frameCount 로그, Player Loop(Initialization/Update/LateUpdate/EndOfFrame), IEnumerator 상태 머신이다.

yield return null과 yield return new WaitForSeconds(0) 차이는 뭔가?

yield return null은 엔진이 ‘다음 프레임 재개’로 분류하는 가장 기본 규칙이다. 반면 WaitForSeconds(0)은 타입이 YieldInstruction이라서 ‘시간 기반 대기 노드’로 등록되며, 내부적으로는 현재 시간과 목표 시간을 비교하는 경로를 탄다. 0초라 하더라도 등록/해제 타이밍이 null과 동일하다고 가정하면 엇갈릴 수 있다. 특히 Time.timeScale, WaitForSecondsRealtime 여부, 프레임 경계에서의 처리 순서가 결과를 바꾼다. 학습 키워드는 YieldInstruction 해석, timeScale, WaitForSecondsRealtime이다.

왜 Coroutine은 async/await처럼 보이는데 실제로는 비동기가 아닌가?

async/await는 보통 Task 스케줄러와 연계되어 스레드 풀 작업이나 비동기 I/O 완료 시점에 이어서 실행될 수 있다. Unity Coroutine은 그런 실행 모델이 아니라, 엔진의 메인 스레드 Player Loop에서 매 프레임 MoveNext를 호출해주는 협력적(cooperative) 스케줄링이다. 그래서 코루틴 안에서 Unity API를 마음껏 써도 안전한 대신, yield를 만나기 전까지는 프레임을 점유한다. 큰 루프가 있으면 화면이 멈추는 이유가 여기 있다. 학습 키워드는 cooperative scheduling, main thread, Task와 UnitySynchronizationContext이다.

Coroutine 객체를 반환받아 저장하는 이유가 뭔가? IEnumerator만 있으면 되지 않나?

Coroutine 타입은 C#에서 보이는 핸들이고, 네이티브 런타임에 등록된 코루틴 엔트리를 빠르게 찾아 끊는 용도로 쓰인다. IEnumerator 참조로 StopCoroutine을 호출하는 방식도 가능하지만, ‘동일 인스턴스’라는 조건이 필요하다. 코루틴 메서드를 다시 호출하면 상태 머신 인스턴스가 새로 만들어져 참조가 달라진다. 반면 Coroutine 핸들은 StartCoroutine이 반환한 ‘등록 결과’라서, 중복 실행 방지/정확한 중단에 유리하다. 학습 키워드는 핸들 기반 중단, 상태 머신 인스턴스, StopCoroutine 오버로드 차이다.

WaitForEndOfFrame은 왜 UI/렌더 타이밍 문제를 해결해주나?

WaitForEndOfFrame은 Player Loop에서 렌더링 직전(프레임 끝)에 코루틴을 재개시키는 대기 조건이다. UI 레이아웃/캔버스 갱신, LateUpdate 이후에 반영되는 값, 카메라 렌더 직전 상태를 잡고 싶을 때 유효하다. 예를 들어 스크린샷 캡처나 프레임 끝의 최종 Transform/Canvas 상태를 읽어야 할 때 yield return null만 쓰면 ‘Update 사이’에 재개되어 아직 렌더 반영 전 값을 읽을 수 있다. 학습 키워드는 Player Loop EndOfFrame, Canvas 업데이트 순서, LateUpdate와 렌더링 경계다.

코루틴을 많이 쓰면 성능이 얼마나 나빠지나? 기준이 있나?

절대 기준은 없고, ‘프레임당 재개되는 코루틴 수 × MoveNext 비용’이 실질 비용이다. yield return null로 매 프레임 재개되는 코루틴이 500개면, 최소 500번의 MoveNext 호출과 분기(Current 해석)가 발생한다. 각 코루틴이 가벼우면 0.1ms 미만일 수도 있지만, 내부에서 문자열 생성, GetComponent, LINQ, Instantiate 같은 무거운 호출이 섞이면 1~5ms까지 쉽게 튄다. Profiler Hierarchy에서 Scripts → Coroutine.MoveNext 계열(또는 사용자 메서드)을 찾아 ms와 GC Alloc을 같이 본 뒤, 매 프레임 코루틴 수를 줄이거나 이벤트 기반으로 바꾸는 식으로 접근한다. 학습 키워드는 Profiler Hierarchy, GC Alloc, MoveNext 비용이다.

GetComponent를 코루틴 안에서 매 프레임 호출하면 왜 느린가? 캐싱하면 왜 빨라지나?

GetComponent는 C# 한 줄이지만, 내부적으로는 C# 래퍼에서 네이티브(GameObject/Component 시스템)로 넘어가 컴포넌트 리스트를 탐색하는 경로를 탄다. 이 리스트는 네이티브 메모리에 있고, 호출마다 바인딩 비용과 탐색 비용이 붙는다. 코루틴이 yield null로 매 프레임 재개되는 구조라면, 그 안의 GetComponent도 매 프레임 반복되어 비용이 누적된다. Awake/Start에서 한 번만 GetComponent로 참조를 잡아 필드에 저장하면, 이후는 단순한 C# 참조 접근이라 바인딩/탐색 비용이 0에 수렴한다. Profiler에서는 GetComponent 호출이 프레임당 0.01~0.1ms 수준으로 보일 수 있고, 100회면 1~10ms로 커진다. 학습 키워드는 native binding, component lookup, caching pattern이다.

관련 글

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

11. Start(), Update(), FixedUpdate() 차이와 호출 타이밍을 엔진 루프로 이해하기

Start, Update, FixedUpdate가 Player Loop에서 언제 호출되는지와 C#→C++ 바인딩 흐름, 물리 스텝·프레임 스텝 분리 이유, 성능·GC 관점의 사용 기준을 다룬다. 초보도 납득 가능하게 엔진 관점으로 설명한다. 60fps 기준의 실전 팁 포함.

28. Prefab Variant로 공통 설정 유지하며 개별 오버라이드 적용하기

28. Prefab Variant로 공통 설정 유지하며 개별 오버라이드 적용하기

Prefab Variant로 공통 프리팹 설정을 유지하면서 개별 오버라이드를 적용하는 방법을 엔진 직렬화, 오버라이드 저장 구조, Player Loop 관점에서 설명한다. 실습 포함. 154자 내외 조정됨: Prefab Variant로 공통 설정을 유지하면서 개별 오버라이드를 적용하는 방법을 엔진 직렬화와 오버라이드 저장

26. Unity Profiler 처음 열었을 때: 패널 구조와 실시간 측정 흐름

26. Unity Profiler 처음 열었을 때: 패널 구조와 실시간 측정 흐름

Unity Profiler 창의 기본 패널, Play 모드에서 샘플이 수집되는 타이밍, C# 호출이 네이티브로 넘어가는 지점과 GC Alloc 해석까지 연결한다. 초보도 원인 추적이 된다. 60fps 기준으로 읽는다. 60fps 기준으로 읽는다. 60fps 기준으로 읽는다.