Published on

Rust async에서 Result·Option 모나드 체이닝 패턴

Authors

비동기 Rust를 쓰다 보면 가장 자주 마주치는 형태가 async fn ... -> Result<T, E> 입니다. 네트워크 호출, DB 쿼리, 파일 IO처럼 실패 가능성이 큰 작업을 await로 이어 붙이다 보면, 중간중간 match/if let이 늘어나 가독성이 급격히 떨어집니다.

이때 ResultOption을 “모나드처럼” 체이닝한다는 표현을 자주 쓰는데, 핵심은 간단합니다.

  • map/and_then으로 성공 경로를 연결하고
  • ?실패 경로를 자동 전파하며
  • ok_or/transpose/map_err 같은 변환기로 경계(Option↔Result, 에러 타입, async 경계) 를 정리합니다.

이 글에서는 async 환경에서 특히 자주 쓰이는 체이닝 패턴을 “컴파일되는 코드” 중심으로 정리합니다.

1) async에서 체이닝이 어려워지는 지점

동기 코드에서는 Result::and_then에 클로저를 넘기면 끝입니다. 하지만 async에서는 클로저가 async가 되면서 반환 타입이 Future가 되어, 그대로는 and_then에 넣기 어렵습니다.

즉, 아래처럼 쓰고 싶지만(의도), Rust 표준 and_thenResult<T, E> -> Result<U, E>를 기대하기 때문에 바로는 안 됩니다.

// 의도: result.and_then(|v| async move { ... }.await)

그래서 async에서는 보통 다음 중 하나로 해결합니다.

  • ?와 지역 변수로 “단계”를 나누기
  • Option/Result의 변환을 먼저 끝내고 마지막에 await하기
  • 필요하면 futures 크레이트의 확장 트레이트(TryFutureExt 등) 사용하기

2) 기본형: ?로 에러 전파 + map으로 값 변환

가장 추천되는 기본 패턴은 “await 후 즉시 ?로 풀고, 다음 단계는 일반 함수처럼” 쓰는 것입니다.

use anyhow::{Context, Result};

async fn fetch_user_name(user_id: i64) -> Result<String> {
    let resp = reqwest::get(format!("https://api.example.com/users/{user_id}"))
        .await
        .context("request failed")?;

    let user: serde_json::Value = resp.json().await.context("invalid json")?;

    let name = user
        .get("name")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .context("missing field: name")?;

    Ok(name)
}

포인트는 await가 끝난 시점부터는 Result가 “평범한 값”이 되므로, Option 체이닝(get/and_then/map)을 마음껏 쓸 수 있다는 점입니다.

여기서 contextanyhow::Context로, 에러 체이닝을 더 읽기 좋게 만듭니다.

3) OptionResult로 승격: ok_or/ok_or_else

async에서 가장 흔한 조합이 “중간에 Option이 끼어드는 경우”입니다. 예를 들어 캐시 조회는 Option을 반환하고, 없으면 DB를 조회하고 싶을 때가 많습니다.

OptionResult로 바꾸는 표준 도구는 ok_or/ok_or_else입니다.

use anyhow::{anyhow, Result};

async fn must_get_token_from_env() -> Result<String> {
    let token = std::env::var("API_TOKEN").ok();

    let token = token.ok_or_else(|| anyhow!("API_TOKEN is missing"))?;
    Ok(token)
}

ok_or_else는 에러 생성이 비싼 경우(문자열 포맷, 로그 컨텍스트 구성 등) 지연 평가가 되므로 더 자주 권장됩니다.

4) Result<Option<T>, E> 다루기: transpose로 뒤집기

비동기 파이프라인에서 자주 나오는 타입이 Result<Option<T>, E>입니다.

  • IO/네트워크는 실패할 수 있으니 Result
  • 값은 없을 수 있으니 Option

예: “사용자 조회는 실패할 수도 있고, 없을 수도 있다”

이때 호출자 입장에서는 Option<Result<T, E>> 또는 Result<T, E>로 바꾸고 싶을 때가 많습니다. transposeOption<Result<T, E>>Result<Option<T>, E>를 상호 변환합니다.

use anyhow::Result;

fn parse_optional_i64(s: Option<&str>) -> Result<Option<i64>> {
    // Option<Result<i64, _>>
    let parsed = s.map(|v| v.parse::<i64>().map_err(|e| anyhow::anyhow!(e)));

    // Result<Option<i64>, _>
    parsed.transpose()
}

