async, await: 비동기 작업을 위한 키워드

글쓴이: Ch. (sftblw) , 2016년 10월 18일

몇 주 전에 게임 엔진을 찾아본다며 여기저기 찾아보다가, 유니티와 비슷하다는 어떤 C# 엔진에 도달했는데, C#을 자주 써온 저도 모르는 이상한 키워드를 스크린샷에서 발견했습니다. 다름아닌 async, await 두 키워드였습니다. 궁금한 나머지 찾아보게 되었는데, 어찌저찌 트위터에서도 도움을 얻게 되어, 대략적인 이해를 할 수 있었습니다. 그런 고로 짧게 소개해볼까 합니다.

disclaimer: 완전히 이해하고 쓰는 것이 아니라 옳지 못한 내용이 있을 수도 있습니다. 지적 감사히 받겠습니다.

async가 선언된 함수, 비동기 함수렸다

일단 이 두 키워드의 의도는 비동기 작업입니다. node.js 사용자라면 익히 들어왔을 말인데, 입출력 등 프로그램 동작에 병목 현상이 되는 부분이 완료되도록 시스템에 맡겨두고, 프로그램의 나머지 부분을 계속 진행하면 성능을 대폭 향상할 수 있죠. 이러한 작업을 프로그래머가 구현하려면 상당히 성가신데, 이걸 언어 차원에서 지원해주는 것이 asyncawait죠.

async는 메소드를 선언할 때 한정자로서 사용할 수 있습니다. 이게 무슨 소리냐면, 이 함수는 비동기 함수다! 라는 말을 하기 위해 public 같은 키워드를 쓰는 자리에 async를 같이 써준다는 말입니다. 예를 들면 이렇게요.

// MyASyncFunc()는 비동기 함수다!!!
//     ↓↓
public async void MyAsyncFunc() {
}

비동기 함수란, 비동기 함수의 실행이 끝나지 않아도 언제든지 원래 실행하던 곳으로 돌아가서 진행될 수 있는 함수입니다. 그러니까 비동기 함수에 출력문으로 1, 2, 3 을 출력하게 해놓으면 1만 출력하고 원래 실행하던 곳으로 돌아와서 계속 실행될 수 있다는 거죠.

그럼 비동기 함수는 어중간하게 실행하고 끝내는 함수냐? 아뇨, 실행을 마칠 수 있는 시점이 되면 거기로 돌아가서 마저 실행을 합니다. 앞의 예제라면 2, 3 을 출력하고 끝내겠네요.

예를 들자면 이렇습니다. (클래스는 생략했습니다.)

public async void MyAsyncFunc() {
    Console.WriteLine('MyAsync 1');

    SomeIOFunction(); // 무언가 오래 걸리는 입출력 작업

    Console.WriteLine('MyAsync 2');
    Console.WriteLine('MyAsync 3');
}
public static void Main() {
    Console.WriteLine('Main 1');
    MyASyncFunc();
    Console.WriteLine('Main 2');
    // 출력 결과물은
    // Main 1
    // MyAsync 1
    // Main 2
    // MyAsync 2
    // MyAsync 3
}

신기하죠? 알아서 마저 실행하러 돌아갑니다. 이건 내부적으로는 이벤트 루프 같은 것으로 구현되었다고 생각하면 편합니다. 실행하던 비동기 함수를 이벤트 루프? 큐? 에 넣고, 하던 일을 마저 하러 비동기 함수를 호출했던 부분으로 돌아갑니다. 그 후, 비동기 함수의 오래 걸리는 작업이 끝났다면 이벤트 루프에서 비동기 함수를 재개하도록 호출하는 거죠. (아쉽게도 전 정확하게는 모릅니다… 언어별로 구현이 다르지 않을까 싶네요.)

그 작업을 하염없이 기다리는 await

