Rust tokio::time::timeout: 비동기 작업에 시간 제한 걸기

Rust tokio::time::timeout: 비동기 작업에 시간 제한 걸기

비동기 코드를 짜다 보면 외부에 의존하는 작업을 마주하게 됩니다. 외부 API 호출, 데이터베이스 쿼리, 다른 서비스로의 gRPC 요청 같은 것들이죠. 이런 작업은 대부분 빠르게 끝나지만, 상대방이 느려지거나 응답을 아예 안 주면 우리 코드는 그 자리에서 하염없이 기다리게 됩니다.

이럴 때 필요한 게 시간 제한입니다. “이 작업은 3초 안에 끝나야 하고, 안 끝나면 포기한다”는 약속을 거는 거죠. Tokio는 이를 위해 tokio::time::timeout 함수를 제공합니다. 이번 글에서는 이 함수의 기본 사용법부터, 한 가지 짚고 넘어가야 할 동작(타임아웃이 작업을 강제로 죽이지 않는다는 점)까지 살펴보겠습니다.

tokio::time::timeout의 기본 사용법

timeout 함수는 시간 제한(Duration)과 Future 하나를 받아서, 그 future를 감싼 새로운 future를 돌려줍니다. 제한 시간 안에 작업이 끝나면 결과를 Ok로 감싸 주고, 시간을 초과하면 Err(Elapsed)를 돌려줍니다.

use tokio::time::{sleep, timeout, Duration};

#[tokio::main]
async fn main() {
    let result = timeout(Duration::from_secs(3), async {
        sleep(Duration::from_secs(1)).await;
        "작업 결과"
    })
    .await;

    match result {
        Ok(value) => println!("완료: {value}"),
        Err(_elapsed) => println!("3초 안에 끝나지 못했습니다"),
    }
}
결과
완료: 작업 결과

안쪽 작업이 1초 만에 끝나므로 3초 제한 안에 들어와 Ok("작업 결과")가 됩니다. 만약 안쪽 sleep을 5초로 바꾸면 제한을 넘겨서 Err로 빠지죠.

timeout을 쓰려면 Cargo.toml에서 tokio 크레이트의 time 기능을 켜둬야 합니다.

Cargo.toml
[dependencies]
tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }

반환 타입은 Result<T, Elapsed>인데요. 여기서 Elapsed는 “시간이 다 지났다”는 사실만 담은 단순한 에러 타입입니다. 안에 별다른 정보가 없어서 보통 Err(_)로 받아 타임아웃 분기 처리만 합니다.

이중 Result 다루기

실무에서 시간 제한을 거는 작업은 대부분 그 자체가 Result를 반환합니다. 데이터베이스 쿼리도, 네트워크 요청도 실패할 수 있으니까요. 이런 future를 timeout으로 감싸면 결과 타입이 Result<Result<T, E>, Elapsed>처럼 이중으로 중첩됩니다.

use tokio::time::{timeout, Duration};

async fn fetch_user(id: u64) -> Result<String, DbError> {
    // ... 데이터베이스 조회
}

let outcome = timeout(Duration::from_secs(2), fetch_user(42)).await;

match outcome {
    Ok(Ok(user)) => println!("유저: {user}"),      // 시간 내 성공
    Ok(Err(e)) => println!("조회 실패: {e}"),       // 시간 내 실패 (쿼리 에러)
    Err(_) => println!("2초 초과 (타임아웃)"),       // 타임아웃
}

세 갈래로 나뉘는 게 보이시죠. 바깥 Result는 타임아웃 여부, 안쪽 Result는 작업 자체의 성공/실패를 나타냅니다. 이 구분이 중요한 이유는 둘의 대응이 다르기 때문입니다. 쿼리 에러는 보통 그대로 위로 전파하지만, 타임아웃은 재시도하거나 폴백을 쓰는 식으로 다르게 처리하는 경우가 많거든요.

매번 이중 매칭을 쓰는 게 번거롭다면, 에러 타입을 하나로 합쳐 ? 연산자로 평탄화할 수 있습니다.

