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

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

동시성(Concurrency)과 스레드(Thread)

1. 동시성(Concurrency)이란?

동시성(Concurrency)은 프로그램이 여러 작업을 “동시에” 처리하는 능력을 말합니다. 꼭 물리적으로 동시에 실행되는 것(병렬성)이 아니라, 여러 작업을 논리적으로 동시에 실행하는 것입니다. 즉, 동시성은 스레드보다 더 큰 개념이며, 스레드는 그 구현 수단 중 하나입니다.

Rust에서 동시성은 다음과 같은 방식으로 구현할 수 있습니다:

  • 스레드(Thread)
  • 메시지 전달(Channel)
  • 공유 메모리(Arc<Mutex<T>>)
  • 비동기 프로그래밍(async/await)

2. Rust의 동시성 철학

Rust의 동시성은 다음 원칙을 따릅니다:

  1. 안전성 (Safety): 동시성 버그(데이터 경쟁, 데드락 등)를 컴파일 타임에 방지
  2. 명시성 (Explicitness): 공유 자원, 락, 메시지 전달 등이 명확히 드러남
  3. 추론 가능성: 컴파일러가 Send, Sync 트레잇으로 스레드 간 이동 가능 여부를 추론

C++/Java는 런타임 오류가 나야 알 수 있는 동시성 문제를, Rust는 컴파일러가 사전에 차단합니다.


3. 스레드를 이용한 동시성

use std::thread;

fn main() {
    let handle = thread::spawn(|| {
        println!("스레드에서 실행 중");
    });

    println!("메인 스레드 실행 중");
    handle.join().unwrap();
}

thread:spawn을 통해 새로운 스레드를 생성하고, join()을 통해 스레드 종료를 기다립니다.
이 방식은 동시에 여러 작업을 실행하려는 목적, 즉 동시성을 달성하는 가장 기본적인 방식입니다.

use std::thread;
  • Rust 표준 라이브러리의 thread 모듈을 가져옵니다(import).
  • 이 모듈은 스레드 생성 및 제어에 관련된 기능을 제공합니다.

let handle = thread::spawn(|| { … });
  • thread::spawn은 새로운 스레드를 생성하고 그 안에서 클로저(익명 함수)를 실행합니다.
  • 이 예제에서 생성된 스레드는 println!(“스레드에서 실행 중”);을 실행합니다.
  • spawn은 JoinHandle이라는 객체를 반환하며, 이를 handle 변수에 저장합니다.
  • 이 handle은 이후 스레드가 언제 종료됐는지 기다릴 수 있게(join) 도와줍니다.

println!(“메인 스레드 실행 중”);
  • 메인 스레드는 새로 만든 스레드와 동시에 실행됩니다.
  • 따라서 이 줄과 스레드에서 실행 중 메시지 중 어느 것이 먼저 출력될지는 실행 시마다 달라질 수 있습니다.
  • 이는 Rust의 동시성(Concurrency) 특징입니다.

handle.join().unwrap();
  • join()은 새로 생성한 스레드가 끝날 때까지 기다리는 함수입니다.
  • 스레드 실행이 끝나면 Result<T, E>를 반환하고, 성공하면 Ok(()), 실패하면 Err(e)가 반환됩니다.
  • 여기서는 unwrap()을 사용해에러가 발생하면 패닉(panic)하도록 합니다.

4. 메시지 전달로 동시성 구현 (채널)

Rust는 공유 메모리 대신 메시지 전달(Message Passing) 모델을 권장합니다. std::sync::mpsc 모듈을 통해 채널을 만들 수 있습니다.

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        tx.send("데이터").unwrap();
    });

    let received = rx.recv().unwrap();
    println!("수신: {}", received);
}

위 코드는 멀티 스레드 환경에서 채널(channel)을 이용한 스레드 간 통신을 보여줍니다.


