Rust 기초: Send 트레이트로 스레드 안전성 보장하기
Rust로 멀티스레드 프로그래밍을 하다 보면 이런 컴파일 에러를 만나게 되는 경우가 있는데요.
error[E0277]: `Rc<Vec<i32>>` cannot be sent between threads safely
--> src/main.rs:6:18
|
6 | let handle = thread::spawn(move || {
| ^^^^^^^^^^^^^ `Rc<Vec<i32>>` cannot be sent between threads safely
|
= help: the trait `Send` is not implemented for `Rc<Vec<i32>>`
“스레드 간에 안전하게 보낼 수 없다”는 말은 대체 무슨 뜻일까요? 그리고 Send 트레이트는 뭘까요? 🤔
사실 이 에러 메시지 안에 Rust가 동시성 프로그래밍에서 데이터 경합(data race)을 원천 차단하는 메커니즘이 담겨 있습니다. Send 트레이트가 어떻게 동작하고, 어떤 상황에서 우리를 보호해 주는지 살펴볼게요.
Send 트레이트란?
Send는 Rust 표준 라이브러리의 std::marker 모듈에 정의된 마커 트레이트(marker trait)입니다. 마커 트레이트란 메서드가 하나도 없는 트레이트로, 타입에 특정 속성이 있다는 것을 컴파일러에게 알려주는 역할을 합니다.
// 표준 라이브러리 내부 정의 (단순화)
pub unsafe auto trait Send { }
이 정의를 보면 세 가지 특별한 키워드가 눈에 띕니다.
unsafe가 붙어 있으니 이 트레이트를 수동으로 구현할 때는 프로그래머가 안전성을 직접 보장해야 합니다. 잘못 구현하면 정의되지 않은 동작(undefined behavior)이 발생할 수 있기 때문에 Rust가 unsafe를 요구하는 것이죠.
auto라는 키워드도 눈에 띄는데요, 조건만 충족하면 컴파일러가 알아서 구현해 줍니다. 우리가 직접 impl Send for MyType {}을 작성할 필요가 없는 거죠.
본문은 비어 있습니다. 메서드가 없다는 건 런타임에 아무런 비용이 들지 않는다는 의미고요. 순전히 컴파일 타임에만 작동하는 안전장치입니다.
한마디로, Send를 구현한 타입의 값은 스레드 간에 소유권을 안전하게 이동할 수 있다는 뜻입니다.
왜 Send가 필요할까?
멀티스레드 프로그래밍에서 가장 무서운 버그 중 하나가 데이터 경합인데요. 두 개 이상의 스레드가 동시에 같은 메모리에 접근하면서 그중 하나라도 쓰기 작업을 하면 프로그램이 예측 불가능한 동작을 할 수 있습니다.
C나 C++에서는 이런 버그가 런타임에 간헐적으로 발생해서 디버깅하기가 정말 어려운데요. Rust는 이 문제를 아예 컴파일 타임에 잡아냅니다. 그 핵심 도구가 바로 Send 트레이트입니다.
간단한 예를 들어볼게요.
use std::thread;
fn main() {
let numbers = vec![1, 2, 3, 4, 5];
let handle = thread::spawn(move || {
println!("다른 스레드에서: {:?}", numbers);
});
handle.join().unwrap();
}
이 코드가 정상적으로 컴파일되는 이유는 Vec<i32>가 Send를 구현하기 때문입니다. move 키워드로 numbers의 소유권을 새 스레드로 옮기는데, Vec<i32>는 스레드 간 이동이 안전한 타입이므로 컴파일러가 허용하는 거죠.
그런데 만약 Send를 구현하지 않은 타입을 스레드로 넘기려 하면 어떻게 될까요?
use std::rc::Rc;
use std::thread;
fn main() {
let data = Rc::new(vec![1, 2, 3]);
let handle = thread::spawn(move || {
println!("다른 스레드에서: {:?}", data);
});
handle.join().unwrap();
}
error[E0277]: `Rc<Vec<{integer}>>` cannot be sent between threads safely
Rc는 Send를 구현하지 않습니다. Rc의 참조 카운트는 일반 정수로 관리되기 때문에 여러 스레드에서 동시에 조작하면 카운트가 꼬일 수 있거든요. Rust 컴파일러는 이런 위험한 상황을 사전에 차단해 줍니다.
Send를 구현하는 타입들
대부분의 Rust 타입은 Send를 구현합니다. 직접 따로 작성하지 않아도 컴파일러가 자동으로 구현해 주기 때문인데요. 어떤 기준으로 자동 구현이 이루어지는지 알아보겠습니다.
원시 타입들(i32, f64, bool, char 등)은 모두 Send입니다. 스택에 저장되는 단순한 값이라 스레드 간에 복사하거나 이동해도 아무 문제가 없거든요.
use std::thread;
fn main() {
let x: i32 = 42;
let y: f64 = 3.14;
let flag: bool = true;
thread::spawn(move || {
println!("정수: {}, 실수: {}, 불리언: {}", x, y, flag);
}).join().unwrap();
}
String, Vec<T>, HashMap<K, V> 같은 컬렉션 타입도 내부 요소가 Send이면 자동으로 Send가 됩니다. 이걸 “전이적 Send(transitive Send)“라고 하는데, 구성 요소 전체가 스레드 안전하면 전체도 안전하다는 원리입니다.
use std::collections::HashMap;
use std::thread;
fn main() {
let mut scores: HashMap<String, Vec<i32>> = HashMap::new();
scores.insert("Alice".to_string(), vec![95, 87, 92]);
scores.insert("Bob".to_string(), vec![78, 85, 90]);
let handle = thread::spawn(move || {
for (name, grades) in &scores {
let avg: f64 = grades.iter().sum::<i32>() as f64 / grades.len() as f64;
println!("{}: 평균 {:.1}점", name, avg);
}
});
handle.join().unwrap();
}
Box로 감싼 타입도 내부 값이 Send이면 Send입니다. Box<i32>는 Send이지만 Box<Rc<i32>>는 Send가 아닌 식이죠.
Arc<T>도 T가 Send이고 Sync이면 Send를 구현합니다. Arc는 원자적 참조 카운트를 사용하기 때문에 스레드 간에 안전하게 공유할 수 있거든요. 이게 바로 단일 스레드 전용인 Rc와의 핵심 차이점입니다.
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let handles: Vec<_> = (0..3).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
let sum: i32 = data.iter().sum();
println!("스레드 {}: 합계 = {}", i, sum);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
Mutex<T>와 RwLock<T> 같은 동기화 프리미티브도 T가 Send이면 Send를 구현합니다. 잠금(lock)으로 동시 접근을 제어하기 때문에 안전하게 스레드 간에 이동할 수 있습니다.
Send를 구현하지 않는 타입들
반대로 일부 타입은 의도적으로 Send를 구현하지 않는데요. 이 타입들에는 스레드 간 이동이 위험한 이유가 분명히 있습니다.
가장 대표적인 예가 Rc입니다. 앞에서 봤듯이 Rc는 참조 카운트를 일반 정수로 관리하기 때문에 여러 스레드에서 동시에 카운트를 올리고 내리면 정확한 값을 보장할 수 없습니다. 스레드 안전한 참조 카운트가 필요하다면 Arc를 사용해야 합니다.
원시 포인터(*const T, *mut T)도 Send를 구현하지 않습니다. 원시 포인터는 Rust의 소유권 시스템 밖에 있어서 컴파일러가 안전성을 보장할 수 없거든요.
use std::thread;
fn main() {
let value = 42;
let ptr: *const i32 = &value;
// 컴파일 에러!
// thread::spawn(move || {
// unsafe { println!("값: {}", *ptr); }
// });
}
Cell<T>과 RefCell<T>도 Send는 구현하지만 Sync는 구현하지 않는 특이한 케이스인데요. 단일 스레드 내에서 내부 가변성(interior mutability)을 제공하기 위한 타입이라 여러 스레드에서 동시에 참조하는 건 안전하지 않습니다. 다만 소유권 자체를 다른 스레드로 옮기는 건 괜찮아서 Send는 구현합니다.
Send와 Sync의 관계
Send와 함께 자주 언급되는 트레이트가 Sync인데요. 이 둘은 서로 밀접하게 연관되어 있지만 의미가 다릅니다.
Send는 소유권을 다른 스레드로 이동할 수 있는지를 나타내고, Sync는 여러 스레드에서 동시에 참조(&T)를 통해 접근할 수 있는지를 나타냅니다. 좀 더 정확히 말하면, T가 Sync이면 &T가 Send입니다.
// Send: 소유권 이동
// 스레드 A ---[T]---> 스레드 B (값 자체를 넘김)
// Sync: 참조 공유
// 스레드 A ---[&T]---> 스레드 B (참조를 보냄)
// 스레드 A도 여전히 &T로 접근 가능
이걸 표로 정리하면 다음과 같습니다.
| 트레이트 | 의미 | 예시 |
|---|---|---|
Send | 값을 다른 스레드로 이동 가능 | Vec<T>, String, Arc<T> |
Sync | 참조를 여러 스레드에서 공유 가능 | i32, Arc<T>, Mutex<T> |
Send + Sync | 이동도, 공유도 가능 | 대부분의 타입 |
!Send + !Sync | 이동도, 공유도 불가 | Rc<T> |
대부분의 타입은 Send와 Sync를 둘 다 구현하는데요. 몇 가지 흥미로운 예외가 있습니다.
Rc<T>는 Send도 Sync도 아닙니다. 단일 스레드에서만 사용해야 합니다.
Cell<T>과 RefCell<T>은 Send이지만 Sync는 아닌데요. 값을 다른 스레드로 옮기는 건 괜찮지만 여러 스레드에서 동시에 참조하면 내부 상태가 꼬일 수 있기 때문입니다.
MutexGuard<T>는 Sync이지만 Send는 아닌데요. 뮤텍스의 잠금을 획득한 스레드에서만 해제해야 하는 운영체제 수준의 제약 때문입니다.
thread::spawn의 Send 제약
Send가 실제로 어떻게 강제되는지 궁금하실 텐데요. 비밀은 thread::spawn의 시그니처에 있습니다.
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
// ...
}
이 시그니처를 뜯어보면 클로저 F가 Send여야 하고, 클로저가 반환하는 값 T도 Send여야 합니다. 클로저가 Send라는 건 클로저가 캡처하는 모든 값도 Send여야 한다는 뜻이거든요.
그래서 Rc를 캡처하는 클로저를 thread::spawn에 넘기면 컴파일 에러가 나는 겁니다. Rc가 Send가 아니니까 그 Rc를 캡처한 클로저도 Send가 아니게 되는 거죠.
'static 바운드도 주목할 만한데요. 이건 클로저가 캡처하는 모든 참조가 프로그램 전체 수명을 가져야 한다는 뜻입니다. 새 스레드가 언제까지 살아있을지 보장할 수 없으니까 빌린 참조를 넘기는 걸 막는 거죠.
use std::thread;
fn main() {
let message = String::from("안녕하세요");
// 이렇게 참조를 넘기면 컴파일 에러
// thread::spawn(|| {
// println!("{}", message); // message를 빌려옴
// });
// move로 소유권을 넘기면 OK
thread::spawn(move || {
println!("{}", message); // message의 소유권을 가져옴
}).join().unwrap();
}
커스텀 타입과 Send
우리가 만드는 구조체나 열거형은 모든 필드가 Send이면 자동으로 Send가 됩니다.
use std::thread;
struct Player {
name: String,
score: u32,
history: Vec<i32>,
}
fn main() {
let player = Player {
name: "Dale".to_string(),
score: 100,
history: vec![10, 20, 30, 40],
};
let handle = thread::spawn(move || {
println!("{}의 점수: {}", player.name, player.score);
println!("기록: {:?}", player.history);
});
handle.join().unwrap();
}
String, u32, Vec<i32> 모두 Send이므로 Player도 자동으로 Send가 됩니다.
반면에 Send가 아닌 필드가 하나라도 있으면 전체 타입이 Send가 아니게 됩니다.
use std::rc::Rc;
struct CachedData {
data: Vec<u8>,
cache: Rc<String>, // Rc는 Send가 아님
}
// CachedData는 Send가 아님!
// thread::spawn에 넘길 수 없음
이런 경우 Rc를 Arc로 바꾸면 Send를 되찾을 수 있습니다.
use std::sync::Arc;
use std::thread;
struct CachedData {
data: Vec<u8>,
cache: Arc<String>, // Arc는 Send
}
fn main() {
let cached = CachedData {
data: vec![1, 2, 3],
cache: Arc::new("캐시된 값".to_string()),
};
thread::spawn(move || {
println!("데이터: {:?}", cached.data);
println!("캐시: {}", cached.cache);
}).join().unwrap();
}
unsafe impl Send
극히 드문 경우지만 컴파일러가 자동으로 Send를 구현해 주지 않는 타입에 대해 수동으로 Send를 구현해야 할 때가 있습니다. 대표적인 예가 FFI(Foreign Function Interface)를 통해 C 라이브러리와 상호작용하는 경우입니다.
struct DatabaseConnection {
handle: *mut std::ffi::c_void,
}
// 안전성을 프로그래머가 보장해야 함
unsafe impl Send for DatabaseConnection {}
unsafe impl이라는 키워드가 말해주듯이 이건 컴파일러에게 “이 타입이 스레드 간에 안전하게 이동할 수 있다는 걸 내가 보장할게”라고 약속하는 겁니다.
하지만 이 약속이 거짓이면 데이터 경합이나 정의되지 않은 동작이 발생할 수 있기 때문에 정말 확실할 때만 사용해야 합니다. 일반적인 Rust 코드에서는 unsafe impl Send를 직접 작성할 일이 거의 없습니다.
반대로 자동 구현된 Send를 명시적으로 제거할 수도 있는데요.
use std::marker::PhantomData;
struct NotSendable {
data: i32,
_marker: PhantomData<*const ()>, // *const ()는 Send가 아님
}
PhantomData<*const ()>를 필드로 넣으면 이 타입은 원시 포인터를 포함하는 것처럼 취급되어 Send가 자동 구현되지 않습니다. 라이브러리에서 의도적으로 단일 스레드 전용 타입을 만들 때 이런 패턴을 씁니다.
실전 예제: 병렬 데이터 처리
마지막으로 Send가 실제로 어떻게 활용되는지 좀 더 현실적인 예제를 살펴보겠습니다. 큰 데이터를 여러 스레드로 나누어 처리하는 패턴인데요.
use std::sync::{Arc, Mutex};
use std::thread;
fn parallel_sum(numbers: Vec<i32>, num_threads: usize) -> i32 {
let chunk_size = (numbers.len() + num_threads - 1) / num_threads;
let result = Arc::new(Mutex::new(0i32));
let handles: Vec<_> = numbers
.chunks(chunk_size)
.map(|chunk| chunk.to_vec()) // Vec<i32>는 Send
.map(|chunk| {
let result = Arc::clone(&result); // Arc<Mutex<i32>>는 Send
thread::spawn(move || {
let partial_sum: i32 = chunk.iter().sum();
let mut total = result.lock().unwrap();
*total += partial_sum;
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
let total = result.lock().unwrap();
*total
}
fn main() {
let numbers: Vec<i32> = (1..=100).collect();
let sum = parallel_sum(numbers, 4);
println!("1부터 100까지의 합: {}", sum); // 5050
}
이 코드에서 Send가 보장하는 것들을 정리해 보면, 각 chunk(Vec<i32>)는 스레드로 안전하게 이동합니다. Arc<Mutex<i32>>의 클론도 마찬가지고요. 클로저 자체도 캡처한 값이 전부 Send이니까 Send입니다. 이 모든 검증이 컴파일 타임에 이루어지기 때문에 런타임 비용이 전혀 없습니다.
마치며
Rust의 Send 트레이트는 “이 타입의 값은 스레드 간에 안전하게 소유권을 이동할 수 있다”는 계약입니다. 컴파일러가 자동으로 구현해 주고 컴파일 타임에 위반까지 잡아줍니다. 런타임 비용도 없고요. 이런 특성 덕분에 Rust는 “두려움 없는 동시성(fearless concurrency)“을 실현합니다.
다른 언어에서 멀티스레드 버그로 고생해 본 경험이 있다면 이게 얼마나 대단한 건지 체감하실 거예요. 데이터 경합이 런타임에 간헐적으로 터지는 게 아니라 코드를 작성하는 순간 컴파일러가 잡아주니까요 😄
Send와 함께 Sync 트레이트도 이해하면 Rust의 동시성 모델을 완전히 파악할 수 있는데요. 관련해서 소유권과 빌림이나 Arc에 대한 글도 함께 읽어보시면 도움이 될 것입니다.
더 자세한 내용은 std::marker::Send - Rust 공식 문서와 Send and Sync - The Rustonomicon를 참고하세요.
This work is licensed under
CC BY 4.0