async fn fetch_with_timeout(id: u64) -> Result<String, MyError> {
    // Elapsed를 MyError로 바꾼 뒤, ??로 두 겹의 Result를 한 번에 푼다
    let user = timeout(Duration::from_secs(2), fetch_user(id))
        .await
        .map_err(|_| MyError::Timeout)??;
    Ok(user)
}

map_errElapsed를 우리 에러 타입으로 바꾼 뒤 ??로 두 겹을 한 번에 풀어내는데요. 안쪽 ?가 동작하려면 From<DbError> for MyError 변환이 정의되어 있어야 합니다. 에러 변환 패턴은 thiserror로 에러 정의하기와 함께 보면 더 깔끔하게 정리할 수 있습니다.

한 가지 짚고 갈 점: 타임아웃은 작업을 강제로 죽이지 않는다

여기서 오해하기 쉬운 부분이 있는데요. 타임아웃이 발생하면 안쪽 작업이 어떻게든 즉시 중단되고 알아서 정리될 거라 생각하기 쉽지만, 실제로는 그렇지 않습니다.

Tokio가 하는 일은 단순합니다. 제한 시간이 지나면 안쪽 future를 그냥 드롭(drop)해 버립니다. Rust의 Future는 런타임이 poll을 호출해줘야 진행되는 게으른 상태 기계인데, 드롭되면 더 이상 폴링되지 않으니 그 시점에서 멈추는 거죠.

use tokio::time::{sleep, timeout, Duration};

async fn job() {
    println!("1단계 시작");
    sleep(Duration::from_secs(5)).await; // 여기서 취소됨
    println!("2단계 — 출력되지 않음");
}

#[tokio::main]
async fn main() {
    let _ = timeout(Duration::from_secs(1), job()).await;
    println!("타임아웃 후 메인 계속");
}
결과
1단계 시작
타임아웃 메인 계속

job은 “1단계 시작”을 찍고 5초를 자려고 하지만, 1초 만에 타임아웃이 걸립니다. future가 sleepawait 지점에서 드롭되기 때문에 “2단계”는 영영 출력되지 않죠.

여기서 두 가지를 기억해두면 좋습니다. 우선 취소는 드롭으로 일어나기 때문에, 이미 실행된 부수 효과는 되돌아가지 않습니다. 타임아웃 직전에 데이터베이스에 레코드를 하나 썼다면, 그 쓰기는 그대로 남습니다. 드롭은 아직 실행하지 않은 나머지 작업을 멈출 뿐, 이미 벌어진 일을 롤백해주지는 않으니까요.

또한 취소는 await 지점에서만 일어날 수 있습니다. future는 제어권을 런타임에 넘길 때(즉 await에서 멈출 때)만 드롭될 수 있는데요. 그래서 안쪽 작업이 await 없이 동기적으로 오래 도는 코드라면 타임아웃이 끼어들 틈이 없습니다.

async fn busy() {
    // await가 없는 동기 루프 — 런타임에 제어권을 넘기지 않는다
    let mut sum = 0u64;
    for i in 0..50_000_000_000u64 {
        sum = sum.wrapping_add(i);
    }
    println!("{sum}");
}

// 10ms 제한을 걸어도 busy()가 끝날 때까지 멈출 수 없다
let _ = timeout(Duration::from_millis(10), busy()).await;

이 코드는 10ms를 줬지만 busy가 다 돌 때까지 멈추지 않습니다. 타임아웃 타이머조차 busy가 제어권을 넘겨줘야 확인될 수 있는데, 동기 루프는 끝까지 제어권을 쥐고 있거든요. CPU를 오래 쓰는 작업이나 동기 블로킹 호출에 시간 제한을 걸고 싶다면, tokio::fs에서 본 것처럼 spawn_blocking으로 별도 스레드에 떼어내야 합니다.

timeout_at: 절대 시각으로 마감 정하기

timeout이 “지금부터 N초”라는 상대적인 제한이라면, timeout_at은 “몇 시 몇 분까지”라는 절대 시각(Instant) 기준으로 마감을 겁니다.

use tokio::time::{timeout_at, Duration, Instant};

let deadline = Instant::now() + Duration::from_secs(1);

