- Published on
Rust async에서 Result·Option 모나드 체이닝 패턴
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
비동기 Rust를 쓰다 보면 가장 자주 마주치는 형태가 async fn ... -> Result<T, E> 입니다. 네트워크 호출, DB 쿼리, 파일 IO처럼 실패 가능성이 큰 작업을 await로 이어 붙이다 보면, 중간중간 match/if let이 늘어나 가독성이 급격히 떨어집니다.
이때 Result와 Option을 “모나드처럼” 체이닝한다는 표현을 자주 쓰는데, 핵심은 간단합니다.
map/and_then으로 성공 경로를 연결하고?로 실패 경로를 자동 전파하며ok_or/transpose/map_err같은 변환기로 경계(Option↔Result, 에러 타입, async 경계) 를 정리합니다.
이 글에서는 async 환경에서 특히 자주 쓰이는 체이닝 패턴을 “컴파일되는 코드” 중심으로 정리합니다.
1) async에서 체이닝이 어려워지는 지점
동기 코드에서는 Result::and_then에 클로저를 넘기면 끝입니다. 하지만 async에서는 클로저가 async가 되면서 반환 타입이 Future가 되어, 그대로는 and_then에 넣기 어렵습니다.
즉, 아래처럼 쓰고 싶지만(의도), Rust 표준 and_then은 Result<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)을 마음껏 쓸 수 있다는 점입니다.
여기서 context는 anyhow::Context로, 에러 체이닝을 더 읽기 좋게 만듭니다.
3) Option을 Result로 승격: ok_or/ok_or_else
async에서 가장 흔한 조합이 “중간에 Option이 끼어드는 경우”입니다. 예를 들어 캐시 조회는 Option을 반환하고, 없으면 DB를 조회하고 싶을 때가 많습니다.
Option을 Result로 바꾸는 표준 도구는 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>로 바꾸고 싶을 때가 많습니다. transpose는 Option<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에서도 동일하게 적용됩니다. await로 Result<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) futures의 TryFutureExt로 진짜 체이닝하기
정말로 체이닝 스타일을 유지하고 싶다면 futures 크레이트의 확장 트레이트가 도움이 됩니다. 예를 들어 TryFutureExt::and_then은 Future<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/join과 Result 체이닝의 결합
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번 이상 중첩되며 흐름이 꼬일 때Option과Result가 2겹 이상으로 중첩되어 타입이 읽기 어려울 때- 에러에 컨텍스트를 달아야 해서 단계별 메시지가 중요한 때
- 로깅/메트릭/트레이싱을 단계별로 심어야 할 때
이럴 때는 과감히 지역 변수로 풀고, 함수로 쪼개고, 각 단계에 context나 map_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 서비스 코드를 읽기 좋게 만드는 방향을 추천합니다.