본문 바로가기

`Sync`와 `Send` 트레이트를 이용한 확장 가능한 동시성

Send와 Sync가 스레드 간 소유권 이동과 공유를 보장하는 방식을 이해하고 async 런타임·서버 스택으로 확장합니다.

흥미롭게도, 러스트 언어는 매우 적은 숫자의 동시성 기능을 갖고 있습니다.

이 장에서 여태껏 이야기한 거의 모든 동시성 기능이 언어의 부분이 아닌 표준 라이브러리의 영역이었습니다.

동시성 처리를 위한 옵션은 언어 혹은 표준 라이브러리에만 국한되지 않습니다; 여러분만의 동시성 기능을 작성하거나 다른 이들이 작성한 것을 이용할 수 있습니다.

그러나, 두 개의 동시성 개념은 언어에 내재되어 있습니다.

바로 std::marker 트레이트인 Sync와 Send입니다.


Send를 사용하여 스레드 사이에 소유권 이동을 허용하기

RUST · SEND / SYNC CONTRACTS

Send와 Sync는 이동시키는 대상에 따라 함께 요구될 수 있다

값 T의 이동과 공유 참조 &T의 이동은 서로 다른 조건입니다. 아래 비교는 std 라이브러리 타입을 기준으로 하며, 도구의 소유권 책임과 접근 동기화 책임을 함께 살펴봅니다.

Send · 값 자체의 이동

T: Send

값을 스레드 경계로 옮겨도 안전하다는 약속입니다. Rc<T>는 Send가 아닙니다. RefCell<T>는 T: Send이면 이동할 수 있으나 공유 참조는 별도 조건입니다.

Sync · 공유 참조의 이동

T: Sync ⇔ &T: Send

T의 공유 참조를 여러 스레드에 보낼 수 있다는 약속입니다. RefCell과 Cell은 Sync가 아닙니다. 값과 공유 참조를 모두 보내는 API는 두 조건이 함께 필요할 수 있습니다.

Arc · 소유권 공유의 조건

Arc<T>: Send + Sync
    if T: Send + Sync

원자적 참조 카운트가 T의 내부 연산까지 동기화하지는 않습니다. Arc<RefCell<i32>>로 감싸도 안전한 스레드 공유를 얻지 못합니다.

Mutex · 배타 접근의 조건

Mutex<T>: Send + Sync
    if T: Send

Mutex는 T에 대한 동시 접근을 잠금으로 직렬화합니다. 따라서 Arc<Mutex<i32>>는 스레드 간 소유권 공유와 내부 변경을 함께 처리합니다.

일반 구조체 · 필드에서 자동 결정

보통 모든 필드가 해당 auto trait를 만족하면 구조체도 그 trait를 얻습니다. 라이브러리 타입의 명시적 구현과 부정 구현도 적용되므로 Arc/Mutex 같은 래퍼는 실제 바운드를 확인합니다.

unsafe impl · 수동 안전성 약속

unsafe impl Send/Sync는 검사를 강제로 무시해도 된다는 뜻이 아닙니다. 안전한 사용으로 데이터 경합 등이 생기지 않는다는 불변식을 작성자가 입증하고 유지해야 합니다.

Send·Sync는 데이터 경합에 관한 계약입니다. 데드락·논리적 오류·모든 비동기 실행 요구사항까지 자동으로 해결하지는 않습니다.

Send 마커 트레이트는 Send가 구현된 타입의 소유권이 스레드 사이에서 이동될 수 있음을 나타냅니다.

대부분의 러스트 타입이 Send이지만, 몇 개의 예외가 있는데, 그중 Rc<T>도 있습니다.

이것은 Send가 될 수 없는데 그 이유는 여러분이 Rc<T> 값을 복제하여 다른 스레드로 복제본의 소유권 전송을 시도한다면, 두 스레드 모두 동시에 참조 카운트 값을 업데이트할지도 모르기 때문입니다.

이러한 이유로, Rc<T>는 여러분이 스레드-안전성 성능 저하를 지불하지 않아도 되는 싱글스레드의 경우에 사용되도록 구현되었습니다.