use std::sync::mpsc;
  • mpsc는 “multiple producer, single consumer”의 약자로, 여러 개의 생산자(보내는 쪽)가 하나의 소비자(받는 쪽)를 지원하는, 다시 말해 여러 생산자(스레드)가 하나의 소비자(스레드)에게 데이터를 보낼 수 있는 채널(channel) 시스템입니다.

let (tx, rx) = mpsc::channel();
  • channel() 함수는 전송자(Sender)수신자(Receiver) 한 쌍을 반환합니다.
    • tx: 데이터 전송에 사용 (transmitter)
    • rx: 데이터 수신에 사용 (receiver)

thread::spawn(move || { … })
  • 새로운 스레드를 생성합니다.
  • move 키워드는 클로저 내부로 tx를 소유권 이동(move)시키기 위해 사용됩니다.
  • tx를 다른 스레드에서 사용할 수 있게 하려면 반드시 move가 필요합니다.

tx.send(“데이터”).unwrap();
  • 생성된 스레드에서 “데이터”라는 문자열을 메인 스레드로 전송합니다.
  • 전송 결과가 Result이기 때문에, .unwrap()을 호출하여 에러가 나면 패닉하게 합니다.

let received = rx.recv().unwrap();
  • 메인 스레드에서 recv()로 데이터가 도착할 때까지 블로킹(기다림)합니다.
  • 다른 스레드에서 “데이터”를 보냈기 때문에, 수신이 성공하면 received에 저장됩니다.

println!(“수신: {}”, received);
  • 받은 데이터를 출력합니다.

출력 결과는 “수신: 데이터”입니다.


공유된 메모리에 락을 걸 필요 없이, 스레드 간 메시지를 전달함으로써 동시성을 구현합니다.

이 방식은 Go 언어의 고루틴 + 채널과 비슷하며, 병렬성이 아닌 동시성 컨트롤에 적합합니다.


5. Arc<Mutex<T>>를 통한 공유 메모리 동시성

다수의 스레드가 데이터를 공유해야 한다면, Arc로 참조를 공유하고 Mutex로 락을 걸어야 합니다.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(0));

    let mut handles = vec![];
    for _ in 0..5 {
        let data = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            let mut num = data.lock().unwrap();
            *num += 1;
        }));
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("최종 값: {}", *data.lock().unwrap());
}

위 코드는 여러 스레드가 안전하게 하나의 데이터를 공유하고 수정하는 예제입니다. 여기서는 Mutex와 Arc를 조합하여 스레드 간 공유 데이터의 동기화를 구현하고 있습니다.


가. 주요 개념 정리

(1) Mutex<T>: 상호 배제
  • 여러 스레드가 동시에 데이터에 접근하지 못하도록 막아주는 락(lock).
  • .lock().unwrap()을 호출하면 락을 얻고, 반환된 객체(MutexGuard)를 통해 데이터를 읽고 쓸 수 있음.
  • 락을 가진 객체는 스코프를 벗어나면 자동으로 해제됨.
(2) Arc<T>: 원자적 참조 카운트
  • 여러 스레드에서 읽기 전용 또는 Mutex와 결합한 공유 사용이 가능.
  • Rc는 단일 스레드용이고, Arc는 멀티스레드에서도 안전하게 참조를 공유할 수 있도록 설계됨.
  • Arc::clone(&data)는 참조 카운트를 늘릴 뿐, 실제 데이터를 복사하지 않음.

나. 코드 분석

let data = Arc::new(Mutex::new(0));
  • 0이라는 값을 Mutex로 감싸고, 이를 다시 Arc로 감쌈.
  • 이렇게 하면 여러 스레드가 이 데이터를 공유하면서도 동시에 수정하지 못하도록 보장할 수 있음.

let mut handles = vec![];

