[C#] async await 예제 코드 #2 (+ 동기 비동기의 개념)

 

async await 두 번째 편이자 마지막 편!

빵! 끗!

 

인트로

이틀간 밤을 새우며 stackoverflow와 저명한 C# 개발자의 개인 홈페이지에서 글을 읽으며 async await 개념을 정리했다. 아직도 궁금한 부분이 많고 이해하지 못하는 부분도 많다.

그럼에도 불구하고 포스팅을 하는 이유는 국내에 async와 await에 관한 글이 많지 않기 때문이다. 

 

※ 꽤 긴 글이 될 것 같습니다. 여러 내용 가운데 본인이 필요로 하는 지식이 있길 기원합니다.

※ 잘못된 지식이 있을 수 있습니다. 잘못된 내용이 있다면 댓글로 알려주세요. 반영하겠습니다 :)

 

async awiat의 선행 지식인 동기 비동기의 개념이 아직 부족하시다면 해당 포스팅을 참고하시기 바랍니다.

 

async await 기초 #1 (+ 동기 비동기의 개념)

 

[C#] async await 기초 #1 : 동기 비동기 개념 이해하기

인트로 C# .NET FRAMEWORK 4.5부터 추가된 async awiat 키워드에 대해서 알아보려 한다. async awiat는 서버(ex 게임 서버 웹서버)를 구축할 때 사용되는 중요한 개념 중 하나다. 블로그에 정리하고 싶었는데

kangworld.tistory.com

 

async

async 키워드는 해당 메서드 내에 await 키워드를 사용할 수 있게 만들어준다. 추가로 async 메서드의 반환 값을 일반적인 메서드와 다른 방식으로 다루도록 변경한다.

 

말이 조금 어렵다. 일단 지금은 async는 await 키워드를 메서드 내에 사용할 수 있게 만들어준다를 기억하자.

 

형식에 대해서 간단히 알아보면 async메서드는 반드시 void 또는 Task 또는 Task<T>를 반환해야 한다.

대부분 Task 혹은 Task<T>를 반환한다.

 

void를 사용하게 되면 비동기 메서드를 호출하는 쪽에서 비동기 제어할 수 없다. 종종 이벤트 핸들러로 사용할 때 void를 사용하곤 하는데 UI버튼을 클릭하면 일어나는 작업들을 비동기로 처리할 때 void를 사용하는 것이 대표적인 예시이다.

public async void MyAsyncFunc()
{

}
public async Task MyAsyncFunc()
{
	await Task.Delay(1000);
}
public async Task<int> MyAsyncFunc()
{
	await Task.Delay(1000);

	return 1;
}

 

await

await는 비동기 작업의 흐름을 제어하는 키워드라고 할 수 있다. 나아가 비동기 작업이 실행될 수 있는 곳이 바로 await이다. 

아래 코드를 보면 알 수 있듯 await는 단항 연산자이며 awaitable이라는 하나의 인수(argument)를 가진다.

awaitable
간단하게 Task 또는 Task<T>를 반환하는 함수(+메서드)라고 생각하면 된다. 

+awaitable은 void도 반환하지만 Task를 반환하는 awaitable과는 결이 다르다고 한다.
public static void Main(string[] args)
{
    MyAsyncFunc();
    Console.WriteLine("End Main");
    Console.Read();
}

public static async void MyAsyncFunc()
{
    await Task.Delay(5000);
    Console.WriteLine("End MyAsyncFunc");
}

 

메인 스레드는 MyAsyncFunc 메서드를 호출하고 await Task.Delay(5000)를 실행한다.

 

Task.Delay(5000)는 내부적으로 타이머를 사용하며 타이머는 스레드 풀의 큐에 들어가게 된다. 이후 await에 의해 작업의 흐름이 MyAsyncFunc를 호출한 호출자 스레드에게 넘어간다.

 

호출자 스레드(메인 스레드)는 "End Main"을 출력하고 5초 뒤에 Task.Delay(5000) 작업이 끝나면 스레드 풀에 있는 잉여 스레드가 "End MyAsyncFunc"를 출력된다. 중요한 점은 호출자 스레드를 5초간 Block하지 않는다는 사실이다.

C# Console 또는 Web apps의 실행 결과입니다.
Windows Forms 또는 WPF와 같이 GUI를 다루는 app은 다르게 동작합니다.

 

지금 이해가 안 되더라도 이후에 다시 설명할 테니 대충 흐름만 알고 넘어가자.

await 키워드를 만나는 순간 내부적으로 복잡한 일이 발생하는데 지금 당장 다룰 문제는 아닌 것 같다.

 

 

그럼 이제 await를 더 자세하게 이해하기 위한 예제 코드를 살펴보자

 

await 예제 #1 - Task

Task 클래스는 비동기 작업 래퍼(wrapper)이다.

이해하기 쉽게 Task.Delay(5000)를 설명하면 5초 후 완료되는 작업을 Task 형태로 래핑하는 작업을 수행한다. 참고로 Thread.Sleep(5000)는 스레드의 실행을 5초간 중지하는 반면 Task.Delay(5000)는 현재 스레드를 중지하지 않는다.

using System;
using System.Threading.Tasks;

namespace AsyncTest
{
    class Program
    {
        public static void Main(string[] args)
        {
            TaskTest();
            System.Console.WriteLine("Main Done");
        }
        private static void TaskTest()
        {
            Task.Delay(5000);
            System.Console.WriteLine("TaskTest Done");
        }
    }
}

 

실행 결과를 보면 "TaskTest Done"이 바로 출력되는 것을 확인할 수 있다.

(+ 2021.08.29 수정

5초를 기다리지 않고 "TaskTest Done"이 바로 출력되는 이유는 Task.Delay(5000);의 반환 값인 5초를 기다리는 작업(Task)을 await 하지 않았기 때문이다. Task를 await 하지 않았기에 프로그램은 5초를 기다리는 작업이 완료되길 기다리지 않고 계속 실행된다. 실제로 await 하지 않은 Task의 Status를 살펴보면 대기(WaitingForActivation)후 완료(RanToCompletion)됨을 볼 수 있다. 마치 혼자 눈 감고 5초를 셌는데 기다리는 친구는 아무도 없는 느낌이다.

 

여기서 한 가지 중요한 사실을 알 수 있다. Task와 같은 awaitable을 await 키워드 없이 사용하게 되면 작업의 종료 시점이 언제인지 알 수 없게 되며 작업을 통제할 수 없다. 작업을 통제할 수 없게 되면 때에 따라 해당 작업이 무의미할 수도 있다. 작업의 통제라는 개념이 지금은 이해가 안 될 수 있지만 후에 서술한 예제를 보면 이해하기 쉬울 것이다.)

 

await 예제 #2 - async await

이전 예제를 살펴봤다면 이런 궁금증이 들지도 모른다.

"만약 모든 Task가 비동기라면 Task.Delay(5000)는 왜 쓴 거고 async await는 도대체 뭔데? 어디에 사용하는 건데?"

 

이젠 async와 await 키워드를 사용해서 Task.Delay(5000)를 비동기적으로 수행함과 동시에 흐름을 제어해보자.

 

[변경점 1] TaskTest 메서드에 async 키워드가 추가됐다.

[변경점 2] Task.Delay(5000) 앞에 await 키워드가 추가됐다.

using System;
using System.Threading.Tasks;

namespace AsyncTest
{
    class Program
    {
        public static void Main(string[] args)
        {
            TaskTest();
            System.Console.WriteLine("Main Thread is NOT Blocked");
            Console.ReadLine();
        }
        private static async void TaskTest()
        {
            await Task.Delay(5000);
            System.Console.WriteLine("TaskTest Done");
        }
    }
}

 

대략적으로 실행 과정은 이러하다.

[1] Main 메서드 진입

[2] TaskTest 메서드 진입

[3] await Task.Delay(5000)를 실행

[4] 스레드 풀의 스레드가 Task.Delay(5000)를 실행하고 작업의 흐름이 TaskTest를 호출한 호출자에게 넘어간다.

Task.Delay는 새로운 Thread를 생성하지 않습니다.
Task.Delay는 내부적으로 thread-pool을 사용하는 timer를 사용합니다. 

[5] 호출자 스레드는 "Main Thread is NOT Blocked"를 출력한다.

[6] 5초간의 딜레이를 마치고 Task.Delay(5000)를 실행한 스레드는 "TaskTest Done"를 출력한다. 

 

await 예제 #3 - Task를 반환하는 async 메서드

비동기로 실행할 수 있는 더 현실적이고 멋진 일을 해보자.

await를 처음 설명할 때 await는 비동기 작업의 흐름을 제어하는 키워드라고 언급한 적 있다. (사실 우리는 이미 TaskTest에서 await키워드로 흐름을 제어하고 있었다!) 흐름을 제어할 수 있다는 말은 어떤 일의 순서를 정할 수 있다는 의미이기도 하다.

 

동기로 실행되는 코드에 사이에 await 키워드를 끼워 넣어 작업의 순서를 정해보고 비동기 작업이 왜 유용했는지 체감해보자.

 

[변경점 1] Main 메서드에 async 키워드가 추가됐다.

[변경점 2] Main 메서드가 Task를 반환한다.

[변경점 3] TaskTest 메서드가 Task를 반환한다.

[변경점 4] Task t = TaskTest(); 코드를 통해 TaskTest로부터 t를 반환받는다.

[변경점 5] await t; t(awaitable)이 끝날 때까지 기다린다.

[변경점 6] await t; 전후로 for문이 추가되었다.

 

이제 Main 메서드도 async 메서드이다. 즉 Main 내부에서 await 키워드를 사용할 수 있으며 TaskTest 메서드처럼 t(awaitable)이 끝나기 전까지 그다음 코드가 실행되지 않게 된다.

using System;
using System.Threading.Tasks;

namespace AsyncTest
{
    class Program
    {
        public static async Task Main(string[] args)
        {
            Task t = TaskTest();
            
            for(int i = 0; i < 10; i++)
            {
                System.Console.WriteLine("Do Something Before TaskTest");
            }

            await t;

            for (int i = 0; i < 10; i++)
            {
                System.Console.WriteLine("Do Something after TaskTest");
            }

            Console.ReadLine();
        }

        private static async Task TaskTest()
        {
            await Task.Delay(5000);
            System.Console.WriteLine("TaskTest Done");
        }
    }
}

 

실행결과에서 보듯 await를 통해 비동기 작업의 순서를 정해줄 수 있으며, Task.Delay(5000)처럼 오래 걸리는 비동기 작업이 처리되는 동안 다른 작업을 수행할 수 있다는 장점이 있다. 

 

await 예제 #4 - Task<T>를 반환하는 async 메서드

마지막이며 가장 중요한 예제이다.

 

예제 #3까지 사용했던 TaskTest 예제이다. 

private static async Task TaskTest()
{
    await Task.Delay(5000);
    System.Console.WriteLine("TaskTest Done");
}

 

이제 TaskTest를 다르게 생각해보자. Task.Delay(5000)이 그저 5초를 기다리는 작업이 아닌 DB에서 데이터를 가져온다던지 혹은 네트워크상에서 데이터를 받아온다던지, 물리적인 저장공간에서 데이터를 입력하고 읽어오는 작업 등 매우 오래 걸리는 작업이라고 생각해보자. 그리고 그 작업이 끝나면 어떤 값을 반환할 수 있다고 생각해보자.

private static async Task TaskTest()
{
    await Task.Delay(5000); // DB or Server에서 데이터 가져오기 등 매우 오래 걸리는 작업이라 생각해보자
    System.Console.WriteLine("TaskTest Done");
}

 

예를 들어 서버로부터 어떤 유저의 메신저 ID를 가져와서 반환한다고 가정하면 다음과 같이 변경할 수 있다.

private static async Task<int> TaskTest()
{
    await Task.Delay(5000); // DB or Server에서 데이터 가져오기 등 매우 오래 걸리는 작업이라 생각해보자
    System.Console.WriteLine("TaskTest Done");

    int UID = 100;

    return UID;
}

 

그리고 이 반환된 UID를 호출한 쪽에서 받아보자.

 

[변경점 1] 더 이상 Task t = TaskTest();가 아닌 제네릭이 추가된 Task<int> t = TaskTest();이다.

[변경점 2] int UID = await t;

반환 값이 있는 경우 await를 통해서 반환 값을 추출할 수 있다.
public static async Task Main(string[] args)
{
    Task<int> t = TaskTest();
            
    for(int i = 0; i < 10; i++)
    {
        System.Console.WriteLine("Do Something Before TaskTest");
    }

    int UID = await t;

    Console.WriteLine($"UserID : {UID}");

    Console.ReadLine();
}

눈치가 빠른 분들은 TaskTest 메서드의 어색한 부분을 찾았을 것이다.

이해를 돕기 위해 int UID = 100;라고 하드 코딩한 것이지 다음과 같이 코딩하는 게 일반적인 방법일 것이다.

private static async Task<int> TaskTest()
{
    int UID = await DB or server에서 UID 얻어오는 비동기 메서드 호출...;
    System.Console.WriteLine("TaskTest Done");

    return UID;
}

 

await 마무리

await 키워드를 실행하려 할 때 발생하는 상황을 정리하려 한다.

서술할 내용이 이해가 안 된다면 예제를 다시 보고 깊게 여러 번 생각하길 바랍니다.

 

async 메서드 내부의 await를 만나면 세 가지 경우로 나뉘게 된다.

 

첫 번째, awaitable이 예외를 발생한 채 끝난다면 await는 exception을 던진다. (본 포스팅에서 다루진 않았다.)

 

두 번째, awaitable이 이미 끝난 상태라면 async 메서드를, 마치 일반 메서드처럼, 동기 방식으로 계속 실행한다.

(참고 : Task.FromResult 이해하기) 

 

세 번째, awaitable이 끝나지 않았다면 작업이 끝난 후 await 이후의 나머지 코드를 실행하도록 대기 작업으로 등록하고  async 메서드의 호출자에게 Task를 반환한다.  

 

 

 

사실 몇 가지 예제로 async와 await를 이해하는 건 정말 어려운 일이다. 이해했다면 당신은 천재일지도...

언제나 그렇듯 코드로 이해하자. microsoft docs에 좋은 예제가 있어서 소개하려 한다. 

 

예제 : 동기식 코드

식당에서 음식을 준비하는 과정을 동기식으로 구현한 코드다. 로직은 다음과 같다.

 

1. 커피 한 잔을 따릅니다.

2. 팬을 데운 다음, 계란 프라이 두 개를 만듭니다.

3. 베이컨 세 조각을 튀깁니다.

4. 빵 두 조각을 굽습니다.

5. 토스트에 버터와 잼을 바릅니다.

6. 오렌지 주스 한잔을 따릅니다.

https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/concepts/async/#final-version

코드가 길다. 근데 다른 거 다 볼 필요 없이 지금은 Main 함수만 이해하면 된다.

Main에서 커피를 따르고 프라이를 만들고 베이컨을 튀기는 작업들이 순차적으로 일어나고 있다. 한 작업이 끝나야 다른 작업도 끝이 난다는 의미이다. 그 이유는 요리하는 함수 내부의 .Wait 작업자 스레드를 블로킹하기 때문이다. (동기적으로 수행된다)

일반적인 동기식 프로그래밍이 바로 이런 방식이다. 하나가 끝나야 그다음이 있다. 

using System;
using System.Threading.Tasks;

namespace AsyncBreakfast
{
    class Toast{}
    class Juice{}
    class Bacon{}
    class Egg{}
    class Coffee{}

    class Program
    {
        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!");
        }

        private static Juice PourOJ()
        {
            Console.WriteLine("Pouring orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) =>
            Console.WriteLine("Putting jam on the toast");

        private static void ApplyButter(Toast toast) =>
            Console.WriteLine("Putting butter on the toast");

        private static Toast ToastBread(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("Putting a slice of bread in the toaster");
            }
            Console.WriteLine("Start toasting...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Remove toast from toaster");

            return new Toast();
        }

        private static Bacon FryBacon(int slices)
        {
            Console.WriteLine($"putting {slices} slices of bacon in the pan");
            Console.WriteLine("cooking first side of bacon...");
            Task.Delay(3000).Wait();
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("flipping a slice of bacon");
            }
            Console.WriteLine("cooking the second side of bacon...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put bacon on plate");

            return new Bacon();
        }

        private static Egg FryEggs(int howMany)
        {
            Console.WriteLine("Warming the egg pan...");
            Task.Delay(3000).Wait();
            Console.WriteLine($"cracking {howMany} eggs");
            Console.WriteLine("cooking the eggs ...");
            Task.Delay(3000).Wait();
            Console.WriteLine("Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("Pouring coffee");
            return new Coffee();
        }
    }
}

동기식 실행 결과

 

예제 : 비동기식 코드 Version 1

Main 함수와 일부 함수가 수정되었다.

수정된 내용으로는 await가 추가되었다. Main함수 내에도 await가 있으니 async 키워드가 붙어야 한다.

실행 결과는 달라진 게 없다. 요리하는 함수 내에 await가 붙었지만 Main함수에서 요리하는 함수를 await로 호출했기 때문에 await가 코드가 끝날 때까지 순차적으로 기다려야 한다.

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!");
}

