- Published on
Rust async/await에서 borrow across await 해결법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust의 async/await는 “비동기 문법 설탕”이지만, 컴파일러 관점에서는 Future 상태 머신으로 변환됩니다. 이 변환 과정에서 await 지점은 함수 실행이 잠시 중단(suspend)되었다가 재개(resume)되는 경계가 되고, 그 경계를 “가로질러” 빌린 참조(&T, &mut T)가 살아있으면 대표적으로 borrow across await 류의 에러가 발생합니다.
이 글에서는 왜 이런 일이 생기는지, 그리고 실무에서 가장 많이 쓰는 해결책(스코프 축소, 소유권 이동, Arc/Mutex, split/take, spawn 경계 설계 등)을 패턴으로 정리합니다.
비동기에서 리소스가 해제되지 않아 누수처럼 보이는 문제는 Rust에서도 구조적으로 발생할 수 있는데, 개념적으로는 Go의 컨텍스트/채널 종료 패턴과 비슷한 “경계 설계”가 중요합니다. 필요하면 Go goroutine 누수 잡기 - context·채널 종료 패턴도 같이 보면 비동기 수명 관리 감각을 잡는 데 도움이 됩니다.
borrow across await가 생기는 구조적 이유
async fn은 대략 아래처럼 변환된다고 이해하면 좋습니다.
- 로컬 변수와 임시 값이
Future의 필드로 캡처됨 await는 상태 머신의 “중간 반환 지점”await를 넘어서 살아남는 참조는Future내부에 저장되어야 함
문제는 참조를 Future 내부에 저장하려면 그 참조가 가리키는 대상이 Future보다 오래 살아야 한다는 점입니다. 특히 다음 상황에서 충돌이 잦습니다.
&mut self또는&mut something을 잡은 채로.await- 컬렉션에서 어떤 원소를
&mut로 빌린 채로.await RefCell/MutexGuard같은 “가드 타입”을 잡은 채로.await- 임시로 만든 값의 참조를
.await이후에 사용
컴파일러 에러 메시지는 다양하지만, 핵심은 보통 이 문장으로 요약됩니다.
- “
await사이에 mutable borrow가 유지된다” - “borrowed value does not live long enough”
- “future cannot be sent between threads safely” (가드를 들고
spawn한 경우)
재현: 가장 흔한 실패 예제
아래는 &mut self의 일부를 빌린 채로 네트워크 호출을 await하면서 터지는 전형적인 형태입니다.
use std::collections::HashMap;
struct Client;
impl Client {
async fn fetch(&self, key: &str) -> String {
format!("value:{key}")
}
}
struct Service {
client: Client,
cache: HashMap<String, String>,
}
impl Service {
async fn get_or_fetch(&mut self, key: String) -> String {
// 1) cache 엔트리를 가변 참조로 빌림
if let Some(v) = self.cache.get_mut(&key) {
// 2) 여기서 await를 해버리면, v(&mut String)가 await 경계를 넘어 살아야 함
let fetched = self.client.fetch(&key).await;
*v = fetched;
return v.clone();
}
let fetched = self.client.fetch(&key).await;
self.cache.insert(key, fetched.clone());
fetched
}
}
왜 위험할까요?
self.cache.get_mut는self.cache에 대한&mutborrow를 만들고- 그 borrow가
v로 유지되는 동안 .await로 함수가 중단되면, 중단된 상태에서도self.cache의 가변 borrow가 “열려있는” 셈이 됩니다.
비동기 런타임 입장에서는 그 사이에 다른 작업이 실행될 수 있고(동일 태스크 내에서도 재진입 지점이 생김), Rust는 이런 상황을 원천적으로 막습니다.
해결 패턴 1: await 전에 borrow 스코프를 끝내기
가장 권장되는 1순위는 await 전에 참조가 drop되도록 스코프를 재구성하는 것입니다.
핵심은 “빌림을 만들기 전에 await를 끝내거나, await 전에 필요한 값을 복사/클론해 두고 빌림을 해제”입니다.
use std::collections::HashMap;
struct Client;
impl Client {
async fn fetch(&self, key: &str) -> String {
format!("value:{key}")
}
}
struct Service {
client: Client,
cache: HashMap<String, String>,
}
impl Service {
async fn get_or_fetch(&mut self, key: String) -> String {
// 먼저 캐시에 있는지 '불변'으로 확인하고 값은 소유권으로 가져옴
if let Some(v) = self.cache.get(&key).cloned() {
return v;
}
// 여기서는 self.cache를 빌리지 않으니 안전하게 await 가능
let fetched = self.client.fetch(&key).await;
// await 이후에 캐시를 갱신
self.cache.insert(key, fetched.clone());
fetched
}
}
이 방식은 단순하지만, 캐시 hit 시 clone 비용이 생깁니다. 데이터가 크면 Arc를 캐시 값으로 쓰거나(아래 패턴 4), 갱신 로직을 분리하는 식으로 최적화합니다.
해결 패턴 2: Option::take, mem::take로 소유권을 뽑아오기
구조체 필드 일부를 잠깐 “꺼내서” 작업하고 다시 넣는 패턴입니다. 특히 Vec/HashMap 같은 큰 구조를 통째로 빼서 조작하고 싶은데 await가 끼는 경우 유용합니다.
예: pending 큐를 비우고(소유권 이동), 비동기 처리 후 다시 합치기.
use std::mem;
struct Service {
pending: Vec<String>,
}
impl Service {
async fn flush(&mut self) {
// pending을 비워두고, 기존 벡터는 소유권으로 꺼냄
let items = mem::take(&mut self.pending);
// await가 있어도 self.pending은 더 이상 빌린 상태가 아님
self.send_all(items).await;
}
async fn send_all(&self, items: Vec<String>) {
// 네트워크 전송 등
let _ = items;
}
}
Option<T>라면 take()가 더 직관적입니다.
struct Service {
current: Option<String>,
}
impl Service {
async fn process(&mut self) {
if let Some(v) = self.current.take() {
self.do_work(v).await;
}
}
async fn do_work(&self, _v: String) {}
}
이 패턴의 장점은 clone 없이 “빌림을 소유권 이동으로 바꿔” await 경계를 깨끗하게 만든다는 점입니다.
해결 패턴 3: 컬렉션 원소 &mut 대신 인덱스/키로 2단계 처리
컬렉션에서 원소를 &mut로 잡고 await를 하면 거의 항상 문제가 됩니다. 해결은 보통 아래처럼 키/인덱스만 확보하고, await 이후에 다시 접근하는 2단계로 바꿉니다.
use std::collections::HashMap;
struct Client;
impl Client {
async fn fetch(&self, key: &str) -> String {
format!("value:{key}")
}
}
struct Service {
client: Client,
cache: HashMap<String, String>,
}
impl Service {
async fn refresh(&mut self, key: String) {
// await 전에 key만 준비
let fetched = self.client.fetch(&key).await;
// await 이후에 컬렉션을 가변으로 접근
self.cache.insert(key, fetched);
}
}
“원소를 빌린 채 기다리지 말고, 기다린 다음 원소를 수정하라”가 원칙입니다.
해결 패턴 4: 공유 상태는 Arc + Mutex/RwLock로, 락은 짧게
여러 태스크에서 공유해야 하는 상태라면 Arc로 소유권을 공유하고, 내부 변경은 Mutex/RwLock로 보호합니다. 여기서도 핵심은 락 가드를 잡은 채로 await하지 않는 것입니다.
use std::{collections::HashMap, sync::Arc};
use tokio::sync::Mutex;
struct Client;
impl Client {
async fn fetch(&self, key: &str) -> String {
format!("value:{key}")
}
}
struct Service {
client: Client,
cache: Arc<Mutex<HashMap<String, String>>>,
}
impl Service {
async fn get_or_fetch(&self, key: String) -> String {
// 1) 락을 짧게 잡고 조회만 한 뒤 즉시 drop
if let Some(v) = {
let guard = self.cache.lock().await;
guard.get(&key).cloned()
} {
return v;
}
// 2) 락 없이 네트워크 await
let fetched = self.client.fetch(&key).await;
// 3) 다시 락을 짧게 잡고 저장
{
let mut guard = self.cache.lock().await;
guard.insert(key, fetched.clone());
}
fetched
}
}
주의할 점:
std::sync::Mutex를 async 코드에서 쓰면 스레드를 블로킹할 수 있어tokio::sync::Mutex같은 async 뮤텍스를 고려합니다.- 그래도 “락을 잡고
await하지 않는다” 원칙은 동일합니다. 락을 잡은 채await하면 교착/지연 폭발이 쉽게 생깁니다.
해결 패턴 5: spawn 경계에서는 Send와 'static을 만족시키기
tokio::spawn은 보통 Future + Send + 'static을 요구합니다. 즉, 스택 참조를 캡처한 future는 스폰할 수 없습니다. 이때도 borrow across await와 비슷한 맥락의 에러를 보게 됩니다.
해결은 “스폰되는 태스크가 필요한 값을 소유하도록 만들기”입니다.
use tokio::task;
use std::sync::Arc;
struct Worker;
impl Worker {
async fn run(self: Arc<Self>, job: String) {
let _ = job;
}
}
async fn submit(worker: Arc<Worker>, job: String) {
// 참조를 넘기지 말고, Arc와 String처럼 소유 가능한 값을 move
task::spawn({
let worker = Arc::clone(&worker);
async move {
worker.run(job).await;
}
});
}
여기서 async move가 핵심입니다. 캡처를 참조가 아니라 소유권 이동으로 바꿉니다.
해결 패턴 6: RefCell/가드 타입을 await 전에 drop시키기
RefCell::borrow_mut()의 RefMut, Mutex::lock()의 MutexGuard 같은 “가드 타입”은 살아있는 동안 빌림/락이 유지됩니다. 따라서 아래는 위험합니다.
- 가드 생성
- 가드 사용
await- 가드 사용
해결은 가드의 스코프를 블록으로 강제 종료하는 것입니다.
use tokio::sync::Mutex;
use std::sync::Arc;
struct State {
n: usize,
}
async fn bump_and_wait(state: Arc<Mutex<State>>) {
{
let mut guard = state.lock().await;
guard.n += 1;
// guard는 이 블록 끝에서 drop
}
do_io().await;
}
async fn do_io() {}
이 패턴은 단순하지만 효과가 큽니다. “락/빌림/가드의 생존 범위를 눈으로 보이게 만드는” 것만으로도 많은 문제가 사라집니다.
언제 clone이 정답인가
Rust를 하다 보면 결국 clone을 하게 되는 순간이 옵니다. 비동기에서는 특히 다음 경우 clone이 가장 싸고 안전한 선택일 수 있습니다.
String, 작은Vec, 작은 DTO 등 복사 비용이 충분히 낮음- 캐시 hit 경로가 매우 빈번하고, 락/구조 변경이 더 비쌈
- “정확성”이 우선이고, 이후에 프로파일링으로 최적화할 계획
다만 큰 데이터를 자주 복사해야 한다면, 값 타입을 Arc<T>로 바꾸고 Arc::clone으로 공유하는 방식이 일반적입니다.
실무 디버깅 체크리스트
borrow across await가 뜨면 아래 순서로 보면 빠르게 해결됩니다.
.await라인 위아래로 “어떤 참조/가드/락이 살아있나” 찾기- 그 참조가
await를 넘지 않도록 스코프를 쪼개기(블록, 함수 분리) - 참조 대신 소유권 이동으로 바꾸기(
clone,to_owned,Arc,take) - 컬렉션 원소
&mut을 잡는 구조라면 2단계(키 확보 후 갱신)로 재설계 spawn이면 캡처 값이Send + 'static인지 확인하고async move로 고치기
비동기 시스템에서는 “경계에서 무엇을 들고 넘어가느냐”가 성능과 안정성을 좌우합니다. Rust의 소유권 규칙은 이 경계를 강제해 주는 장치이고, 처음엔 불편하지만 한 번 패턴이 손에 익으면 오히려 설계가 단단해집니다.
상태를 공유하고 업데이트하는 로직이 커질수록, 렌더/리렌더 같은 연쇄 비용을 줄이기 위해 경계를 잘 쪼개야 한다는 점에서 프론트엔드의 전역 상태 관리 이슈와도 닮았습니다. 관심 있으면 Next.js App Router 전역상태 리렌더 폭발 막는 법처럼 “의존 범위를 최소화하는 설계” 글도 함께 참고해 보세요.
마무리: 핵심은 await 경계에서 빌림을 끊는 것
정리하면 borrow across await는 Rust가 까다로워서가 아니라, await가 “중단 가능한 경계”이기 때문에 생기는 필연적인 제약입니다. 해결의 방향은 거의 항상 아래 중 하나로 귀결됩니다.
await전에 빌림/가드를 끝낸다(스코프 축소)- 참조 대신 소유권을 넘긴다(
clone,Arc,take) - 컬렉션/상태 업데이트를
await전후로 분리한다
위 패턴들을 템플릿처럼 익혀두면, 비동기 Rust에서 가장 성가신 에러 중 하나를 훨씬 빠르게 처리할 수 있습니다.