RUST · 심층 가이드
Rust 비동기/동시성 완전 정리
async 함수 시작부터 병렬 실행, timeout, retry, 취소 신호, producer-consumer까지 Rust 비동기의 15가지 실전 패턴을 정리했습니다.
Rust의 async fn은 호출해도 아무 일도 일어나지 않습니다. 반환되는 것은 실행 결과가 아니라 아직 폴링되지 않은 Future이고, .await로 소비하거나 런타임에 넘겨야 비로소 움직입니다. 게다가 표준 라이브러리에는 실행기가 없어 Tokio 같은 런타임을 따로 붙여야 합니다. 다른 언어의 async와 겉모양이 같아 보여도 이 두 가지 때문에 초기 체감이 꽤 다릅니다.
비동기 함수 시작과 순차 작업 처리로 기본기를 잡은 뒤 병렬 작업 처리로 넘어가면 .await를 줄줄이 붙이는 것이 병렬이 아니라 순차라는 사실이 분명해집니다. 그다음 timeout 처리와 retry 로직, 취소 신호를 묶어 보십시오. Rust의 취소는 별도 API가 아니라 Future를 드롭하는 것으로 이뤄지므로, 이 세 항목을 함께 읽어야 그림이 맞습니다.
실무에서 가장 자주 터지는 문제는 비동기 함수 안에서 블로킹 호출을 하는 것입니다. 파일을 동기로 읽거나 무거운 계산을 그대로 돌리면 그 태스크만 느려지는 게 아니라 실행기 스레드 자체가 묶여 다른 태스크까지 멈춥니다. 무거운 동기 작업은 전용 블로킹 스레드로 넘기는 것이 원칙입니다. .await 지점을 가로질러 락 가드를 들고 있는 코드도 같은 이유로 위험합니다.
01비동기 함수 시작
thread::spawn이 클로저를 새 OS 스레드에서 실행하고, join이 그 반환값 1을 받아 옵니다. 제목과 달리 async나 await는 등장하지 않으며, 작업 하나를 백그라운드로 떼어 놓고 결과를 기다릴 때 쓰는 형태입니다. join은 Result를 돌려주므로 스레드가 패닉하면 unwrap에서 그 패닉이 호출부로 옮겨 옵니다.
Rust code
// 비동기 함수 시작
use std::thread;
fn main() {
let handle = thread::spawn(|| 1);
println!("{}", handle.join().unwrap());
}join은 스레드가 끝날 때까지 호출 쪽을 막고 클로저의 반환값을 소유권째 돌려주므로, 결과를 받으려면 핸들을 버리지 말고 붙잡아 두어야 합니다.
핸들을 받지 않고 thread::spawn만 호출하면 결과를 회수할 길이 사라지고, main이 먼저 끝나면 그 스레드는 완료 여부와 상관없이 프로세스와 함께 사라집니다.
02순차 작업 처리
메인 스레드를 1밀리초 재운 뒤 done을 출력하는 것이 전부입니다. 작업 사이에 간격을 두는 순차 흐름을 흉내 낸 코드로, 비동기 대기가 아니라 스레드 자체를 멈추는 블로킹 대기입니다. Duration::from_millis은 최소 대기 시간만 보장하고 OS 스케줄러 사정에 따라 실제로는 더 길게 잡니다.
Rust code
// 순차 작업 처리
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}thread::sleep은 CPU를 점유하지 않고 스케줄러에 자리를 넘기므로, 같은 대기를 빈 루프로 도는 스핀 대기보다 전력과 코어를 훨씬 아낍니다.
작업 사이마다 밀리초 단위 sleep을 끼워 순서를 맞추려 하면, 실제 소요가 조금만 길어져도 순서가 어긋나 원인을 찾기 어려운 간헐적 실패가 됩니다.
03병렬 작업 처리
여러 작업을 동시에 굴리는 대신 실제로는 스레드 하나만 띄워 값 3을 받아 오는 최소 골격입니다. 병렬 폭을 넓히려면 핸들을 Vec에 모아 두고 나중에 한꺼번에 join해야 하며, spawn 직후 바로 join하면 순차 실행과 다를 게 없습니다. 스레드 생성 비용은 async 태스크보다 훨씬 커서 작업 수가 많아지면 풀이 필요해집니다.
Rust code
// 병렬 작업 처리
use std::thread;
fn main() {
let handle = thread::spawn(|| 3);
println!("{}", handle.join().unwrap());
}핸들을 Vec에 모을 때는 생성 루프와 회수 루프를 따로 돌려야 하며, 그래야 모든 스레드가 먼저 뜨고 나서 join이 시작됩니다.
생성 루프 안에서 곧바로 join을 부르면 스레드가 하나씩 끝나기를 기다리게 되어, 스레드를 띄운 비용만 남고 병렬 효과는 전혀 나지 않습니다.
04timeout 처리
타임아웃 로직은 없고 thread::sleep으로 1밀리초를 보낸 뒤 문자열 하나를 출력합니다. 실제 제한 시간을 걸려면 Receiver::recv_timeout처럼 대기 자체에 상한을 주는 API가 필요합니다. sleep은 기다리는 쪽을 멈출 뿐 이미 시작된 작업을 끊지 못하는데, Rust에는 스레드를 강제로 죽이는 표준 수단이 없기 때문입니다.
Rust code
// timeout 처리
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}제한 시간을 걸려면 대기하는 쪽에 상한을 주어야 하며, mpsc 채널의 recv_timeout은 정해진 시간이 지나면 Err로 빠져나와 흐름을 되찾아 줍니다.
sleep으로 시간을 보낸 뒤 결과를 확인하는 방식은 타임아웃이 아니라 추측이라, 작업이 조금 늦게 끝나면 멀쩡한 결과를 실패로 처리하게 됩니다.
05retry 로직
재시도 횟수나 실패 조건 없이, 스레드가 5를 돌려주고 그것을 출력하는 흐름만 남아 있습니다. 재시도를 붙인다면 클로저가 Result를 반환하게 하고 루프 안에서 join 결과를 검사하는 식이 됩니다. 클로저는 한 번 spawn에 넘기면 그대로 소비되므로, 시도마다 새 클로저를 만들거나 호출 가능한 값을 참조로 다시 부르게 됩니다.
Rust code
// retry 로직
use std::thread;
fn main() {
let handle = thread::spawn(|| 5);
println!("{}", handle.join().unwrap());
}재시도를 붙일 때는 클로저가 Result를 돌려주게 하고 시도마다 새 클로저를 만들어야 하는데, spawn이 클로저를 소유권째 가져가 한 번 쓰면 소비되기 때문입니다.
간격 없이 곧바로 다시 시도하면 상대가 부하로 실패한 경우 오히려 요청을 몰아 주게 되므로, 대기 시간을 점점 늘리고 최대 횟수를 정해 두어야 합니다.
06스트림 또는 채널 소비
채널이나 이터레이터는 쓰이지 않고 1밀리초 대기 후 한 줄 출력이 전부입니다. 소비 쪽을 실제로 만들려면 std::sync::mpsc의 Receiver를 for로 순회하는 형태가 됩니다. 이때 반복은 송신자가 모두 drop될 때 끝나므로, 송신자를 하나라도 살려 두면 루프가 영원히 멈춘 채 남습니다.
Rust code
// 스트림 또는 채널 소비
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}Receiver를 for로 순회하면 값이 올 때까지 알아서 막히고 송신자가 모두 사라지면 반복이 끝나므로, 종료 조건을 따로 둘 필요가 없습니다.
송신자를 복제해 넘긴 뒤 원본 Sender를 drop하지 않고 남겨 두면 채널이 영영 닫히지 않아 수신 루프가 끝나지 않고 멈춘 채로 남습니다.
07producer consumer
생산자와 소비자를 나누지 않고 스레드 하나가 7을 만들어 메인이 받아 가는 구조라, 값이 한 번만 오가는 축소판에 가깝습니다. 실제 큐를 두려면 mpsc::channel의 송신부를 생산자 스레드로 move해야 합니다. 클로저가 캡처한 값은 스레드가 언제 끝날지 알 수 없어 'static이 요구되고, 그래서 참조 대신 소유권을 넘기게 됩니다.
Rust code
// producer consumer
use std::thread;
fn main() {
let handle = thread::spawn(|| 7);
println!("{}", handle.join().unwrap());
}생산자 스레드에는 Sender를 move로 넘기고 소비자 쪽에 Receiver를 두면, 값의 소유권이 스레드 경계를 넘어가므로 공유 잠금이 필요 없습니다.
생산 속도가 소비 속도보다 빠른데 상한 없는 채널을 쓰면 대기열이 계속 쌓여 메모리를 잠식하므로, 용량이 정해진 sync_channel로 생산자를 눌러야 합니다.
08취소 신호
취소 플래그 없이 정해진 시간만 자고 끝나므로, 중단 신호가 어디에 들어갈지 자리만 보여 주는 코드입니다. 협조적 취소를 구현하려면 AtomicBool을 Arc로 공유하고 작업 루프가 주기적으로 그 값을 확인해야 합니다. 자고 있는 스레드는 플래그를 볼 수 없어서, 긴 sleep 대신 짧은 대기를 반복하는 편이 반응이 빠릅니다.
Rust code
// 취소 신호
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}협조적 취소는 Arc<AtomicBool>을 공유하고 작업 루프가 load로 주기적으로 확인하는 형태이며, 확인 지점이 촘촘할수록 반응이 빨라집니다.
취소 플래그를 켜 두어도 스레드가 긴 sleep 안에 들어가 있으면 그 시간이 다 지나야 깨어나므로, 대기를 잘게 쪼개지 않으면 종료가 한참 늦어집니다.
09비동기 에러 처리
에러 타입은 등장하지 않고 스레드가 9를 반환해 정상 경로만 지나갑니다. 스레드에서 난 실패를 다루려면 클로저가 Result를 반환하게 하고 join()이 주는 이중 Result를 두 겹 다 풀어야 합니다. 바깥쪽 Err는 패닉 페이로드인 Box<dyn Any>라서 ?로 그대로 전파되지 않고 별도 변환이 필요합니다.
Rust code
// 비동기 에러 처리
use std::thread;
fn main() {
let handle = thread::spawn(|| 9);
println!("{}", handle.join().unwrap());
}클로저가 Result를 돌려주면 join의 결과는 이중 Result가 되고, 바깥쪽은 패닉 여부를 안쪽은 작업 자체의 성패를 나타냅니다.
join().unwrap()은 자식 스레드의 패닉을 그대로 호출부로 옮겨 오므로, 하나가 실패해도 나머지를 진행해야 하는 자리에서는 match로 갈라 받아야 합니다.
10배치 처리
묶음 처리 대신 1밀리초 대기와 출력 한 번으로 끝나서, 배치 사이의 간격을 두는 자리만 표시한 셈입니다. 실제로는 입력을 chunks로 잘라 덩어리마다 처리하고 사이에 쉬는 식으로 확장합니다. 덩어리를 키우면 호출 횟수는 줄지만 실패했을 때 되돌려야 할 작업량도 같이 커지므로 재처리 단위를 함께 정해야 합니다.
Rust code
// 배치 처리
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}입력을 chunks로 잘라 덩어리 단위로 처리하면 호출 횟수와 고정 비용이 줄고, 마지막 조각만 크기가 작을 수 있다는 점만 감안하면 됩니다.
덩어리를 너무 크게 잡으면 중간에 실패했을 때 되돌려야 할 작업량도 그만큼 커지므로, 배치 크기를 정할 때 재처리 단위를 함께 정해 두어야 합니다.
11debounce
입력이 잠잠해질 때까지 기다리는 로직은 없고, 스레드에서 받은 11을 출력합니다. 디바운스가 필요한 자리는 검색어 입력이나 창 크기 변경처럼 이벤트가 몰려 들어오는 경우입니다. 마지막 이벤트만 남겨야 하므로 타이머를 다시 걸 때 이전 대기를 무효화할 세대 번호나 채널이 있어야 하고, sleep만 걸면 호출마다 지연이 쌓입니다.
Rust code
// debounce
use std::thread;
fn main() {
let handle = thread::spawn(|| 11);
println!("{}", handle.join().unwrap());
}디바운스는 마지막 이벤트만 살려야 하므로, 새 이벤트마다 세대 번호를 올리고 대기가 끝난 시점에 번호가 그대로일 때만 실행하는 방식이 간단합니다.
이벤트마다 sleep을 걸어 두면 이전 대기가 취소되지 않아 지연이 겹겹이 쌓이고, 한 번만 실행되어야 할 작업이 들어온 횟수만큼 실행됩니다.
12throttle
호출 빈도를 제한하는 코드는 없고 짧게 자고 done을 찍는 흐름만 있습니다. 스로틀은 Instant로 마지막 실행 시각을 기억해 두고 정해진 간격이 지나지 않았으면 그냥 건너뛰는 방식으로 만듭니다. 디바운스가 마지막 호출을 지연시켜 한 번 실행한다면, 스로틀은 첫 호출을 즉시 통과시키고 나머지를 버린다는 점이 다릅니다.
Rust code
// throttle
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}스로틀은 마지막 실행 시각을 Instant로 들고 있다가 elapsed()가 정해진 간격을 넘었을 때만 통과시키는 식이라 상태 하나로 구현됩니다.
간격이 지나지 않은 호출을 버리기만 하면 마지막 이벤트가 사라질 수 있어, 최종 상태가 중요한 화면 갱신에서는 끝에 한 번 더 실행해 주어야 합니다.
13파일 읽기 작업
파일 입출력은 전혀 없고 스레드가 13을 돌려주는 자리 표시용 코드입니다. 실제 읽기는 std::fs::read_to_string 한 줄이면 되고, 이를 스레드로 감싸는 이유는 디스크 대기 동안 메인 흐름을 막지 않기 위해서입니다. 이 함수는 파일이 올바른 UTF-8이 아니면 내용 대신 InvalidData 오류를 돌려주므로 바이너리에는 fs::read를 씁니다.
Rust code
// 파일 읽기 작업
use std::thread;
fn main() {
let handle = thread::spawn(|| 13);
println!("{}", handle.join().unwrap());
}fs::read_to_string은 파일 크기만큼 메모리를 한 번에 잡으므로, 큰 로그를 다룰 때는 BufReader의 lines로 한 줄씩 흘려보내는 편이 안전합니다.
읽기를 스레드로 감쌌더라도 그 결과를 곧바로 join으로 기다리면 메인 흐름이 그대로 막혀, 스레드를 띄운 비용만 더해지고 이득은 사라집니다.
14요청 작업 감싸기
요청을 감싸는 래퍼는 없고 대기 후 출력만 남았습니다. 네트워크 호출을 감쌀 때는 소요 시간 측정, 재시도, 오류 변환을 한곳에 모아 호출부를 단순하게 유지합니다. 다만 이런 래퍼가 클로저를 받게 만들면 클로저마다 타입이 달라져 impl Fn으로 단형화하거나 박싱해야 하고, 박싱하면 호출마다 간접 분기가 생깁니다.
Rust code
// 요청 작업 감싸기
use std::{thread, time::Duration};
fn main() {
thread::sleep(Duration::from_millis(1));
println!("done");
}래퍼를 F: Fn() -> T 같은 제네릭으로 받으면 호출이 인라인될 수 있어 비용이 거의 없고, 여러 종류를 한곳에 담아야 할 때만 박싱으로 넘어가면 됩니다.
래퍼 안에서 오류를 문자열로 뭉개면 상위에서 재시도 가능한 실패와 영구 실패를 구분할 수 없게 되어, 그냥 포기하거나 무의미하게 반복하게 됩니다.
15스케줄 작업
일정에 맞춰 반복하는 부분 없이 스레드 하나가 15를 반환하고 끝납니다. 주기 실행은 보통 루프 안에서 작업을 수행하고 남은 시간만큼 자는 형태로 만듭니다. 작업 시간을 무시하고 고정 간격을 자면 주기가 조금씩 밀리므로, 다음 실행 시각을 정해 두고 Instant 차이만큼만 자는 편이 흐트러짐이 적습니다.
Rust code
// 스케줄 작업
use std::thread;
fn main() {
let handle = thread::spawn(|| 15);
println!("{}", handle.join().unwrap());
}주기 실행은 다음 실행 시각을 미리 정해 두고 Instant 차이만큼만 자는 방식이 안정적이며, 작업 시간이 들쭉날쭉해도 전체 간격이 밀리지 않습니다.
작업이 끝난 뒤 매번 고정 시간을 자면 실제 주기가 작업 시간만큼 길어지고, 그 오차가 반복마다 쌓여 하루가 지나면 예정보다 한참 뒤처집니다.
정리하며
- async fn은 await하거나 런타임에 넘기기 전까지 실행되지 않습니다
- await를 나열하면 순차 실행이며 병렬은 조인 계열 도구로 명시해야 합니다
- 취소는 별도 API가 아니라 Future를 드롭하는 방식으로 이뤄집니다
- 비동기 안의 블로킹 호출은 실행기 스레드를 묶어 전체를 느리게 만듭니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.