// 두 작업이 같은 마감 시각을 공유한다
let a = timeout_at(deadline, fetch_user(1)).await;
let b = timeout_at(deadline, fetch_user(2)).await;

이게 유용한 상황은 여러 단계를 하나의 전체 마감 안에서 처리할 때입니다. 위 코드에서 첫 번째 작업이 0.7초를 쓰면, 두 번째 작업에는 남은 0.3초만 허용됩니다. 각 단계마다 timeout으로 새 제한을 거는 것과 달리, 전체 작업에 하나의 데드라인을 씌우는 셈이죠.

select 매크로로도 타임아웃을 만들 수 있다

사실 시간 제한은 select 매크로로도 구현할 수 있습니다. 작업 future와 sleep future를 경쟁시켜서, sleep이 먼저 끝나면 타임아웃으로 보는 식이죠.

use tokio::time::{sleep, Duration};

tokio::select! {
    result = fetch_user(42) => {
        println!("작업 먼저 완료: {result:?}");
    }
    _ = sleep(Duration::from_secs(3)) => {
        println!("타임아웃! 3초 초과");
    }
}

그렇다면 둘 중 무엇을 써야 할까요? 단순히 “future 하나에 시간 제한”만 필요하다면 timeout이 간결합니다. 사실 timeout은 이 select 패턴을 깔끔하게 감싼 편의 함수에 가깝습니다.

반면 select!는 타임아웃 외에 다른 이벤트와도 경쟁시켜야 할 때 빛을 발합니다. 예를 들어 “작업 완료”, “타임아웃”, “종료 신호 수신”처럼 여러 갈래를 동시에 기다리거나, 타임아웃이 걸렸을 때 단순히 에러를 반환하는 대신 다른 분기를 실행하고 싶을 때죠. 분기가 하나뿐이면 timeout, 여러 갈래를 경쟁시켜야 하면 select!라고 기억해두면 됩니다.

Tower의 timeout과는 무엇이 다른가

Rust 비동기 생태계에는 시간 제한을 거는 또 다른 방법이 있습니다. 바로 Tower 미들웨어timeout인데요. 이름이 같아서 헷갈리기 쉽지만 쓰임새가 다릅니다.

tokio::time::timeout은 코드 중간에서 특정 future 하나를 그때그때 감싸는 함수입니다. 호출 지점마다 다른 시간을 줄 수 있고, 타임아웃됐을 때 그 자리에서 맞춤 분기를 할 수 있죠.

반면 Tower의 timeoutService를 감싸는 미들웨어입니다. 그 서비스를 통과하는 모든 요청에 동일한 제한이 자동으로 적용되죠.

use tower::ServiceBuilder;
use std::time::Duration;

let svc = ServiceBuilder::new()
    .timeout(Duration::from_secs(5)) // 들어오는 모든 요청에 일괄 적용
    .service(handler);

그래서 선택 기준은 명확합니다. “이 작업 하나”가 대상이면 tokio::time::timeout, “이 서비스로 오는 요청 전부”에 일관된 정책을 걸고 싶으면 Tower의 timeout을 씁니다. 그리고 사실 Tower의 미들웨어도 내부적으로는 tokio::time을 이용해 구현되어 있으니, 같은 엔진을 다른 추상화 수준에서 쓰는 셈입니다.

마치며

tokio::time::timeout은 무한정 매달릴 수 있는 비동기 작업에 안전장치를 달아주는 함수입니다. Duration과 future를 받아 Result<T, Elapsed>를 돌려주고, 작업 자체가 Result라면 이중 중첩을 풀어 처리하면 됩니다.

가장 중요한 건 타임아웃이 작업을 강제로 죽이는 게 아니라 future를 드롭해 취소한다는 점입니다. 그래서 await 지점이 없는 동기 코드는 멈출 수 없고, 이미 일어난 부수 효과는 되돌아가지 않습니다. 이 동작은 Future의 게으른 실행 모델을 이해하면 자연스럽게 받아들일 수 있습니다.

서버 전체에 일관된 시간 제한 정책이 필요하다면 함수 호출마다 감싸는 대신 Tower 미들웨어로 선언적으로 관리하는 편이 깔끔합니다.

더 자세한 내용은 tokio::time 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord