비동기 프로그래밍의 기초: Async, Await, Futures, 그리고 Streams

컴퓨터가 수행하는 많은 작업은 완료까지 시간이 걸릴 수 있다. 이러한 오래 걸리는 프로세스가 완료될 때까지 기다리는 동안 다른 작업을 할 수 있다면 좋을 것이다. 현대 컴퓨터는 한 번에 여러 작업을 처리하기 위해 두 가지 기술을 제공한다: 병렬성(parallelism)과 동시성(concurrency). 그러나 병렬 또는 동시 작업을 포함하는 프로그램을 작성하기 시작하면, 작업이 시작된 순서대로 완료되지 않을 수 있는 비동기 프로그래밍의 고유한 문제에 직면하게 된다. 이 장에서는 16장에서 다룬 스레드를 통한 병렬성과 동시성에 이어, 비동기 프로그래밍을 위한 대안적인 접근 방식을 소개한다. Rust의 Futures, Streams, 그리고 이를 지원하는 asyncawait 문법, 그리고 비동기 작업을 관리하고 조율하는 도구들에 대해 알아볼 것이다.

예를 들어, 가족 행사를 담은 동영상을 내보내는 작업을 한다고 가정해 보자. 이 작업은 몇 분에서 몇 시간까지 걸릴 수 있다. 동영상 내보내기는 가능한 한 많은 CPU와 GPU 자원을 사용할 것이다. 만약 CPU 코어가 하나뿐이고 운영체제가 내보내기 작업이 완료될 때까지 이를 일시 중지하지 않는다면, 즉 동기적으로(synchronously) 실행한다면, 그 작업이 실행되는 동안 컴퓨터에서 다른 작업을 할 수 없을 것이다. 이는 매우 불편한 경험이 될 것이다. 다행히, 운영체제는 내보내기 작업을 자주 눈에 띄지 않게 중단시켜 다른 작업을 동시에 수행할 수 있게 해준다.

이제 다른 사람이 공유한 동영상을 다운로드하는 경우를 생각해 보자. 이 작업도 시간이 걸리지만 CPU 시간을 많이 차지하지는 않는다. 이 경우, CPU는 네트워크에서 데이터가 도착하기를 기다려야 한다. 데이터가 도착하기 시작하면 읽을 수는 있지만, 모든 데이터가 도착하려면 시간이 걸릴 수 있다. 데이터가 모두 도착하더라도, 동영상이 매우 크다면 이를 모두 로드하는 데 최소 1~2초가 걸릴 수 있다. 이는 짧은 시간처럼 들릴 수 있지만, 현대 프로세서가 1초에 수십억 번의 연산을 수행할 수 있다는 점을 고려하면 매우 긴 시간이다. 다시 말해, 운영체제는 네트워크 호출이 완료될 때까지 기다리는 동안 프로그램을 눈에 띄지 않게 중단시켜 CPU가 다른 작업을 수행할 수 있게 해준다.

동영상 내보내기는 CPU-bound 또는 compute-bound 작업의 예시이다. 이는 CPU 또는 GPU 내에서 컴퓨터의 잠재적 데이터 처리 속도와 그 작업에 할당할 수 있는 속도에 의해 제한된다. 반면, 동영상 다운로드는 IO-bound 작업의 예시이다. 이는 컴퓨터의 입출력(input and output) 속도에 의해 제한되며, 네트워크를 통해 데이터를 보낼 수 있는 속도만큼 빠르게 진행된다.

이 두 예시에서 운영체제의 눈에 띄지 않는 중단은 동시성의 한 형태를 제공한다. 그러나 이 동시성은 전체 프로그램 수준에서만 발생한다: 운영체제는 한 프로그램을 중단시켜 다른 프로그램이 작업을 수행할 수 있게 한다. 많은 경우, 우리는 운영체제보다 더 세부적인 수준에서 프로그램을 이해하기 때문에, 운영체제가 볼 수 없는 동시성의 기회를 발견할 수 있다.

예를 들어, 파일 다운로드를 관리하는 도구를 만든다면, 하나의 다운로드를 시작해도 UI가 멈추지 않도록 프로그램을 작성할 수 있어야 하며, 사용자가 동시에 여러 다운로드를 시작할 수 있어야 한다. 그러나 네트워크와 상호작용하는 많은 운영체제 API는 블로킹(blocking) 방식이다. 즉, 처리 중인 데이터가 완전히 준비될 때까지 프로그램의 진행을 막는다.

참고: 대부분의 함수 호출이 이렇게 동작한다고 생각할 수 있다. 그러나 블로킹이라는 용어는 일반적으로 파일, 네트워크, 또는 컴퓨터의 다른 리소스와 상호작용하는 함수 호출에 사용된다. 왜냐하면 이러한 경우에 개별 프로그램이 작업이 **논블로킹(non-blocking)**으로 수행되는 것에서 이점을 얻을 수 있기 때문이다.

우리는 각 파일을 다운로드하기 위해 전용 스레드를 생성함으로써 메인 스레드가 블로킹되는 것을 피할 수 있다. 그러나 이러한 스레드의 오버헤드는 결국 문제가 될 수 있다. 처음부터 호출이 블로킹되지 않는 것이 더 바람직하다. 또한 블로킹 코드에서 사용하는 것과 같은 직접적인 스타일로 작성할 수 있다면 더 좋을 것이다. 예를 들어 다음과 같다:

let data = fetch_data_from(url).await;
println!("{data}");

