Rust 기초: Sync 트레이트로 스레드 간 안전한 참조 공유하기
Send 트레이트를 공부하다 보면 자연스럽게 따라오는 질문이 있는데요. “값을 스레드로 옮기지 않고, 여러 스레드에서 동시에 참조만 하고 싶으면 어떻게 하지?”
예를 들어 Arc로 RefCell을 감싸서 여러 스레드에서 접근하려고 하면 이런 컴파일 에러가 납니다.
use std::cell::RefCell;
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(RefCell::new(vec![1, 2, 3]));
let handle = thread::spawn({
let data = Arc::clone(&data);
move || {
let borrowed = data.borrow();
println!("{:?}", *borrowed);
}
});
handle.join().unwrap();
}
error[E0277]: `RefCell<Vec<{integer}>>` cannot be shared between threads safely
--> src/main.rs:8:18
|
8 | let handle = thread::spawn({
| ^^^^^^^^^^^^^ `RefCell<Vec<{integer}>>` cannot be shared between threads safely
|
= help: the trait `Sync` is not implemented for `RefCell<Vec<{integer}>>`
= note: if you want to do aliasing and mutation between multiple threads, use `std::sync::RwLock` instead
“스레드 간에 안전하게 공유할 수 없다”는 이 에러의 핵심이 바로 Sync 트레이트입니다. Send가 소유권 이동의 안전성을 보장한다면, Sync는 참조 공유의 안전성을 보장하는 건데요. 이 글에서 Sync가 어떤 역할을 하고 어떤 타입이 Sync이고 어떤 타입은 아닌지, 그 이유까지 파헤쳐 보겠습니다.
Sync 트레이트란?
Sync는 Send와 마찬가지로 std::marker 모듈에 정의된 마커 트레이트(marker trait)입니다.
// 표준 라이브러리 내부 정의 (단순화)
pub unsafe auto trait Sync { }
Send의 정의와 동일한 구조죠? unsafe이니 수동 구현 시 안전성을 직접 보장해야 하고, auto이니 조건만 맞으면 컴파일러가 자동으로 구현해 주며, 본문이 비어 있으니 런타임 비용이 없습니다.
Sync의 의미를 한 문장으로 정리하면 이렇습니다. 타입 T가 Sync를 구현하면 &T를 여러 스레드에서 동시에 사용해도 안전합니다. 여기서 주목할 건 “참조(&T)“인데요. 값 자체를 넘기는 게 아니라 불변 참조를 여러 스레드가 공유하는 상황이죠.
좀 더 정확한 정의를 보면, T가 Sync이면 &T가 Send입니다. T의 참조를 다른 스레드로 보내도 안전하다는 뜻이에요.
// T: Sync ↔ &T: Send
//
// Sync인 타입의 참조는 스레드 간에 안전하게 보낼 수 있다
왜 Sync가 필요할까?
“이미 Send가 있는데 왜 Sync까지 필요하지?”라고 생각할 수 있는데요. 두 트레이트는 보호하는 영역이 다릅니다.
Send는 값의 소유권을 다른 스레드로 넘길 때의 안전성을 다루는데요. 한 스레드가 값을 포기하고 다른 스레드가 가져가는 거라 한 번에 하나의 스레드만 그 값에 접근하니까 동시 접근 문제가 없습니다.
Sync는 완전히 다른 상황을 다룹니다. 스레드 여러 개가 동시에 같은 데이터의 참조를 들고 있는 경우인데요. 한쪽에서 데이터를 읽는 동안 다른 쪽에서도 읽을 수 있어야 하고, 그 과정에서 데이터가 깨지면 안 됩니다.
use std::sync::Arc;
use std::thread;
fn main() {
// config를 여러 스레드에서 동시에 읽고 싶다
let config = Arc::new(vec![
"database_url=localhost:5432".to_string(),
"max_connections=10".to_string(),
]);
let handles: Vec<_> = (0..3).map(|i| {
let config = Arc::clone(&config);
thread::spawn(move || {
// 여러 스레드가 동시에 config를 읽는다
println!("스레드 {}: {}", i, config[0]);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
이 코드가 동작하는 이유는 Vec<String>이 Sync이기 때문입니다. Arc는 내부적으로 여러 스레드에서 &T를 공유하는데, T가 Sync여야만 이게 안전하거든요.
Sync를 구현하는 타입들
대부분의 Rust 타입은 Sync를 구현합니다. 어떤 타입들이 있는지 살펴볼게요.
원시 타입(i32, f64, bool, char 등)은 당연히 Sync입니다. 불변 참조를 통해 읽기만 하는 건 언제나 안전하니까요.
String, Vec<T>, HashMap<K, V> 같은 컬렉션도 내부 요소가 Sync이면 자동으로 Sync가 됩니다. Send와 마찬가지로 전이적으로 적용되죠.
Mutex<T>와 RwLock<T>은 T가 Send이면 Sync를 구현합니다. 잠금 메커니즘이 동시 접근을 안전하게 제어하기 때문인데요. 이 부분은 뒤에서 좀 더 자세히 다루겠습니다.
원자적 타입(AtomicBool, AtomicI32, AtomicUsize 등)도 Sync입니다. 하드웨어 수준의 원자적 연산을 사용해서 잠금 없이도 스레드 안전한 읽기와 쓰기가 가능하거든요.
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicI32::new(0));
let handles: Vec<_> = (0..5).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..1000 {
counter.fetch_add(1, Ordering::Relaxed);
}
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
println!("최종 카운트: {}", counter.load(Ordering::Relaxed)); // 5000
}
AtomicI32는 Mutex 없이도 여러 스레드에서 안전하게 값을 증가시킬 수 있습니다. 잠금이 없으니 성능도 더 좋고요.
Sync를 구현하지 않는 타입들
일부 타입은 의도적으로 Sync를 구현하지 않는데요. 여러 스레드에서 동시에 참조하면 위험한 이유가 분명히 있습니다.
가장 대표적인 예가 Cell<T>과 RefCell<T>입니다. 이 둘은 내부 가변성(interior mutability)을 제공하는 타입인데요. 불변 참조(&self)를 통해서도 내부 값을 바꿀 수 있습니다.
use std::cell::RefCell;
fn main() {
let data = RefCell::new(42);
// &data만으로도 내부 값을 바꿀 수 있다
*data.borrow_mut() = 100;
println!("{}", data.borrow()); // 100
}
문제는 이 내부 변경이 아무런 동기화 없이 이루어진다는 겁니다. RefCell은 빌림 규칙을 런타임에 검사하긴 하지만, 이 검사 자체가 스레드 안전하지 않아요. 두 스레드가 동시에 borrow_mut()을 호출하면 둘 다 런타임 검사를 통과해 버릴 수 있거든요.
Rc도 마찬가지입니다. 참조 카운트를 일반 정수로 관리하는데, 스레드 여러 개가 동시에 카운트를 건드리면 값이 꼬일 수 있으니까요. 원시 포인터(*const T, *mut T)는 Rust의 안전성 시스템 밖에 있어서 컴파일러가 Sync 여부를 판단할 수가 없습니다.
내부 가변성과 Sync
Sync에서 가장 흥미로운 부분은 내부 가변성과의 관계인데요. “왜 RefCell은 Sync가 아닌데 Mutex는 Sync일까?”라는 질문에 답하면 Sync의 본질을 이해할 수 있습니다.
핵심은 동기화 메커니즘의 유무입니다.
RefCell은 빌림 상태를 일반 정수로 추적합니다. borrow()를 호출하면 카운터를 올리고 borrow_mut()을 호출하면 독점 플래그를 세우는데, 이 과정이 원자적이지 않거든요. 스레드 A가 카운터를 읽고 “괜찮네” 하고 판단하는 사이에 스레드 B가 이미 값을 바꿔버릴 수 있습니다.
// RefCell 내부 (개념적 설명)
// borrow_flag: 0 = 아무도 빌리지 않음, >0 = 읽기 빌림 수, -1 = 쓰기 빌림
//
// 스레드 A: flag 읽기 (0) → "빌릴 수 있네" → flag = -1로 변경
// 스레드 B: flag 읽기 (0) → "빌릴 수 있네" → flag = -1로 변경 ← 데이터 경합!
반면에 Mutex는 운영체제의 잠금 메커니즘을 사용합니다. lock()을 호출하면 다른 스레드가 이미 잠금을 잡고 있으면 대기하고, 하나의 스레드만 내부 데이터에 접근할 수 있게 보장합니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
let handles: Vec<_> = (0..3).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
let mut guard = data.lock().unwrap();
guard.push(i);
println!("스레드 {}: {:?}", i, *guard);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
그래서 도입부에서 본 Arc<RefCell<T>>은 컴파일이 안 되지만 Arc<Mutex<T>>는 잘 되는 겁니다. 둘 다 불변 참조를 통해 내부 값을 바꾸는 내부 가변성 타입이지만, Mutex는 동기화가 보장되니까 Sync인 거죠.
정리하면 이런 관계입니다.
| 타입 | 내부 가변성 | 동기화 | Sync |
|---|---|---|---|
Cell<T> | O (복사) | X | X |
RefCell<T> | O (빌림) | X | X |
Mutex<T> | O (잠금) | O | O |
RwLock<T> | O (읽기/쓰기 잠금) | O | O |
AtomicI32 등 | O (원자적 연산) | O | O |
RwLock은 Mutex보다 좀 더 세분화된 접근 제어를 제공하는데요. 읽기는 여러 스레드가 동시에 할 수 있고 쓰기만 독점이라서, 읽기가 많고 쓰기가 적은 상황에서 성능이 더 좋습니다.
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let config = Arc::new(RwLock::new(HashMap::from([
("timeout".to_string(), 30),
("retries".to_string(), 3),
])));
// 읽기 스레드 여러 개가 동시에 접근 가능
let handles: Vec<_> = (0..3).map(|i| {
let config = Arc::clone(&config);
thread::spawn(move || {
let read = config.read().unwrap();
println!("스레드 {}: timeout = {}", i, read["timeout"]);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
thread::scope로 Sync 활용하기
지금까지 예제들은 모두 Arc를 사용해서 데이터를 공유했는데요. Rust 1.63에서 추가된 thread::scope를 사용하면 Arc 없이 참조만으로 데이터를 공유할 수 있습니다.
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
thread::scope(|s| {
for i in 0..3 {
s.spawn(|| {
// &data를 직접 사용 — Arc 불필요!
let sum: i32 = data.iter().sum();
println!("스레드 {}: 합계 = {}", i, sum);
});
}
});
// 스코프가 끝나면 모든 스레드가 종료됨
println!("원본 데이터: {:?}", data);
}
thread::spawn은 클로저에 'static 수명을 요구하기 때문에 로컬 변수의 참조를 넘길 수 없었는데요. thread::scope는 스코프가 끝날 때 모든 스레드의 종료를 보장하기 때문에 로컬 변수의 참조를 안전하게 공유할 수 있습니다.
바로 여기서 Sync가 빛을 발합니다. thread::scope 안에서 &data를 여러 스레드가 공유하려면 Vec<i32>가 Sync여야 하거든요. Sync가 아닌 타입의 참조를 공유하려 하면 컴파일 에러가 납니다.
use std::cell::Cell;
use std::thread;
fn main() {
let counter = Cell::new(0);
thread::scope(|s| {
s.spawn(|| {
counter.set(counter.get() + 1); // 컴파일 에러! Cell<i32>는 Sync가 아님
});
});
}
Cell은 Sync가 아니라서 여러 스레드에서 참조를 공유할 수 없습니다. 대신 AtomicI32를 사용하면 되는데요.
use std::sync::atomic::{AtomicI32, Ordering};
use std::thread;
fn main() {
let counter = AtomicI32::new(0);
thread::scope(|s| {
for _ in 0..5 {
s.spawn(|| {
for _ in 0..1000 {
counter.fetch_add(1, Ordering::Relaxed);
}
});
}
});
println!("최종 카운트: {}", counter.load(Ordering::Relaxed)); // 5000
}
Arc도 없고 Mutex도 없는데 스레드 안전하게 카운터가 올라갑니다. AtomicI32가 Sync이기 때문에 thread::scope 안에서 참조를 바로 공유할 수 있는 거죠.
커스텀 타입과 Sync
Send와 마찬가지로 우리가 만드는 구조체는 모든 필드가 Sync이면 자동으로 Sync가 됩니다.
use std::thread;
struct AppConfig {
db_url: String,
max_connections: u32,
debug_mode: bool,
}
fn main() {
let config = AppConfig {
db_url: "localhost:5432".to_string(),
max_connections: 10,
debug_mode: false,
};
thread::scope(|s| {
for i in 0..3 {
s.spawn(|| {
println!(
"스레드 {}: DB = {}, 디버그 = {}",
i, config.db_url, config.debug_mode
);
});
}
});
}
String, u32, bool 모두 Sync이므로 AppConfig도 자동으로 Sync가 됩니다. thread::scope 안에서 &config를 여러 스레드가 공유해도 아무 문제가 없죠.
반면에 Sync가 아닌 필드가 하나라도 있으면 전체 타입이 Sync가 아니게 됩니다.
use std::cell::RefCell;
struct CachedData {
data: Vec<u8>,
cache: RefCell<Option<String>>, // RefCell은 Sync가 아님
}
// CachedData는 Sync가 아님!
// thread::scope에서 참조 공유 불가
이런 경우 RefCell 대신 Mutex를 사용하면 Sync를 되찾을 수 있습니다.
use std::sync::Mutex;
use std::thread;
struct CachedData {
data: Vec<u8>,
cache: Mutex<Option<String>>,
}
fn main() {
let cached = CachedData {
data: vec![1, 2, 3],
cache: Mutex::new(None),
};
thread::scope(|s| {
s.spawn(|| {
let mut cache = cached.cache.lock().unwrap();
if cache.is_none() {
*cache = Some(format!("계산 결과: {:?}", cached.data));
}
println!("{}", cache.as_ref().unwrap());
});
});
}
수동으로 Sync를 구현해야 하는 경우는 드물지만, FFI 타입처럼 컴파일러가 안전성을 판단할 수 없을 때 unsafe impl Sync를 사용합니다.
struct ThreadSafeHandle {
ptr: *mut std::ffi::c_void,
}
// C 라이브러리가 스레드 안전성을 보장하는 경우
unsafe impl Sync for ThreadSafeHandle {}
반대로 Sync를 명시적으로 제거하고 싶으면 PhantomData를 활용할 수 있습니다.
use std::cell::Cell;
use std::marker::PhantomData;
struct SingleThreadOnly {
data: i32,
_not_sync: PhantomData<Cell<()>>, // Cell은 Sync가 아님
}
실전 예제: 병렬 번역 사전
마지막으로 Sync와 thread::scope가 실제로 어떻게 활용되는지 좀 더 현실적인 예제를 살펴보겠습니다. 읽기 전용 데이터를 여러 스레드에서 동시에 조회하는 패턴인데요.
use std::collections::HashMap;
use std::thread;
fn main() {
let dictionary: HashMap<&str, &str> = HashMap::from([
("hello", "안녕하세요"),
("world", "세계"),
("rust", "러스트"),
("thread", "스레드"),
("safe", "안전한"),
]);
let words = vec![
vec!["hello", "world"],
vec!["rust", "thread"],
vec!["safe", "rust"],
];
let results: Vec<Vec<String>> = thread::scope(|s| {
words.iter().map(|batch| {
s.spawn(|| {
batch.iter().map(|word| {
match dictionary.get(word) {
Some(translated) => format!("{} → {}", word, translated),
None => format!("{} → (번역 없음)", word),
}
}).collect()
})
}).collect::<Vec<_>>()
.into_iter()
.map(|handle| handle.join().unwrap())
.collect()
});
for (i, batch) in results.iter().enumerate() {
println!("배치 {}: {:?}", i, batch);
}
}
HashMap이 Sync이기 때문에 thread::scope 안에서 &dictionary를 여러 스레드가 동시에 읽을 수 있습니다. Arc로 감싸지 않아도 되니 코드가 깔끔하고요.
이 코드에서 Sync가 지켜주는 것들을 정리해 볼게요. HashMap<&str, &str>과 Vec<Vec<&str>> 모두 Sync라서 동시에 참조해도 안전하고, thread::scope가 스레드 종료를 보장하니 로컬 변수 수명도 문제없습니다. 이 모든 검증이 컴파일 타임에 이루어지니 런타임 비용은 전혀 없고요.
마치며
Rust의 Sync 트레이트는 “이 타입의 참조(&T)를 여러 스레드에서 동시에 사용해도 안전하다”는 계약입니다. Send가 소유권 이동을 보호한다면 Sync는 참조 공유를 보호하는데요. 이 두 트레이트 덕분에 Rust는 컴파일 타임에 데이터 경합을 원천 차단합니다.
한 줄로 정리하면, Sync가 아닌 타입은 대부분 동기화 없는 내부 가변성을 제공하는 타입(Cell, RefCell)입니다. 스레드 안전한 대안(Mutex, RwLock, Atomic*)은 Sync를 구현하고요. thread::scope를 사용하면 Arc 없이도 Sync인 데이터의 참조를 여러 스레드에서 공유할 수 있어서 코드가 한결 깔끔해집니다.
Send와 Sync를 함께 이해하면 Rust의 동시성 모델을 완전히 파악한 셈인데요. 소유권과 빌림도 함께 읽어보시면 Rust가 메모리 안전성을 어떻게 보장하는지 더 넓은 그림이 그려질 거예요 😄
더 자세한 내용은 std::marker::Sync - Rust 공식 문서와 Send and Sync - The Rustonomicon을 참고하세요.
This work is licensed under
CC BY 4.0