하나 빼먹은게 있죠. await 키워드입니다. awaitasync가 선언된 함수, 즉 비동기 함수 안에서 사용할 수 있는 연산자 입니다 (C# 기준). 웬 연산자? 왜냐면 지금 실행하고 있는 비동기 함수 안에서 또다른 비동기 함수를 호출할 때 쓰거든요.

기다리지 않고 원래 호출한 곳으로 돌아갈 수 있다면, 오히려 기다릴 수도 있어야 합니다. 좀 헷갈릴 테니까 이름을 붙여볼까요. Main 함수가 있고, Async1 함수가 있고, Async2 함수가 있다고 해봅시다. 그리고, 이 부분이 중요한데, (MainAsync1을 호출하는 건 아무래도 좋고) Async1Async2를 호출할 겁니다.

public void Main() { Async1(); }
public void Async1() { /* 뭐시기1; */ Async2(); /* 뭐시기2; */ }
public void Async2() { /* 뭐시기3; */ }

이름에서 알 수 있듯이 둘 다 비동기 함수라, 함수가 완전히 끝나던 말던 원래 실행하던 곳으로 돌아가서 마저 실행하다가, 각각의 비동기 함수를 끝낼 수 있는 상황이 오면 그 부분으로 돌아가서 마저 실행을 할 겁니다. 근데 말이죠, Async1 에서 Async2를 호출한 다음에 쓰인 코드, 위에서는 뭐시기2;Async2가 다 끝난 뒤에서야 동작할 필요가 있다면요? Async2가 다 끝나기를 요구하는 코드라면요?

이 때 await라는 키워드를 사용합니다. await Async2()로 호출하면, 뭐시기2;Async2가 끝난 뒤에서야 비로소 실행될 겁니다. 그러니까, await 연산자로 함수를 호출하면, 논리적으로 비동기 함수는 그 호출이 정말로 끝날 때까지 기다립니다.

public void Main() { Async1(); }
public void Async1() { /* 뭐시기1; */ await Async2(); /* 뭐시기2; */ }
public void Async2() { /* 뭐시기3; */ }

비동기 함수 1의 내부에서 호출한 다른 비동기 함수 2가 끝나길 기다렸다 비동기 함수 1을 마저 실행하는 셈이죠.

이러면 무작정 기다려서 생기는 성능 문제는 없느냐, 없습니다. await는 비동기 함수 안에서만 쓸 수 있거든요.

여담 및 잡담

설명할 건 다 설명한 것 같고, 여러가지 기타등등을 얘기해볼까 합니다.

다른 언어에서

asyncawait는 물론 C# 말고도 JavaScript에서도 쓸 수 있습니다. 현재 최신 표준인 ECMAScript 6 에서는 안 되고, ECMAScript 2016 에서는 된다고 하는군요. 참고1 참고2 그 때까지는 JavaScript에서는 Promise나 Generator 패턴으로 우회해야 할 것 같다고 하네요. (잠깐, Promise는 아는데 Generator는 뭐지…? 그건 나중에 알아봐야겠어요.) Java에는 없다고 했던 것 같고요… 아마 다른 여러 언어에도 있거나 생기지 않을까요?

C#의 구현

C#의 구현에서 await가 연산자인 이유는, 비동기 함수는 Task<T>를 반환하기 때문인 것 같아요. 그래서 변수에 보관했다 나중에 await로 기다릴수도 있는데, 누가 굳이 그렇게 짤까요…? 마치 대리자(delegate)의 옛날 버전 같은 느낌이 들죠. 그쵸?

비동기 함수 체인의 끝에는?

비동기 함수는 그럼 대체 어느 시점에서 원래 실행하던 곳으로 돌아가는 걸까 하고 잠시 생각을 해봤었는데요. 비동기 함수의 체인을 따라가다보면 끝에는 시스템 함수가 있지 않을까하고 생각해요. node.js에서는 setTimeout()으로 강제로 비동기로 만들어버리기도 하지만요.

싱글 스레드라면서요?

JavaScript는 싱글 스레드라던데, 그럼 asyncawait도 단일 스레드로 유지되는가 하면 반만 맞다고 하면 될 것 같아요. 프로그램의 실행 문맥 자체는 하나로 유지되어서, 비동기 함수에서 막히던 부분이 끝나서 이벤트 루프? 큐? 에서 돌아오면 실행하던 걸 멈추고 그 비동기 함수를 마저 실행하러 간 뒤에야 실행하던 부분으로 돌아가거든요. 그래서 실행 문맥은 하나, 즉 단일 스레드인데, 비동기 함수를 구현하기 위해 시스템에서 별도의 스레드를 준비해서 돌리고 있다고 봐야할 것 같아요.

그렇단 말은 C#도 마찬가지로 프로그램 실행 문맥은 단일 스레드일 거에요. 아마도요? 내부 구현은 컴파일러가 풀어써서 이렇게 만든다고 하는데, 솔직히 이해하기 어려웠어요. 지금 다시 보면 간신히 알 것 같지만 그럴 필요도 없을 것 같고요. 그냥 유한 상태 기계로 구현된다고만 알아두면 될 것 같아요.