콘텐츠로 이동

Rust 에러 E0373: closure may outlive the current function (클로저와 대여)

E0373은 클로저가 지역 변수를 빌렸는데(대여), 그 변수를 소유한 함수가 끝난 뒤에도 클로저가 계속 실행되거나 계속 남아 있을 수 있다는 뜻이에요. 처음으로 thread::spawn을 호출하거나 함수에서 클로저를 반환할 때 자주 만나요. 좋은 소식은 컴파일러가 이미 해결책을 알려 준다는 거예요. 거의 언제나 move라는 단어 하나면 돼요. 이 페이지는 해결책을 먼저 보여 주고, 그다음에 이유를 설명해요. 이 시리즈의 다른 에러는 Rust 컴파일러 에러에서 볼 수 있어요.

error[E0373]: closure may outlive the current function, but it borrows `scores`, which is owned by the current function
--> src/main.rs:5:32
|
5 | let handle = thread::spawn(|| {
| ^^ may outlive borrowed value `scores`
6 | println!("Scores: {scores:?}"); // ❌ scores를 빌리기만 해요
| ------ `scores` is borrowed here
|
note: function requires argument type to outlive `'static`
--> src/main.rs:5:18
|
5 | let handle = thread::spawn(|| {
| __________________^
6 | | println!("Scores: {scores:?}"); // ❌ scores를 빌리기만 해요
7 | | });
| |______^
help: to force the closure to take ownership of `scores` (and any other referenced variables), use the `move` keyword
|
5 | let handle = thread::spawn(move || {
| ++++

“클로저가 현재 함수보다 오래 살 수 있는데, 현재 함수가 소유한 scores를 빌리고 있어요”라는 뜻이에요. 마지막 help:는 move 키워드로 클로저가 scores의 소유권을 가져가게 하라고 알려 줘요.

이 에러가 나는 프로그램은 이거예요.

use std::thread;
fn main() {
let scores = vec![90, 85, 77];
let handle = thread::spawn(|| {
println!("Scores: {scores:?}"); // ❌ scores를 빌리기만 해요
});
handle.join().unwrap();
}

async 코드에서는 같은 상황에 문장만 조금 다르게 나와요. “async block may outlive the current function, but it borrows user_id”(async 블록이 현재 함수보다 오래 살 수 있는데 user_id를 빌리고 있다)예요. 같은 에러이고, 해결책도 같아요(async move).

라벨을 위에서부터 차례로 읽어 보세요.

  1. ^^ may outlive borrowed value(빌린 값보다 오래 살 수 있다)는 클로저 ||를 가리켜요. main이 다음으로 넘어가기 전에 클로저가 끝난다고 Rust가 약속할 수 없다는 뜻이에요.
  2. `scores` is borrowed here(여기서 scores를 빌린다)는 클로저가 scores를 쓰는 곳을 가리켜요. println!은 읽기만 하면 되니까, 클로저는 벡터 자체가 아니라 scores의 참조자를 캡처했어요.
  3. function requires argument type to outlive 'static(함수가 인자 타입이 'static보다 오래 살기를 요구한다)은 그게 왜 문제인지 설명해요. thread::spawn은 프로그램이 필요로 하는 만큼 오래 살 수 있는 클로저만 받아요. 'static은 “사라질 수 있는 것을 하나도 빌리지 않는다”는 뜻이에요.

다른 도시로 이사 가기 직전에 친구에게 공책을 빌려주면서 “다 쓰면 돌려줘”라고 하는 상황을 떠올려 보세요. 친구가 천천히 쓰면, 공책이 돌아올 집이 없어져요. Rust는 이런 약속을 거절하고, 대신 공책을 아예 주라고 해요.

클로저는 변수를 캡처할 때, 통하는 방법 중 가장 가벼운 방법을 골라요. 변수를 읽기만 하면 &로 빌리죠. .map()이나 .filter()에 넘기는 클로저처럼 바로 실행되는 클로저라면 이게 아주 좋아요. 자세한 건 클로저 페이지에서 볼 수 있어요.

새 스레드는 달라요. 스레드는 얼마나 오래 실행될지 알 수 없고, join을 호출하지 않으면 main은 기다려 주지 않아요. 스레드가 scores의 참조자를 들고 있는데 main이 먼저 끝나면, scores는 해제되고 스레드는 이미 해제된 메모리를 읽게 돼요. 이게 “댕글링 참조”로, C와 C++ 스레드 코드에서 가장 흔한 버그 중 하나예요. Rust는 이걸 컴파일할 때 막아 줘요.

클로저를 반환할 때도 같은 일이 일어나요. 함수의 지역 변수(와 매개변수)는 함수가 끝날 때 해제돼요. 그것들을 아직 빌리고 있는 클로저는 아무것도 없는 곳을 가리키게 되겠죠.

방법 1: move 추가하기 (보통의 정답)

섹션 제목: “방법 1: move 추가하기 (보통의 정답)”

거의 언제나 이 방법을 고르세요. rustc가 제안하는 것도 바로 이거예요. move는 클로저가 사용하는 변수의 소유권(ownership)을 가져가게 해서, 변수가 클로저와 함께 이동하게 만들어요.

use std::thread;
fn main() {
let scores = vec![90, 85, 77];
let handle = thread::spawn(move || {
let total: i32 = scores.iter().sum();
println!("점수: {scores:?}, 합계 {total}");
});
handle.join().unwrap();
}

실행 결과:

점수: [90, 85, 77], 합계 252
▶ 직접 실행해 보기

클로저를 반환하는 함수에도 같은 해결책이 통해요. move가 없으면 |x| x + n은 매개변수 n을 빌리는데, make_adder가 반환되면 n은 사라져요. move를 붙이면 클로저마다 n의 복사본을 따로 가져요.

fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
fn main() {
let add_five = make_adder(5);
let add_ten = make_adder(10);
println!("{}", add_five(1));
println!("{}", add_ten(1));
}

실행 결과:

6
11
▶ 직접 실행해 보기

move를 붙여도 클로저가 어떤 클로저 트레이트가 되는지는 바뀌지 않아요. 데이터를 읽기만 하는 move 클로저는 여전히 Fn이고, 여러 번 호출할 수 있어요.

방법 2: 값이 아직 필요하다면 먼저 clone하기

섹션 제목: “방법 2: 값이 아직 필요하다면 먼저 clone하기”

move를 쓰면 그 변수는 클로저의 것이 돼요. 그 뒤에 main에서 그 변수를 쓰면 E0382(“borrow of moved value”)가 나고, rustc는 이동하기 전에 값을 복제(clone)하라고 제안해요. 클로저에는 복사본을 주고 원본은 남겨 두세요.

use std::thread;
fn main() {
let guests = vec![String::from("Ana"), String::from("Ben")];
let guests_for_thread = guests.clone();
let handle = thread::spawn(move || {
println!("스레드가 본 손님: {}명", guests_for_thread.len());
});
handle.join().unwrap();
println!("main에 아직 남아 있는 값: {guests:?}");
}

실행 결과:

스레드가 본 손님: 2명
main에 아직 남아 있는 값: ["Ana", "Ben"]
▶ 직접 실행해 보기

작은 목록을 복제하는 건 저렴하고 전혀 문제없어요. 데이터가 크거나 여러 스레드가 필요로 한다면, 모두가 한 벌을 공유하도록 Arc를 쓰세요(방법 3).

방법 3: 여러 스레드에서 Arc로 공유하기

섹션 제목: “방법 3: 여러 스레드에서 Arc로 공유하기”

여러 스레드가 같은 데이터를 읽어야 할 때 이 방법을 고르세요. Arc(“atomically reference counted”, 원자적 참조 카운팅)는 여러 소유자가 값 하나를 공유하게 해 줘요. Arc::clone은 포인터만 복사하고 카운터를 하나 올려요. 데이터 자체는 복사되지 않아요.

use std::sync::Arc;
use std::thread;
fn main() {
let prices = Arc::new(vec![4.5, 2.0, 3.5]);
let mut handles = Vec::new();
for discount in [0.0, 0.1, 0.5] {
let prices = Arc::clone(&prices); // 스레드마다 핸들 하나
handles.push(thread::spawn(move || {
let total: f64 = prices.iter().sum();
total * (1.0 - discount)
}));
}
for handle in handles {
println!("합계: {:.2}", handle.join().unwrap());
}
println!("가격: {prices:?}");
}

실행 결과:

합계: 10.00
합계: 9.00
합계: 5.00
가격: [4.5, 2.0, 3.5]
▶ 직접 실행해 보기

Arc만으로는 읽기 전용으로만 공유할 수 있어요. 스레드가 데이터를 바꿔야 한다면 Arc<Mutex<T>>로 감싸세요. 동시성과 공유 상태를 참고하세요.

방법 4: std::thread::scope로 안전하게 빌리기

섹션 제목: “방법 4: std::thread::scope로 안전하게 빌리기”

스레드가 짧은 작업에만 데이터를 쓰고, 이동하거나 복제하기보다 빌리고 싶을 때 이 방법을 고르세요. thread::scope 안에서 시작한 스레드는 scope가 반환되기 전에 반드시 끝나요. 그래서 지역 변수를 빌려도 안전하고, move도 필요 없어요.

use std::thread;
fn main() {
let scores = vec![90, 85, 77, 64];
let (first, second) = scores.split_at(2);
let (a, b) = thread::scope(|s| {
let h1 = s.spawn(|| first.iter().sum::<i32>());
let h2 = s.spawn(|| second.iter().sum::<i32>());
(h1.join().unwrap(), h2.join().unwrap())
}); // 여기서는 두 스레드 모두 끝났어요
println!("{a} + {b} = {}", a + b);
println!("여전히 우리 것: {scores:?}");
}

실행 결과:

175 + 141 = 316
여전히 우리 것: [90, 85, 77, 64]
▶ 직접 실행해 보기

“일을 나눠서 하고, 그 결과를 쓴다”는 상황에는 범위 스레드(scoped thread)가 가장 깔끔한 답이에요. 공유 상태 강좌도 범위 스레드로 시작해요.

반복문에서 스레드를 만들 때. 클로저가 읽기만 한다면 평범한 숫자도 빌려요.

for worker in 1..=3 {
handles.push(thread::spawn(|| {
println!("Worker {worker} started"); // ❌ `worker`를 빌려요
}));
}

해결: thread::spawn(move || ...). worker는 i32라서, move는 각 스레드에 값을 복사해 넣을 뿐이에요.

박스에 담은 클로저를 반환할 때. Box<dyn Fn() -> String>을 반환하는 함수 안에서 Box::new(|| greeting.clone())이라고 쓰면 지역 변수 greeting을 빌리게 돼요. rustc는 note: closure is returned here(클로저가 여기서 반환된다)를 덧붙여요. 해결: Box::new(move || greeting.clone()). 자세한 건 Fn, FnMut, FnOnce에 있어요.

비동기 태스크(Tokio 등). tokio::spawn은 thread::spawn처럼 동작해요. 태스크는 현재 함수가 반환된 뒤에도 한참 실행될 수 있으니까 'static 데이터가 필요해요. tokio::spawn(async { ... })이라고 쓰고 안에서 지역 변수를 쓰면 “async block may outlive the current function”이 나요. 해결: tokio::spawn(async move { ... }). 태스크 밖에서도 필요한 값은 미리 clone하거나 Arc::clone하세요. async/await를 참고하세요.

이해도 점검

1. 스레드의 클로저가 move 없이 name을 출력해요. 왜 E0373이 날까요?

2. move를 붙였는데, 스레드를 만든 뒤에도 main에서 벡터를 출력해야 해요. 가장 간단한 해결책은?

3. 스레드가 move 없이 지역 변수를 빌릴 수 있게 해 주는 도구는?

점수: 0 / 3