- Published on
Rust E0502 소유권 오류, NLL로 고치기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Rust를 쓰다 보면 어느 순간 빌드가 멈추고, 다음과 같은 메시지를 만납니다.
- 같은 값에 대해 불변 참조를 잡아둔 상태에서 가변 참조를 만들었다
- 혹은 가변 참조를 잡아둔 상태에서 불변 참조를 만들었다
그 대표가 E0502입니다. 처음엔 “내가 뭘 그렇게 잘못했지?” 싶지만, 실제로는 코드가 틀렸다기보다 차용(borrow) 범위를 컴파일러가 어떻게 추론하느냐를 이해하면 해결이 쉬워집니다.
이 글은 E0502를 NLL(Non-Lexical Lifetimes) 관점에서 읽는 법과, NLL이 있어도 여전히 터지는 케이스를 리팩터링 패턴으로 고치는 방법을 다룹니다.
참고로, 성능/안정성 디버깅을 체계적으로 접근하는 관점은 다른 주제에서도 비슷합니다. 예를 들어 브라우저 성능에서 Long Task를 잘게 쪼개는 접근은 문제의 “범위”를 줄이는 점에서 유사합니다: Chrome INP 급락? Long Task 추적·분해 실전
E0502가 의미하는 것: “동시에”가 아니라 “겹치는 범위”
E0502는 보통 아래 상황에서 발생합니다.
- 어떤 값
x를&x로 불변 차용해 둔 상태에서 - 같은
x를&mut x로 가변 차용하려고 한다
핵심은 “동시에”라는 말이 실제 실행 시점의 동시가 아니라, 컴파일러가 추론한 차용의 유효 범위가 서로 겹친다는 뜻이라는 점입니다.
Rust의 규칙을 한 줄로 요약하면 다음과 같습니다.
- 불변 참조는 여러 개 가능
- 가변 참조는 단 하나만 가능
- 그리고 가변 참조가 살아 있는 동안에는 불변 참조도 불가
NLL이 뭘 바꿨나
과거(lexical lifetimes)에는 참조의 수명이 “스코프 끝까지”로 길게 잡히는 경우가 많았습니다. NLL은 이를 개선해 참조가 실제로 마지막으로 사용되는 지점까지로 수명을 줄여 잡을 수 있게 해줍니다.
즉, NLL은 많은 E0502를 “자동으로” 해결해줍니다. 하지만 NLL이 만능은 아닙니다.
- 참조가 정말로 이후에도 사용된다면 수명은 줄어들지 않습니다.
- 특히 클로저 캡처, 반복문, match 분기, 여러 참조가 얽힌 구조에서는 여전히 겹침이 생깁니다.
가장 흔한 E0502 패턴 1: 불변으로 읽고, 같은 컨테이너를 가변으로 수정
예를 들어 벡터의 첫 값을 읽고, 그 값을 기반으로 벡터를 수정하려는 코드입니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{}", first);
}
이 코드는 전형적으로 E0502를 냅니다. 이유는 간단합니다.
first는v내부 요소를 가리키는 불변 참조입니다.v.push(4)는 벡터의 재할당(reallocation)을 일으킬 수 있습니다.- 재할당이 발생하면
first가 가리키던 메모리가 무효가 될 수 있으므로, Rust는 이를 금지합니다.
NLL로 해결되는가
println!이 push 뒤에 있으므로, first는 push 시점에도 살아 있어야 합니다. NLL이 수명을 줄일 여지가 없습니다. 따라서 NLL만으로는 해결되지 않습니다.
해결 1: 값 복사 또는 클론으로 참조를 끊기
원하는 게 “첫 값” 자체라면 참조가 아니라 값을 가져오면 됩니다.
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32는 Copy
v.push(4);
println!("{}", first);
}
Copy가 아닌 타입이면 clone이나 to_owned로 소유 값을 확보합니다.
fn main() {
let mut v = vec![String::from("a"), String::from("b")];
let first = v[0].clone();
v.push(String::from("c"));
println!("{}", first);
}
이 패턴은 “차용 범위 줄이기”의 가장 직관적인 해법입니다.
해결 2: 수정 시점을 뒤로 미루기
참조를 쓰는 구간을 먼저 끝내고, 그 다음에 수정합니다.
fn main() {
let mut v = vec![1, 2, 3];
{
let first = &v[0];
println!("{}", first);
} // 여기서 first의 차용이 끝남
v.push(4);
}
NLL이 이 블록을 “굳이” 요구하진 않지만, 사람이 읽기에도 차용 범위를 명확하게 만들어 줍니다.
가장 흔한 E0502 패턴 2: HashMap에서 get하고 insert하기
HashMap에서 값을 조회한 뒤, 같은 맵을 수정하려 하면 자주 터집니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
let v = m.get("a");
m.insert(String::from("b"), 2);
println!("{:?}", v);
}
m.get("a")는 &m을 차용합니다. v를 나중에 출력하므로 불변 차용이 길게 유지되고, 그 사이에 insert가 &mut m을 요구하면서 충돌합니다.
해결 1: 필요한 값만 추출해서 참조를 끊기
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
m.insert(String::from("a"), 1);
let v = m.get("a").copied();
m.insert(String::from("b"), 2);
println!("{:?}", v);
}
여기서 핵심은 Option<&i32>를 Option<i32>로 바꿔 차용을 종료시키는 것입니다.
해결 2: Entry API로 읽기와 쓰기를 한 번에
조회 후 갱신 같은 로직은 entry가 정답인 경우가 많습니다.
use std::collections::HashMap;
fn main() {
let mut m: HashMap<String, i32> = HashMap::new();
let key = String::from("a");
*m.entry(key).or_insert(0) += 1;
}
entry는 내부적으로 “이 키에 대한 접근”을 하나의 가변 borrow로 모델링하므로, 불변/가변 참조를 따로 들고 다닐 필요가 없습니다.
NLL로 “갑자기” 해결되는 케이스: 마지막 사용 지점이 앞당겨질 때
다음 코드를 보겠습니다.
fn main() {
let mut s = String::from("hello");
let r = &s;
println!("{}", r);
// r은 여기서 더 이상 쓰이지 않음
s.push_str(" world");
}
이 코드는 NLL 덕분에 대체로 컴파일됩니다.
r은println!에서 마지막으로 사용- 그 이후
s.push_str에서 가변 차용을 요구하지만 - NLL은
r의 수명을 “스코프 끝”이 아니라 “마지막 사용 지점”까지로 줄여 겹침이 없다고 판단
만약 println!을 뒤로 옮기면 다시 E0502가 납니다.
fn main() {
let mut s = String::from("hello");
let r = &s;
s.push_str(" world");
println!("{}", r);
}
즉, NLL은 “자동 해결”을 제공하지만, 참조를 나중에 쓰면 그만큼 수명이 늘어나고 충돌이 재발합니다.
실전 리팩터링 패턴: E0502를 구조적으로 없애는 방법
여기부터는 단순히 “clone 하세요”를 넘어서, 코드 구조를 바꿔 차용 충돌을 근본적으로 줄이는 패턴들입니다.
패턴 1: 읽기 단계와 쓰기 단계를 분리
특히 루프에서 많이 씁니다.
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
// 1) 읽기 단계: 필요한 정보만 소유 값으로 수집
let odds: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 1).collect();
// 2) 쓰기 단계: 그 다음에 변경
v.push(odds.len() as i32);
println!("{:?}", v);
}
iter()로 읽는 동안에는 &v가 필요하고, push는 &mut v가 필요합니다. 두 단계를 분리하면 차용이 겹치지 않습니다.
이 방식은 성능 측면에서도 예측 가능성이 좋습니다. 병렬 처리에서 잘못된 공유로 성능이 무너지는 패턴을 피하는 것과 비슷한 맥락입니다: Java Stream 병렬 처리 성능 망치는 5가지 패턴
패턴 2: 인덱스 기반 접근으로 “요소 참조” 수명을 최소화
벡터의 특정 요소를 읽고 난 뒤 벡터를 수정해야 한다면, 요소의 참조를 오래 들고 있지 말고 인덱스를 들고 있으세요.
fn main() {
let mut v = vec![10, 20, 30];
let idx = 0;
let first_value = v[idx];
v.push(40);
println!("{}", first_value);
}
요지는 “컨테이너 내부를 가리키는 참조”를 오래 유지하지 않는 것입니다.
패턴 3: split_at_mut로 가변 참조를 안전하게 쪼개기
서로 다른 두 요소를 동시에 수정하려다 E0502 또는 E0499를 만나는 경우가 많습니다.
fn main() {
let mut v = vec![1, 2, 3, 4];
// v[0]과 v[2]를 동시에 바꾸고 싶다
let (left, right) = v.split_at_mut(2);
left[0] += 10;
right[0] += 100; // 원래 v[2]
println!("{:?}", v);
}
split_at_mut는 “서로 겹치지 않는 두 슬라이스”임을 타입 수준에서 보장하므로, 컴파일러가 안심하고 두 개의 &mut를 허용합니다.
패턴 4: 클로저 캡처를 피하고, 필요한 값만 인자로 넘기기
클로저가 외부 변수를 캡처하면, 차용 수명이 예상보다 길어져 E0502로 이어질 수 있습니다. 특히 반복문에서 자주 발생합니다.
fn main() {
let mut s = String::from("hello");
let mut append = |suffix: &str| {
// s를 가변 캡처
s.push_str(suffix);
};
append(" world");
// 여기서 s를 다시 불변으로 쓰거나, 다른 차용을 만들면 충돌이 생길 수 있음
println!("{}", s);
}
이럴 때는 “캡처” 대신 “인자”로 넘기는 구조로 바꾸는 것이 깔끔합니다.
fn append_to(s: &mut String, suffix: &str) {
s.push_str(suffix);
}
fn main() {
let mut s = String::from("hello");
append_to(&mut s, " world");
println!("{}", s);
}
함수 경계로 차용 범위가 명확해져, 컴파일러와 사람 모두에게 읽기 쉬운 코드가 됩니다.
“NLL로 고치기”를 실무적으로 해석하는 법
NLL은 기능 스위치가 아니라, 이미 Rust의 기본 동작(현대 에디션)입니다. 그래서 실무에서 “NLL로 고치기”는 보통 아래 의미로 쓰는 게 정확합니다.
- 참조를 오래 들고 있는 코드를 찾는다
- 참조의 마지막 사용 지점을 앞당긴다
- 필요하면 소유 값으로 복사하거나, API를
entry같은 형태로 바꾼다 - 컨테이너 내부 참조를 들고 있는 동안 컨테이너 구조를 바꾸지 않는다
이 과정을 거치면 E0502는 대개 사라집니다.
디버깅 체크리스트
E0502가 뜨면 아래 순서대로 확인하면 빠릅니다.
- 에러 메시지에서 “immutable borrow occurs here”와 “mutable borrow occurs here” 위치를 정확히 본다
- 불변 참조(또는 가변 참조)가 마지막으로 사용되는 지점이 어디인지 찾는다
- 그 마지막 사용을 앞당길 수 있는지 본다
- 출력/로깅을 먼저 하거나
- 임시 블록으로 스코프를 닫거나
- 참조 대신 소유 값이 필요한지 검토한다
copied()/cloned()/to_owned()
- 조회 후 갱신이라면
entry로 바꿀 수 있는지 본다 - 서로 다른 부분을 동시에 수정해야 한다면
split_at_mut같은 안전한 분할 API를 찾는다
마무리
E0502는 Rust가 “까다롭다”는 증거가 아니라, 런타임에서 터질 수 있는 use-after-free나 데이터 레이스 가능성을 컴파일 타임에 제거하고 있다는 신호입니다. NLL 덕분에 많은 코드가 자연스럽게 컴파일되지만, 여전히 핵심은 같습니다.
- 참조는 가능한 짧게
- 컨테이너 내부 참조를 잡은 상태에서 구조를 바꾸지 말기
- 읽기와 쓰기를 분리하거나, 원자적으로 처리하는 API(
entry)를 쓰기
이 원칙만 잡히면 E0502는 “이유를 알 수 없는 벽”이 아니라, 코드 구조를 개선하는 가이드로 바뀝니다.