이것이 바로 Rust의 async(비동기의 줄임말) 추상화가 제공하는 것이다. 이 장에서는 다음과 같은 주제를 다루며 async에 대해 자세히 알아볼 것이다:

  • Rust의 asyncawait 문법을 사용하는 방법
  • 16장에서 다룬 동일한 문제를 해결하기 위해 async 모델을 사용하는 방법
  • 멀티스레딩과 async가 상호 보완적인 솔루션을 제공하며, 많은 경우에 이를 결합할 수 있는 방법

그러나 async가 실제로 어떻게 동작하는지 살펴보기 전에, 병렬성과 동시성의 차이에 대해 짧게 논의할 필요가 있다.

병렬성과 동시성

지금까지 병렬성(parallelism)과 동시성(concurrency)을 거의 같은 개념으로 다뤘다. 하지만 이제는 이 둘을 더 정확히 구분해야 한다. 차이점을 명확히 이해해야 실제 작업을 시작할 때 혼란을 피할 수 있다.

소프트웨어 프로젝트에서 팀이 작업을 나누는 다양한 방식을 생각해 보자. 한 멤버에게 여러 작업을 할당하거나, 각 멤버에게 하나의 작업을 할당하거나, 두 방식을 혼합할 수 있다.

한 사람이 여러 작업을 동시에 진행하되, 아직 완료되지 않은 상태에서 작업을 전환하는 것을 동시성이라고 한다. 예를 들어, 컴퓨터에서 두 개의 프로젝트를 동시에 진행하다가 한 프로젝트에서 막히거나 지루해지면 다른 프로젝트로 전환하는 경우를 생각해 보자. 한 사람이기 때문에 두 작업을 정확히 동시에 진행할 수는 없지만, 작업을 번갈아 가며 진행하면서 각각의 작업에 조금씩 진전을 이룰 수 있다(그림 17-1 참조).

Task A와 Task B로 표시된 상자와 그 안에 하위 작업을 나타내는 다이아몬드가 있는 다이어그램. A1에서 B1, B1에서 A2, A2에서 B2, B2에서 A3, A3에서 A4, A4에서 B3으로 가는 화살표가 있다. 하위 작업 사이의 화살표는 Task A와 Task B 사이의 상자를 가로지른다.
그림 17-1: Task A와 Task B 사이를 전환하는 동시성 워크플로우

반면, 팀이 작업을 나누어 각 멤버가 하나의 작업을 맡아 독립적으로 진행하는 것을 병렬성이라고 한다. 이 경우 팀의 각 멤버는 정확히 동시에 작업을 진행할 수 있다(그림 17-2 참조).

Task A와 Task B로 표시된 상자와 그 안에 하위 작업을 나타내는 다이아몬드가 있는 다이어그램. A1에서 A2, A2에서 A3, A3에서 A4, B1에서 B2, B2에서 B3으로 가는 화살표가 있다. Task A와 Task B 사이의 상자를 가로지르는 화살표는 없다.
그림 17-2: Task A와 Task B에서 독립적으로 작업이 진행되는 병렬 워크플로우

이 두 워크플로우에서도 서로 다른 작업 간의 조정이 필요할 수 있다. 예를 들어, 한 사람에게 할당된 작업이 다른 사람의 작업과 완전히 독립적이라고 생각했지만, 실제로는 팀의 다른 사람이 먼저 작업을 완료해야 할 수도 있다. 일부 작업은 병렬로 수행할 수 있지만, 일부는 순차적으로만 수행할 수 있다. 즉, 한 작업이 끝난 후에야 다음 작업을 시작할 수 있다(그림 17-3 참조).

Task A와 Task B로 표시된 상자와 그 안에 하위 작업을 나타내는 다이아몬드가 있는 다이어그램. A1에서 A2, A2에서 '일시정지' 기호 같은 두꺼운 수직선, 그 기호에서 A3, B1에서 B2, B2에서 B3, B3에서 A3, B3에서 B4로 가는 화살표가 있다.
그림 17-3: 부분적으로 병렬적인 워크플로우. Task A와 Task B는 독립적으로 진행되다가 Task A3이 Task B3의 결과를 기다리며 차단된다.

마찬가지로, 자신의 작업 중 하나가 다른 작업에 의존한다는 사실을 깨달을 수도 있다. 이제 동시에 진행하던 작업이 순차적으로 바뀌게 된다.

병렬성과 동시성은 서로 교차할 수도 있다. 동료가 자신의 작업 중 하나를 완료할 때까지 막혀 있다는 사실을 알게 되면, 아마도 그 작업에 모든 노력을 집중해 동료를 ’차단 해제’하려 할 것이다. 이제는 더 이상 병렬로 작업할 수 없고, 자신의 작업을 동시에 진행할 수도 없다.

이러한 기본적인 역학은 소프트웨어와 하드웨어에서도 동일하게 적용된다. 단일 CPU 코어를 가진 머신에서는 CPU가 한 번에 하나의 작업만 수행할 수 있지만, 여전히 동시성을 활용할 수 있다. 스레드, 프로세스, 비동기(async)와 같은 도구를 사용하면 컴퓨터는 하나의 작업을 일시 중지하고 다른 작업으로 전환한 후 다시 원래 작업으로 돌아올 수 있다. 반면, 여러 CPU 코어를 가진 머신에서는 병렬로 작업을 수행할 수 있다. 하나의 코어가 한 작업을 수행하는 동안 다른 코어는 완전히 무관한 작업을 동시에 수행할 수 있다.

Rust에서 비동기(async) 작업을 다룰 때는 항상 동시성을 다룬다. 사용하는 하드웨어, 운영체제, 비동기 런타임(나중에 자세히 설명)에 따라, 이 동시성은 내부적으로 병렬성을 활용할 수도 있다.

이제 Rust에서 비동기 프로그래밍이 실제로 어떻게 동작하는지 자세히 알아보자.