async에서도 동일하게 적용됩니다. awaitResult<Option<T>, E>를 얻은 다음, 필요하면 transpose로 형태를 정리하고 다음 로직을 단순화할 수 있습니다.

5) async 경계에서의 “체이닝”: 단계 분해 + 작은 함수로 and_then 효과 내기

async에서 and_then(async ...)이 안 되는 문제는, “단계를 함수로 빼서” 해결하는 경우가 많습니다.

use anyhow::Result;

struct User {
    id: i64,
    email: String,
}

async fn fetch_user(id: i64) -> Result<User> {
    // ...
    Ok(User { id, email: "a@b.com".to_string() })
}

fn validate_email(user: User) -> Result<User> {
    if user.email.contains('@') {
        Ok(user)
    } else {
        Err(anyhow::anyhow!("invalid email"))
    }
}

async fn load_and_validate(id: i64) -> Result<User> {
    let user = fetch_user(id).await?;     // async 단계
    let user = validate_email(user)?;     // sync 단계
    Ok(user)
}

이 방식은 “모나드 체이닝의 정신”을 유지하면서도 Rust의 async 타입 제약을 피하는 가장 현실적인 패턴입니다.

6) map_err로 에러 타입 정리하기 (레이어링)

여러 라이브러리를 섞으면 에러 타입이 뒤섞입니다. async에서는 특히 reqwest::Error, serde_json::Error, DB 에러 등이 섞이기 쉽습니다.

thiserror로 도메인 에러를 만들고, 각 단계에서 map_err로 변환하면 호출자 API가 깔끔해집니다.

use thiserror::Error;

#[derive(Debug, Error)]
enum ServiceError {
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),

    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),

    #[error("not found")]
    NotFound,
}

type SResult<T> = Result<T, ServiceError>;

async fn fetch_json(url: &str) -> SResult<serde_json::Value> {
    let resp = reqwest::get(url).await?;     // From 변환
    let v = resp.json().await?;              // From 변환
    Ok(v)
}

async fn fetch_required_field(url: &str) -> SResult<String> {
    let v = fetch_json(url).await?;

    let s = v.get("name")
        .and_then(|x| x.as_str())
        .map(|x| x.to_string())
        .ok_or(ServiceError::NotFound)?;

    Ok(s)
}

여기서는 From 기반 자동 변환을 썼지만, 더 복잡한 상황에서는 map_err(|e| ServiceError::X(e)) 형태로 명시 변환을 섞는 게 좋습니다.

7) Option 폴백 체이닝: or_else와 async의 현실적인 타협

Option::or_else는 동기 클로저만 받습니다. 그래서 “캐시 없으면 async로 DB 조회” 같은 코드는 그대로는 안 됩니다.

실전에서는 다음처럼 “먼저 캐시를 확인하고, 없을 때만 await” 패턴이 가장 명확합니다.

use anyhow::Result;

async fn get_from_cache(_key: &str) -> Option<String> {
    None
}

async fn get_from_db(_key: &str) -> Result<String> {
    Ok("value".to_string())
}

async fn get_value(key: &str) -> Result<String> {
    if let Some(v) = get_from_cache(key).await {
        return Ok(v);
    }

    let v = get_from_db(key).await?;
    Ok(v)
}

“체이닝이 끊긴 것처럼 보인다”는 단점이 있지만, async에서는 이 형태가 타입·수명·가독성 측면에서 가장 유지보수성이 높습니다.

8) futuresTryFutureExt로 진짜 체이닝하기

정말로 체이닝 스타일을 유지하고 싶다면 futures 크레이트의 확장 트레이트가 도움이 됩니다. 예를 들어 TryFutureExt::and_thenFuture<Output = Result<T, E>>에 대해 다음 async 단계를 붙일 수 있습니다.

use anyhow::Result;
use futures::TryFutureExt;

async fn step1() -> Result<i32> {
    Ok(10)
}

async fn step2(v: i32) -> Result<i32> {
    Ok(v * 2)
}

async fn chained() -> Result<i32> {
    step1()
        .and_then(step2) // async fn을 그대로 연결
        .await
}

이 패턴은 파이프라인이 길어질수록 “선형으로 읽히는” 장점이 있습니다. 다만 팀 컨벤션에 따라 futures 의존성을 선호하지 않을 수도 있고, 디버깅 시 스택 트레이스가 덜 직관적일 수 있습니다.

9) try_join/joinResult 체이닝의 결합

