동시성 조율 모델 — 공유 상태 vs 메시지 전달
여러 실행 흐름이 데이터를 안전하게 나눠 쓰는 두 갈래 — 공유 상태를 락으로 지키기(C++ std::mutex)와 메시지 전달 규약으로 공유를 줄이기(Go 채널/CSP)를 정리한다. safe Rust가 Mutex
난이도 중급 · 선행 스레드/코루틴을 들어봤으면 충분. 이 글은 코루틴의 “조율 반쪽”이다 — 실행 단위를 어떻게 통신·조율하느냐.
🗺️ 프로그래밍 언어 개념 로드맵의 한 편
한 줄 요약
여러 실행 흐름(스레드·코루틴)이 같은 가변 데이터에 동기화 없이 접근하면 데이터 레이스가 난다. 이걸 막는 길이 두 갈래다 — 공유 상태를 락으로 지키거나(C++ std::mutex), 메시지 전달 규약으로 공유를 줄이거나(Go 채널). Rust는 safe API에서 소유권·Send/Sync로 안전하지 않은 공유 경로를 막고, 필요한 공유 가변 상태는 동기화 타입 안에 넣게 한다.
어떤 문제를 푸는가 — C++ std::mutex에서 출발
스레드 둘이 같은 카운터를 올린다. C++ 개발자는 이 답을 안다.
1
2
3
4
5
6
7
int counter = 0;
std::mutex m;
void increment() {
std::lock_guard<std::mutex> lock(m); // 잠그고 (RAII로 스코프 끝에 자동 unlock)
++counter; // 한 번에 하나의 스레드만 이 줄에 있다
}
std::mutex로 공유 데이터에 한 번에 한 스레드만 들어가게 막는다. 이게 공유 상태(shared-state) 모델이고, 대부분의 언어가 기본으로 제공하는 길이다.
여기서 이미 알던 게 나온다. 뮤텍스로 임계 구역을 감싸는 것 — C++에서
std::mutex+lock_guard로 하던 그것. Go·Rust도 이걸 그대로 갖고 있다(sync.Mutex,std::sync::Mutex). 즉 공유 상태 모델은 세 언어 공통의 바닥이다. 갈리는 건 그 위에 무엇을 더 얹느냐다.
모델 ② 메시지 전달 — 공유를 규약으로 줄인다 (Go 채널 / CSP)
Go는 다른 길을 전면에 내세운다. 데이터를 공유하지 말고, 채널로 넘겨라.
1
2
3
ch := make(chan int)
go func() { ch <- compute() }() // 한 goroutine이 결과를 채널에 넣고
result := <-ch // 다른 goroutine이 꺼낸다
이 예제에는 공유 가변 상태가 없어서 락도 없다. 채널은 데이터를 넘기는 통신 지점을 만들지만 Rust처럼 소유권을 이동시키지는 않는다. 포인터·slice·map을 보낸 쪽이 별칭을 계속 들고 수정하면 여전히 데이터 레이스가 난다. 따라서 “보낸 뒤에는 건드리지 않는다”는 소유권 전달 규약을 프로그램이 지켜야 한다. 이 접근의 바탕이 CSP(Communicating Sequential Processes)이고, Go의 유명한 격언이 방향을 요약한다:
“Don’t communicate by sharing memory; share memory by communicating.” (메모리를 공유해서 통신하지 말고, 통신해서 메모리를 공유하라.)
C++엔 이게 없다 — 발판과 그 한계. C++ 표준에 Go 같은 채널은 없다. 굳이 대면
std::future/promise가 “값을 한 번 건네주는 일회성 채널”에 가깝다. 단 여기서 등치가 깨진다 —future는 1회성 값 전달이고, 채널은 스트림(여러 값을 계속, 양방향,select로 다중화)이다. 진짜 채널이 필요하면 C++에선std::queue+mutex+condition_variable로 직접 짜야 한다. 즉 Go는 “손으로 짜던 그 큐”를 언어 기본기로 올린 것.
채널 조합에 맞는 용어 정리
아카이브 동기/비동기 글에서 자주 헷갈리는 부분을 정리하면 아래와 같다.
- 뮤텍스 vs 세마포어
mutex: 한 번에 하나의 실행 흐름만 임계구역에 진입.semaphore: 정해진 개수만큼 동시에 진입 허용.
- 언제 필요한가
- 순차 자원 접근(카운터·파일 핸들) →
mutex - 동시성 제한(동시 요청 수 제한) →
semaphore
- 순차 자원 접근(카운터·파일 핸들) →
모델 ③ 액터 — 상태를 가둔다 (Erlang, Akka)
spin 대기나 잠금 경쟁이 심한 영역에서는 세마포어의 개수 조정만으로도 병목이 완화되지만, 동기화 비용 자체는 피할 수 없다. 이건 동기화 자체를 없애는 게 아니라 조율을 바꾸는 접근이다.
한 걸음 더. 각 액터가 자기 상태를 독점하고, 바깥은 오직 메시지 큐로만 말을 건다. 액터는 자기 메시지를 하나씩 처리하므로 내부에 락이 필요 없다. 메시지 전달을 “객체 단위로” 밀어붙인 형태다. Erlang/Elixir·Akka(JVM)·Actix(Rust)가 이 모델이고, C++엔 표준은 없고 CAF 같은 라이브러리가 있다.
Rust의 반전 — 조율을 타입이 강제한다
Rust도 Mutex와 채널(mpsc)을 다 가졌다. 진짜 차이는 safe Rust에서 동기화 규칙을 타입 경계로 강제한다는 것이다. 두 장치로.
하나 — Mutex<T>가 데이터를 품는다. C++ std::mutex는 뮤텍스와 데이터가 남남이다. 그래서 잠그는 걸 깜빡하고 counter에 바로 접근해도 컴파일된다(런타임에 터진다). Rust는 데이터를 뮤텍스 안에 넣는다.
1
2
let counter = Mutex::new(0);
*counter.lock().unwrap() += 1; // lock()을 거치지 않으면 안의 0에 접근할 방법이 없다
아, C++ std::mutex의 그 함정을 Rust는 타입으로 막았구나.
Mutex<T>의 안전한 API는 guard를 얻지 않고 내부T에 접근할 수 없게 합니다. 뮤텍스 자체가 사라지는 게 아니라, 데이터와 락을 한 타입으로 묶어 잠금 누락 경로를 닫습니다.
둘 — Send/Sync 표식. “이 타입은 스레드 간 이동해도(Send)/공유해도(Sync) 안전한가”를 타입마다 표시하고, 컴파일러가 안전하지 않은 걸 스레드 경계로 넘기면 컴파일 에러를 낸다. C++엔 이런 강제가 없어 “이 객체를 다른 스레드에 넘겨도 되나?”가 순전히 개발자 책임이다.
언어별 정리
| 공유 상태 + 락 | 메시지 전달(채널) | 레이스 방지 | |
|---|---|---|---|
| C++ | std::mutex + lock_guard (기본) | 표준 없음 (queue+mutex+condvar 수동) | 개발자 책임 |
| Go | sync.Mutex(있음) | 채널 + select (전면) | 런타임 race detector |
| Rust | Mutex<T>(데이터를 품음) | mpsc 채널 | safe Rust의 소유권·Send/Sync + 런타임 동기화 |
- C++: 공유 상태 + 뮤텍스가 정석. 채널은 직접 만든다. → 동시성 — thread·mutex·async
- Go: 뮤텍스도 있지만 채널을 권장한다. “accept interfaces”처럼 관용구 차원의 선택. → Go 학습 로드맵 — ⑤ 동시성
- Rust: 둘 다 주되, safe API가 소유권 계약과 동기화 경계를 강제한다.
unsafe에서는 그 계약을 개발자가 증명해야 한다. → Rust 학습 로드맵 — 동시성
정리: 공유 상태 + 뮤텍스는 세 언어 공통 바닥입니다. Go 채널은 소유권을 강제하지 않지만 메시지 전달 규약으로 공유를 줄이고, safe Rust는 소유권과 동기화 타입으로 동기화 없는 접근 경로를 닫습니다. 서로 다른 층의 도구를 같은 “락 제거”로 뭉뚱그리면 안 됩니다.
스스로 점검
1. “메모리를 공유해 통신하지 말고 통신해 공유하라”가 락을 없애는 원리는?
답
채널로 값을 보내는 것만으로 소유권이 강제되지는 않는다. 대신 “보낸 뒤 송신자는 그 가변 데이터에 다시 접근하지 않는다”는 규약을 지키면 동시 접근을 없앨 수 있다. Go 컴파일러는 이 규약을 보장하지 않으므로 포인터·slice·map의 별칭이 남는다면 락이나 다른 동기화가 여전히 필요하다.
2. C++ std::mutex와 Rust Mutex<T>의 결정적 차이는?
답
C++은 뮤텍스와 데이터가 분리돼 있어 잠그지 않고 데이터에 접근해도 컴파일된다(깜빡 잠금 버그). Rust Mutex<T>는 데이터를 뮤텍스 안에 품어 lock()을 거치지 않으면 접근할 방법이 없다 — 그 함정을 타입으로 막는다.
3. 채널이 있는데도 Rust가 Send/Sync를 두는 이유는?
답
채널·뮤텍스를 써도 “안전하지 않은 값을 스레드 경계로 넘기는” 실수는 가능하다. Send/Sync는 그걸 타입 수준에서 표시해, 안전하지 않은 공유·이동을 컴파일 에러로 잡는다. C++엔 없는 강제라, 거기선 순전히 개발자 책임이다.