
Rust로 만들었는데 생각보다 안 빠를 때
Rust에 Tokio를 얹어서 서버를 만들면 무조건 빠를 거라고 기대하는 분들이 많은데요, 막상 부하를 걸어보면 Go나 Node로 짠 것과 별 차이가 없거나 오히려 지연 시간이 들쭉날쭉한 경우가 꽤 있어요. Rust 프로젝트 dial9의 블로그에 올라온 "Principles for Fast Tokio Applications"라는 글이 이 문제를 정면으로 다뤘는데요, 결론부터 말하면 대부분의 원인은 Rust나 Tokio가 아니라 런타임의 동작 방식을 모르고 쓴 코드에 있다는 거예요. 글에서 정리한 원칙들을 제 언어로 풀어볼게요.
Tokio가 뭐냐면
Tokio는 Rust의 비동기 런타임이에요. 이게 뭐냐면, Rust의 async/await 문법은 "이 함수는 중간에 멈췄다 다시 이어갈 수 있어요"라는 표시만 해줄 뿐, 실제로 언제 어떤 스레드에서 돌릴지는 언어가 정해주지 않거든요. 그 스케줄링을 해주는 게 런타임이고, Tokio가 사실상의 표준이에요. 기본 설정에서는 CPU 코어 수만큼 워커 스레드를 만들고, 그 위에서 수만 개의 태스크(task)를 돌아가며 실행해요. 여기서 제일 중요한 개념이 하나 있는데, Tokio는 협력적(cooperative) 스케줄링이라는 거예요. 태스크가 .await 지점에서 스스로 "나 지금 기다려야 하니까 다른 애 먼저 해"라고 양보해야만 다음 태스크가 실행돼요. 운영체제 스레드처럼 강제로 뺏어오는 게 아니에요. 이 한 가지 사실에서 빠른 Tokio 앱의 원칙 대부분이 나와요.
원칙 1: 워커 스레드를 절대 막지 마세요
식당에 비유하면 워커 스레드는 홀 서빙 직원이고, 태스크는 테이블이에요. 직원 8명이 테이블 1,000개를 돌면서 주문받고 음식 나르는 구조죠. 그런데 직원 한 명이 주문받다 말고 주방에 들어가서 직접 30분 동안 요리를 하면요? 그 직원이 담당하던 테이블 100여 개가 전부 멈춰요. 이게 런타임 블로킹이에요. 구체적으로는 std::fs로 파일을 동기적으로 읽기, 동기 HTTP 클라이언트 호출, 큰 JSON 파싱이나 이미지 리사이즈 같은 무거운 CPU 작업, std::thread::sleep 같은 것들이 범인이에요. 이런 작업은 tokio::task::spawn_blocking으로 별도의 블로킹 전용 스레드풀에 넘기거나, CPU 작업이면 rayon 같은 병렬 처리 라이브러리에 맡겨야 해요. Tokio에는 태스크가 너무 오래 양보 없이 돌면 강제로 양보 지점을 끼워넣는 예산(budget) 기능이 있긴 한데, Tokio의 I/O 함수를 쓸 때만 동작하지 순수 계산 루프에는 아무 도움이 안 돼요.
원칙 2: await를 사이에 두고 락을 잡지 마세요
std::sync::Mutex를 잠근 채로 .await를 하면 두 가지 문제가 생겨요. 첫째, 그 태스크가 양보하고 잠들었는데 다른 태스크가 같은 락을 잡으려 하면 워커 스레드가 통째로 막히고, 심하면 데드락이에요. 둘째, 컴파일러가 Send 에러를 내면서 아예 빌드가 안 되는 경우도 많죠. 그렇다고 tokio::sync::Mutex로 바꾸면 되냐 하면, 그건 락 자체가 비동기라 훨씬 느려요. 정답은 락을 잡는 구간을 await 없이 아주 짧게 유지하는 거예요. 값을 꺼내서 복사하고 바로 풀어주는 식으로요. 공유 상태가 복잡하면 락 대신 채널로 메시지를 보내서 한 태스크가 상태를 전담하게 하는 액터(actor) 패턴이 더 깔끔할 때가 많아요.
원칙 3: 백프레셔 없는 채널은 시한폭탄이에요
unbounded_channel은 이름 그대로 무한히 쌓여요. 생산자가 초당 10만 건을 넣는데 소비자가 초당 5만 건밖에 못 처리하면 메모리가 선형으로 늘다가 서버가 죽어요. 항상 bounded 채널을 쓰고 용량을 정해주세요. 그러면 채널이 가득 찼을 때 생산자가 자연스럽게 기다리게 되는데, 이게 백프레셔(backpressure)예요. 이게 뭐냐면, 하류가 밀리면 상류가 알아서 속도를 늦추는 거예요. 고속도로 진입로에 신호등 달아놓는 것과 같아요.
원칙 4: 태스크는 싸지만 공짜는 아니에요
태스크 하나 만드는 비용은 OS 스레드에 비하면 정말 싸서 요청마다 spawn하는 건 괜찮아요. 하지만 데이터 한 건마다 spawn하면 스케줄러 오버헤드가 실제 일보다 커질 수 있어요. 반대 실수도 있어요. 태스크 하나 안에서 for 루프로 요청 100개를 순서대로 await하면 병렬성이 전혀 없죠. 이럴 땐 JoinSet이나 futures의 buffer_unordered로 동시 실행 개수를 정해두고 돌리는 게 맞아요.
원칙 5: 시스템 콜과 할당을 줄이고, 감이 아니라 측정으로
작은 데이터를 write 여러 번 하는 건 시스템 콜을 여러 번 하는 거예요. BufWriter로 감싸서 한 번에 내보내고, 네트워크 버퍼는 bytes 크레이트의 Bytes 타입으로 복사 없이 공유하는 게 좋아요. String을 clone해서 태스크에 넘기는 습관도 Arc로 바꾸면 차이가 나요. 그리고 이 모든 걸 감으로 하지 말고 측정하세요. tokio-console을 붙이면 어떤 태스크가 얼마나 오래 양보 없이 돌았는지, 어디서 대기하는지 실시간으로 보여줘요.
업계 맥락: Go와 뭐가 다르길래
Go의 고루틴은 1.14 버전부터 선점형이라 하나가 오래 돌아도 런타임이 강제로 끊어줘요. 그래서 Go 개발자는 블로킹을 덜 신경 써도 돼요. Tokio는 그런 안전장치가 약한 대신 스케줄링 오버헤드가 거의 없고 개발자가 완전히 제어할 수 있어요. 양날의 검인 셈이죠. Rust 진영 안에서도 Tokio의 워크 스틸링 방식 대신 스레드마다 독립 런타임을 두는 thread-per-core 모델(glommio, monoio)이 있는데, 극단적인 처리량이 필요할 때 쓰는 거고 일반적인 웹 서비스에서는 axum, tonic, sqlx, reqwest 전부가 Tokio 기반이라 다른 선택지가 사실상 없어요.
한국 개발자에게 주는 시사점
국내에서도 게임 서버, 결제 프록시, 로그 파이프라인처럼 지연 시간에 민감한 영역에서 Rust를 도입하는 팀이 늘고 있는데요, 위 원칙들은 그런 프로젝트에서 첫 부하 테스트 전에 코드 리뷰 체크리스트로 쓰기 딱 좋아요. 그리고 Rust를 안 쓰는 분들한테도 의미가 있어요. Node.js의 이벤트 루프, Python의 asyncio도 똑같이 협력적 스케줄링이거든요. "이벤트 루프를 막지 마라", "무한 큐 쓰지 마라"는 언어를 가리지 않는 원칙이에요.
정리하면
Tokio가 느린 게 아니라, 협력적 스케줄링이라는 약속을 어기는 코드가 느린 거예요. 양보하지 않고 오래 도는 작업을 찾아내는 것만으로 성능 문제의 절반은 해결돼요. 여러분 프로젝트에서는 비동기 코드 성능 문제를 어떻게 찾아내셨나요? tokio-console 같은 도구를 실제로 써보신 분 계시면 경험 공유 부탁드려요.
🔗 출처: Hacker News