비동기 프로그래밍은 정말 쉽지 않은 것 같다. 개념을 어느정도 익히고 사용하더라도 실제 개발을 진행하다보면 내 마음같이 움직이지 않는 것이 비동기 프로그래밍인 것 같다. 이왕 괴롭힌 받은 김에 비동기 프로그래밍을 정리해본다.
동기 프로그래밍
비동기 프로그래밍에 대해 알아보기 전에, 동기 프로그래밍을 먼저 살펴보자.
동기 프로그래밍 방식에서는 이전 작업이 완료되기 전까지 다음 작업이 시작되지 않으며, 요청과 요청에 대한 결과가 동시에 일어난다. 즉, 순차적으로 하나의 작업이 실행되는 방식이다. 때문에 여러가지 작업을 동시에 처리할 수 없다.
예를 들어 아침식사를 준비하는 과정을 동기적으로 진행해보자.
1번부터 7번까지의 작업을 순차적으로 진행하는데 총 30분이라는 시간이 소요되었다. 각 작업은 동기적으로 이루어지기 때문에 이전 작업이 완료되어야만 다음 작업이 진행된다. 이것을 코드로 나타내보면 아래와 같다.
using System;
using System.Threading.Tasks;
namespace AsyncBreakfast
{
// 예제 클래스
internal class Bacon { }
internal class Coffee { }
internal class Egg { }
internal class Juice { }
internal class Toast { }
class Program
{
static void Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("커피 준비 완료");
Egg eggs = FryEggs(2);
Console.WriteLine("계란 준비 완료");
Bacon bacon = FryBacon(3);
Console.WriteLine("베이컨 준비 완료");
Toast toast = ToastBread(2);
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("토스트 준비 완료");
Juice oj = PourOJ();
Console.WriteLine("오렌지 주스 준비 완료");
Console.WriteLine("아침식사 준비 완료!");
}
private static Juice PourOJ()
{
Console.WriteLine("오렌지 주스 따르기");
return new Juice();
}
private static void ApplyJam(Toast toast) =>
Console.WriteLine("토스트에 잼 바르기");
private static void ApplyButter(Toast toast) =>
Console.WriteLine("토스트에 버터 바르기");
private static Toast ToastBread(int slices)
{
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("토스트기에 빵 넣기");
}
Console.WriteLine("토스트 굽기 시작");
Task.Delay(3000).Wait();
Console.WriteLine("토스트기에서 빵 빼기");
return new Toast();
}
private static Bacon FryBacon(int slices)
{
Console.WriteLine($"프라이팬에 {slices} 올리기");
Console.WriteLine("베이컨 앞면 굽기");
Task.Delay(3000).Wait();
for (int slice = 0; slice < slices; slice++)
{
Console.WriteLine("베이컨 뒤집기");
}
Console.WriteLine("베이컨 반대면 굽기");
Task.Delay(3000).Wait();
Console.WriteLine("베이컨 접시에 담기");
return new Bacon();
}
private static Egg FryEggs(int howMany)
{
Console.WriteLine("프라이팬 달구기");
Task.Delay(3000).Wait();
Console.WriteLine($"달걀 {howMany}개 깨기");
Console.WriteLine("달갈 프라이하기");
Task.Delay(3000).Wait();
Console.WriteLine("계란프라이 접시에 담기");
return new Egg();
}
private static Coffee PourCoffee()
{
Console.WriteLine("커피 따르기");
return new Coffee();
}
}
}
동기 방식으로 준비하는 아침식사의 총 소요시간은 각 작업의 소요시간의 합과 같다. 실제 우리가 아침식사를 만들 때는 계란 프라이를 하면서 토스트기를 돌리는 등 한 번에 여러가지 작업을 하지만, 컴퓨터는 기본적으로 동기적으로 작동하기 때문에 실제보다 시간이 훨씬 많이 소요되었다.
이러한 사태를 방지하기 위해 우리는 위의 코드를 비동기적으로 실행해야 한다. 실제로 우리가 웹 서치를 할 때, 뉴스, 메일 등 다른 데이터들을 다운로드하는 동안에도 검색창에 입력이 가능하다. (다른 작업에 의해 애플리케이션이 중단되지 않는다)
이처럼 동기 프로그래밍은 순차적으로 작업을 실행하기 때문에 로직이 직관적이고 작업순서를 명확하게 파악할 수 있다는 장점이 있다. 하지만 시간이 오래 걸리는 작업이 있는 경우(I/O, 네트워크 요청 등) 프로그램이 Blocking되는 문제가 발생할 수 있으며 전체 작업을 실행하는데 소요시간이 오래 걸릴 수 있다.
이러한 상황에 필요한 것이 비동기 프로그래밍이다.
비동기 프로그래밍
비동기 프로그래밍에서는 동기 프로그래밍과는 반대로 진행 중인 작업이 끝나지 않더라도, 다음 작업을 시작할 수 있다. 작업이 병렬적으로 이루어질 수 있기 때문에 1번 작업이 완료되기를 기다리기 않고, 다음 작업들을 동시에 처리가능하다.
이로 인해 작업 완료 시점을 직접 관리해야 하므로 코드가 복잡해질 수 있다는 단점이 있지만, 애플리케이션의 응답성을 유지할 수 있다는 매우 큰 장점이 있다.
C#에서 비동기 프로그래밍은 오랜 발전 과정을 거쳐왔다. 이전에는 비동기를 위해 콜백(callback), 이벤트(event)를 주로 사용했었지만 현재는 C# 5.0에서 출시된 async와 await 키워드가 도입되면서 비동기 프로그래밍이 단순화되었다.
async와 await는 반응성(responsiveness)과 확장성(scalability)을 갖춘 애플리케이션을 개발하는 데 필수적이다. 현대 애플리케이션은 인터넷에서 데이터 가져오기, 파일 처리(I/O), 복잡한 계산 수행 등 다양한 작업을 동시에 처리해야 하는 경우가 많기 때문이다. async-await는 이러한 작업을 별도의 스레드에서 처리하도록 함으로써 메인 스레드가 반응성을 유지하고 사용자 상호작용을 처리할 수 있도록 한다.
Async
async는 메서드를 비동기로 표현하는 데 사용된다. async가 있는 메서드는 비차단 작업(Non-Blocking)을 수행하며 Task 또는 Task<TResult> 객체를 리턴한다.(void도 사용은 가능)
asnyc의 특징
- 메서드, 람다 표현식, 익명 메서드에 적용 가능
- 최소한 하나 이상의 await 표현식을 포함
- 여러 await 표현식을 포함할 수 있어 여러 비차단 작업을 처리 가능
- 비동기 메서드 체이닝을 통해 복잡한 비동기 워크플로우를 구성가능
Await
await는 비동기 메서드 내에서 사용되며 작업이 완료될 때까지 실행을 일시 중단하고 호출 메서드로 제어를 반환한다.
이를 통해 다른 작업이 동시에 실행될 수 있도록 하여 애플리케이션의 반응성을 유지한다.
await의 특징
- 비동기 메서드 내에서만 사용 가능
- Task 또는 Task<TResult> 객체를 반환하는 모든 표현식에 적용 가능
- Task<TResult>의 결과를 자동으로 풀어서 작업 결과를 직접 사용 가능.
- await된 작업에서 발생한 예외를 자동으로 처리하여 호출 메서드에서 처리 가능
async-await를 사용하면 비교적 단순화된 코드를 작성할 수 있으며 효율적인 자원 활용이 가능하다. 또한, 비동기 코드에서 예외처리를 쉽게 만들어 준다는 장점을 가질 수 있다. 그럼 asnyc-await는 어떻게 사용할까?
아래 코드 구조는 UI 응답성을 유지하면서 비동기 작업과 동기 작업을 조합한 일반적인 패턴을 보여준다.
// async를 사용하여 비동기 메서드를 작성
// 비동기 메서드 FetchDataAsync()를 호출한 후, ProcessData 동기처리
public async Task<string> FetchAndProcessDataAsync()
{
string rawData = await FetchDataAsync();
string processedData = ProcessData(rawData);
return processedData;
}
private string ProcessData(string rawData)
{
return rawData.ToUpper();
}
// 비동기 메서드 선언
public async Task<string> FetchDataAsync()
{
using (var httpClient = new HttpClient())
{
string result = await httpClient.GetStringAsync("https://example.com/data");
return result;
}
}
- FetchAndProcessDataAsync 메서드 실행 → 비동기 호출로 작업 시작.
- FetchDataAsync → HTTP 요청이 비동기로 진행되며 호출자에게 제어 반환.
- HTTP 응답 완료 → 데이터를 받아 FetchAndProcessDataAsync로 제어 반환.
- ProcessData 실행 → 동기적으로 데이터를 처리.
- 최종 데이터 반환 → 모든 작업 완료.
*HTTP요청 외에 DB쿼리, 파일I/O를 할 때도 비동기 메서드를 사용하여 더 많은 요청을 동시처리할 수 있다.
// DB쿼리
public async Task<List<Customer>> GetCustomersAsync()
{
using (var context = new MyDbContext())
{
return await context.Customers.ToListAsync();
}
}
// 파일 업로드 및 처리
public async Task<string> SaveUploadedFileAsync(IFormFile file)
{
var targetPath = Path.Combine("uploads", file.FileName);
using (var fileStream = new FileStream(targetPath, FileMode.Create))
{
await file.CopyToAsync(fileStream);
}
return targetPath;
}
이번에는 비동기 방식으로 아침식사를 준비해 보자. 이전과는 다르게 계란프라이를 하면서 베이컨과 빵을 굽는다. 동시에 병렬적으로 작업을 처리하기 때문에 이전보다 훨씬 빠르게 아침식사를 준비할 수 있게 되었다.
위 과정을 다시 코드로 나타내보자.
static async Task Main(string[] args)
{
Coffee cup = PourCoffee();
Console.WriteLine("커피 준비 완료");
var eggsTask = FryEggsAsync(2);
var baconTask = FryBaconAsync(3);
var toastTask = MakeToastWithButterAndJamAsync(2);
var eggs = await eggsTask;
Console.WriteLine("계란 준비 완료");
var bacon = await baconTask;
Console.WriteLine("베이컨 준비 완료");
var toast = await toastTask;
Console.WriteLine("토스트 준비 완료");
Juice oj = PourOJ();
Console.WriteLine("오렌지 주스 준비 완료");
Console.WriteLine("아침식사 준비 완료!");
}
static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
var toast = await ToastBreadAsync(number);
ApplyButter(toast);
ApplyJam(toast);
return toast;
}
*Task는 C#(.NET)에서 비동기 작업을 표현하는 객체. 비동기 메서드를 통해 시작된 작업의 상태 및 결과를 추적할 수 있으며, 작업 완료 시 다양한 후속 작업을 처리할 수 있음
코드 진행 순서를 표현해보면 아래와 같다.
- 커피를 따르는 작업 시작
- Coffee cup = PourCoffee();
- 커피가 즉시 준비되며, "커피 준비 완료" 메시지가 출력
- 달걀 프라이 비동기 작업 시작
- Task<Egg> eggsTask = FryEggsAsync(2);
- 달걀을 굽는 작업이 비동기로 시작. 결과를 기다리지 않고 다음으로 진행.
- 베이컨 프라이 비동기 작업 시작
- Task<Bacon> baconTask = FryBaconAsync(3);
- 베이컨을 굽는 작업이 비동기로 시작. 결과를 기다리지 않고 다음으로 진행.
- 빵 굽기 비동기 작업 시작
- Task<Toast> toastTask = ToastBreadAsync(2);
- 빵을 굽는 작업이 비동기로 시작. 결과를 기다리지 않고 다음으로 진행.
- 빵 작업 완료 대기
- Toast toast = await toastTask;
- 빵 굽기 작업이 완료 대기. 완료 후 버터와 잼을 바르고 "토스트 준비 완료" 메시지를 출력.
- 오렌지 주스 준비
- Juice oj = PourOJ();
- 주스는 동기적으로 준비되며, "오렌지 주스 준비 완료" 메시지를 출력.
- 달걀 작업 완료 대기
- Egg eggs = await eggsTask;
- 달걀 프라이 작업이 완료 대기. 완료 후 "계란 준비 완료" 메시지를 출력.
- 베이컨 작업 완료 대기
- Bacon bacon = await baconTask;
- 베이컨 프라이 작업이 완료 대기. 완료 후 "베이컨 준비 완료" 메시지를 출력.
- 아침식사 준비 완료
- 모든 작업이 완료된 후 "아침식사 준비 완료!" 메시지가 출력.
이처럼 비동기 프로그래밍을 통해 독립적인 작업들을 동시에 시작하고, 각 작업이 완료되면 준비된 다른 작업을 계속할 수 있기 때문에 총 소요시간이 줄어들게 된다.
요약하자면 동기는 로직이 매우 간단하고 직관적일 수 있지만, 작업 요청에 대한 결과가 주어질 때까지 대기해야하는 시간이 많을 수 있다는 단점이 있고, 비동기는 동기보다 사용하기가 복잡하지만 작업을 병렬적으로 수행할 수 있기 때문에 자원을 효율적으로 사용할 수 있다는 장점이 있다.
결국 상황에 맞게 동기, 비동기 프로그래밍을 적절하게 사용하는 것이 매우 중요하다.
'C#' 카테고리의 다른 글
[C#] Byte 크기로 문자열 길이 제한하기 (0) | 2024.08.29 |
---|---|
[C#] IOException: Sharing violation on path 에러 해결 (0) | 2024.08.22 |
[C#] InstallUtil.exe로 Windows Service 설치 시 에러 (0) | 2023.12.22 |
[C#,MSSQL] The timeout period elapsed prior to obtaining a connection from the pool 에러 해결 (1) | 2023.11.19 |
[SOAP,C#] SOAP 통신 하는법 (0) | 2023.11.03 |