비동기 프로그래밍 (async/await)

라이브러리 설치(Cargo.toml)

Rust는 성능과 안전성을 동시에 추구하는 언어입니다. 이런 철학은 비동기 프로그래밍(asynchronous programming)에도 그대로 적용됩니다. Rust의 async/await 문법은 동시성(concurrency)을 효과적으로 다루기 위한 강력한 도구로, 네트워크 프로그래밍이나 고성능 IO 처리에 자주 사용됩니다.


1. 비동기 프로그래밍이란?

비동기 프로그래밍이란, 어떤 작업이 완료될 때까지 기다리는 대신 다른 작업을 먼저 수행하도록 코드를 구성하는 방식입니다. 예를 들어 웹 서버가 여러 클라이언트의 요청을 동시에 처리할 때, 각각의 요청마다 새로운 스레드를 만들기보다, 하나의 스레드에서 여러 요청을 비동기적으로 처리하면 더 적은 리소스로 높은 성능을 얻을 수 있습니다.


2. async/await 개념

Rust의 비동기 프로그래밍은 크게 세 가지로 나뉩니다:

  • async fn: 비동기 함수를 정의하는 키워드
  • await: 비동기 함수의 결과를 기다리는 키워드
  • Future: 아직 완료되지 않은 비동기 작업을 나타내는 타입
async fn say_hello() {
println!("Hello!");
}

이 함수는 호출해도 바로 실행되지 않고, Future를 반환합니다. 실제로 실행되려면 .await를 사용해야 합니다.

say_hello().await;

3. 비동기 실행을 위한 런타임 (tokio)

Rust 표준 라이브러리는 자체적인 비동기 런타임을 제공하지 않습니다. 따라서 일반적으로 tokio 같은 서드파티 런타임을 사용합니다. tokio는 가장 널리 사용되는 비동기 런타임이며, 다양한 네트워크, 타이머, 채널 등 유틸리티를 제공합니다.

[dependencies]
tokio = { version = "1", features = ["full"] }

Cargo.toml의 dependencies에 tokio를 추가하고 cargo run을 하면 관련 라이브러리들이 자동으로 설치됩니다.

비동기 main 함수를 사용하려면 다음과 같이 작성합니다:

#[tokio::main]
async fn main() {
say_hello().await;
}

Cargo.toml과 별도로 main.rs의 코드는 아래와 같습니다.

#[tokio::main]
async fn main() {
    say_hello().await; 
}

async fn say_hello() {
    println!("Hello!");
}

#[tokio::main] 어트리뷰트

이 부분은 Tokio 런타임을 자동으로 시작해주는 매크로 어트리뷰트입니다.

  • Rust에서 async fn main()을 그냥 실행할 수는 없습니다. 왜냐하면 Rust는 기본적으로 비동기 실행 환경(런타임)을 제공하지 않기 때문이에요.
  • [tokio::main]은 main 함수에 Tokio 런타임을 삽입해서 비동기 코드를 실행할 수 있게 만들어줍니다.

async fn main()

이 함수는 비동기 함수입니다.

  • 비동기 함수는 실행될 때 Future를 반환합니다.
  • 이 Future는 .await될 때까지는 실제로 실행되지 않습니다.
  • 하지만 #[tokio::main] 덕분에 main() 함수도 비동기 함수로 정의할 수 있게 되었고, 프로그램은 say_hello().await를 실행하면서 say_hello 함수의 Future를 기다립니다.

say_hello().await

  • .await를 사용하면 이 Future의 실행을 기다립니다.
  • 즉, say_hello() 함수의 본문이 실행될 때까지 main 함수는 멈춰서 기다립니다.

async fn say_hello()

비동기 함수이지만 내부에 특별한 비동기 작업은 없습니다.

async fn say_hello() {
println!("Hello!");
}
  • 이 함수는 단지 “Hello!”를 출력합니다.
  • 비록 내부에 await는 없지만, 비동기 함수로 작성된 이유는 비동기 구조의 연습 또는 나중에 비동기 작업 (예: 네트워크 요청) 을 넣기 위함입니다.

위 코드를 실행하면 Hello!가 출력되는데,

아래와 같이 main과 say_hello함수의 순서를 바꾸고 실행하니 에러가 발생합니다.