private static async Task<Toast> ToastBreadAsync(int slices)
{
    for (int slice = 0; slice < slices; slice++)
    {
    	Console.WriteLine("Putting a slice of bread in the toaster");
    }
    Console.WriteLine("Start toasting...");
    await Task.Delay(3000);
    Console.WriteLine("Remove toast from toaster");

    return new Toast();
}

private static async Task<Bacon> FryBaconAsync(int slices)
{
    Console.WriteLine($"putting {slices} slices of bacon in the pan");
    Console.WriteLine("cooking first side of bacon...");
    await Task.Delay(3000);
    for (int slice = 0; slice < slices; slice++)
    {
    	Console.WriteLine("flipping a slice of bacon");
    }
    Console.WriteLine("cooking the second side of bacon...");
    await Task.Delay(3000);
    Console.WriteLine("Put bacon on plate");

    return new Bacon();
}

private static async Task<Egg> FryEggsAsync(int howMany)
{
    Console.WriteLine("Warming the egg pan...");
    await Task.Delay(3000);
    Console.WriteLine($"cracking {howMany} eggs");
    Console.WriteLine("cooking the eggs ...");
    await Task.Delay(3000);
    Console.WriteLine("Put eggs on plate");

    return new Egg();
}

 

예제 : 비동기식 코드 Version 2