async에서는 “체이닝”만큼 중요한 게 “병렬화”입니다. 서로 독립인 작업은 tokio::try_join!으로 동시에 실행하고, 결과를 모나드적으로 후처리하는 방식이 성능과 가독성을 같이 잡습니다.

use anyhow::Result;

async fn fetch_a() -> Result<i32> { Ok(1) }
async fn fetch_b() -> Result<i32> { Ok(2) }

async fn fetch_sum() -> Result<i32> {
    let (a, b) = tokio::try_join!(fetch_a(), fetch_b())?;
    Ok(a + b)
}

여기서 try_join! 자체가 “첫 에러를 즉시 반환”하는 의미에서 Result 모나드의 조합을 병렬로 확장한 형태라고 볼 수 있습니다.

Tokio 런타임 경계에서 자원 해제가 꼬이면 runtime dropped 류의 패닉을 만나기도 하는데, 런타임 생명주기와 스코프를 점검하는 것도 중요합니다. 관련해서는 Rust Tokio - runtime dropped 패닉 원인과 해결 글이 참고가 됩니다.

10) Iterator 체이닝 감각을 async에도 가져오기

Option/Result 체이닝은 사실 Iterator 체이닝과 읽는 방식이 비슷합니다. “중간 변환은 map”, “필터링은 and_then/filter”, “마지막에 수집/반환” 같은 감각입니다.

동기 컬렉션 처리에서 체이닝 감각을 익혀두면 async 파이프라인도 훨씬 자연스럽게 정리됩니다. for 루프를 줄이고 체이닝으로 표현력을 높이는 쪽은 Rust Iterator로 for 루프 제거하고 성능 잡기 도 같이 보면 연결이 잘 됩니다.

11) 실전 패턴: Option 입력을 받아 async로 처리하고 Result로 반환

예를 들어 “입력 파라미터가 없으면 기본값을 쓰고, 있으면 검증 후 원격 조회” 같은 케이스를 보겠습니다.

use anyhow::{anyhow, Result};

fn validate_id(raw: &str) -> Result<i64> {
    let id = raw.parse::<i64>().map_err(|_| anyhow!("id must be i64"))?;
    if id <= 0 {
        return Err(anyhow!("id must be positive"));
    }
    Ok(id)
}

async fn fetch_name_by_id(id: i64) -> Result<String> {
    // 실제로는 HTTP/DB
    Ok(format!("user-{id}"))
}

async fn resolve_name(maybe_id: Option<String>) -> Result<String> {
    let id = match maybe_id {
        Some(s) => validate_id(&s)?,
        None => 1,
    };

    let name = fetch_name_by_id(id).await?;
    Ok(name)
}

여기서도 핵심은 “async 단계와 sync 단계를 분리”하고, sync 단계에서는 Result/Option 체이닝을 적극 활용하는 것입니다.

12) 가독성 체크리스트 (언제 체이닝을 멈출까)

체이닝은 강력하지만, async에서는 특히 다음 기준으로 “끊어서 쓰는 것”이 더 좋을 때가 많습니다.

  • await가 2번 이상 중첩되며 흐름이 꼬일 때
  • OptionResult가 2겹 이상으로 중첩되어 타입이 읽기 어려울 때
  • 에러에 컨텍스트를 달아야 해서 단계별 메시지가 중요한 때
  • 로깅/메트릭/트레이싱을 단계별로 심어야 할 때

이럴 때는 과감히 지역 변수로 풀고, 함수로 쪼개고, 각 단계에 contextmap_err를 붙이는 게 유지보수에 유리합니다.

13) 정리

  • async Rust에서 Result/Option 모나드 체이닝의 기본은 await?로 풀고, sync 구간에서 map/and_then/ok_or_else로 정리하는 것입니다.
  • Result<Option<T>, E>transpose로 형태를 뒤집어 호출자 관점의 API를 단순화할 수 있습니다.
  • 진짜 체이닝 스타일이 필요하면 futures::TryFutureExt 계열을 고려할 수 있지만, 팀 컨벤션과 디버깅 난이도를 함께 봐야 합니다.
  • 병렬 조합은 tokio::try_join!이 사실상 “Result 조합의 async 확장”이라, 체이닝만 고집하기보다 병렬화와 함께 설계하는 게 좋습니다.

다음 단계로는 “도메인 에러 설계(thiserror) + 컨텍스트(anyhow) + 트레이싱(tracing)”을 함께 묶어, 실패 경로가 많은 async 서비스 코드를 읽기 좋게 만드는 방향을 추천합니다.