4. 비동기 예제: 타이머

다음은 두 개의 비동기 작업을 동시에 실행하는 예제입니다:

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
let task1 = async {
sleep(Duration::from_secs(2)).await;
println!("2초 작업 완료");
};

let task2 = async {
println!("즉시 실행");
};

tokio::join!(task1, task2);
}

위 코드에서 sleep은 비동기적으로 대기하는 함수입니다. tokio::join! 매크로는 두 작업을 동시에 실행하고, 둘 다 완료될 때까지 기다립니다.

위 코드를 실행하면 “즉시 실행”을 먼저 출력하고, 2초 후에 “2초 작업 완료”를 출력합니다.


5. Future의 본질

Rust의 비동기 함수는 내부적으로 Future 트레잇을 구현하는 구조체를 반환합니다. 이 구조체는 “언제 실행될지 모르는 작업”을 표현하며, .await가 호출되었을 때에만 실행이 시작됩니다.

간단한 예시:

use std::future::Future;

async fn return_num() -> u32 {
10
}

fn main() {
let fut = return_num(); // 실행되지 않음
// fut는 Future 타입, 여기선 실행되지 않음
}

async 블록이나 함수는 Future를 만들기 위한 “공장(factory)” 역할만 하며, 실제 실행은 .await 또는 런타임에 의해 수행됩니다.

  • async fn return_num() -> u32:
    이 함수는 u32를 비동기로 반환하는 함수입니다.
    하지만 실제로는 u32인 10을 바로 리턴하는 단순한 함수입니다. Rust의 async fn은 항상 Future를 반환합니다.
  • let fut = return_num();
    여기서 return_num()을 호출했지만 실제 10을 반환하지 않습니다.
    대신 미래에 10을 반환할 수 있는 준비 상태의 Future 객체만 생성했을 뿐입니다.
  • fut는 실행 가능한 비동기 작업을 담고 있는 Future 타입입니다.
    하지만 실제로 실행(await)하지 않았기 때문에 아무 일도 일어나지 않습니다.
    실제 실행하려면 아래와 같이 .await를 사용해서 실행해야 합니다.
#[tokio::main]
async fn main() {
    let fut = return_num(); // fut: Future
    let result = fut.await; // 이제 실행됨!
    println!("결과: {}", result); // 출력: 결과: 10
}

6. 동시성(concurrency) vs 병렬성(parallelism)

Rust에서 async는 동시성을 위한 기능입니다. 하나의 스레드에서 여러 작업을 번갈아가며 처리하는 구조입니다. 반면, 병렬성은 여러 CPU 코어에서 동시에 작업을 수행하는 것으로, std::thread 등을 통해 구현합니다. 둘은 목적이 다르지만 상호 보완적으로 사용될 수 있습니다.


7. 실전 예제: HTTP 요청

비동기의 진가는 네트워크 작업에서 드러납니다. 예를 들어, reqwest 라이브러리를 이용한 HTTP GET 요청은 다음과 같습니다.

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let resp = reqwest::get("https://httpbin.org/get").await?;
let body = resp.text().await?;
println!("응답 본문:\n{}", body);
Ok(())
}
async fn main() -> Result<(), Box<dyn std::error::Error>>
  • async fn main(): main 함수 자체가 비동기 함수로 정의되어 있습니다.
  • Result<(), Box<dyn std::error::Error>> :
    ? 연산자를 사용하기 위해 오류 처리를 Result 타입으로 합니다.
    다양한 종류의 오류(reqwest::Error, std::io::Error, 등)를 포괄하기 위해 Box<dyn std::error::Error>>를 사용합니다.