이젠 비동기식 코드로 한 단계 개선했다. 계란, 베이컨, 토스트를 만드는 일을 동시에 시작하게 되었다.

하지만 아직 아쉬운 부분은  Toast toast = await toastTask;이다. 사실 계란 프라이 또는 베이컨이 먼저 완료될 수도 있는데 무조건 토스트가 완료되길 기다리고 있기에 완벽하게 비동기라고 보긴 어렵다.

다만 이전보다 수행 속도가 눈에 띄게 차이 나기 시작했다.

static async Task Main(string[] args)
{
    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");

    Task<Egg> eggsTask = FryEggsAsync(2);
    Task<Bacon> baconTask = FryBaconAsync(3);
    Task<Toast> toastTask = ToastBreadAsync(2);

    Toast toast = await toastTask;
    ApplyButter(toast);
    ApplyJam(toast);
    Console.WriteLine("toast is ready");
    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");

    Egg eggs = await eggsTask;
    Console.WriteLine("eggs are ready");
    Bacon bacon = await baconTask;
    Console.WriteLine("bacon is ready");

    Console.WriteLine("Breakfast is ready!");
}

https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/concepts/async/#final-version

 

예제 : 비동기식 코드 Version 3

최종적인 비동기식 코드이다. List에 Task를 저장하고 가장 빠르게 조리된 음식을 출력하며 이후에 List에서 Task를 삭제한다. Version 2와 다르게 임의의 Task를 고정적으로 기다릴 필요 없이 완료된 시간에 따라 Task의 처리가 이루어진다.

 

