프로그램의 실행을 비동기적으로 처리하는, 즉 하나의 작업이 완료될 때까지 기다리지 않고 다른 작업을 수행할 수 있도록 하는 프로그래밍 방식이다.
아침 식사로 예를 들자면,
1. 커피 한 잔을 따릅니다.
2. 팬을 가열한 다음 계란 두 개를 볶습니다.
3. 베이컨 세 조각을 튀깁니다.
4. 빵 두 조각을 굽습니다.
5. 토스트에 버터와 잼을 바릅니다.
6. 오렌지 주스 한잔을 따릅니다.
라는 6단계를, 시간이 많이 걸리는 가열 작업들을 동시에 처리하는 것으로
1. 커피 한 잔을 따릅니다.
2. 팬을 가열한 다음 계란 두 개를 볶습니다. && 베이컨 세 조각을 같이 튀깁니다. && 빵 두 조각을 같이 굽습니다.
3. 토스트에 버터와 잼을 바릅니다.
4. 오렌지 주스 한잔을 따릅니다.
와 같이 단축할 수 있다.
이처럼 시간이 오래 걸리는 작업 때문에 프로그램이 멈추는 병목 현상을 피하고 작업 시간을 줄이기 위해 비동기 프로그래밍은 사용된다.
특히, 작업이 언제 마무리될지 시간을 가늠하기 어렵고, 그 시간 동안 프로세스가 멈추어선 안되는 작업에 많이 사용된다.
즉 다음과 같은 장점이 있어 비동기는 활용된다.
자원을 효율적으로 활용할 수 있다.
메인 스레드를 막지 않고 긴 작업을 수행할 수 있다.
C# 비동기 프로그래밍의 대표적인 구조이다.
async
키워드를 붙인 메소드가 await
으로 비동기 작업의 완료 시점까지 대기한다.
Task
또는 Task<T>
를 반환형으로 사용하며, 내부에서 await
키워드를 통해 실제로 비동기 메서드를 호출한다.
동기 코드
static void Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
Egg eggs = FryEggs(2);
Console.WriteLine("eggs are ready");
Bacon bacon = FryBacon(3);
Console.WriteLine("bacon is ready");
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
}
비동기 코드
static async Task Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("coffee is ready");
Egg eggs = await FryEggsAsync(2);
Console.WriteLine("eggs are ready");
Bacon bacon = await FryBaconAsync(3);
Console.WriteLine("bacon is ready");
Toast toast = await ToastBreadAsync(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("toast is ready");
Juice oj = PourOJ();
Console.WriteLine("oj is ready");
Console.WriteLine("Breakfast is ready!");
}
async
가 붙은 각 작업(FryEggsAsync 등)이 실행되는 동안 스레드가 차단되지 않고, 작업이 완료되면 이어서 계속하게 된다..NET에서 권장하는 비동기 설계 방식으로, Task
객체를 통해 비동기 작업을 표현한다.
TaskCompletionSource
나 CancellationToken
등을 함께 사용해 작업 완료, 취소, 예외 처리를 체계적으로 관리할 수 있다.
후에 더 자세히 조사해 내용을 추가하자.
async
키워드를 사용해 메소드를 정의하는 것으로 해당 메소드가 비동기적으로 동작함을 선언한다.
반환형은 주로 Task
혹은 Task<T>
를 사용하며, 경우에 따라 ValueTask
또는 void
가 될 수 있으나, void
는 이벤트 핸들러 등 특수한 경우에만 권장된다.
일반적인 형태
public async Task MyAsyncMethod()
{
// 비동기로 처리할 로직
}
await
키워드로 비동기 작업을 호출하고, 그 완료 시점을 자연스럽게 기다릴 수 있다.
await
키워드가 붙은 작업은 호출할 때 스레드를 블로킹하지 않고 별개 스레드에 비동기적으로 진행된다. 작업이 끝날 때까지 다른 스레드가 정상적으로 동작한다.
일반적인 형태
public async Task<string> DownloadDataAsync(string url)
{
using (var client = new HttpClient())
{
// 실제 비동기 메서드 (GetStringAsync)는 Task<string>을 반환
// await를 붙여서 비동기 완료 시점을 기다린다
string data = await client.GetStringAsync(url);
return data; // 작업 완료 후 결과 반환
}
}
async/await
패턴을 사용하면, 컴파일러가 자동으로 메서드를 “상태 기계(State Machine)” 형태로 변환한다.
이는 await
키워드로 실행 흐름이 일시 정지된 경우, 함수 내부에 여러 개의 중단점을 설정하고 어떤 단계에서 멈췄는지를 기억해야 한다.
C# 컴파일러는 이러한 처리를 위해 메소드를 "상태 기계" 형태로 변환하여, 각 await
지점마다 메소드의 실행 상태(현재 위치, 지역 변수, 예외 처리 상태 등)를 보관한다.
async/await
뿐만 아니라 C#의 코루틴 같은 yield return
(이터레이터) 문법도 유사한 방식으로 구현된다.
호출 측에서는 메서드가 즉시 Task
(또는 Task<T>
)를 반환하므로, 스레드가 블로킹되지 않고 비동기 실행이 진행된다.
await
키워드 기준으로 코드가 분할되어, 비동기 작업이 완료되면 메서드가 중단된 지점부터 다시 실행을 이어간다.
예시 코드
public async Task ProcessDataAsync()
{
// 비동기 메서드 호출
string result = await DownloadDataAsync("http://example.com");
Console.WriteLine(result);
// 다운로드 후, 추가 로직 수행
// (다운로드가 끝날 때까지 메서드의 나머지 부분이 자동으로 대기)
}
위 코드에서 ProcessDataAsync
는 DownloadDataAsync
가 끝날 때까지 프로그램이 멈추지 않고 다른 작업을 수행할 수 있다.
await
이후 로직(출력 등)은 다운로드가 완료된 후에 실행된다.
예외 처리
await
구문 내부에서 발생하는 예외는 try/catch
구문으로 일반적으로 잡을 수 있다.
예시
public async Task SafeDownloadAsync()
{
try
{
string data = await DownloadDataAsync("http://example.com");
// 성공 시 처리
}
catch (Exception ex)
{
// 예외 처리
}
}
취소(CancellationToken)
Cancellation
파라미터를 받고, 비동기 작업 도중에 token.ThrowIfCancellationRequested()
등을 적절히 호출해줄 수 있다.Task
: 결과값이 없는 단순 비동기 작업
Task<T>
: 비동기 처리 후 결과값이 있는 작업
ValueTask
/ ValueTask<T>
: .NET Core 2.1 이상부터 지원하는 반환형으로, 오버헤드가 큰 경우 성능 최적화를 위해 사용할 수 있으나, 사용 시 주의사항이 많다.
void
: 이벤트 핸들러 등의 특수 상황에서만 사용하도록 한다. 예외 전파가 어렵고 호출 측에서 작업 완료나 예외를 추적하기 불편하므로 일반 메소드 구현에는 지양하자.
/// <summary>
/// [250102 Jinho]
/// 해당 기기가 갖는 기능 권한 내용을 설정
/// 비동기적으로, 0.5초씩 딜레이를 넣어 권한을 매핑
/// </summary>
private async void SetPermission()
{
if (permissions == null)
{
permissions = new Dictionary<PermissionType, bool>();
}
foreach (PermissionType p in System.Enum.GetValues(typeof(PermissionType)))
{
await Task.Delay(500);
permissions[p] = GetPermissionQuery(p);
UnityEngine.Debug.Log("permission:" + p);
}
}
/// <summary>
/// 프레임 큐에서 프레임을 가져와 FFmpeg에 인코딩하도록 전달하는 메서드입니다.
/// </summary>
/// <param name="frameQueue">인코딩할 프레임 큐입니다.</param>
/// <param name="ffmpegStream">FFmpeg의 표준 입력 스트림입니다.</param>
/// <param name="queueLock">프레임 큐에 대한 동기화 잠금 객체입니다.</param>
/// <remarks>
/// 이 메서드는 큐에서 프레임을 가져와 FFmpeg로 전달하며, 녹화가 종료될 때까지 계속 실행됩니다.
/// 녹화가 종료되면 FFmpeg 스트림을 닫습니다.
/// </remarks>
private async void EncodeFrames(Queue<byte[]> frameQueue, Stream ffmpegStream, object queueLock)
{
while (isRecording || frameQueue.Count > 0)
{
byte[] frame = null;
lock (queueLock)
{
if (frameQueue.Count > 0)
frame = frameQueue.Dequeue();
}
if (frame != null)
{
await ffmpegStream.WriteAsync(frame, 0, frame.Length);
await ffmpegStream.FlushAsync();
}
else
{
await Task.Delay(1);
}
}
ffmpegStream.Close();
}
Thread.Sleep(밀리세컨드)
호출한 스레드를 지정된 시간만큼 정지시킨다.
UI 스레드에서 호출하면 화면과 이벤트 처리가 멈춘다.
일반적인 UI 스레드에서 권장되지 않는다.
Task.Delay(밀리세컨드)
지정된 시간 동안 비동기로 대기하고, 그동안 스레드를 정지하지 않는다.
async/await
키워드와 같이 사용하면 UI 프리즈 없이 자연스럽게 지연된다.
DispatcherTimer
, System.TimeSpan
, System.Timers.Timer
등 주기적 호출을 사용한 지연 및 시간 재기도 가능하다.
///// timeDelay01 - All Stop 멈춤
private void btn01_Click(object sender, RoutedEventArgs e)
{
lbl01.Content = DateTime.Now.ToString("G") + " StartTime \r\n";
Thread.Sleep(3000); // 1000은 1초
lbl01.Content += DateTime.Now.ToString("G") + " EndTime \r\n";
}
///// timeDelay02 - 창은 멈추지 않으나 마우스로 창 이동중에는 시간이 흐르지 않음
void timeDelay02(int tDelaySecond)
{
DateTime dtStart = DateTime.Now;
TimeSpan firstTime = new TimeSpan(DateTime.Now.Ticks);
while (firstTime.Ticks + (tDelaySecond * 10000000) >= DateTime.Now.Ticks)
{
TimeSpan elapsedSpan = new TimeSpan(DateTime.Now.Ticks - firstTime.Ticks);
lbl02.Content =
dtStart + " StartTime \r\n"
+ dtStart.AddSeconds(tDelaySecond) + " EndTime \r\n"
+ elapsedSpan.Seconds.ToString();
this.Dispatcher.Invoke((ThreadStart)(() => { }), DispatcherPriority.Input);
}
}
private void btn02_Click(object sender, RoutedEventArgs e)
{
timeDelay02(3); // Second
MessageBox.Show("The End");
}
///// timeDelay03 - 백그라운드에서 시간을 재는 가장 단순하고 좋은 방법
async private void timeDelay03(int tDelaySecond)
{
await Task.Delay(tDelaySecond);
lbl03.Content += DateTime.Now.ToString("G") + " EndTime \r\n";
MessageBox.Show("Completed");
}
private void btn03_Click(object sender, RoutedEventArgs e)
{
lbl03.Content = DateTime.Now.ToString("G") + " StartTime \r\n";
timeDelay03(1000 * 3); // 1000 = 1 Second
MessageBox.Show("The End");
}
///// timeDelay04 - 백그라운드에서 시간을 재는 또다른 방법
private void btn04_Click(object sender, RoutedEventArgs e)
{
lbl04.Content = DateTime.Now.ToString("G") + " StartTime \r\n";
DispatcherTimer timer = new DispatcherTimer();
timer.Interval = TimeSpan.FromSeconds(3);
timer.Tick += (s, a) =>
{
lbl04.Content += DateTime.Now.ToString("G") + " EndTime \r\n";
MessageBox.Show("Completed");
timer.Stop();
};
timer.Start();
MessageBox.Show("The End");
}
유니티에서의 로직은 라이프 사이클처럼 보통 동기 방식으로 돌아간다. 그 사이에 흐름이나 시간을 조절하는 등 비동기적인 작업은 코루틴을 사용해 구현된다.
코루틴은 메소드의 호출과 반환이 같은 프레임에 완료되어야 하는 일반적인 메소드들과 달리, 작업을 여러 프레임에 분산시킬 수 있는 Unity에서 제공하는 메소드이다.
Unity에서 코루틴은 실행을 일시 중지(yield
)하고 Unity에 제어권을 반환한 다음, 다음 프레임에서 중단된 부분(yield
)부터 계속할 수 있는 메소드이다.
yield
: yield
를 사용하여 메소드의 호출자에게 제어권을 양보할 수 있다.다음과 같은 경우에 코루틴은 사용된다.
여러 프레임 단위에 걸쳐 처리해야하는 작업을 해야하는 경우
한 프레임 단위에서 처리하기에는 작업이 너무 오래 걸리는 경우
코루틴은 함수의 실행 흐름 자체를 제어하며, 작업을 멈췄다가 계속하는 방식으로, 결국 Main Thread에서 모든 작업이 이루어지기에, 비동기적인 방식이지만 멀티 스레드는 아니다.
Async/Await은 await
키워드로 표시되는 비동기 작업 완료 시점에 그 스레드로 돌아와 코드를 이어 실행하는 방식으로, 복수의 스레드를 사용하는 비동기적 방식이다.
두 개 이상의 스레드가 공유 데이터에 접근하는 과정에서 데드락
, 레이스 컨디션
같은 동기화 문제가 발생할 수 있다.
이를 락(Lock)
, 뮤텍스(Mutex)
나 세마포어(Semaphore)
등의 동기화 수단을 사용하거나, 애초에 불변하도록 구조를 짜는 것으로 방지해야 한다.
비동기 메소드의 예외 흐름
try/catch
구문 안에서 await
키워드를 사용하면, 해당 비동기 호출에서 발생하는 예외도 catch
블록에서 잡을 수 있다.
비동기 메소드 내에서 throw
된 예외가 최상위까지 전파될 경우, Task.Exception
또는 AggregateException
형태로 포착된다.
비동기 이벤트 핸들러
WPF/WinForms 등의 이벤트 핸들러에서 async void
형태로 사용할 때, 예외 처리가 누락되기 쉬우므로 주의한다.
가급적이면 이벤트 핸들러를 async Task
형태로 사용하거나, DispatcherUnhandledException
등의 글로벌 핸들러로 감싼다.
CancellationToken
긴 시간 소요 작업(예: 네트워크 요청, 대용량 파일 처리 등)에서는 취소 기능이 중요하다.
메소드를 async
로 만들 때 CancellationToken
매개변수를 받아, 도중에 작업을 중단할 수 있도록 구현한다.
타임아웃
HttpClient
등 외부 I/O 호출 시 타임아웃을 설정해 무한 대기를 방지한다.
필요에 따라 Task.WhenAny
등을 사용해 일정 시간 내 작업 미완료 시 다른 로직으로 진행하는 패턴을 사용할 수 있다.
async
메소드 이름에는 "Async" 접미사를 붙이는 것이 관례이다. 반환형은 보통 Task
나 Task<T>
로 한다.
여러 단계의 비동기 호출이 연쇄적으로 이어질 경우, 작업 단위를 명확히 구분하여 재활용 가능하게 구성한다.
간단한 작업에서의 CancellationToken 사용 예시
using System;
using System.Threading;
using System.Threading.Tasks;
public class CancellationExample
{
public async Task DoWorkAsync(CancellationToken cancellationToken)
{
Console.WriteLine("작업 시작...");
for (int i = 0; i < 10; i++)
{
// 토큰이 취소 상태인지 확인
if (cancellationToken.IsCancellationRequested)
{
Console.WriteLine("작업 취소 요청됨.");
// 필요한 정리 로직이 있다면 추가
return;
}
// 시뮬레이션용 비동기 대기
await Task.Delay(500);
Console.WriteLine($"작업 진행 중... {i + 1}/10");
}
Console.WriteLine("작업 완료!");
}
}
cancellationToken.IsCancellationRequested
)하고, 취소되면 즉시 메서드를 종료한다.HttpClient
에서 취소 토큰 사용 예시
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
public class HttpDownloadExample
{
private static readonly HttpClient _client = new HttpClient();
public async Task<string> DownloadStringWithCancellationAsync(string url, CancellationToken cancellationToken)
{
// HttpClient의 GetAsync, GetStringAsync 등은 CancellationToken을 받는 오버로드가 있음.
HttpResponseMessage response = await _client.GetAsync(url, cancellationToken);
// 응답 상태코드가 성공이 아니면 예외 발생
response.EnsureSuccessStatusCode();
// 응답 본문을 문자열로 읽음
string content = await response.Content.ReadAsStringAsync(cancellationToken);
return content;
}
}
HttpClient
의 GetAsync
, ReadAsStringAsync
메소드는 취소 토큰을 인자로 받아서, 네트워크 작업 도중에 토큰이 취소 신호를 감지하면 OperationCanceledException
예외를 던진다.호출부에서의 사용 예시
using System;
using System.Threading;
using System.Threading.Tasks;
public class Caller
{
public async Task RunAsync()
{
using var cts = new CancellationTokenSource();
// 지정된 시간 후 자동 취소: 3초 뒤 취소
cts.CancelAfter(TimeSpan.FromSeconds(3));
var worker = new CancellationExample();
try
{
// 비동기 작업에 취소 토큰 전달
await worker.DoWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("비동기 작업이 취소되었습니다.");
}
}
}
CancellationTokenSource
: 취소 요청을 발생시키기 위한 객체
CancelAfter(TimeSpan)
으로 일정 시간 이후 자동으로 Cancel()
을 호출해줄 수 있다.cts.Token
: 실제 작업에 넘길 취소 토큰(CancellationToken
)으로, 작업 내부에서 IsCancellationRequested
확인 혹은 API에서 직접 지원하는 방식으로 사용한다.
취소가 발생하면 OperationCanceledException
이 발생하고, 이를 try/catch
로 처리할 수 있다.
Unity에서의 코루틴
IEnumerator
형태의 메소드에 yield return
으로 프레임 단위나 특정 조건에서 실행을 제어한다.
코루틴은 Unity 메인 스레드를 기반으로 동작하므로, 무거운 작업(네트워크, 대용량 처리 등)은 코루틴에서 직접 돌기보단 async/await
또는 별도 쓰레드를 사용하는 것이 낫다.
상태 제어
Pause/Resume
제어가 쉽지만, 중단되는 지점이 많으면 프로그램 흐름이 복잡해진다. 각 코루틴의 목적과 중단 조건을 명확히 설계한다.코루틴 정리
코루틴은 스케줄링, 비동기는 I/O
코루틴은 게임 루프나 특정 타이밍 제어(프레임에서 매번 조금씩 작업)를 다루기 좋다.
비동기는 I/O 작업(HTTP 요청, DB 쿼리, 파일 I/O 등)을 다루기 좋다.
두 기법을 필요에 따라 적절히 사용하되, 각각의 책임 범위를 분명히 나누어야 한다.
에러 핸들링 일관성
Unity 코루틴에서 예외가 발생하면 별도 로깅이 없으면 알기 어려울 수 있다.
async
메소드와 결합된 경우, 예외 처리를 코루틴 내부와 비동기 메소드 내부 어디서 할지 미리 정해둔다.
LINQ, Rx, UniTask 등 활용
Unity 환경에서 코루틴과 async/await을 쉽게 혼합할 수 있도록 도와주는 UniTask와 같은 라이브러리를 사용하면 코드를 단순화할 수 있다.
Reactive Extensions(Rx)를 통해 이벤트 기반으로 비동기 흐름을 선언형으로 작성할 수도 있다.
단일 책임 원칙(SRP)
추상화(Abstraction)와 테스트
리팩토링 주기
비동기 프로그래밍은 긴 시간 소요 작업으로 인해 UI나 다른 작업이 멈추지 않도록 하는 핵심 기술로, C#에서는 async/await
와 Task
가 있다.
코루틴은 중단과 재개 매커니즘으로, 게임 엔진 또는 특정 시점 제어가 필요한 환경에서 유용하지만, 일반적인 비동기 프로그래밍과는 설계가 다르다.
비동기를 실제로 적용할 때는 UI 스레드를 정지시키지 않는지와 예외 처리, 동기화 문제(데드락, 레이스 컨디션), 성능 모니터링 등에 각별히 주의해야 한다.