let resp = reqwest::get(“https://httpbin.org/get”).await?;
  • reqwest::get(…): reqwest 라이브러리로 GET 요청을 보냅니다.
  • .await: 비동기 요청이 완료될 때까지 기다립니다.
  • ?: 요청 중 오류가 발생하면 main 함수에서 곧바로 리턴됩니다.
  • resp는 reqwest::Response 타입입니다.

let body = resp.text().await?;
  • 응답 객체인 resp에서 본문을 텍스트로 변환합니다.
  • text()는 Future를 반환하므로 .await로 기다립니다.
  • ?로 오류 처리합니다.
  • 결과는 String 타입입니다.

println!(“응답 본문:\n{}”, body);
  • 응답 본문 전체를 콘솔에 출력합니다.

Ok(())

함수가 정상적으로 끝났다는 것을 알리는 반환값입니다.reqwest::get()과 resp.text()는 모두 Future를 반환하므로 .await를 사용해야 합니다.


실행했더니 reqwest에서 에러가 발생해서 Cargo.toml에 reqwest = “0.12.20”을 추가해야 합니다.

[dependencies]
reqwest = "0.12.20"
tokio = { version = "1", features = ["full"] }

그리고, 실행하면 관련 라이브러리들을 설치하고, 컴파일하고 run을 하고, get한 결과를 출력합니다.

8. async와 에러 처리

비동기 함수에서도 Result 타입을 반환하여 에러를 처리할 수 있습니다.

#[tokio::main]
async fn main() {
match fetch_data().await {
Ok(body) => println!("응답 본문:\n{}", body),
Err(e) => eprintln!("요청 중 오류 발생: {}", e),
}
}

async fn fetch_data() -> Result<String, reqwest::Error> {
let resp = reqwest::get("https://example.com").await?;
let text = resp.text().await?;
Ok(text)
}
fetch_data().await
  • fetch_data는 비동기 함수이므로 실행하려면 .await를 붙여야 실제로 실행됩니다.
  • Result를 반환합니다.

이줄에서 fetch_data()의 실행이 끝날 때까지 기다리고, 그 결과에 따라 아래 match가 분기됩니다.

성공한 경우 (Ok)
  • HTTP 요청이 성공하면 응답 본문이 String 타입으로 body에 담깁니다.
  • 그것을 println!으로 콘솔에 출력합니다.
실패한 경우 (Err)
  • 요청 도중 네트워크 오류, 타임아웃 등 문제가 발생하면 reqwest::Error가 e에 들어옵니다.
  • println!는 표준 에러 스트림에 메시지를 출력합니다 (보통 터미널에서 빨간 글씨로 나옴).
async fn fetch_data() -> Result <String, reqwest::Error> {
  • async fn: 비동기 함수입니다. 호출 시 바로 실행되지 않고, Future를 반환합니다.
  • Result<String, reqwest::Error>:
    Ok(String): 요청이 성공하면 응답 본문을 String으로 감싸서 반환합니다.
    Err(reqwest::Error): 네트워크 오류, HTTP 오류 등이 발생하면 오류를 반환합니다.

let resp = reqwest::get(“https://example.com”).await?;
  • reqwest::get(…): GET 방식으로 HTTP 요청을 보냅니다.
  • .await: 이 작업이 완료될 때까지 기다립니다. 이때 다른 작업은 블로킹되지 않으며, tokio 런타임이 비동기적으로 대기합니다.
  • ?: 요청 도중 에러가 발생하면 바로 Err를 반환하며, 함수가 종료됩니다.

이 줄의 결과는 resp 변수에 저장되며, 이는 응답(Response) 객체입니다.


let text = resp.text().await?;
  • resp.text(): 응답 본문을 String으로 변환하는 Future를 반환합니다.
  • .await: 이 본문을 받아올 때까지 대기합니다.
  • ?: 변환 과정에서 에러가 나면 역시 Err를 반환하며 함수가 종료됩니다.

Ok(text)
  • 모든 작업이 성공적으로 끝나면 text 값을 Result의 Ok로 감싸서 반환합니다.

성공할 경우의 실행 결과는 아래와 같고,

사이트 주소에서 e를 제거해서 exampl.com으로 수정하고 실행하니 아래와 같이 “요청 중 오류 발생: error sending request” 에러 메시지가 표시됩니다.


9. 정리

개념설명
async fn비동기 함수 정의
.awaitFuture의 완료를 기다림
Future완료되지 않은 작업
tokio비동기 런타임 라이브러리
join!여러 Future를 동시에 실행
sleep()비동기 대기 함수

10. 마무리

Rust의 비동기 프로그래밍은 고성능 서버나 네트워크 애플리케이션을 만들 때 매우 강력합니다. 하지만 컴파일러가 엄격하게 검사하므로 안전한 동시성 코드를 작성할 수 있습니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다