따라서 러스트의 타입 시스템과 트레이트 바운드는 우발적으로 스레드 간에 Rc<T> 값을 안전하지 않게 보낼 수 없도록 보장해 줍니다.

예제 15-14를 시도할 때는 트레이트 `Send`가 `Rc<Mutex<i32>>`에 대해 구현되지 않았습니다라는 에러를 얻었습니다.

Arc<Mutex<i32>>로 바꿨을 때는 코드가 컴파일되었습니다. Arc<T>가 Send와 Sync를 구현하려면 T: Send + Sync 조건을 만족해야 합니다.

보통 명시적인 다른 구현이 없는 일반 구조체는 각 필드가 Send이면 자동으로 Send를 얻습니다. 라이브러리 래퍼의 별도 바운드나 부정 구현도 적용되므로 내부 타입만 보고 모든 경우를 단정하지는 않습니다.

원시 포인터(raw pointer)를 빼고 거의 모든 기초 타입이 Send인데, 이는 18장에서 다루겠습니다.


Sync를 사용하여 여러 스레드로부터의 접근을 허용하기

Sync 마커 트레이트는 Sync가 구현된 타입이 여러 스레드로부터 안전하게 참조 가능함을 나타냅니다.

바꿔 말하면, 만일 &T(T의 불변 참조자)가 Send이면, 즉 참조자가 다른 스레드로 안전하게 보내질 수 있다면, T는 Sync를 구현합니다.

Send와 유사하게, 기초 타입들은 Sync하고, 또한 전체가 Sync한 타입들로 구성된 타입 또한 Sync합니다.

스마트 포인터 Rc<T>는 또한 Send가 아닌 이유와 동일한 이유로 Sync하지도 않습니다.

(14장에서 이야기한) RefCell<T> 타입과 연관된 Cell<T> 타입의 가족들도 Sync하지 않습니다.

RefCell<T>가 런타임에 수행하는 대여 검사 구현은 스레드-안전하지 않습니다.

Mutex<T>는 T: Send일 때 Sync이고 여러분이 ‘여러 스레드 사이에서 Mutex<T> 공유하기’ 절에서 본 것처럼 여러 스레드에서 접근을 공유하는 데 사용될 수 있습니다.

Send와 Sync를 같은 표로 외우기보다는, 값의 소유권 이동과 참조 공유라는 두 질문으로 나누면 자동 구현과 예외를 함께 볼 수 있습니다.


Send와 Sync를 손수 구현하는 것은 안전하지 않습니다

Send와 Sync 트레이트들로 구성된 타입들이 자동으로 Send가 될 수 있고 Sync하기 때문에, 이 트레이트들은 손수 구현하지 않아도 됩니다.

이들은 심지어 마커 트레이트로서 구현할 어떠한 메서드도 없습니다.

이들은 그저 동시성과 관련된 불변성을 강제하는 데 유용할 따름입니다.

이 트레이트들을 손수 구현하는 것은 안전하지 않은 (unsafe) 러스트 코드 구현을 수반합니다.

18장에서 안전하지 않은 러스트 코드에 대하여 이야기하겠습니다; 지금으로서 중요한 정보는 Send와 Sync하지 않은 구성 요소들로 구성된 새로운 동시적 타입을 만드는 것이 안전성 보장을 유지하기 위해 신중한 고려가 필요하다는 점입니다.

‘러스토노미콘’에 이러한 보장과 이를 유지하는 방법에 대한 더 많은 정보가 있습니다.

Send와 Sync는 값이 어떤 경계를 넘는지에 대한 질문으로 나누어 보면 실제 코드에서 적용하기 쉽습니다.

예시

트레이트 바운드를 검사하는 함수는 실제 스레드를 시작하지 않아도 타입 조건을 읽는 도구가 됩니다. 다음 전체 프로그램은 표준 타입의 허용되는 조건만 선택합니다.

src/main.rs
use std::cell::{Cell, RefCell};
use std::sync::{Arc, Mutex};

fn require_send<T: Send>() {}
fn require_sync<T: Sync>() {}

fn main() {
    require_send::<Cell<i32>>();
    require_send::<RefCell<i32>>();
    require_send::<Mutex<RefCell<i32>>>();
    require_sync::<Mutex<RefCell<i32>>>();
    require_send::<Arc<Mutex<i32>>>();
    require_sync::<Arc<Mutex<i32>>>();
}

