Published on

Rust E0502 소유권 오류, NLL로 고치기

Authors

Rust를 쓰다 보면 어느 순간 빌드가 멈추고, 다음과 같은 메시지를 만납니다.

  • 같은 값에 대해 불변 참조를 잡아둔 상태에서 가변 참조를 만들었다
  • 혹은 가변 참조를 잡아둔 상태에서 불변 참조를 만들었다

그 대표가 E0502입니다. 처음엔 “내가 뭘 그렇게 잘못했지?” 싶지만, 실제로는 코드가 틀렸다기보다 차용(borrow) 범위를 컴파일러가 어떻게 추론하느냐를 이해하면 해결이 쉬워집니다.

이 글은 E0502NLL(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를 냅니다. 이유는 간단합니다.

  • firstv 내부 요소를 가리키는 불변 참조입니다.
  • v.push(4)는 벡터의 재할당(reallocation)을 일으킬 수 있습니다.
  • 재할당이 발생하면 first가 가리키던 메모리가 무효가 될 수 있으므로, Rust는 이를 금지합니다.

NLL로 해결되는가

println!push 뒤에 있으므로, firstpush 시점에도 살아 있어야 합니다. 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 덕분에 대체로 컴파일됩니다.

  • rprintln!에서 마지막으로 사용
  • 그 이후 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로 고치기”는 보통 아래 의미로 쓰는 게 정확합니다.

  1. 참조를 오래 들고 있는 코드를 찾는다
  2. 참조의 마지막 사용 지점을 앞당긴다
  3. 필요하면 소유 값으로 복사하거나, API를 entry 같은 형태로 바꾼다
  4. 컨테이너 내부 참조를 들고 있는 동안 컨테이너 구조를 바꾸지 않는다

이 과정을 거치면 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는 “이유를 알 수 없는 벽”이 아니라, 코드 구조를 개선하는 가이드로 바뀝니다.