본문으로 건너뛰기
엔지니어링Mar 28, 2026

Deep EVM #24: 비동기 Rust에서의 컨텍스트 전파 — 데드라인, 취소, 트레이싱

OS
Open Soft Team

Engineering Team

Go의 context.Context가 해결하는 문제

Go의 context.Context는 비동기 작업의 세 가지 핵심 문제를 해결합니다: 데드라인(이 작업은 언제까지 완료되어야 하는가?), 취소(이 작업을 중단해야 하는가?), 값 전파(이 요청과 관련된 메타데이터는 무엇인가?). Rust에는 context.Context에 대한 직접적인 대응물이 없지만, tokio의 원시 자료형으로 동일한 패턴을 구축할 수 있습니다.

데드라인: tokio::time::timeout

가장 간단한 형태의 컨텍스트는 데드라인입니다 — 작업이 지정된 시간 내에 완료되지 않으면 취소합니다:

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

async fn fetch_with_deadline(url: &str) -> Result<Response> {
    timeout(Duration::from_secs(5), reqwest::get(url))
        .await
        .map_err(|_| anyhow::anyhow!("Request timed out after 5s"))?
        .map_err(Into::into)
}

중첩된 데드라인도 올바르게 작동합니다 — 내부 타임아웃이 외부 타임아웃보다 짧으면 내부가 먼저 트리거됩니다:

async fn process_request() -> Result<()> {
    // 전체 요청: 10초
    timeout(Duration::from_secs(10), async {
        // DB 조회: 2초
        let user = timeout(Duration::from_secs(2),
            db.get_user(id)
        ).await??;

        // 외부 API: 5초
        let data = timeout(Duration::from_secs(5),
            api.fetch_data(&user)
        ).await??;

        Ok(data)
    }).await?
}

취소: CancellationToken

tokio_util::sync::CancellationToken은 Go의 context.WithCancel()에 가장 가까운 대응물입니다:

use tokio_util::sync::CancellationToken;
use tokio::select;

async fn long_running_task(token: CancellationToken) -> Result<()> {
    loop {
        select! {
            _ = token.cancelled() => {
                tracing::info!("Task cancelled, cleaning up");
                return Ok(());
            }
            result = do_work() => {
                result?;
            }
        }
    }
}

// 사용
let token = CancellationToken::new();
let child_token = token.child_token();

tokio::spawn(long_running_task(child_token));

// 나중에 — 모든 자식 작업을 취소
token.cancel();

child_token()은 Go의 context.WithCancel(parent)와 동일합니다. 부모 토큰이 취소되면 모든 자식 토큰도 자동으로 취소됩니다.

토큰 트리와 계층적 취소

복잡한 시스템에서는 취소 토큰의 트리를 구성합니다:

struct RequestContext {
    token: CancellationToken,
    deadline: Instant,
    request_id: String,
    trace_id: String,
}

impl RequestContext {
    fn new(timeout: Duration) -> Self {
        Self {
            token: CancellationToken::new(),
            deadline: Instant::now() + timeout,
            request_id: Uuid::new_v4().to_string(),
            trace_id: Uuid::new_v4().to_string(),
        }
    }

    fn child(&self) -> Self {
        Self {
            token: self.token.child_token(),
            deadline: self.deadline,
            request_id: self.request_id.clone(),
            trace_id: self.trace_id.clone(),
        }
    }

    fn remaining(&self) -> Duration {
        self.deadline.saturating_duration_since(Instant::now())
    }

    async fn with_deadline<F, T>(&self, fut: F) -> Result<T>
    where
        F: Future<Output = Result<T>>,
    {
        select! {
            _ = self.token.cancelled() => {
                Err(anyhow::anyhow!("Cancelled"))
            }
            _ = tokio::time::sleep_until(self.deadline.into()) => {
                Err(anyhow::anyhow!("Deadline exceeded"))
            }
            result = fut => result
        }
    }
}

tokio::select!를 사용한 취소 패턴

tokio::select!는 여러 비동기 작업 중 먼저 완료되는 것을 선택하는 Rust의 핵심 취소 메커니즘입니다:

