← 목록으로 돌아가기

Rust Tokio로 만드는 고성능 비동기 백엔드: Node.js에서 넘어온 우리가 부딪힌 동시성 함정 7가지

Backend

Rust Tokio Axum Async Backend Pitfalls

Node.js 베테랑이 Rust로 백엔드를 옮기며 깨진 가정 7가지

저희 백엔드 팀은 5년 동안 Node.js + TypeScript 위에서 트래픽을 견뎌왔습니다. Express, Fastify, NestJS를 거치며 RPS 한계가 보일 때마다 horizontal scaling으로 버텼지만, 2025년 말 메인 결제 게이트웨이의 p99 latency가 320ms를 넘기면서 한계점에 도달했습니다. CPU 사용률은 여유로운데도 이벤트 루프가 막히는 전형적인 단일 스레드 한계였고, 저희는 핵심 서비스 두 개를 Rust + Tokio + Axum으로 옮기기로 했습니다. 결과부터 말하자면 RPS는 4.7배 늘었고 p99는 6분의 1로 줄었습니다. 다만 이 숫자에 도달하기까지 Node.js 시절에는 존재하지 않던 7가지 함정에 정확히 한 번씩 빠졌습니다. 이 글은 2026년 4월 현재 운영 중인 Tokio 1.40 + Axum 0.8 기반 미니 결제 API의 실제 코드와, 그 코드를 쓰며 저희가 부서뜨린 가정들을 정리한 실전 보고서입니다. 프론트엔드 핫패스를 Rust로 옮긴 사례는 다른 글에서 다뤘으니, 여기서는 백엔드 동시성 주제 하나에만 집중했습니다.

1. 함정 1 — block_on 안에서 다시 block_on: 런타임 진입 위반

처음 저희가 Rust 비동기 모델을 잘못 이해한 흔적은 임베디드 스크립트 호출부에 남아 있었습니다. Node.js에서는 await을 어디서든 호출할 수 있지만, Tokio에서는 이미 진입한 런타임 위에서 tokio::runtime::Handle::current().block_on(...)을 다시 부르는 순간 "Cannot start a runtime from within a runtime"라는 panic이 떨어지거나, current-thread 런타임이라면 그 자리에서 멎어버립니다. multi-thread 런타임이라면 같은 워커 스레드 위에서 자기 자신을 기다리는 형태가 되어 부하가 조금만 올라도 곧바로 데드락에 빠집니다.

처음 저희 결제 서비스는 다음과 비슷한 코드를 가지고 있었습니다.

