Skip to main content

12. 동시성과 비동기 프로그래밍 첫걸음

동시성과 비동기 프로그래밍 첫걸음

왜 이 장이 중요한가

Rust를 배우다 보면 어느 순간 이런 생각이 듭니다. "문법은 알겠는데, Rust가 정말 빛나는 순간은 언제일까?" 그 대답 중 하나가 바로 동시성입니다. Rust는 여러 작업을 동시에 다루는 코드에서 자주 터지는 메모리 오류와 데이터 경쟁 문제를 컴파일 단계에서 강하게 막아 줍니다. 그래서 Rust에는 흔히 "두려움 없는 동시성"이라는 표현이 따라붙습니다.1

초보자 입장에서는 동시성이 조금 무섭게 느껴질 수 있습니다. 스레드, 잠금, 공유 상태, 비동기 같은 단어만 봐도 머리가 바빠지지요. 하지만 걱정하지 않아도 됩니다. 이 장에서는 "왜 필요한지"부터 "언제 무엇을 쓰는지"까지, 처음 배우는 사람의 눈높이에서 차근차근 살펴보겠습니다.

동시성과 병렬성, 비슷하지만 다르다

먼저 말맛부터 정리해 봅시다.

동시성은 여러 작업이 겹쳐서 진행되는 것을 뜻합니다. 꼭 같은 순간에 실행될 필요는 없습니다. 한 작업을 조금 하다가, 다른 작업으로 넘어가고, 다시 돌아오는 식으로도 동시성은 성립합니다.2

병렬성은 여러 작업이 실제로 같은 시점에 함께 실행되는 것입니다. CPU 코어가 여러 개 있을 때 더 잘 일어납니다.2

쉽게 비유하면 이렇습니다.

  • 동시성은 한 사람이 냄비 두 개를 번갈아 보며 요리하는 것입니다.

  • 병렬성은 두 사람이 각자 냄비 하나씩 맡아 동시에 요리하는 것입니다.

  • 실제 프로그램은 이 둘이 섞여 나타나는 경우가 많습니다.

Rust에서는 스레드로 병렬 작업을 만들 수도 있고, async/await로 많은 대기 작업을 효율적으로 다룰 수도 있습니다. 둘은 경쟁자가 아니라 역할이 다른 도구입니다.

스레드: 가장 직접적인 동시성 도구

가장 먼저 만날 도구는 스레드입니다. 스레드는 프로그램 안에서 별도로 실행되는 작업 흐름이라고 생각하면 됩니다. Rust에서는 std::thread::spawn으로 새 스레드를 만들 수 있습니다.3

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..=3 {
            println!("작업 스레드: {i}");
            thread::sleep(Duration::from_millis(100));
        }
    });

    for i in 1..=2 {
        println!("메인 스레드: {i}");
        thread::sleep(Duration::from_millis(100));
    }

    handle.join().unwrap();
}

여기서 중요한 점은 join()입니다. spawn()은 새 스레드를 만들고 JoinHandle을 돌려주는데, join()을 호출하면 그 스레드가 끝날 때까지 기다립니다. 이것이 없으면 메인 함수가 먼저 끝나 버려 작업 스레드가 다 실행되기 전에 프로그램이 종료될 수도 있습니다.3

처음 보면 "그럼 스레드만 많이 만들면 다 빨라지는 거 아닌가요?"라는 생각이 들 수 있습니다. 하지만 스레드는 공짜가 아닙니다. 운영체제 자원도 쓰고, 생성 비용도 있고, 너무 많이 만들면 오히려 부담이 됩니다. 특히 네트워크 요청처럼 기다리는 시간이 긴 작업은 스레드만으로 처리하면 비효율적일 수 있습니다. 이 이야기는 뒤에서 async와 함께 다시 보겠습니다.4

스레드와 소유권: Rust가 까다로운 이유이자 강점

Rust의 스레드 예제를 보다 보면 move가 자주 등장합니다.

use std::thread;

fn main() {
    let message = String::from("안녕, 스레드");

    let handle = thread::spawn(move || {
        println!("{message}");
    });

    handle.join().unwrap();
}

왜 move가 필요할까요? 새 스레드는 현재 스코프보다 오래 살아남을 수 있기 때문입니다. 만약 바깥 변수에 대한 참조만 들고 스레드가 계속 살아 있다면, 원래 값이 사라진 뒤 잘못된 메모리를 가리킬 위험이 생깁니다. Rust는 이런 상황을 허용하지 않고, 필요한 값을 스레드 쪽으로 아예 옮기게 만듭니다.5