async fn resilient_fetch(
    ctx: &RequestContext,
    primary_url: &str,
    fallback_url: &str,
) -> Result<Response> {
    select! {
        _ = ctx.token.cancelled() => {
            Err(anyhow::anyhow!("Request cancelled"))
        }
        result = async {
            // 기본에 먼저 시도, 실패 시 폴백
            match timeout(Duration::from_secs(2), reqwest::get(primary_url)).await {
                Ok(Ok(resp)) => Ok(resp),
                _ => {
                    tracing::warn!("Primary failed, trying fallback");
                    reqwest::get(fallback_url).await.map_err(Into::into)
                }
            }
        } => result
    }
}

select!의 중요한 특성: 선택되지 않은 분기의 Future는 드롭됩니다. 이것이 Rust의 구조적 취소입니다 — Go와 달리 고루틴이 암묵적으로 누수되는 문제가 없습니다.

트레이싱 스팬 전파

tracing 크레이트의 스팬은 컨텍스트의 또 다른 측면을 전파합니다 — 구조화된 로깅 컨텍스트:

use tracing::{instrument, info_span, Instrument};

#[instrument(skip(ctx, db))]
async fn handle_request(
    ctx: RequestContext,
    db: Arc<Database>,
    request: Request,
) -> Result<Response> {
    let user = get_user(&ctx, &db, request.user_id)
        .instrument(info_span!("get_user", user_id = request.user_id))
        .await?;

    let orders = get_orders(&ctx, &db, &user)
        .instrument(info_span!("get_orders", user_name = %user.name))
        .await?;

    Ok(Response::new(user, orders))
}

.instrument(span) 메서드는 비동기 작업을 스팬과 연결하여, 해당 작업의 모든 로그가 올바른 컨텍스트를 포함하도록 합니다.

task_local!을 사용한 암시적 전파

명시적으로 컨텍스트를 모든 함수에 전달하는 것이 지루하다면, tokio::task_local!을 사용할 수 있습니다:

tokio::task_local! {
    static REQUEST_CONTEXT: RequestContext;
}

async fn handle_request(ctx: RequestContext) -> Result<Response> {
    REQUEST_CONTEXT.scope(ctx, async {
        // 이 스코프 내의 모든 코드가 컨텍스트에 접근 가능
        let user = get_user().await?;
        let orders = get_orders(&user).await?;
        Ok(Response::new(user, orders))
    }).await
}

async fn get_user() -> Result<User> {
    REQUEST_CONTEXT.with(|ctx| {
        tracing::info!(request_id = %ctx.request_id, "Fetching user");
    });
    // ...
}

이 패턴은 Go의 context.Value()와 유사하지만, 타입 안전합니다.

우아한 종료

컨텍스트 전파의 가장 중요한 응용 중 하나는 우아한 종료입니다:

#[tokio::main]
async fn main() -> Result<()> {
    let shutdown = CancellationToken::new();

    // Ctrl+C 핸들러
    let shutdown_clone = shutdown.clone();
    tokio::spawn(async move {
        tokio::signal::ctrl_c().await.unwrap();
        tracing::info!("Shutdown signal received");
        shutdown_clone.cancel();
    });

    // 모든 서비스에 취소 토큰 전달
    let server = tokio::spawn(
        run_server(shutdown.child_token())
    );
    let worker = tokio::spawn(
        run_background_worker(shutdown.child_token())
    );

    // 모든 서비스가 우아하게 종료될 때까지 대기
    let _ = tokio::join!(server, worker);
    tracing::info!("Clean shutdown complete");
    Ok(())
}

결론

Rust에서의 컨텍스트 전파는 Go의 context.Context와 동일한 문제를 해결하지만, 다른 원시 자료형으로 구성됩니다: tokio::time::timeout은 데드라인, CancellationToken은 취소, tracing 스팬은 구조화된 로깅 컨텍스트, task_local!은 암시적 값 전파. select! 매크로는 이 모든 것을 연결하는 접착제입니다. Rust의 소유권 시스템과 드롭 의미론은 Go에서 흔한 고루틴 누수 문제를 구조적으로 방지합니다.