use axum::{routing::post, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct ChargeReq { user_id: i64, amount_krw: i64 }
#[derive(Serialize)]
struct ChargeRes { tx_id: String }

async fn charge(Json(req): Json<ChargeReq>) -> Json<ChargeRes> {
    // 안티패턴: async 컨텍스트 안에서 또 다시 block_on을 부른다.
    let tx_id = tokio::runtime::Handle::current()
        .block_on(persist_tx(req.user_id, req.amount_krw));
    Json(ChargeRes { tx_id })
}

async fn persist_tx(user_id: i64, amount: i64) -> String {
    // ... DB I/O
    format!("tx_{user_id}_{amount}")
}

위 코드는 부하 테스트가 시작되자마자 워커가 자기 자신을 기다리는 형태로 멈춥니다. Tokio 공식 가이드도 동기/비동기 경계를 넘는 작업은 별도 런타임이나 spawn_blocking을 통해 처리해야 한다고 안내합니다(공식 가이드: https://tokio.rs/tokio/topics/bridging). 해법은 단순합니다. async 함수에서는 await을 쓰고, 동기 코드에서 비동기 진입이 필요할 때만 별도 런타임의 block_on을 부르는 것입니다.

async fn charge(Json(req): Json<ChargeReq>) -> Json<ChargeRes> {
    let tx_id = persist_tx(req.user_id, req.amount_krw).await;
    Json(ChargeRes { tx_id })
}

이 한 줄짜리 변경이 부하 테스트의 60초 행 현상을 즉시 해결했습니다. Node.js의 await이 너무 자유로웠던 탓에 저희는 "어디서든 동기 호출로 바꿀 수 있다"는 잘못된 직관을 갖고 있었습니다. Tokio는 그 자유를 허용하지 않습니다.

2. 함정 2 — Blocking I/O를 그대로 호출, spawn_blocking을 잊다

두 번째 함정은 이미지 썸네일 처리와 PDF 영수증 생성에서 터졌습니다. 저희는 무거운 변환 함수를 단순히 tokio::spawn으로 감싸기만 하면 충분하다고 생각했습니다. 그러나 Tokio 워커 스레드는 본질적으로 협력적 스케줄링을 전제로 하고, await 포인트가 없는 동기 코드를 그대로 돌리면 다른 어떤 future도 진행되지 못합니다. 저희 서비스가 50 RPS 부하에서 갑자기 health check까지 지연된 이유가 정확히 이것이었습니다.

해법은 tokio::task::spawn_blocking입니다. 기본 worker pool과 분리된 blocking pool로 작업을 던지고, 결과를 future로 받아오는 방식입니다.

use axum::{extract::Multipart, http::StatusCode};
use tokio::task;

async fn make_thumbnail(mut mp: Multipart) -> Result<Vec<u8>, StatusCode> {
    let field = mp.next_field().await
        .map_err(|_| StatusCode::BAD_REQUEST)?
        .ok_or(StatusCode::BAD_REQUEST)?;
    let bytes = field.bytes().await
        .map_err(|_| StatusCode::BAD_REQUEST)?
        .to_vec();

    // CPU 바운드 작업은 blocking pool로 격리
    let thumb = task::spawn_blocking(move || -> Result<Vec<u8>, image::ImageError> {
        let img = image::load_from_memory(&bytes)?;
        let out = img.thumbnail(256, 256);
        let mut buf = Vec::with_capacity(64 * 1024);
        out.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Jpeg)?;
        Ok(buf)
    })
    .await
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(thumb)
}

운영적으로 중요한 디테일이 하나 있습니다. blocking pool의 기본 한계는 Tokio 1.x 공식 문서 기준 512입니다만, 컨테이너 환경의 메모리·CPU 한계를 고려해 저희는 Builder::new_multi_thread().max_blocking_threads(64)로 강제 제한했습니다. 무한정 늘어난 blocking 스레드가 OOM-killer에 살해당하던 사고를 두 번 겪은 뒤 내린 결정이었습니다. 한 가지 더 강조하자면, 파일 I/O는 무조건 비동기라고 믿어선 안 됩니다. std::fs::read를 그대로 부르면 워커 스레드가 막히니, tokio::fs를 쓰거나 명시적으로 spawn_blocking으로 격리해야 합니다.

3. 함정 3 — std::sync::Mutex를 await 너머로 들고 가다

세 번째 함정은 가장 자주 마주치는 컴파일러 에러로 시작합니다. 메모리 캐시를 단순히 HashMap + Mutex로 감싸 Axum의 State로 넘기면, 컴파일러가 "future cannot be sent between threads safely"라는 메시지를 던집니다. 신참 개발자가 가장 많이 헤매는 지점이고, 저희도 처음 두 PR에서 이 에러로 1시간 이상을 잃었습니다.

핵심은 std::sync::MutexGuard!Send라는 사실입니다. lock을 보유한 채 await을 만나면, 그 future는 다른 스레드로 옮겨질 수 없습니다. Tokio의 work-stealing 스케줄러 위에서는 future가 통째로 Send여야 하니, 컴파일이 거부되는 것입니다. 잘못된 코드는 다음과 같습니다.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use axum::extract::State;

#[derive(Clone, Default)]
struct Cache(Arc<Mutex<HashMap<String, String>>>);

async fn handle(State(state): State<Cache>, key: String) -> String {
    // 컴파일 에러: MutexGuard가 await을 가로질러 산다.
    let guard = state.0.lock().unwrap();
    let v = guard.get(&key).cloned().unwrap_or_default();
    fetch_remote(&key).await; // <- 이 await 너머로 guard가 살아 있다.
    v
}

async fn fetch_remote(_k: &str) {}

해법은 두 갈래입니다. 첫째, await을 가로지르지 않도록 lock을 짧게 잡고 즉시 떨어뜨리는 패턴입니다. 둘째, 정말로 await 동안 보유해야 한다면 tokio::sync::Mutex를 씁니다. 단, tokio::sync::Mutex는 비동기 대기를 지원하기 위해 내부적으로 wakers 큐를 관리하므로 표준 Mutex보다 무겁고, Tokio 공식 문서도 "데이터만 보호하는 경우라면 표준 라이브러리의 blocking mutex를 우선 고려하라"고 명시합니다. 즉 공유 자원이 정말 비동기 경계를 넘어야 할 때만 선택해야 합니다.

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use axum::extract::State;

#[derive(Clone, Default)]
struct Cache(Arc<Mutex<HashMap<String, String>>>);

async fn handle(State(state): State<Cache>, key: String) -> String {
    // 락을 작은 스코프 안에서만 잡는다.
    let cached = {
        let guard = state.0.lock().unwrap();
        guard.get(&key).cloned()
    };
    if let Some(v) = cached { return v; }

    let fresh = fetch_remote(&key).await;
    {
        let mut guard = state.0.lock().unwrap();
        guard.insert(key, fresh.clone());
    }
    fresh
}

async fn fetch_remote(k: &str) -> String { format!("v_for_{k}") }

저희 팀은 신규 코드 리뷰 체크리스트에 "lock guard가 await을 넘어가지 않는가"를 1번 항목으로 박았습니다. Rust에서 가장 흔한 컴파일 에러 메시지인 cannot be sent between threads safely는 십중팔구 이 패턴 때문에 발생합니다(공식 해설: https://docs.rs/tokio/latest/tokio/sync/struct.Mutex.html).

4. 함정 4 — tokio::spawn join handle을 버려서 panic이 사라지다

Rust의 panic은 기본적으로 task 단위로 격리됩니다. tokio::spawn으로 띄운 작업이 panic해도 메인 서비스는 로그 한 줄도 남기지 않고 그냥 계속 돕니다. Node.js의 unhandledRejection은 어쨌든 stderr에 흐릿하게라도 흔적을 남기지만, Tokio는 침묵합니다. 저희는 결제 후처리 작업(영수증 발송, 정산 큐잉)이 panic하던 것을 2주간 감지하지 못했고, 결과적으로 영수증 누락 8,200건을 사후에 메우는 사고를 냈습니다.

구조적 해법은 JoinHandle을 절대 버리지 않는 것입니다. 스폰한 작업은 JoinSet으로 묶거나, 최소한 결과를 await하는 supervisor task를 둡니다. 그리고 panic을 감지하도록 JoinError::is_panic()을 명시적으로 체크합니다. JoinSet::join_nextOption<Result<T, JoinError>>를 돌려주므로, 두 단계 매칭이 자연스럽게 들어옵니다.

use tokio::task::JoinSet;
use tracing::{error, info};

pub async fn run_after_charge(tx_ids: Vec<String>) {
    let mut set: JoinSet<Result<(), anyhow::Error>> = JoinSet::new();
    for id in tx_ids {
        set.spawn(async move {
            send_receipt(&id).await?;
            push_settlement_queue(&id).await?;
            Ok(())
        });
    }
    while let Some(joined) = set.join_next().await {
        match joined {
            Ok(Ok(())) => {}
            Ok(Err(e)) => error!(error = ?e, "post-charge step failed"),
            Err(je) if je.is_panic() => {
                error!(panic = ?je, "task panicked");
                // 운영 환경에서는 알람 채널로 직접 발사한다.
            }
            Err(je) => error!(cancel = ?je, "task cancelled"),
        }
    }
    info!("post-charge batch done");
}

async fn send_receipt(_: &str) -> anyhow::Result<()> { Ok(()) }
async fn push_settlement_queue(_: &str) -> anyhow::Result<()> { Ok(()) }

추가로 저희는 글로벌 panic hook을 설정해 process 단위 fatal panic도 즉시 Sentry로 흘려보냅니다. std::panic::set_hook은 Tokio 런타임 시작 전에 한 번만 호출하면 됩니다. 이 작은 변경 이후 silent failure가 0건으로 떨어졌습니다.

5. 함정 5 — Unbounded 채널이 메모리를 먹어치우다

다섯 번째 함정은 백프레셔(backpressure)에 대한 저희의 방심에서 나왔습니다. 결제 이벤트를 외부 BI 시스템으로 보내는 파이프라인을 처음 짤 때 저희는 단순한 tokio::sync::mpsc::unbounded_channel을 사용했습니다. 평소 부하에서는 문제가 없었지만, BI 측 외부 큐가 30분간 다운되었던 어느 새벽, 송신 측 메모리가 13GB까지 치솟다가 OOM-killer에 의해 process가 강제 종료됐습니다. 이벤트는 사라졌고, 저희는 결제 로그를 RDB에서 다시 추출해 메우느라 6시간을 쏟았습니다.

bounded 채널은 단지 "느려질 뿐"이 아니라 시스템의 한계를 외부로 통보하는 명시적 신호입니다. send().await이 뒤로 밀리면 그것은 곧 상류(upstream)에 "지금은 받을 수 없다"고 말해주는 셈이며, HTTP 핸들러가 그 신호를 503이나 429로 변환할 수 있게 됩니다.

use tokio::sync::mpsc;
use tokio::time::{timeout, Duration};
use tracing::warn;

#[derive(Clone)]
pub struct EventBus {
    tx: mpsc::Sender<Event>,
}

#[derive(Debug)]
pub struct Event { pub kind: String, pub payload: serde_json::Value }

impl EventBus {
    pub fn new(buf: usize) -> (Self, mpsc::Receiver<Event>) {
        let (tx, rx) = mpsc::channel(buf);
        (Self { tx }, rx)
    }

    pub async fn try_emit(&self, ev: Event) -> Result<(), &'static str> {
        match timeout(Duration::from_millis(50), self.tx.send(ev)).await {
            Ok(Ok(())) => Ok(()),
            Ok(Err(_)) => Err("event_bus_closed"),
            Err(_) => {
                warn!("event_bus backpressure timeout");
                Err("event_bus_busy")
            }
        }
    }
}

표는 unbounded → bounded(8K) → bounded + 50ms timeout으로 순차 전환하며 측정한 결과입니다.

구성p99 latency메모리 피크손실 정책
unbounded_channel38 ms (정상시)13.4 GB (장애시)손실 없음 → 프로세스 종료
channel(8192)41 ms240 MBsend().await 영구 대기
channel(8192) + 50ms timeout44 ms240 MB50ms 초과 시 503 응답

세 번째 구성이 저희의 표준이 됐습니다. SLA에 "결제 이벤트 BI 적재 5분 이내, 단 BI 장애 시 결제 자체는 차단하지 않는다"는 조항이 있었기에, 결제 본 흐름과 이벤트 송신은 명확히 격리해야 했습니다.

6. 함정 6 — select! arm에서 future를 부분 진행하고 drop, cancel safety 위반

여섯 번째 함정은 가장 미묘하고 가장 발견하기 어렵습니다. Tokio select! 매크로는 여러 future 중 가장 먼저 완료되는 것을 고르고, 나머지 future는 drop합니다. 문제는 drop된 future가 이미 일부 진행 상태를 들고 있을 수 있다는 점이며, 그 future가 cancel-safe하지 않다면 부분 상태가 누락되거나 외부 자원이 일관성 없는 상태로 남습니다.

저희가 겪은 사고는 Redis 기반 분산 락이었습니다. select!의 한 arm에서 락 해제 future를 await했는데, 다른 arm의 timeout이 먼저 발화하며 해제 future가 drop되었습니다. 결과적으로 락이 해제되지 않은 채 TTL이 만료될 때까지 결제 상관 키가 잠긴 상태로 남았고, 동일 사용자의 재시도가 모두 실패했습니다.

cancel safety 규칙은 단순합니다. 부분 진행이 외부 상태에 영향을 주는 future는 select! arm에 직접 두지 말 것입니다. 저희는 이런 future를 항상 별도 task로 spawn한 뒤, 그 JoinHandle만 select!에 넘기는 패턴으로 통일했습니다.

use tokio::time::{sleep, Duration};
use tokio::select;
use tokio::task::JoinHandle;

async fn release_lock(_key: &str) -> anyhow::Result<()> {
    // 외부 Redis DEL 호출. 부분 진행 시 일관성 깨짐.
    Ok(())
}

pub async fn safe_release_with_timeout(key: String) -> anyhow::Result<()> {
    // 1) 해제 작업을 별도 태스크로 분리하면, 그 태스크는 select!의 cancel과 무관하게 끝까지 돈다.
    let h: JoinHandle<anyhow::Result<()>> = tokio::spawn(async move {
        release_lock(&key).await
    });

    select! {
        res = h => res?,                     // 정상 완료
        _ = sleep(Duration::from_secs(2)) => {
            // 타임아웃이 떠도 위 spawn된 태스크는 백그라운드에서 끝까지 진행됨
            anyhow::bail!("release timeout, but task continues in background");
        }
    }
}

추가로 tokio::sync::mpsc::Receiver::recvtokio::io::AsyncReadExt::read처럼 cancel-safe로 명시된 future가 있는 반면, AsyncReadExt::read_exactAsyncWriteExt::write_all은 cancel-safe가 아닙니다. 함수 단위로 tokio::macro.select! 문서의 cancel safety 표를 확인하는 습관이 결국 가장 빠른 길입니다(참고: https://docs.rs/tokio/latest/tokio/macro.select.html).

7. 함정 7 — JoinSet/FuturesUnordered 에러 누락과 tracing으로 본 future poll 분포

마지막 함정은 함정 4와 닮았지만 결이 다른 사고입니다. 동시에 50개의 외부 호출을 띄우고 결과를 모으려고 JoinSet이나 futures::stream::FuturesUnordered를 쓰는 패턴은 흔합니다. 그런데 한 작업의 에러를 그냥 let _ = res;로 무시하면, 일부 외부 호출이 영구히 실패하면서도 메트릭에는 잡히지 않습니다. 저희는 이 사각지대를 tracing으로 메웠습니다. 모든 spawn 지점에 span을 두고, future poll 횟수와 wall-clock duration을 함께 기록한 뒤 1주일치 데이터를 집계했더니 다음과 같은 분포가 잡혔습니다.

future 카테고리평균 poll 횟수p50 wallp99 wall비고
DB select (sqlx)3.11.4 ms22 ms풀 한도 80
Redis GET2.00.6 ms4 mscluster 6 노드
외부 KG 결제 호출7.484 ms380 mstimeout 2s
S3 PUT5.921 ms190 msmultipart 비활성
내부 settlement RPC4.29 ms71 msgRPC over HTTP/2

이 표가 의외로 강력했던 이유는, poll 횟수가 많고 p99 wall이 큰 카테고리가 곧 "취소되거나 묻힐 가능성이 가장 큰 작업"이라는 사실을 알려줬기 때문입니다. 외부 KG 결제 호출이 7.4회 poll된다는 사실은, 그 future가 자주 pending 상태로 떨어졌다가 다시 깨어나며 그 사이 외부 네트워크 지연을 견딘다는 뜻입니다. 즉 이 호출은 cancel safety에 가장 민감하고, 가장 먼저 명시적 retry/circuit-breaker로 감싸야 할 후보였습니다.

코드는 다음과 같이 정리됩니다. 매 spawn 지점마다 span을 깔고 결과를 집계하며, 누락 없는 에러 처리를 강제합니다.

use futures::{stream::FuturesUnordered, StreamExt};
use tracing::{instrument, info_span, Instrument};

#[derive(Clone)]
pub struct ChargeReq { pub user_id: i64, pub amount_krw: i64 }

#[instrument(skip(reqs))]
pub async fn fanout_charge(reqs: Vec<ChargeReq>) -> Vec<Result<String, String>> {
    let mut fu = FuturesUnordered::new();
    for r in reqs {
        let span = info_span!("kg_call", user = r.user_id, amt = r.amount_krw);
        fu.push(async move {
            call_kg(&r).await.map_err(|e| e.to_string())
        }.instrument(span));
    }
    let mut out = Vec::new();
    while let Some(res) = fu.next().await {
        out.push(res);
    }
    out
}

async fn call_kg(_req: &ChargeReq) -> anyhow::Result<String> {
    Ok("ok".into())
}

벤치마크는 oha로 잡았습니다. 동일 하드웨어(8 vCPU, 16GB RAM, Ubuntu 22.04)에서 Express 4 + pg와 Axum 0.8 + sqlx를 비교한 결과는 다음과 같습니다.

# Node.js Express
oha -c 256 -z 60s http://localhost:3000/charge

# Rust Axum + Tokio
oha -c 256 -z 60s http://localhost:8080/charge
지표Node.js ExpressRust Axum + Tokio배수
RPS12,40058,3004.70x
p50 latency14 ms3 ms4.66x
p99 latency318 ms51 ms6.23x 감소
RSS (peak)740 MB138 MB5.36x 감소
CPU 사용률(8 vCPU 기준)760% (full)410%효율 우위

p99 차이가 RPS 차이보다 큰 이유는 단일 이벤트 루프의 대기열 길이 효과입니다. Node.js는 RPS가 한계에 가까울수록 큐가 길어지며 latency가 hockey stick 곡선을 그리는 반면, Tokio는 work-stealing 덕분에 같은 부하에서도 스케줄러 내부 분포가 평탄합니다. 다만 Rust 쪽이 무조건 우월하다고 단정해서는 안 됩니다. 개발 속도, 라이브러리 생태계의 성숙도, 채용 시장에서의 인력 수급은 여전히 Node.js가 유리합니다. 저희는 트래픽 상위 5% 핵심 서비스만 Rust로 옮기고, BFF나 어드민은 Node 그대로 둡니다.

8. 결론: 운영 체크리스트 5개

Tokio + Axum 기반 백엔드를 안정화하려는 동료에게 저희는 다음 5가지를 코드 리뷰 첫 줄에 박으라고 권합니다.

  1. block_on 금지령: async 함수 안에서는 절대 block_on을 부르지 않는다. CI에 clippy::disallowed_methods로 강제한다.
  2. CPU 바운드는 무조건 spawn_blocking: 이미지·암호화·압축·정규식 빌드 등은 격리. blocking pool 상한은 컨테이너 메모리에 맞춰 명시적으로 설정한다.
  3. lock guard가 await을 넘지 않게 하라: 표준 Mutex는 짧게 잡고 즉시 떨어뜨린다. 비동기 동안 보유가 필요하면 tokio::sync::Mutex로 전환하되 비용을 인지한다.
  4. 모든 task는 supervisor가 있다: tokio::spawn의 JoinHandle은 절대 버리지 않는다. JoinSet + is_panic() 체크로 silent failure를 0으로 만든다.
  5. bounded 채널 + cancel safety: 모든 mpsc는 bounded. select! arm에는 cancel-safe future만 둔다. 외부 부수효과를 가진 future는 별도 task로 spawn한 후 JoinHandle만 select에 노출한다.

이 다섯 줄을 지키는 것만으로도 저희 팀은 새로 합류한 동료가 첫 PR에서 만들 수 있는 동시성 사고의 90%를 차단했습니다. 나머지 10%는 cancel safety의 미묘한 케이스인데, 결국 도메인별 외부 자원의 멱등성을 함께 설계해야만 풀리는 문제입니다. Tokio가 저희에게 준 가장 큰 선물은 RPS도 latency도 아니었습니다. 타입 시스템 안에 동시성 결함을 컴파일 타임에 잡아주는 안전망, 그것이 저희가 Node.js를 떠나 Rust에 정착한 진짜 이유입니다.