표준 출력은 없습니다. Cell·RefCell 자체의 값을 옮길 수 있는 조건과 공유 참조를 옮길 수 있는 조건은 같지 않습니다. RefCell<i32>는 Send이고 Sync는 아니지만, 그 값을 한 번에 한 스레드만 접근하게 하는 Mutex<RefCell<i32>>는 Send와 Sync를 모두 만족합니다. 모든 동작을 안전하게 병렬 실행했다는 성능·실행 검사는 아닙니다.

스스로 래퍼 조건 판단하기

각 시도는 독립입니다. 위 코드에 A=require_sync::<Cell<i32>>(), B=require_sync::<RefCell<i32>>(), C=require_send::<Arc<RefCell<i32>>>(), D=require_sync::<Mutex<RefCell<i32>>>()를 하나씩 추가한다면 어느 것이 타입 조건을 만족하나요? C와 D의 래퍼가 요구하는 payload 조건이 어떻게 다른지 설명하세요.

조건 확인

D만 만족합니다. Cell과 RefCell은 Sync가 아니므로 A·B는 거부됩니다. Arc<RefCell<i32>>를 보내려면 내부 RefCell에도 Send와 Sync가 함께 필요하여 C도 거부됩니다. Mutex<RefCell<i32>>의 Sync는 내부 값의 Send를 요구하고, 잠금이 동시 접근을 직렬화하므로 RefCell 자체의 Sync를 요구하지 않습니다. 이름에 스마트 포인터가 들어간다는 이유로 같은 조건을 적용하지 않습니다.

정의는 Sync 계약, 조건부 구현은 Mutex와 Arc를 대조하세요.


Tokio 런타임과 async 동시성 연결하기

앞의 예제에서는 스레드를 만들고 채널이나 공유 상태로 일을 연결했습니다. 이번에는 태스크 하나가 잠시 기다린 뒤 결과를 돌려주는 경우를 보겠습니다. 락과 서버 코드를 함께 넣기 전에, async 코드가 언제 실행되고 어디서 기다리는지 먼저 살펴보겠습니다.

async 블록은 아직 끝나지 않은 계산을 나타내는 Future 값을 만듭니다. 이 블록을 만들기만 해서는 안의 코드가 실행되지 않습니다. Tokio 같은 런타임은 poll을 호출해 Future의 계산을 진행하고, 타이머나 I/O가 준비되면 다시 실행할 차례를 잡습니다. 런타임에 맡긴 실행 단위를 여기서는 태스크(task)라고 부릅니다.

비동기 대기는 Pending → wake → poll 흐름으로 생각할 수 있습니다. Pending은 아직 결과가 준비되지 않았다는 뜻이고, wake는 다시 진행해 볼 수 있다는 통지입니다. 다시 poll한 뒤에도 아직 준비되지 않았다면 Pending을 받을 수 있습니다. Ready가 돌아오면 기다리던 .await 다음으로 이어집니다. .await에 도달했다고 매번 실행을 양보하는 것은 아니며, 이미 결과가 준비되어 있으면 바로 이어질 수 있습니다.

스레드 방식과의 차이를 그림으로 비교하려면 19장의 최종 정리를 참고하세요. 이번에는 아래의 태스크 하나로 이 흐름을 따라가겠습니다.

태스크 하나를 준비하고 결과 받기

Cargo 기초에서 사용한 방식으로 별도 연습 프로젝트를 만드세요. 이 예제는 Rust 2024 에디션을 지원하는 stable Rust(1.85.0 이상)를 기준으로 합니다.

cargo new tokio_first --edition 2024
cd tokio_first

프로젝트의 Cargo.toml을 다음처럼 맞춥니다. Tokio의 버전을 정확히 지정하고 이 예제에서 쓰는 feature만 켭니다. feature는 크레이트 안에서 사용할 기능을 선택하는 설정입니다.

Cargo.toml
[package]
name = "tokio_first"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "=1.53.1", features = ["rt", "macros", "time"] }

rt는 태스크 실행과 현재 스레드 스케줄러, macros는 #[tokio::main], time은 비동기 타이머를 준비합니다. Tokio 기능 설정에서 각 기능을 확인할 수 있습니다. 다음 코드를 src/main.rs 전체로 저장하세요.

src/main.rs
use std::time::Duration;
use tokio::time::sleep;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let waiting = async {
        println!("task: waiting");
        sleep(Duration::from_millis(100)).await;
        println!("task: ready");
        7
    };

    println!("main: future created");
    let task = tokio::spawn(waiting);
    println!("main: task scheduled");

    let result = task.await.expect("task failed");
    println!("main: result = {result}");
}

current_thread는 태스크를 현재 스레드에서 실행하도록 정합니다. 태스크마다 새 OS 스레드를 만드는 설정은 아닙니다. #[tokio::main]은 런타임을 준비하고 async main의 완료를 기다리는 시작 코드를 만들어 줍니다.

프로젝트 디렉터리에서 다음 명령을 사용합니다. 첫 의존성 다운로드에는 인터넷 연결이 필요하며, Cargo가 생성하는 Cargo.lock은 선택된 전이 의존성 버전도 기록합니다.

cargo check
cargo run

프로그램 자체의 예상 출력은 다음과 같습니다. Cargo가 앞에 표시하는 빌드 메시지는 버전과 빌드 상태에 따라 달라집니다.

main: future created
main: task scheduled
task: waiting
task: ready
main: result = 7

waiting을 만든 뒤 처음 출력되는 것은 main의 두 줄입니다. spawn은 태스크를 예약하지만 호출 안에서 곧바로 poll하지 않고, 이 단일 스레드 예제의 main은 task.await에 이르기 전까지 계속 진행합니다. 멀티스레드 런타임으로 바꾸면 main과 태스크의 출력 순서를 그대로 기대할 수 없습니다.

태스크가 실행되면 task: waiting을 출력하고 타이머를 기다립니다. 타이머가 아직 준비되지 않았다면 Future는 Pending을 반환해 진행을 멈춥니다. 런타임은 준비된 다른 일이 있으면 그 일을 진행할 수 있고, 타이머 통지가 오면 이 태스크를 다시 poll합니다. 100 ms는 요청한 대기 시간이며 출력이 정확히 그 간격으로 나타난다는 뜻은 아닙니다.

task.await는 태스크의 결과를 받는 JoinHandle을 기다립니다. 태스크가 완료해 7을 돌려주면 마지막 줄로 이어집니다. 핸들에 .await를 붙인 순간이 태스크를 처음 예약하는 시점은 아니며, 핸들을 기다리기 전에 런타임을 끝내면 태스크가 완료되지 못할 수 있습니다.

컴파일러가 tokio::main이나 tokio::time을 찾지 못하면 Cargo.toml의 macros와 time을 먼저 확인하세요. 기본 멀티스레드 런타임 feature가 없다는 오류가 나오면 current_thread 설정을 빠뜨리지 않았는지 살펴봅니다. 의존성을 받지 못했다면 코드보다 다운로드 오류와 연결 상태부터 확인합니다.

이 예제처럼 런타임에 태스크를 맡길 때도 Send 규칙이 이어집니다. tokio::spawn은 current_thread에서도 Future와 결과에 Send + 'static을 요구합니다. .await를 넘겨 보관하는 값과 참조를 확인해야 하며, 'static은 태스크가 영원히 살아야 한다는 뜻이 아닙니다. 여기서는 외부 지역 변수를 빌리지 않고 정수 결과를 반환합니다.

async 안에서 std::thread::sleep이나 긴 계산을 호출하면 그 코드를 실행하는 런타임 스레드는 막힙니다. 이번 태스크의 타이머 대기와 같은 방식으로 실행을 양보한다고 읽지 마세요. 이 작은 예제는 실행 흐름을 설명하며 여러 코어의 병렬 실행이나 성능 향상을 보여 주는 실험은 아닙니다.

실무 선택 기준: 동기 vs 비동기 프리미티브

  • std::sync::Mutex: 락을 짧게 잡고 .await를 넘기지 않을 때 적합합니다.
  • tokio::sync::Mutex: 락을 잡은 상태에서 비동기 대기를 포함할 수 있을 때 적합합니다.
  • std::sync::mpsc: 스레드 기반 작업 분배에 단순하고 빠른 선택지입니다.
  • tokio::sync::mpsc: 비동기 태스크 간 backpressure가 필요한 파이프라인에 적합합니다.

동시성 기본기(채널, 공유 상태, Send/Sync)는 15장 2절, 15장 3절, 15장 4절에서 다시 확인할 수 있습니다.

이를 실제 서버 구조에 적용하는 과정은 19장 2절과 19장 3절에서 이어집니다.


선택 심화: 비동기 서버 스택 연결 (axum + tracing + sqlx)

Tokio 런타임 위에 HTTP 프레임워크(axum), 요청 흐름을 기록하는 도구(tracing), DB 접근 도구(sqlx)를 조합할 수 있습니다.

다음 코드는 서버 스택의 구성 관계를 보여 주는 선택 참고입니다. 앞의 tokio_first 프로젝트에 이어 붙이는 단계가 아니며 axum·tracing·tracing-subscriber와 Tokio의 net·rt-multi-thread feature를 추가로 준비해야 합니다. 아래 코드에는 sqlx를 이용한 DB 질의는 없습니다.

use axum::{routing::get, Router};
use tracing::info;

async fn health() -> &'static str {
    info!("헬스 체크");
    "ok"
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_target(false).init();

    let app = Router::new().route("/health", get(health));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}
  • axum: 라우팅/미들웨어/추출기(extractor) 중심의 타입 안정적 HTTP 계층.
  • tracing: 요청 단위 span/field 기반 로그를 남겨 비동기 호출 경로를 추적하기 쉽습니다.
  • sqlx: 컴파일 타임 쿼리 검증(query!)을 통해 런타임 SQL 오류를 줄일 수 있습니다.

동시성 챕터에서 배운 공유 상태/채널 모델은 이 스택에서도 동일하게 적용됩니다.

차이는 스레드 단위보다 태스크 단위로 사고하는 비중이 커진다는 점입니다.


동시성 요약

지금 부분이 이 책에서 동시성에 대해 보게 될 마지막은 아닙니다.

19장의 프로젝트에서는 이번 장에서 다룬 개념들을 조금 전 다루었던 작은 예제보다 더 실질적인 상황에서 이용하게 될 것입니다.

일찍이 언급한 것처럼, 러스트가 동시성을 처리하는 방법이 언어의 매우 작은 부분이기 때문에, 많은 동시성 솔루션이 크레이트로 구현됩니다.

이들은 표준 라이브러리보다 더 빠르게 진화하므로, 현재 가장 최신 기술의 크레이트를 온라인으로 검색해서 멀티스레드 상황에 사용해 보세요.

러스트 표준 라이브러리는 메시지 패싱을 위한 채널을 제공하고, 동시적 컨텍스트에서 사용하기 안전한 Mutex<T>와 Arc<T> 같은 스마트 포인터 타입들을 제공합니다.

타입 시스템과 대여 검사기는 이 솔루션을 이용하는 코드가 데이터 경합 혹은 유효하지 않은 참조자로 끝나지 않을 것을 보장합니다.

컴파일 성공은 데이터 경합 방지에 도움이 되지만, 교착 상태나 논리적 실행 순서 오류까지 없다는 뜻은 아닙니다.

Rust의 동시성 모델을 사용하면 여러 스레드에서 동작하는 코드를 더 안전하게 작성할 수 있습니다.

다음으로는 러스트 프로그램이 점차 커짐에 따라서 문제를 모델링하고 솔루션을 구조화하는 자연스러운 방법에 대해 이야기할 것입니다.

더불어 객체 지향 프로그래밍으로부터 친숙할 수 있을 개념들과 러스트의 관용구가 어떻게 연관되어 있는지 다루겠습니다.


Sync와 Send 트레이트를 이용한 확장 가능한 동시성 학습 판단 기준표

Sync와 Send 트레이트를 이용한 확장 가능한 동시성에서는 소유권, 타입, 오류 처리 경계를 실제 코드에서 어떤 순서로 확인할지 정리합니다.