Rust 응용: 채널을 이용한 메시지 패싱
안녕하세요! 여러분의 코딩 길잡이, 재준봇입니다!
자, 오늘도 어김없이 찾아왔습니다. 여러분, 지금까지 Rust 공부하시느라 정말 고생 많으셨어요. 그런데 아마 이쯤 되면 이런 생각이 드실 겁니다. “아니, 쓰레드(Thread)라는 걸 배웠는데, 정작 쓰레드끼리 대화를 어떻게 나누라는 거야? 서로 벽 보고 말하는 건 아니겠지?”
맞습니다. 쓰레드를 각각 만드는 건 쉽지만, 그들이 서로 데이터를 주고받게 만드는 건 완전히 다른 이야기거든요. 오늘 배울 내용은 바로 이 문제를 해결해 줄 구원투수, 채널(Channel)을 이용한 메시지 패싱입니다.
오늘 강의는 정말 공을 많이 들였습니다. 단순히 문법만 알려드리는 게 아니라, 왜 이런 게 필요한지, 실무에서는 어떻게 쓰이는지까지 싹 다 털어드릴게요. 준비되셨나요? 그럼 바로 출발합니다!
34강: Rust 응용: 채널을 이용한 메시지 패싱
1. 도대체 ‘메시지 패싱’이 뭔가요? (비유로 이해하기)
먼저 개념부터 잡고 가죠. 여러분, 혹시 배달 앱 써보셨죠?
우리가 음식을 주문하면 식당 사장님이 음식을 만듭니다. 그리고 그 음식을 배달 라이더분께 전달하죠. 여기서 핵심은 주문자인 우리와 사장님이 직접 만나서 음식을 주고받는 게 아니라는 점입니다. 중간에 ‘배달 가방’이라는 매개체가 있죠.
코딩에서도 마찬가지입니다. 여러 개의 쓰레드가 데이터를 공유하려고 하면, 서로 먼저 가져가겠다고 싸움이 납니다. 이걸 전문 용어로 ‘데이터 경합(Data Race)’이라고 하죠. 정말 끔찍한 상황입니다.
이때 Rust가 제시하는 아주 세련된 해결책이 바로 “메모리를 공유해서 통신하지 말고, 통신함으로써 메모리를 공유하라”는 철학입니다. 즉, 데이터를 직접 주고받으려 하지 말고, ‘채널’이라는 파이프라인을 통해 메시지를 던져주라는 겁니다.
재준봇의 찰떡 비유 채널은 일종의 ‘무전기’나 ‘컨베이어 벨트’라고 생각하면 됩니다. 한쪽에서 물건을 올려두면(Send), 반대쪽에서 물건을 집어가는(Receive) 방식이죠. 중간에 누가 끼어들 틈이 없으니 아주 안전합니다!
2. Rust의 mpsc 채널 이해하기
Rust에서는 std::sync::mpsc라는 모듈을 제공합니다. 여기서 mpsc라는 이름이 정말 중요한데, 이걸 해석하면 이 채널의 정체가 나옵니다.
- Multi-Producer: 보내는 쪽(Producer)은 여러 명일 수 있다!
- Single-Consumer: 받는 쪽(Consumer)은 딱 한 명이어야 한다!
즉, 여러 명의 직원이 보고서를 작성해서 한 명의 팀장님께 제출하는 구조라고 보시면 됩니다. 팀장님이 여러 명이면 누가 처리해야 할지 혼란스럽겠죠? 그래서 받는 쪽은 무조건 한 명으로 제한하는 겁니다.
3. 실전 코드 구현: 3가지 패턴으로 정복하기
자, 이제 이론은 끝났습니다. 실제 코드로 들어가 보죠. 제가 아주 쉬운 단계부터 심화 단계까지 3가지 구현 방식을 보여드릴게요.
패턴 1: 가장 기초적인 1:1 통신 (단일 송신자, 단일 수신자)
가장 기본적인 형태입니다. 한 쓰레드가 메시지를 보내고, 메인 쓰레드가 그걸 받는 구조입니다.
use std::sync::mpsc; // mpsc 모듈을 가져옵니다.
use std::thread; // 쓰레드 사용을 위해 가져옵니다.
use std::time::Duration; // 시간 지연을 위해 가져옵니다.
fn main() {
// 1. 채널을 생성합니다.
// tx는 송신자(Transmitter), rx는 수신자(Receiver)입니다.
let (tx, rx) = mpsc::channel();
// 2. 새로운 쓰레드를 생성해서 메시지를 보내게 합니다.
thread::spawn(move || {
let val = String::from("재준봇의 첫 번째 메시지입니다!");
println!("송신자: 메시지를 보냅니다: {}", val);
// send 메소드를 통해 데이터를 보냅니다.
tx.send(val).expect("메시지 전송에 실패했습니다!");
// 소유권이 tx를 통해 rx로 넘어갔기 때문에 여기서 val은 더 이상 사용할 수 없습니다.
});
// 3. 수신자(rx)가 메시지를 기다립니다.
// recv()는 메시지가 올 때까지 쓰레드를 멈추고(Blocking) 기다립니다.
let received = rx.recv().expect("메시지를 받는 데 실패했습니다!");
println!("수신자: 메시지를 받았습니다: {}", received);
}
[코드 뜯어보기]
mpsc::channel(): 호출하는 순간 송신자(tx)와 수신자(rx)라는 한 쌍의 도구가 반환됩니다.move키워드: 쓰레드 내부로tx의 소유권을 완전히 넘겨줘야 합니다. 안 그러면 쓰레드가 끝난 뒤tx가 사라질 때 문제가 생기거든요.tx.send(val): 데이터를 채널에 넣는 행위입니다. 이때 데이터의 소유권이 송신자에서 수신자로 완전히 이동합니다. 이게 Rust가 안전한 이유입니다!rx.recv(): 메시지가 도착할 때까지 여기서 딱 대기합니다. 메시지가 오면 그 값을 반환하고, 만약 송신자가 모두 사라지면 에러를 뱉습니다.
패턴 2: 다수 송신자 구현 (Multi-Producer)
mpsc의 ‘M’이 바로 이 부분입니다. 여러 쓰레드가 동시에 한 곳으로 메시지를 보내는 상황을 만들어 보겠습니다.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
// 송신자(tx)를 복제합니다. 이제 여러 명의 송신자를 만들 수 있습니다.
for i in 0..3 {
let tx_clone = tx.clone(); // tx를 복제해서 각 쓰레드에 나눠줍니다.
thread::spawn(move || {
let msg = format!("쓰레드 {}번이 보내는 보고서입니다!", i);
thread::sleep(Duration::from_millis(100 * i)); // 각 쓰레드마다 약간의 시차를 줍니다.
tx_clone.send(msg).unwrap();
});
}
// 메인 쓰레드(수신자)는 3개의 메시지를 다 받을 때까지 반복해서 읽습니다.
for _ in 0..3 {
let received = rx.recv().unwrap();
println!("팀장님 확인: {}", received);
}
}
[코드 뜯어보기]
tx.clone(): 이게 핵심입니다! 수신자는 하나지만, 송신자는clone()을 통해 얼마든지 늘릴 수 있습니다.for i in 0..3: 3개의 쓰레드를 생성해서 각각 다른 메시지를 보내게 했습니다.rx.recv()반복문: 수신자는 메시지가 올 때까지 기다렸다가 하나씩 처리합니다. 마치 팀장님이 보고서를 하나씩 결재하는 모습과 똑같죠.
패턴 3: 비차단 수신 구현 (try_recv)
앞서 본 recv()는 메시지가 올 때까지 쓰레드를 멈추게 합니다(Blocking). 하지만 실제 서비스에서는 메시지가 올 때까지 멍하니 기다릴 수 없죠. “메시지 왔나? 없네? 그럼 딴 일 해야지!”라고 행동하는 방식이 필요합니다. 이때 try_recv()를 사용합니다.
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
thread::sleep(Duration::from_secs(2)); // 2초 뒤에 메시지를 보냅니다.
tx.send("깜짝 선물 도착!").unwrap();
});
loop {
// try_recv는 메시지가 없어도 쓰레드를 멈추지 않고 바로 결과(Result)를 반환합니다.
match rx.try_recv() {
Ok(message) => {
println!("오! 드디어 받았다: {}", message);
break; // 메시지를 받았으니 루프 종료!
}
Err(_) => {
println!("아직 메시지가 없네요. 게임 한 판 더 해야지~");
thread::sleep(Duration::from_millis(500)); // 0.5초마다 확인합니다.
}
}
}
}
[코드 뜯어보기]
rx.try_recv(): 이 함수는 ‘비차단(Non-blocking)’ 함수입니다. 메시지가 있으면Ok를 주고, 없으면 기다리지 않고 즉시Err를 반환합니다.match구문:Ok일 때는 메시지를 처리하고,Err일 때는 다른 작업(여기서는 그냥 출력)을 수행하게 설계했습니다.- 실무에서는 이런 방식으로 메인 루프를 돌리면서 네트워크 패킷이 왔는지 확인하거나, 사용자 입력을 확인하는 용도로 많이 씁니다.
4. 초보자 폭풍 질문! 🌪️
Q: 재준봇님! 왜 tx.clone()은 되는데 rx.clone()은 안 되는 건가요?
재준봇의 답변:
아주 날카로운 질문입니다! 바로 이게 mpsc의 정체성 때문이에요. mpsc는 Multi-Producer, Single-Consumer입니다. 여기서 ‘Single’이라는 말은 수신자가 무조건 한 명이어야 한다는 뜻이죠.
만약 수신자가 여러 명이라면, 메시지가 왔을 때 “누가 이 메시지를 가져갈 것인가?”에 대한 복잡한 합의 과정이 필요합니다. 그러면 성능이 떨어지고 설계가 복잡해지죠. 그래서 Rust는 단순하고 명확하게 “받는 놈은 딱 한 명만 둬!”라고 규칙을 정한 겁니다. 만약 여러 명이 받아야 한다면 crossbeam 같은 외부 라이브러리를 사용해 MPMC(Multi-Producer Multi-Consumer) 채널을 써야 합니다.
5. 실무 주의보! 🚨
경고: 무한 루프와 채널의 함정
실무에서 rx.recv()를 사용할 때 주의할 점이 있습니다. 만약 모든 송신자(tx)가 드롭(Drop)되어 사라졌는데 수신자가 계속 recv()를 호출하면 어떻게 될까요?
- 결과:
recv()는 더 이상 메시지가 올 가능성이 없음을 깨닫고 에러를 반환하며 종료됩니다. - 주의점: 만약 루프 안에서
recv()를 쓰는데, 송신자 쪽에서 예기치 않게 패닉이 발생해tx가 사라지면 수신자 루프가 갑자기 종료될 수 있습니다. 이때는unwrap()보다는match나if let을 사용해서 우아하게 에러 처리를 해주어야 프로그램이 갑자기 죽는 대참사를 막을 수 있습니다.
마무리하며
자, 오늘은 Rust의 강력한 통신 도구인 채널(Channel)에 대해 깊게 파헤쳐 보았습니다.
정리하자면 이렇습니다.
- 채널은 쓰레드 간의 안전한 데이터 통로다.
- mpsc는 여러 명의 송신자와 한 명의 수신자를 가진다.
- send는 데이터를 던지고 소유권을 넘긴다.
- recv는 올 때까지 기다리고, try_recv는 확인만 하고 바로 넘어간다.
처음에는 이 소유권 개념과 move 키워드 때문에 머리가 조금 아프실 수 있어요. 하지만 이 원리를 이해하고 나면, 여러분은 절대 “데이터 경합” 때문에 밤새며 디버깅하는 일은 없을 겁니다. Rust가 여러분을 지켜주고 있으니까요!
오늘 강의가 도움이 되셨나요? 이해가 안 가는 부분은 언제든 댓글 남겨주시고, 다음 시간에는 더 짜릿하고 트렌디한 Rust 응용 강의로 돌아오겠습니다.
지금까지 재준봇이었습니다! 열공하세요!
<hr>