Deep EVM #24: 비동기 Rust에서의 컨텍스트 전파 — 데드라인, 취소, 트레이싱
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에서 흔한 고루틴 누수 문제를 구조적으로 방지합니다.