모두가 Rust를 칭송할 때, async Rust는 왜 아직도 미완성인가
요즘 개발자 커뮤니티에서 Rust는 거의 신앙의 영역에 들어선 분위기입니다. 메모리 안전성, 제로 코스트 추상화, 무서울 정도로 빠른 채택률까지. 그런데 그 칭찬 합창 속에서 묘하게 작은 목소리로 반복되는 비판이 하나 있습니다. 바로 async Rust는 여전히 MVP(최소 기능 제품) 상태에서 벗어나지 못했다는 지적입니다.
오늘은 다들 쉬쉬하지만 Rust로 비동기 코드를 한 번이라도 짜본 사람이라면 고개를 끄덕일 수밖에 없는, 그 불편한 진실을 짚어보려고 합니다.
“동기 Rust는 천국, async Rust는 지옥"이라는 농담
Rust 개발자들 사이에서 반쯤 농담처럼 도는 말이 있습니다. 동기(sync) Rust는 천국, async Rust는 지옥이라는 표현인데요. 뼈가 있는 농담입니다.
동기 Rust에서는 컴파일러가 친절합니다. 빌림 검사기(borrow checker)는 까다롭지만 일관적이고, 에러 메시지도 점점 좋아져왔습니다. 그런데 async 키워드를 붙이는 순간 분위기가 달라집니다. Pin, Send, Sync, 'static 같은 단어들이 한꺼번에 쏟아지고, 어떤 함수에서 갑자기 “future is not Send"라는 미궁 같은 에러가 튀어나오면 30분짜리 디버깅이 시작됩니다.
문제는 이게 초보자만의 이야기가 아니라는 점입니다. Rust를 수년간 써온 시니어 엔지니어들도 async 코드의 타입 에러 앞에서 한참을 멈추는 경우가 흔합니다.
표준이 없다는 가장 큰 결함
가장 자주 지적되는 문제는 표준 런타임의 부재입니다. Go에는 goroutine이 언어에 내장되어 있고, JavaScript에는 V8 이벤트 루프가 있습니다. 그런데 Rust는 async/await 문법만 표준 라이브러리에 넣어두고, 실제로 future를 굴리는 런타임은 외부 크레이트에 맡겨버렸습니다.
그 결과 Tokio, async-std, smol, Glommio 같은 런타임이 난립했고, 사실상 Tokio가 1등을 굳혔지만 그것은 표준이 아니라 사실상의 독점입니다. 라이브러리를 만들 때 어떤 런타임을 가정해야 하는지, 런타임 간 호환은 어떻게 해야 하는지가 여전히 골칫거리입니다. “Tokio에서만 동작하는 라이브러리"라는 묘한 이중 생태계가 굳어지고 있는 것이죠.
async trait, 7년 만에 풀린 숙제
오랫동안 async Rust의 상징적인 결함으로 꼽혔던 것이 trait 안에서 async 함수를 쓸 수 없다는 문제였습니다. 우회를 위해 async-trait 매크로 크레이트가 사실상 표준처럼 쓰였고, 그 매크로는 매번 Box<dyn Future>를 만들어 힙 할당을 일으켰습니다. “제로 코스트"를 내세운 언어에서 비동기를 쓰려면 어쩔 수 없이 비용을 떠안아야 했던 셈입니다.
최근에서야 trait 내 async fn이 안정화되긴 했지만, dynamic dispatch와 결합되는 순간 또다시 우회가 필요합니다. 결국 “기본은 되지만 실전에서는 여전히 반쪽짜리"라는 평가가 따라붙는 이유입니다.
취소(cancellation)와 Pin이라는 두 개의 지뢰
async Rust를 진짜로 어렵게 만드는 두 가지 개념이 더 있습니다. 첫 번째는 취소 안전성(cancel safety)입니다. tokio::select! 같은 매크로 안에서 future가 어느 시점에 취소될지 모르기 때문에, 중간 상태가 남으면 데이터가 깨지거나 락이 풀리지 않는 버그가 생깁니다. 동기 코드에서는 존재하지 않던 종류의 버그입니다.
두 번째는 Pin입니다. self-referential한 future를 안전하게 다루기 위한 장치인데, 정작 일반 사용자는 평생 Pin을 직접 만지지 않아야 정상이지만 라이브러리를 조금만 깊게 파면 결국 마주치게 됩니다. 그리고 한 번 마주치면, 문서를 읽어도 머리에 잘 들어오지 않습니다.
그럼에도 왜 다들 쓰는가
이쯤 되면 “그럼 왜 다들 async Rust를 쓰는가"라는 의문이 들 법합니다. 답은 단순합니다. 대안이 더 나쁘기 때문입니다. C++의 코루틴은 더 난해하고, Go의 goroutine은 편하지만 GC가 있고, JavaScript는 시스템 프로그래밍에 적합하지 않습니다. 결국 고성능 네트워크 서비스를 만들면서 메모리 안전성까지 챙기려면, 불완전한 async Rust라도 쓸 수밖에 없는 것이죠.
문제는 이 “어쩔 수 없음"이 비판을 침묵시켜왔다는 점입니다. 누가 async Rust의 문제를 지적하면 “그럼 너는 뭘 쓸 건데"라는 반응이 돌아오기 일쑤였고, 그러는 사이 정작 풀렸어야 할 설계 부채는 차곡차곡 쌓였습니다.
정리하며
Rust는 분명 위대한 언어입니다. 하지만 위대함 속에 가려진 미완성을 인정하지 않으면 다음 단계로 가기 어렵습니다. async Rust는 지금도 표준 런타임 없이 굴러가고 있고, trait 호환성은 절반만 풀렸으며, 취소와 Pin은 여전히 베테랑들도 헷갈리는 영역입니다.
언어를 사랑하는 방법은 두 가지입니다. 흠을 외면하거나, 흠을 인정하고 함께 다듬어가거나. 여러분이 Rust를 쓰고 있다면, 혹은 도입을 고민 중이라면 이 질문을 한 번쯤 던져보면 좋겠습니다. 지금 우리가 ‘Rust답다’고 부르는 코드가, 정말 5년 뒤에도 Rust답게 남아있을까요?
댓글
댓글을 불러오는 중...