처음엔 불편해 보여도, 바로 이 규칙 덕분에 "어? 이 값 누가 아직 쓰고 있었네" 같은 끔찍한 버그를 훨씬 덜 만나게 됩니다. Rust 컴파일러는 잔소리가 많은 대신, 정말 사고를 많이 막아 주는 팀장 같은 존재입니다.

채널: 스레드끼리 말로 주고받기

여러 스레드가 함께 일하면 보통 서로 결과를 주고받아야 합니다. 이때 가장 초보자 친화적인 방법이 채널입니다.

채널은 한쪽에서 메시지를 보내고(tx), 다른 쪽에서 받는(rx) 통로입니다. Rust 표준 라이브러리의 std::sync::mpsc는 이름 그대로 다중 생산자, 단일 소비자 구조를 제공합니다. 즉, 여러 곳에서 보낼 수 있고 받는 쪽은 하나입니다.6

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

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

    thread::spawn(move || {
        let msg = String::from("백그라운드 작업 완료!");
        tx.send(msg).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("받은 메시지: {received}");
}

이 방식의 장점은 아주 분명합니다. 서로 같은 메모리를 붙잡고 실랑이하는 대신, 값의 소유권을 메시지와 함께 넘겨버립니다. Rust 책에서도 공유 메모리 대신 메시지 전달을 중요한 동시성 방식으로 설명합니다.6

recv()는 메시지가 올 때까지 기다립니다. 반대로 try_recv()는 지금 당장 받을 수 있는 메시지가 없으면 바로 돌아옵니다.6

또한 송신자는 복제할 수 있습니다.

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

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

    thread::spawn(move || {
        tx.send(String::from("첫 번째 작업 끝")).unwrap();
    });

    thread::spawn(move || {
        tx2.send(String::from("두 번째 작업 끝")).unwrap();
    });

    for message in rx {
        println!("수신: {message}");
    }
}

이 코드는 여러 작업자가 결과를 한곳으로 모아 보내는 구조를 만들 때 아주 유용합니다. 그리고 모든 송신자가 사라지면 수신 쪽 반복도 끝납니다.7

Arc와 Mutex: 같은 데이터를 함께 써야 할 때

채널은 메시지를 주고받기에 좋지만, 때로는 여러 스레드가 같은 데이터를 함께 봐야 할 때가 있습니다. 예를 들어 공용 카운터, 공용 설정, 공용 작업 목록 같은 것들입니다.

이때 자주 등장하는 조합이 Arc<Mutex<T>>입니다.

Arc<T>는 여러 스레드가 같은 값을 안전하게 공유하도록 도와주는 스마트 포인터입니다. Rc<T>와 비슷하지만, 스레드 환경에서도 안전하도록 참조 개수를 원자적으로 관리합니다.8

Mutex<T>는 한 번에 하나의 스레드만 데이터에 접근하게 만드는 잠금 장치입니다. 어떤 스레드가 lock()으로 잠금을 잡으면, 다른 스레드는 잠금이 풀릴 때까지 기다려야 합니다.8

둘을 합치면 "여러 스레드가 공유할 수 있고, 동시에 막 수정하지는 못하게 하는 상자"가 됩니다.

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

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
        });
        handles.push(handle);
    }

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

    println!("최종 결과: {}", *counter.lock().unwrap());
}

여기서 Arc::clone은 내부 데이터를 통째로 복사하는 것이 아니라, 같은 데이터를 가리키는 소유권 하나를 더 만드는 것입니다. 그리고 lock()이 돌려주는 가드는 스코프를 벗어나면 자동으로 잠금을 풀어 줍니다.8

다만 Mutex는 만능 해결사가 아닙니다. 잠금을 너무 오래 잡고 있으면 다른 스레드들이 줄을 서게 되고, 잠금 순서를 꼬이게 만들면 교착 상태, 즉 서로 영원히 기다리는 상황도 생길 수 있습니다. 그래서 잠금 구간은 짧게 유지하는 습관이 중요합니다.9

채널과 Arc<Mutex<T>>, 무엇을 먼저 고를까

초보자에게는 이 기준이 꽤 실용적입니다.

  • 작업 결과를 전달하면 된다면 채널부터 떠올리기

  • 같은 읽기 전용 데이터를 여러 스레드가 봐야 하면 Arc<T> 떠올리기

  • 같은 데이터를 여러 스레드가 수정해야 하면 Arc<Mutex<T>> 떠올리기

보통은 공유 상태가 적을수록 코드가 단순합니다. 그래서 가능하면 먼저 채널을 생각하고, 정말 공유 상태가 필요할 때 Arc<Mutex<T>>를 쓰는 쪽이 이해하기 쉽습니다.10

그런데 왜 async/await가 필요할까

여기까지 보면 이런 질문이 생깁니다.

"스레드도 있는데 굳이 async가 왜 필요하죠?"

