■ 동기(Synchronous)와 비동기(Asynchronous)
동기와 비동기는 작업을 처리하는 방식의 차이를 나타내는 개념이다. 프로그래밍에서는 아래와 같은 의미로 사용된다.
- 동기(Synchronous) : 작업이 순차적으로, 동일한 흐름 내에서 처리된다. 앞선 작업이 끝나야 다음이 실행된다.(일반적인 코드 실행 흐름.)
OrderFood();
Pay(); // => OrderFood()가 끝나야 실행.
PrintReceipt(); // => Pay()가 끝나야 실행.
- 비동기(Asynchronous) : 작업이 병렬적으로, 또는 다른 흐름에서 실행될 수 있다. 하나의 작업이 완료되기를 기다리지 않고, 그 사이 다른 작업이 함께 진행될 수 있다.
public class Asynchronous : MonoBehaviour
{
private void Start()
{
_ = PrintNumberAsync("A");
_ = PrintNumberAsync("B");
}
private async Task PrintNumberAsync(string name)
{
for (var i = 0; i <= 10; ++i)
{
Debug.Log($"{name} : {i}");
await Task.Delay(300);
}
}
}
간단하게 0부터 9까지 로그를 출력하는 비동기 함수를 두 개 실행해 보면, 한 함수의 작업이 끝날 때까지 기다리는 것이 아니라, 두 함수가 동시에 실행되며 병렬적으로 로그를 출력하는 것을 확인할 수 있다.
■ 프로세스와 스레드
비동기 작업을 좀 더 정확하게 이해하기 위해서는 프로세스(Process)와 스레드(Thread)의 개념에 대해 알아야 한다.
1. 프로세스(Process)
프로그램을 실행하면, 운영체제(OS)는 프로그램을 실행시키기 위한 자원(RAM, CPU시간 등)을 할당하게 된다. 이렇게 운영체제로부터 자원을 할당받아 실행 중인 프로그램의 단위를 프로세스(Process)라고 한다.
OS는 프로세스마다 고유한 메모리 공간을 할당해 주며, 다음과 같은 4가지 주요 영역으로 구성된다.
- Stack : 지역 변수, 매개 변수, 리턴 주소 등을 저장. 함수가 끝나면 자동 해제.
- Heap : 동적(new)으로 할당된 메모리 공간.
- Data : 초기화된 전역 변수와 static 변수 저장.
- BSS(Data의 일부) : 초기화되지 않은 전역변수와 static 변수 저장.
- Text : 프로그램 실행 코드(기계어)를 저장.
Stack과 Heap은 서로 충돌하면 안 되니, 추가적인 여유 공간을 사이에 배치한다.
- Stack은 아래쪽으로 메모리 주소 감소. Heap은 위쪽으로 메모리 주소 증가.
프로그램(Programme) | 프로세스(Process) |
디스크에 저장된 실행 파일(예 : .exe) | 실행 중인 프로그램의 동적인 인스턴스 |
메모리에 자원이 할당되지 않음. | 코드, 데이터, Heap, Stak 등 메모리 공간이 할당됨. |
하나의 프로그램 파일. | 프로그램 파일은 여러개의 프로세스로 실행될 수 있음. |
- 쉽게 말하면 프로그램은 클래스, 프로세스는 클래스를 인스턴스화한 객체라고 보면 된다.
2. 스레드(Thread)
프로세스가 자원의 단위였다면, 스레드(Thread)는 그 프로세스 내에서 실제로 코드를 실행하는 흐름의 단위이다.
void Main()
{
A();
B();
}
- 여기서 말하는 “흐름”이란, 코드가 위에서 아래로 실행되는 경로, 즉 CPU가 따라가는 실행 경로를 의미한다.
- Main() → A() → Main() → B()의 실행 결로 전체를 따라가는 하나의 흐름을 스레드 1개라고 볼 수 있다.
예를 들자면 프로세스는 부엌이고 스레드는 그 부엌에서 일하는 요리사이다. 요리사가 한 명(단일 스레드)이라면, 하나씩 레시피를 따라가면서 요리한다.
하지만 요리사가 2명(멀티 스레드)이라면, 한 명은 국을 끓이고, 다른 한 명은 튀김하고 동시에 요리가 진행이 가능하다.
3. 내 컴퓨터엔 n개의 Thread밖에 없어요..
작업 관리자의 CPU성능 탭을 보면, 내 컴퓨터의 실제 논리 프로세서(Thread)는 24개뿐이라는 걸 확인할 수 있다. 그렇다면 정말로 동시에 실행할 수 있는 프로그램도 24개뿐일까?
- 왼쪽 하단을 보면, 실제로는 369개의 프로세스 7907개의 스레드가 실행증이다.
즉, 운영체제는 24개의 스레드 만으로 수천 개의 작업을 동시에 처리하고 있다는 건데 이 것이 가능한 이유는 다음과 같다.
가수가 4명이고 마이크가 1개뿐이라면, 모든 사람의 노래를 듣기 위해선 마이크를 매우 빠르게 넘겨주는 방법밖에 없다.
- 혹은 한 사람이 노래를 쉬는 동안(예 : 전주가 나올 때), 마이크를 옆 사람에게 넘길 수도 있다.
즉, 작업을 CPU에서 실행하다, 다른 작업의 우선순위가 더 높아지면 현재 하던 일을 잠시 멈추고 다른 작업을 실행할 수 있도록 한다.
- 이렇게 CPU가 실행 중인 하나의 스레드 또는 프로세스의 상태(Context)를 저장하고 다른 스레드/프로세스의 상태로 전환하는 과정을 컨텍스트 스위칭(Context Switching)이라 한다.
- 컨텍스트 스위칭은 스레드나 프로세스가 아닌 운영체제의 스케쥴러에 의해 발생된다.
CPU는 실제로 병렬로 작업을 수행하기도 하지만, 경우에 따라 매우 빠른 속도로 여러 작업을 번갈아 실행함으로써 마치 동시에(병렬) 실행되는 것처럼 보이게 만들기도 한다.
CPU가 직렬 위주로 동작하는 이유
CPU는 GPU와 달리 if, while, switch, 함수 호출과 같은 복잡한 제어 흐름을 처리해야 하기 때문에 실행 순서가 명확하고 일관되게 유지하는 것이 매우 중요하다.
- 또한 CPU는 속보보다는 정확성에 초점을 맞추며, 이전 연산의 결과에 다음 연산이 의존하는 경우가 많아 자연스럽게 직렬 처리 중심의 구조를 갖게 되었다.
이러한 이유로 CPU는 병렬 성능을 높이기 위해 코어 수와 스레드 수를 물리적으로 늘리는 방향(듀얼, 쿼드, 옥타, 헥사 등)으로 발전해 왔다.
■ Coroutine
Unity에서 비동기 작업을 도와주는 Coroutine은 프레임 간에 작업을 일시 중지하고 다시 재개함으로써, 작업을 여러 프레임에 분산 처리될 수 있게 해주는 구조이다.
- 즉, 실제 병렬로 실행되는 “비동기” 작업은 아니고, 메인 스레드 상에서 순차적으로 실행되는 “동기적 흐름”이다.
private IEnumerator TestCoroutine()
{
yield return null;
yield return new WaitForSeconds(1);
yield return new WaitForEndOfFrame();
}
코루틴의 반환 부분을 보면 yield(양보하다)라는 키워드를 사용해, null이나 WaitForEndOfFream 등의 대기 객체를 반환하게 된다.
- 이는 앞서 말했던 컨텍스트 스위칭처럼, yield를 만나면 작업을 중지하고 다른 작업을 실행할 수 있도록 양보하는 동작이다.
그리고 이 yield return으로 반환값에 따라 코루틴이 다시 재개되는 시점이 달라진다. 이는 유니티 이벤트 함수의 실행순서 문서에서도 확인할 수 있다.
반환 값 | 재개 시점 |
null | 다음 프레임의 Update함수 직후 |
WaitForSeconds | x초 뒤 프레임의 Update함수 직후 |
WaitForEndOfFream | 해당 프레임의 랜더링이 끝난 후. |
WaitForFixedUpdate | 다음 FixedUpdate 사이클 |
WaitUntil | 특정 조건이 참이 될 때까지 대기. 람다식으로 조건 작성. |
WaitWhile | 특정 조건이 거짓이 될 때까지 대기. |
WWW | 웹 요청이 끝날 때까지 대기. |
StartCoroutine() | 다른 코루틴이 완료될 때까지 현재 코루틴을 대기. |
private IEnumerator ParentRoutine()
{
yield return StartCoroutine("ChiledRoutine");
Debug.Log("실행") // <= ChiledRoutine()이 끝난 후 실행
}
private IEnumerator ChiledRoutine()
{
yield return new WaitForSeconds(1);
}
[중첩된 코루틴의 동작 순서]
public class CoroutineStopTest : MonoBehaviour
{
private void Start()
{
StartCoroutine("TestRoutine");
}
private IEnumerator TestRoutine()
{
var i = 0;
while (true)
{
Debug.Log(++i);
if (i > 1000)
{
Destroy(gameObject);
}
yield return new WaitForEndOfFrame();
}
}
}
[코루틴 중지]
코루틴은 MonoBehaviour에 종속된다. 따라서 해당 모노가 비활성화되거나 파괴되면, 그 안에서 실행 중이던 모든 코루틴은 자동으로 중지된다.
1. IEnumerator
코루틴의 반환 형식은 반드시 IEnumerator 형식의 객체를 반환해야 한다. (사실상 void랑 다를 게 없다..)
public interface IEnumerator
{
object Current { get; }
bool MoveNext();
void Reset();
}
[IEnumerator 인터페이스]
IEnumerator는. NET의 “열거자” 인터페이스로 컬렉션(List, Array 등)을 반복(iterate)할 수 있는 기능을 제공한다.
- Current는 현재 요소를 반환하고, MoveNext()는 다음 요소로 이동한다.
유니티의 코루틴은 이 IEnumerator를 기반으로 작동한다. 매 프레임마다 내부적으로 MoveNext()를 호출하여 코루틴을 실행하다, yield return에서 멈추고, 다음 프레임에서 다시 MoveNext()를 호출하며 작업을 이어간다.
- 즉, yield return 은 다음 프레임의 특정 시점까지의 중단을 의미하며, Unity는 그 조건을 판단해 자동으로 다음 MoveNext() 타이밍을 결정한다.
public class CustomEnumerator : IEnumerable<int>
{
public IEnumerator<int> GetEnumerator()
{
yield return 1;
yield return 1;
yield return 1;
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class Main
{
private void Start()
{
var customEnumerator = new CustomEnumerator();
foreach(var iter in customEnumerator)
{
// Do Something...
}
}
}
일반 C#에서는 IEnumerator는 위와 같이 반복 가능한 컬렉션을 순회에 쓰인다. 유니티는 이 메커니즘을 활용해 코루틴 흐름 제어에 응용하였다.
2. IEnumerable
public interface IEnumerable<T> : IEnumerable
{
IEnumerator<T> GetEnumerator();
}
IEnumerable은 IEnumerator를 반환해 주는 객체를 의미한다.
IEnumerator가 클래스 내부에 컬렉션에 대해 반복할 수 있도록 만든다면, IEnumerable은 IEnumerator를 노출시켜 준다.
- IEnumerator : 반복 작업 그 자체.
- IEnumerable : 반복을 시작하는 출발점.
항목 | 설명 |
IEnumerator | 반복 작업 그 자체. 현재 요소를 가리키며 MoveNext()로 다음 요소로 이동. |
IEnumerable | 반복을 시작하는 출발점. GetEnumerator()로 반복자를 꺼내준다. |
정리하자면 IEnumerable은 “꺼내는 기능이 있어요~”를 알려주는 거고, IEnumerator는 “내가 꺼내질 거예요~”라는 의미로 기억해 두면 된다.
상황 | 사용 타입 |
foreach를 쓸 수 있게 하고싶다. | IEnumerable 구현 |
직접 MoveNext()로 순회하고 싶다. | IEnumerator 사용 |
반복 가능한 컬렉션을 만들고 싶다. | IEnumerable + yield return 사용 |
- IEnumerable가 없으면 한 번 순회는 가능하지만, 다시 시작은 불가능하다. (시작점이 없다.)
■ Coroutine 사용법
private void Start()
{
StartCoroutine("TestRoutine");
}
코루틴을 실행시키는 StartCoroutine()에 함수 이름을 넘기는 방식은 가능하긴 하지만, 권장하진 않는다.
- 함수명을 문자열로 넘기기 때문에, 오타가 있어도 컴파일 시점에서 잡지 않는다.
- 유니티는 문자열로 넘긴 함수명을 리플렉션을 통해 찾기 때문에, 퍼포먼스상 더 느리고 비용이 큼.
public class CoroutineStopTest : MonoBehaviour
{
private IEnumerator _testEnumerator;
private Coroutine _testCoroutine;
private void Start()
{
_testEnumerator = TestRoutine(); // 코루틴 초기화만(실행 X)
StartCoroutine(_testEnumerator); // 이 시점에 코루틴 실행
_testCoroutine = StartCoroutine(TestRoutine()); // 초기화 하는 시점에 코루틴 실행
}
}
그래서 코루틴을 사용할 땐, IEnumerator이나 Coroutine 형식의 변수에 코루틴 함수를 초기화하여 사용한다.
public class CoroutineStopTest : MonoBehaviour
{
private IEnumerator _testEnumerator;
private Coroutine _testCoroutine;
private void Start()
{
_testEnumerator = TestRoutine();
StartCoroutine(_testEnumerator);
//_testCoroutine = StartCoroutine(TestRoutine());
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.E))
{
StopCoroutine(_testEnumerator);
}
if (Input.GetKeyDown(KeyCode.R))
{
StartCoroutine(_testEnumerator);
}
}
private IEnumerator TestRoutine()
{
var i = 0;
Debug.Log("코루틴 시작");
while (true)
{
Debug.Log(++i);
yield return new WaitForSeconds(0.5f);
}
Debug.Log("코루틴 중지");
}
}
▶ IEnumerator 방식
IEnumerator는 코루틴의 상태를 내부에 유지하므로, 중단되었다가 다시 실행돼도 중지된 위치부터 이어서 실행된다.
- 한 번 초기화된 IEnumerator는 상태가 남아있기 때문에 재시작이 가능하지만, 이미 종료되었다면 재시작되지 않는다.
▶ Coroutine 방식
Coroutine은 StartCoroutine() 시점에 내부적으로 새로운 IEnumerator 인스턴스를 생성한다. 즉, 다시 실행하면 처음부터 다시 시작한다.
위 형식의 특성을 파악하고 상황에 맞춰 유연하게 사용하면 된다.
private WaitForSeconds _waitForSeconds = new WaitForSeconds(1f);
private WaitForEndOfFream _waitForEndOfFream = new WaitForEndOfFrame();
private IEnumerator TestRoutine()
{
var i = 0;
Debug.Log("코루틴 시작");
while (i < 10)
{
Debug.Log(++i);
yield return _waitForSeconds;
//yield return _waitForEndOfFream;
}
Debug.Log("코루틴 중지");
}
코루틴에서 yield return new WaitForSeconds(1f)처럼 매번 new로 객체를 생성하면, 프레임마다 불필요한 메모리 할당과 GC 오버헤드가 발생한다.
- 따라서 대기 객체를 미리 캐싱해 두고 yield return에 재사용하는 방식이 성능상 유리하다.
- 단, WaitUntil이나 WaitWhile 같은 람다식(조건 함수)을 기반으로 동작하는 대기객체는 무조건 캐싱해 둘 필요는 없다. (조건이 고정되기 때문)
'Unity,C# > Unity 정보' 카테고리의 다른 글
[Unity, C#] ScriptableObject - 스크립터블 오브젝트 (6) | 2025.08.02 |
---|---|
[Unity, C#] EditorWindow - 커스텀 에디터 윈도우 (1) | 2025.08.02 |
[Unity, C#] Editor - 커스텀 인스펙터 (0) | 2025.08.02 |
[C#] LINQ(Language Integrated Query) (3) | 2025.06.27 |