static async Task Main(string[] args)
{
    Task.Delay(5000).Wait();

    Coffee cup = PourCoffee();
    Console.WriteLine("coffee is ready");

    var eggsTask = FryEggsAsync(2);
    var baconTask = FryBaconAsync(3);
    var toastTask = MakeToastWithButterAndJamAsync(2);

    var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
    while (breakfastTasks.Count > 0)
    {
        Task finishedTask = await Task.WhenAny(breakfastTasks);
        if (finishedTask == eggsTask)
        {
        	Console.WriteLine("eggs are ready");
        }
        else if (finishedTask == baconTask)
        {
        	Console.WriteLine("bacon is ready");
        }
        else if (finishedTask == toastTask)
        {
        	Console.WriteLine("toast is ready");
        }
        breakfastTasks.Remove(finishedTask);
    }

    Juice oj = PourOJ();
    Console.WriteLine("oj is ready");
    Console.WriteLine("Breakfast is ready!");
}

static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
{
    var toast = await ToastBreadAsync(number);
    ApplyButter(toast);
    ApplyJam(toast);

    return toast;
}

https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/concepts/async/#final-version

마치며

사실 적고 싶은 내용은 많았는데 글이 길어지니 피로도가 급격히 쌓여 생각보다 부실하게 마무리됐다..

생각나는 내용들이 있다면 계속 추가하겠습니다.

 

TODO : 본문 개념 정리하기에 코드 추가, await Task.Run vs await Task, IO bound vs CPU bound, await Task 반환 원리, 이벤트 헨들러

 

참고 자료

https://www.c-sharpcorner.com/article/async-and-await-in-c-sharp/

https://stackoverflow.com/questions/58035605/not-awaiting-an-async-call-is-still-async-right

https://blog.stephencleary.com/2012/02/async-and-await.html