핵심은 기다림입니다.

파일 읽기, 데이터베이스 질의, HTTP 요청, 웹소켓 통신 같은 일은 CPU가 계속 바쁘게 계산하는 작업이 아닙니다. 상당 시간은 외부 응답을 기다립니다. 이런 작업을 스레드 하나당 하나씩 처리하면, 많은 스레드가 그냥 멍하니 대기하게 됩니다. 스레드는 비용이 있으니, 요청 수가 매우 많아지면 부담이 커집니다.11

async/await는 이런 I/O 중심 작업에서 특히 강력합니다. 작업이 기다려야 하는 순간에 스레드를 통째로 붙잡아 두지 않고, "나는 잠깐 멈출게. 다른 일 먼저 해"라고 양보하게 만듭니다. 그러면 적은 수의 스레드로도 아주 많은 작업을 굴릴 수 있습니다.12

정리하면 이렇습니다.

  • CPU를 빡세게 쓰는 계산 작업은 스레드가 잘 맞는 경우가 많습니다.

  • 네트워크, 파일, 데이터베이스처럼 기다림이 많은 작업은 async가 잘 맞습니다.

  • 현실의 프로그램은 둘을 함께 섞어 쓰는 경우가 많습니다.11

async fn과 .await를 처음 보는 눈으로 이해하기

Rust의 async fn은 호출 즉시 결과를 만드는 함수가 아닙니다. 먼저 미래의 작업, 즉 Future를 돌려줍니다. 그리고 그 Future는 await되거나 런타임에 의해 실행되기 전까지는 실제로 일을 시작하지 않습니다. Rust의 future는 게으르다고 말하는 이유가 바로 이것입니다.13

말만 들으면 추상적이니 코드를 봅시다.

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

async fn fetch_name() -> String {
    sleep(Duration::from_secs(1)).await;
    String::from("Rusty")
}

#[tokio::main]
async fn main() {
    let name = fetch_name().await;
    println!("안녕하세요, {name}!");
}

여기서 fetch_name()은 네트워크 요청 대신 sleep으로 기다림을 흉내 냅니다. 중요한 점은 sleep(...).await에서 현재 작업이 멈추는 동안 스레드 전체가 멈춰 버리는 것이 아니라, 런타임이 다른 작업을 진행할 수 있다는 것입니다.12

그리고 눈여겨볼 부분이 하나 더 있습니다. #[tokio::main]이 붙어 있지요. Rust는 언어 차원에서 async 문법을 제공하지만, 기본 내장 런타임은 제공하지 않습니다. 그래서 실제 async 코드를 돌리려면 Tokio 같은 런타임 크레이트를 많이 사용합니다.14

Cargo.toml에는 보통 이렇게 추가합니다.

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

async는 혼자보다 같이 움직일 때 더 빛난다

async의 진짜 재미는 여러 대기 작업을 함께 돌릴 때 느껴집니다.

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

async fn task(name: &str, sec: u64) -> String {
    sleep(Duration::from_secs(sec)).await;
    format!("{name} 완료")
}

#[tokio::main]
async fn main() {
    let start = std::time::Instant::now();

    let (a, b) = tokio::join!(
        task("첫 번째 작업", 2),
        task("두 번째 작업", 2)
    );

    println!("{a}");
    println!("{b}");
    println!("걸린 시간: {:?}", start.elapsed());
}

두 작업이 각각 2초 기다린다고 해서 꼭 4초가 걸리지는 않습니다. 함께 시작해서 함께 기다리면 전체 시간은 대체로 2초 안팎이 됩니다. 이것이 async가 I/O 작업에서 효율적인 이유입니다.15

중요한 오해도 하나 바로잡아야 합니다. async는 "무조건 더 빠른 마법"이 아닙니다. 계산량이 큰 CPU 작업을 async로 감싸기만 한다고 빨라지지 않습니다. 오히려 그런 일은 별도 스레드나 스레드 풀 쪽이 더 적합합니다.11

스레드와 async, 이렇게 역할을 나누면 편하다

처음에는 아래 기준만 기억해도 충분합니다.

상황먼저 떠올릴 도구이유
이미지 압축, 해시 계산, 대량 연산스레드CPU를 실제로 많이 사용함
HTTP 요청 여러 개, DB 질의 여러 개async/await기다림이 많아 스레드를 아낄 수 있음
작업자들이 결과를 모아 보고채널메시지 전달이 단순하고 안전함
여러 작업이 같은 상태를 함께 수정Arc<Mutex<T>>공유 가변 상태를 안전하게 다룸

실무에서도 이 구분은 꽤 오래 갑니다. 웹 서버는 async로 요청을 처리하고, 무거운 압축이나 이미지 처리만 별도 스레드로 넘기는 식입니다.

