잠금(Locks), Send 그리고 Arc
우리가 방금 구현한 패치(Patch) 전략에는 큰 약점이 있습니다. 바로 **경쟁 상태(Race condition)**죠. 두 클라이언트가 거의 동시에 같은 티켓에 패치를 보내면, 서버는 요청이 들어온 임의의 순서대로 이를 처리할 것입니다. 결국 나중에 패치를 보낸 클라이언트가 이전 클라이언트의 수정을 덮어쓰게 됩니다.
버전 번호
이 문제를 해결하는 한 가지 방법은 버전 번호를 사용하는 것입니다. 티켓이 처음 생성될 때 버전 번호 0을 부여하고, 클라이언트가 패치를 보낼 때마다 현재 알고 있는 버전 번호를 같이 보내게 합니다. 서버는 버전 번호가 일치할 때만 패치를 적용하고 버전 번호를 올립니다.
이런 방식은 분산 시스템에서 매우 흔하며, **낙관적 동시성 제어(Optimistic Concurrency Control)**라고 부릅니다. “대부분의 상황에서 충돌이 없을 것“이라고 가정하고 일반적인 상황에 최적화하는 전략이죠. 관심 있다면 나중에 보너스 문제로 직접 구현해 보셔도 좋습니다.
잠금(Locks)
또 다른 방법은 **잠금(Locks)**을 도입하는 것입니다. 클라이언트가 티켓을 수정하기 전에 먼저 티켓에 대한 잠금을 획득하게 만드는 것이죠. 잠금이 활성화된 동안에는 다른 클라이언트가 해당 티켓을 수정할 수 없습니다.
Rust의 표준 라이브러리는 두 가지 대표적인 잠금 기본형(primitive)을 제공합니다. 바로 Mutex<T>와 RwLock<T>입니다. 먼저 **뮤텍스(Mutex)**부터 시작해 봅시다. 뮤텍스는 **상호 배제(Mutual Exclusion)**의 줄임말로, 읽기든 쓰기든 상관없이 오직 하나의 스레드만 데이터에 접근할 수 있게 보장합니다.
Mutex<T>는 보호할 데이터를 감싸는(wrapping) 형태이며 제네릭 타입입니다. 데이터에 직접 접근하는 것은 불가능하며, Mutex::lock이나 Mutex::try_lock 메서드를 통해 먼저 잠금을 얻어야만 합니다. lock은 잠금을 얻을 때까지 스레드를 대기시키고, try_lock은 잠금을 얻을 수 없으면 바로 에러를 반환합니다.
두 메서드 모두 데이터에 접근할 수 있게 해주는 가드(guard) 객체를 반환하며, 이 가드 객체가 스코프를 벗어나 삭제(drop)될 때 잠금이 자동으로 해제됩니다.
use std::sync::Mutex;
// 뮤텍스로 보호되는 정수형 변수 생성 let lock = Mutex::new(0);
// 뮤텍스 잠금 획득 let mut guard = lock.lock().unwrap();
// 가드를 통해 데이터 수정 (Deref 트레이트 활용)
*guard += 1;
// guard가 범위를 벗어날 때 잠금이 자동으로 해제됩니다.
// drop(guard)를 호출해 명시적으로 해제할 수도 있습니다.
잠금의 입도(Lock granularity)
뮤텍스로 무엇을 감싸야 할까요? 가장 간단한 방법은 전체 TicketStore를 하나의 뮤텍스로 감싸는 것입니다. 하지만 이 방법은 성능을 크게 저해합니다. 단순 읽기조차 잠금이 풀리기를 기다려야 해서 여러 티켓을 동시에 읽는 병렬 처리가 불가능해지기 때문입니다. 이를 **굵은 입자의 잠금(Coarse-grained locking)**이라고 부릅니다.
그보다 좋은 방법은 각 티켓이 저마다의 잠금을 가지는 **세밀한 입자의 잠금(Fine-grained locking)**을 사용하는 것입니다. 이렇게 하면 클라이언트들이 서로 다른 티켓에 접근할 때 아무런 방해 없이 병렬로 작업할 수 있습니다.
struct TicketStore {
// 각 티켓마다 개별 뮤텍스를 둡니다.
tickets: BTreeMap<TicketId, Mutex<Ticket>>,
}
성능은 좋아지겠지만, 이제 TicketStore 자체가 다중 스레드 환경임을 의식해야 합니다. 이전까지 TicketStore는 스레드가 존재하는지조차 몰랐지만, 이제는 구조 자체가 변했죠.
누가 잠금을 소유하나?
전체 계획이 작동하려면 잠금(Lock)을 티켓을 수정하려는 클라이언트에게 전달할 수 있어야 합니다. 하지만 여기서 문제가 생깁니다. Mutex는 Clone이 아니며 TicketStore에서 마음대로 꺼내 옮길 수도 없습니다. 채널을 통해 Mutex<Ticket>을 보내는 것은 불가능하죠. 그렇다면 잠금을 획득한 결과인 MutexGuard를 보내면 어떨까요?
간단한 예시로 확인해 봅시다.
use std::thread::spawn;
use std::sync::Mutex;
use std::sync::mpsc::sync_channel;
fn main() {
let lock = Mutex::new(0);
let (sender, receiver) = sync_channel(1);
let guard = lock.lock().unwrap();
spawn(move || {
receiver.recv().unwrap();
});
// 가드(guard)를 다른 스레드로 보내려 시도합니다.
sender.send(guard);
}
이 코드는 컴파일되지 않습니다. Rust 컴파일러가 MutexGuard는 스레드 간에 안전하게 보내질 수 없다고 경고하기 때문입니다.
Send 트레이트
Send는 특정 타입이 한 스레드에서 다른 스레드로 안전하게 이동할 수 있음을 나타내는 **마커 트레이트(Marker trait)**입니다. Sized와 마찬가지로 컴파일러가 자동으로 구현해 주죠. 만약 수동으로 구현하려면 해당 타입이 스레드 간 이동 시 정말 안전하다는 것을 개발자가 보장해야 하므로 unsafe 키워드가 필요합니다.
채널의 요구사항
Sender<T>나 Receiver<T>는 전송하려는 데이터 T가 Send 트레이트를 구현하고 있을 때만 자신도 Send가 됩니다. 스레드 사이를 넘나들어야 하는데, 담긴 물건 자체가 위험하다면 보낼 수 없는 것과 같죠.
MutexGuard는 왜 Send가 아닐까?
일부 운영체제 환경에서는 잠금을 획득한 스레드가 반드시 그 잠금을 해제해야 한다는 규칙이 있습니다. 만약 MutexGuard를 다른 스레드로 보내버리면 잠금이 다른 곳에서 해제될 텐데, 이는 정의되지 않은 동작(undefined behavior)으로 이어질 수 있습니다. 그래서 MutexGuard는 Send가 아니도록 설계되었습니다.
우리의 도전 과제
정리해 봅시다.
- 채널로
MutexGuard를 보낼 수 없으므로, 서버가 잠금을 걸고 클라이언트가 수정하는 방식은 불가능합니다. Mutex자체는 데이터가Send라면Send이므로 채널로 보낼 수 있지만,TicketStore에 담긴 뮤텍스를 복제하거나 꺼내올 수도 없습니다.
이 딜레마를 어떻게 풀까요? 관점을 조금 바꿔 봅시다. 뮤텍스 잠금을 얻기 위해 반드시 소유권(ownership)이 필요한 것은 아닙니다. 뮤텍스는 **내부 가변성(Interior mutability)**을 사용하기 때문에 **공유 참조(&self)**만으로도 잠금을 얻을 수 있죠.
impl<T> Mutex<T> {
// self가 아니라 &self를 받습니다!
pub fn lock(&self) -> LockResult<MutexGuard<'_, T>> {
// ...
}
}
따라서 클라이언트에게 공유 참조만 넘겨줄 수 있다면 해결됩니다. 하지만 일반적인 참조는 수명(lifetime) 문제로 스레드에 넘길 수 없죠. 우리에게 필요한 건 **“소유 가능한 공유 참조”**입니다. 바로 여기서 Arc가 등장합니다.
Arc가 구원합니다
Arc는 **원자적 참조 카운팅(Atomic Reference Counting)**의 줄임말입니다. 데이터를 Arc로 감싸면 참조 횟수를 추적하며, 마지막 참조가 사라질 때 비로소 메모리에서 해제됩니다. Arc 내부의 데이터는 불변이며 공유 참조만 얻을 수 있습니다.
use std::sync::Arc;
let data: Arc<u32> = Arc::new(0);
// 참조 카운트를 올리며 복제합니다.
let data_clone = Arc::clone(&data);
// Arc<T>는 Deref<T>를 구현하므로 &T처럼 쓸 수 있습니다.
let data_ref: &u32 = &data;
어디서 본 것 같나요? 네, 이전에 배운 Rc와 매우 비슷합니다. 결정적인 차이점은 스레드 안전성입니다. Rc는 스레드 간에 공유할 수 없지만(Not Send), Arc는 원자적(Atomic) 연산으로 참조 카운트를 관리하므로 스레드 간에 안전하게 공유할 수 있습니다.
Arc<Mutex<T>> 조합
Arc와 Mutex를 조합하면 우리가 원하던 모든 조건을 만족하게 됩니다.
- 스레드 간에 보낼 수 있습니다 (
Arc와Mutex모두 데이터가Send라면Send이므로). - 복제가 가능합니다 (
Arc는 항상Clone이며, 복제 시 데이터가 아닌 참조 카운트만 늘립니다). - 내부 데이터를 수정할 수 있습니다 (
Arc를 통해 얻은Mutex의 공유 참조로 잠금을 얻을 수 있으므로).
이제 티켓 저장소의 잠금 전략을 구현할 준비가 모두 끝났습니다!
Exercise
The exercise for this section is located in 07_threads/11_locks