for _ in 0..5 {
let data = Arc::clone(&data);
handles.push(thread::spawn(move || {
let mut num = data.lock().unwrap();
*num += 1;
}));
}
  • 5개의 스레드를 생성.
  • 각 스레드는 Arc::clone(&data)를 통해 공유 데이터를 가리키는 참조를 가짐.
  • data.lock().unwrap()을 통해 락을 획득하고 *num += 1로 값을 증가시킴.
  • unwrap()은 락 획득 실패(예: 패닉) 시 프로그램을 종료시키는 간단한 오류 처리 방식.

for handle in handles {
handle.join().unwrap();
}
  • 각 스레드가 종료될 때까지 대기 (join()).
  • 이렇게 해야 모든 스레드가 작업을 마치고 최종 값을 출력함.

println!("최종 값: {}", *data.lock().unwrap());
  • 메인 스레드에서 최종 값을 읽음.
  • 이때도 lock()을 사용하여 락을 획득한 뒤 출력.

다. 결과

  • 각 스레드는 0부터시작한 값을 1씩 증가시킴.
  • 5개의 스레드가 있으므로 최종 값은 5가 됨.

6. 비동기(async/await) 기반 동시성

Rust는 스레드 외에도 async fn, Future, await 등을 이용한 비동기 동시성 모델도 지원합니다. 이 모델은 싱글 스레드에서도 여러 작업을 동시에 처리하는 방식으로, 특히 IO 작업에 적합합니다.

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

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

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

    tokio::join!(f1, f2);
}
use tokio::time::{sleep, Duration};

tokio 라이브러리의 time 모듈에서 sleep함수와 Duration 타입을 불러옵니다(import).

가. 코드 분석

#[tokio::main]
  • 이 어트리뷰트는 비동기 함수 main을 실행할 수 있도록 tokio 런타임을 자동으로 생성해줍니다.
  • main() 함수가 async fn이므로 일반적으로는 실행할 수 없는데, tokio가 런타임을 초기화하고 실행을 담당합니다.

let f1 = async { … }
  • 비동기 블록입니다.
  • 내부에서sleep(Duration::from_secs(1)).await를 사용하여 1초간 대기한 후, “1초 작업 완료”를 출력합니다.
  • sleep은 스레드를 멈추는 것이 아니라, 해당 Future를 잠시 중단(suspend) 시켜 다른 작업을 실행할 수 있게 합니다.

let f2 = async { … }

  • 이 비동기 블록은 바로 “즉시 실행”을 출력합니다.
  • await나 지연 없이 즉시 완료되는 작업입니다.

tokio::join!(f1, f2);

  • 이 매크로는 f1과 f2를 동시에 실행시킵니다.
  • 두 작업이 모두 끝날 때까지 기다립니다.
  • 즉, f2는 바로 실행되고 “즉시 실행”이 출력됩니다.
  • f1은 1초 후 “1초 작업 완료”를 출력합니다.

나. 실행 결과

실제로 프로그램이 출력하는 순서는 다음과 같습니다:

즉시 실행
1초 작업 완료

두 작업은 병렬이 아닌 동시에 스케줄되는 병행(concurrent) 실행입니다.

비동기 동시성은 Tokio 같은런타임이 필요합니다. 이는 경량 스레드처럼 동작하여 수천 개의 작업을 동시 처리할 수 있습니다.


7. 정리

구분특징사용 예
스레드OS 레벨 병렬 실행CPU 작업 처리
채널메시지 기반 동시성안전한 데이터 전달
Arc<Mutex<T>>메모리 공유 및 제어공유 자원 카운터 등
async/await경량 동시성, 비차단네트워크, 파일 IO

8. 마무리

Rust의 동시성은 단순히 스레드를 사용하는 것에 그치지 않고, 메시지 전달 모델, 공유 메모리 모델, 비동기 실행 모델 등 다양한 방식으로 구현될 수 있습니다. Rust는 이러한 모델을 안전하고 명시적으로 구현할 수 있도록 돕는 언어이며, 컴파일 타임에 동시성 버그를 방지하는 강력한 도구입니다.

Rust에서 동시성은 단지 기술이 아니라, 신뢰성과 안전성을 보장하는 언어적 철학입니다.