async에서 Mutex를 쓸 때는 한 가지 더 조심하자

동기 코드에서 쓰던 std::sync::Mutex를 async 코드에서도 볼 수는 있습니다. 하지만 잠금을 잡은 채로 .await를 지나가면 문제가 생길 수 있습니다. 이 뮤텍스는 스레드를 블로킹하기 때문입니다. 그러면 같은 실행기를 쓰는 다른 async 작업들이 굶어버릴 수 있습니다.16

그래서 원칙은 단순합니다.

  • 잠금을 아주 짧게 잡고, .await 전에 반드시 놓을 수 있으면 std::sync::Mutex

  • 잠금을 잡은 상태로 await 경계를 넘어가야 하면 tokio::sync::Mutex 같은 async용 뮤텍스 고려

예를 들어 아래 코드는 비교적 안전한 패턴입니다.

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

async fn next_id(state: Arc<Mutex<i32>>) -> i32 {
    let id = {
        let mut guard = state.lock().unwrap();
        *guard += 1;
        *guard
    }; // 여기서 잠금 해제

    // 이 아래에서 await를 해도 잠금을 쥐고 있지 않다
    id
}

핵심은 간단합니다. 잠금을 쥔 채로 오래 기다리지 말자. 문 앞에서 열쇠를 꽂아 둔 채 커피 사러 가면 뒤 사람들만 괴롭습니다.

작은 그림으로 전체를 다시 묶어 보기

이 장의 도구들을 하나의 흐름으로 보면 더 잘 보입니다.

여러 파일을 동시에 처리하는 프로그램을 만든다고 상상해 봅시다. CPU를 많이 쓰는 압축 작업은 스레드로 나눌 수 있습니다. 각 작업자가 끝난 결과는 채널로 메인 스레드에 보낼 수 있습니다. 전체 진행 상황 같은 공유 카운터는 Arc<Mutex<usize>>로 관리할 수 있습니다. 그리고 만약 이 프로그램이 원격 서버에도 계속 상태를 보고해야 한다면, 네트워크 통신 부분은 async가 훨씬 자연스럽습니다.

즉, Rust의 동시성은 "이것만 써라"가 아닙니다. 문제의 성격에 맞는 도구를 조합하는 방식입니다. 이 조합이 Rust를 강하게 만듭니다.

꼭 기억할 핵심 정리

동시성과 비동기를 처음 배울 때는 세부 문법보다 감각이 중요합니다.

  • 스레드는 실제 작업 흐름을 나누는 가장 직접적인 도구다.

  • 채널은 스레드끼리 안전하게 메시지를 주고받게 해 준다.

  • Arc는 여러 스레드가 같은 값을 함께 소유하게 한다.

  • Mutex는 한 번에 하나만 수정하게 만들어 공유 가변 상태를 보호한다.

  • async/await는 기다림이 많은 I/O 작업을 적은 스레드로 효율적으로 처리하게 해 준다.

  • 스레드와 async는 서로 대체재가 아니라, 잘 섞어 쓰는 도구다.

Rust의 동시성은 처음엔 규칙이 많아 보입니다. 하지만 그 규칙 덕분에 프로그램이 커질수록 훨씬 믿음직해집니다. 이제 여러분은 단순히 "동시에 뭔가 한다"를 넘어, "왜 스레드를 쓰는지", "왜 채널이 편한지", "왜 async가 필요한지"를 설명할 수 있게 되었습니다. 그건 이미 초보 단계를 꽤 멋지게 넘어가고 있다는 뜻입니다.

참고

1Rust Concurrency - Safe Parallel Programming

2Fundamentals of Asynchronous Programming: Async, Await, Futures, and Streams - The Rust Programming Language

3Concurrency in Rust: Introduction to Threads and Channels

4Why Async? - Asynchronous Programming in Rust

5Concurrency in Rust: Fearless Parallelism

6Transfer Data Between Threads with Message Passing - The Rust Programming Language

7Concurrency in Rust (Threads, Channels)

8Arc, Mutex, 채널을 이용한 Rust 동시성 마스터하기

9[Rust] Arc, Mutex: 개념과 예제, 사용법](https://chungcode.tistory.com/52)

10Rust Concurrency - Safe Parallel Programming

11Why Async? - Asynchronous Programming in Rust

12Fundamentals of Asynchronous Programming: Async, Await, Futures, and Streams - The Rust Programming Language

13Async and await - Asynchronous Programming in Rust

14Async and Await - Asynchronous Programming in Rust

15Async/Await in Rust: A Beginner's Guide

16Async vs Std Mutex - Asynchronous Rust | Stanza

책 목록

rust 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기