- Published on
Rust 소유권·빌림으로 데이터 레이스 0% 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치에서 병렬 처리량을 올리다 보면 결국 맞닥뜨리는 문제가 데이터 레이스입니다. 여러 스레드가 같은 메모리를 동시에 읽고/쓰는 순간, 재현이 어렵고 로그로도 잡기 힘든 버그가 됩니다. Rust는 이 문제를 “조심하자”가 아니라 “구조적으로 불가능하게 만들자”로 해결합니다.
핵심은 두 가지 규칙으로 요약됩니다.
- 소유권: 값은 정확히 하나의 소유자만 가진다
- 빌림: 동시에
&T는 여러 개 가능하지만,&mut T는 오직 하나만 가능하며&T와 공존할 수 없다
이 규칙이 스레드 경계까지 확장되면서, 데이터 레이스의 전형적인 조건(동시 접근 + 쓰기 + 동기화 부재)이 컴파일 단계에서 차단됩니다. 이번 글에서는 “이론 설명”보다 실무에서 바로 쓰이는 패턴 중심으로 정리합니다.
참고로 동시성에서 생기는 문제는 애플리케이션 계층에서도 자주 나타납니다. 예를 들어 분산 트랜잭션에서 중복·순서 꼬임을 다루는 방식은 동시성 제어의 다른 얼굴입니다. 필요하면 Saga 보상트랜잭션 설계 - 중복·순서꼬임 해결도 같이 보면 사고방식이 연결됩니다.
데이터 레이스와 Rust가 막는 지점
데이터 레이스는 보통 다음 형태에서 발생합니다.
- 공유 상태
state가 있고 - 여러 실행 흐름이 동시에 접근하며
- 그중 누군가가 쓰기를 수행하고
- 적절한 동기화가 없다
C/C++에서는 포인터와 뮤텍스 사용이 분리되어 있어 “락을 깜빡한 코드”가 쉽게 생깁니다. Rust는 공유/가변성 자체를 타입으로 표현해, “락을 안 잡고는 공유 가변 상태에 접근할 수 없는” 구조를 만들 수 있습니다.
여기서 중요한 키워드는 Send와 Sync입니다.
Send: 값이 스레드로 “이동(move)”될 수 있음Sync:&T가 여러 스레드에서 동시에 접근 가능함
Rust 표준 라이브러리의 동시성 타입들은 이 트레이트 조합으로 안전성을 강제합니다.
패턴 1: 공유하지 말고 소유권을 이동시키기(메시지 패싱)
가장 강력한 패턴은 “공유 상태를 없애는 것”입니다. 스레드가 같은 메모리를 만지지 않으면 데이터 레이스는 원천적으로 사라집니다. Rust에서는 소유권 이동이 기본이기 때문에 메시지 패싱 모델이 자연스럽습니다.
mpsc 채널로 작업 분배
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel::<String>();
let worker = thread::spawn(move || {
while let Ok(job) = rx.recv() {
// worker는 job의 소유권을 받음
println!("work: {}", job);
}
});
tx.send("a".to_string()).unwrap();
tx.send("b".to_string()).unwrap();
drop(tx); // 송신자 종료
worker.join().unwrap();
}
포인트는 String이 복사되는 게 아니라 이동된다는 점입니다. worker는 받은 job을 독점적으로 다루므로 경쟁이 없습니다.
언제 쓰나
- 상태를 공유할 필요가 없는 병렬 파이프라인
- 작업 큐 기반 처리(로그 처리, 이미지 변환, 이벤트 핸들링)
- “한 곳에서만 상태를 갖고” 나머지는 명령을 보내는 구조
이 패턴은 동기화 비용도 줄여 성능에도 유리합니다.
패턴 2: “읽기 많고 쓰기 적음”은 Arc + 불변 데이터
공유가 필요하더라도, 불변 데이터는 안전합니다. 여러 스레드에서 동시에 읽기만 한다면 레이스가 아닙니다. 그래서 Rust에서는 설정, 룰셋, 라우팅 테이블 같은 것을 Arc로 감싸 공유합니다.
use std::sync::Arc;
use std::thread;
#[derive(Debug)]
struct Config {
endpoint: String,
}
fn main() {
let cfg = Arc::new(Config { endpoint: "https://api".into() });
let mut handles = vec![];
for _ in 0..4 {
let cfg = Arc::clone(&cfg);
handles.push(thread::spawn(move || {
// 불변 참조만 사용
println!("endpoint: {}", cfg.endpoint);
}));
}
for h in handles {
h.join().unwrap();
}
}
Arc는 참조 카운팅으로 소유권을 여러 스레드에 “공유”하지만, 내부가 불변이면 Mutex가 필요 없습니다.
자주 하는 실수
- 불변으로 충분한데 습관적으로
Mutex를 씌우는 것 - 공유 상태를 최소화하지 않고 “일단
Arc<Mutex<_>>”로 시작하는 것
후자는 설계가 굳어지면 락 경합이 성능 병목이 됩니다.
패턴 3: 공유 가변 상태는 Arc<Mutex<T>>로 “락을 타입에 포함”
공유하면서 쓰기도 해야 하면 락이 필요합니다. Rust의 장점은 락이 “옵션”이 아니라 “접근 경로”가 된다는 점입니다. 즉 T에 접근하려면 MutexGuard를 얻어야 하므로, 락 없이 쓰는 코드가 구조적으로 어렵습니다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..8 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..10_000 {
let mut guard = counter.lock().unwrap();
*guard += 1;
}
}));
}
for h in handles {
h.join().unwrap();
}
println!("{}", *counter.lock().unwrap());
}
여기서 데이터 레이스는 일어나지 않습니다. 락이 강제되고, 가드의 스코프가 끝나면 자동으로 해제됩니다(RAII).
락 스코프를 줄이는 게 핵심
다음처럼 “락 잡은 채로 오래 걸리는 작업”을 하면 경합이 커지고 지연이 폭증합니다.
use std::sync::{Arc, Mutex};
fn slow_io() {
// pretend
}
fn bad(shared: Arc<Mutex<Vec<u8>>>) {
let mut g = shared.lock().unwrap();
slow_io();
g.push(1);
}
개선은 락 안에서 “필요한 최소 작업만” 수행하는 것입니다.
use std::sync::{Arc, Mutex};
fn slow_io() {
// pretend
}
fn good(shared: Arc<Mutex<Vec<u8>>>) {
slow_io();
let mut g = shared.lock().unwrap();
g.push(1);
}
패턴 4: 읽기 병렬성이 중요하면 RwLock
읽기는 많고 쓰기는 적은 공유 상태(캐시, 인덱스, 룰셋)는 RwLock이 적합합니다. 여러 reader가 동시에 락을 잡을 수 있고, writer는 단독으로 접근합니다.
use std::sync::{Arc, RwLock};
use std::thread;
fn main() {
let map = Arc::new(RwLock::new(std::collections::HashMap::<u64, u64>::new()));
// writer
{
let map = Arc::clone(&map);
thread::spawn(move || {
for i in 0..1000 {
let mut w = map.write().unwrap();
w.insert(i, i * 10);
}
})
.join()
.unwrap();
}
// readers
let mut hs = vec![];
for _ in 0..4 {
let map = Arc::clone(&map);
hs.push(thread::spawn(move || {
let r = map.read().unwrap();
r.get(&10).copied().unwrap_or(0)
}));
}
for h in hs {
println!("{}", h.join().unwrap());
}
}
주의점
- 쓰기 빈도가 높으면
RwLock이 오히려 손해일 수 있습니다 - writer starvation(쓰기 기아) 정책은 구현에 따라 차이가 있습니다
패턴 5: 원자 타입으로 “락 없는 카운터” 만들기
단순 카운터, 플래그, 통계값은 AtomicU64 같은 원자 타입이 더 빠르고 단순합니다. 다만 원자 연산은 “복합 불변식”을 지키기 어렵습니다. 예를 들어 a와 b를 동시에 업데이트해야 하는 경우는 원자 하나로 해결되지 않습니다.
use std::sync::{Arc, atomic::{AtomicU64, Ordering}};
use std::thread;
fn main() {
let hits = Arc::new(AtomicU64::new(0));
let mut hs = vec![];
for _ in 0..8 {
let hits = Arc::clone(&hits);
hs.push(thread::spawn(move || {
for _ in 0..100_000 {
hits.fetch_add(1, Ordering::Relaxed);
}
}));
}
for h in hs {
h.join().unwrap();
}
println!("{}", hits.load(Ordering::Relaxed));
}
메모리 오더링은 최소로 시작
- 통계/카운터:
Relaxed로 충분한 경우가 많음 - 다른 데이터와의 happens-before가 필요하면
Acquire/Release고려
정확한 오더링 설계는 난이도가 있으니, 복잡해지면 Mutex로 돌아가는 게 오히려 안전합니다.
패턴 6: 스레드에 빌림을 넘기지 말고 move로 소유권을 넘기기
스레드 API는 보통 클로저에 move를 요구합니다. 이건 불편함이 아니라 안전장치입니다. 스레드가 언제 끝날지 모르는데, 바깥 스코프의 참조 &T를 들고 있으면 수명 문제가 생깁니다.
다음은 컴파일러가 막는 전형적인 코드입니다(의도적으로 잘못된 예).
use std::thread;
fn main() {
let s = String::from("hello");
// thread::spawn은 'static을 요구하므로 &s를 넘길 수 없음
// let r = &s;
// thread::spawn(|| println!("{}", r));
// 올바른 방식: 소유권 이동
thread::spawn(move || println!("{}", s)).join().unwrap();
}
이 제약 덕분에 “스레드가 살아있는 동안 이미 해제된 메모리를 참조”하는 류의 버그가 사라집니다.
패턴 7: 병렬 반복은 rayon으로 “안전한 분할”을 맡기기
공유 가변 상태를 직접 락으로 감싸기 전에, 데이터 구조를 “분할”해서 각 스레드가 자기 조각만 수정하게 만드는 게 더 좋습니다. rayon은 슬라이스를 안전하게 쪼개 병렬 반복을 제공합니다.
use rayon::prelude::*;
fn main() {
let mut v = vec![1u64; 1_000_000];
v.par_iter_mut().for_each(|x| {
*x = *x + 1;
});
println!("{}", v[0]);
}
여기서 핵심은 par_iter_mut가 내부적으로 데이터 레이스가 없도록 작업을 분할한다는 점입니다. 개발자는 락을 직접 설계하지 않아도 됩니다.
패턴 8: 비동기에서는 tokio::sync::Mutex와 “락 구간에서 await 금지”
비동기 환경에서는 std::sync::Mutex를 잡고 await를 만나면 실행기가 다른 태스크를 돌리면서 교착이나 지연을 유발할 수 있습니다. 그래서 Tokio를 쓴다면 tokio::sync::Mutex를 쓰고, 락 구간 안에서 await를 피하는 게 기본 규칙입니다.
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::main]
async fn main() {
let shared = Arc::new(Mutex::new(Vec::<u64>::new()));
let s1 = Arc::clone(&shared);
let t1 = tokio::spawn(async move {
// 락은 짧게
let mut g = s1.lock().await;
g.push(1);
});
let s2 = Arc::clone(&shared);
let t2 = tokio::spawn(async move {
let mut g = s2.lock().await;
g.push(2);
});
t1.await.unwrap();
t2.await.unwrap();
let g = shared.lock().await;
println!("len: {}", g.len());
}
비동기에서의 동시성 문제는 운영에서도 자주 증폭됩니다. 예를 들어 장애로 재시작이 반복되면 타이밍이 달라져 잠복 버그가 드러납니다. 실전 운영 관점은 Kubernetes CrashLoopBackOff 원인 7가지와 실전 디버깅 같은 글과 함께 보면 도움이 됩니다.
실무 체크리스트: “0%에 가깝게” 만드는 선택 순서
동시성 설계에서 다음 순서로 선택하면 시행착오가 줄어듭니다.
- 공유 상태를 없앨 수 있는가: 채널, 소유권 이동, 단일 writer 패턴
- 불변 공유로 바꿀 수 있는가:
Arc로 설정/데이터를 읽기 전용 공유 - 분할할 수 있는가:
rayon병렬 반복, 샤딩(키 공간 분리) - 정말 공유 가변이 필요하면:
Arc<Mutex<T>>또는Arc<RwLock<T>> - 단순 통계면:
Atomic* - 비동기면:
tokio::sync계열을 쓰고 락 구간에서await금지
이렇게 하면 “락을 어디에 걸지”를 고민하기 전에, 애초에 락이 필요 없는 구조로 문제를 축소할 수 있습니다.
흔한 함정 3가지
1) Arc<Mutex<T>> 남발로 락 경합 병목
처음에는 안전하지만, 트래픽이 오르면 락 대기 시간이 곧 비용이 됩니다. 특히 긴 임계 구역, 잦은 write는 병목을 심화시킵니다.
2) RefCell을 멀티스레드에 들고 가려는 시도
RefCell은 런타임 빌림 검사(단일 스레드)를 위한 타입입니다. 멀티스레드 공유에는 맞지 않고, Rust가 보통 컴파일 단계에서 차단합니다.
3) 비동기에서 동기 락 사용
std::sync::Mutex는 스레드를 블로킹합니다. 비동기 런타임에서는 태스크가 아니라 워커 스레드를 막아버릴 수 있어 지연이 커집니다.
결론: Rust의 규칙은 “규율”이 아니라 “구조”다
Rust 소유권·빌림 규칙의 가치는 개발자에게 더 많은 책임을 지우는 게 아니라, “잘못된 공유”를 설계 단계에서 제거하게 만드는 데 있습니다. 데이터 레이스를 런타임 테스트나 코드 리뷰에 맡기지 않고, 타입 시스템과 컴파일러가 지속적으로 강제합니다.
정리하면 다음 한 문장으로 귀결됩니다.
- 공유를 줄이고(소유권 이동)
- 불변을 기본으로 두고(
Arc) - 공유 가변은 락을 타입에 포함시키며(
Mutex/RwLock) - 병렬 처리는 분할로 해결하고(
rayon) - 비동기에서는 락과
await의 경계를 엄격히 관리한다
이 패턴들을 팀 코딩 규칙으로 고정해두면, “데이터 레이스 0%”에 가까운 코드베이스를 장기적으로 유지할 수 있습니다.