Published on

Rust E0502 해결 - 소유권·빌림 충돌 5패턴

Authors

서로 다른 언어에서라면 “읽고 나서 수정하면 되지”로 끝날 상황이 Rust에서는 E0502로 즉시 막힙니다. E0502는 한 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들 수 없다는 규칙을 위반했을 때 발생합니다.

핵심은 “동시에”라는 말이 실행 시점이 아니라 컴파일러가 추론한 참조의 생존 범위(lifetime) 기준이라는 점입니다. 특히 반복문, 클로저, 메서드 체이닝, 인덱싱 등이 섞이면 “이미 불변으로 빌렸는데 왜 또 빌림?” 같은 상황이 자주 생깁니다.

이 글은 실무에서 자주 터지는 E0502를 5개 패턴으로 분류하고, 각 패턴마다 가장 흔히 쓰는 해결책을 제시합니다. (자기참조 구조체나 Pin 이슈는 다른 주제이므로 생략합니다. 필요하면 Rust self-referential 구조체가 불가능한 이유와 Pin도 함께 보세요.)

E0502 한 줄 정의

  • 불변 빌림: let r = &x;x를 읽기 전용으로 빌립니다.
  • 가변 빌림: let m = &mut x;x를 수정 가능하게 빌립니다.
  • 규칙: 같은 스코프에서 불변 빌림이 살아있는 동안 &mut를 만들 수 없습니다(반대도 동일).

이 규칙은 데이터 레이스/댕글링 포인터를 컴파일 타임에 제거하기 위한 것으로, 해결은 보통 “참조 생존 범위를 줄이기”, “읽기 결과를 값으로 복사/소유”, “데이터 구조를 쪼개기”로 귀결됩니다.

패턴 1) 읽기 참조를 잡아둔 채로 같은 값 수정

가장 교과서적인 E0502입니다.

fn main() {
    let mut s = String::from("hello");

    let r = &s;           // 불변 빌림 시작
    s.push('!');          // 여기서 가변 빌림 필요
    println!("{r}");
}

해결 1: 불변 참조의 생존 범위를 줄이기(스코프 분리)

fn main() {
    let mut s = String::from("hello");

    {
        let r = &s;
        println!("{r}");
    } // r 드롭

    s.push('!');
    println!("{s}");
}

해결 2: 필요한 값만 복사/소유해서 들고 있기

String 전체가 아니라 길이/첫 글자 등 복사 가능한 값만 필요하면 참조를 잡지 마세요.

fn main() {
    let mut s = String::from("hello");

    let len = s.len(); // usize는 Copy
    s.push('!');

    println!("len={len}, s={s}");
}

String 자체를 이후에도 쓰고 싶다면 clone()이 해법이 될 수 있지만, 비용이 있으니 “정말 소유가 필요한가”를 먼저 따져보는 게 좋습니다.

패턴 2) 컬렉션에서 한 원소를 불변으로 보고, 같은 컬렉션을 수정

벡터/맵에서 특정 원소를 참조해 둔 상태로, 같은 컬렉션에 push/insert/remove를 하면 자주 터집니다. 이유는 간단합니다. 재할당(reallocation)으로 참조가 무효화될 수 있기 때문입니다.

fn main() {
    let mut v = vec![10, 20, 30];

    let first = &v[0]; // v 전체를 불변으로 빌린 것으로 취급
    v.push(40);        // v를 가변으로 빌려야 함

    println!("{first}");
}

해결 1: 인덱스(값)를 저장하고, 나중에 다시 접근

fn main() {
    let mut v = vec![10, 20, 30];

    let idx = 0usize;
    let first_val = v[idx]; // i32 Copy

    v.push(40);
    println!("{first_val}");
}

해결 2: 변경이 필요 없도록 로직 순서 바꾸기

불변 참조를 쓰는 작업을 먼저 끝내고, 그 다음 수정합니다.

fn main() {
    let mut v = vec![10, 20, 30];

    let first_val = v[0];
    println!("{first_val}");

    v.push(40);
}

해결 3: split_at_mut로 “서로 다른 영역”을 명시

서로 다른 인덱스 두 개를 동시에 수정/조회하려다 E0502가 날 때는 split_at_mut가 정석입니다.

fn bump_two(v: &mut [i32], i: usize, j: usize) {
    assert!(i != j);

    let (a, b) = if i < j {
        let (l, r) = v.split_at_mut(j);
        (&mut l[i], &mut r[0])
    } else {
        let (l, r) = v.split_at_mut(i);
        (&mut r[0], &mut l[j])
    };

    *a += 1;
    *b += 1;
}

이 패턴은 “컴파일러가 두 참조가 겹치지 않음을 증명할 수 있게” 구조를 바꿔주는 해법입니다.

패턴 3) 반복문에서 불변 순회 중, 같은 컬렉션을 수정

for x in v.iter()로 순회하는 동안 v.push() 같은 수정은 불가능합니다.

fn main() {
    let mut v = vec![1, 2, 3];

    for x in v.iter() {
        if *x == 2 {
            v.push(99); // E0502
        }
    }
}

해결 1: 2단계 처리(수집 후 반영)

“순회로 결정하고, 수정은 나중에”가 가장 안전합니다.

fn main() {
    let mut v = vec![1, 2, 3];

    let mut to_add = Vec::new();
    for x in v.iter() {
        if *x == 2 {
            to_add.push(99);
        }
    }

    v.extend(to_add);
    println!("{v:?}");
}

해결 2: 인덱스 기반 while 루프(신중히)

길이가 변할 수 있는 순회라면 인덱스 기반으로 제어할 수 있습니다. 다만 무한 루프/중복 처리 위험이 있으니 의도를 명확히 하세요.

fn main() {
    let mut v = vec![1, 2, 3];

    let mut i = 0usize;
    while i < v.len() {
        if v[i] == 2 {
            v.push(99);
        }
        i += 1;
    }

    println!("{v:?}");
}

해결 3: drain_filter 대체 전략(안정성/버전 고려)

특정 조건으로 제거하며 다른 곳에 옮기고 싶다면, 안정화 여부에 따라 retain + 별도 수집 등으로 풀어야 합니다. 핵심은 “순회 참조와 수정의 동시성”을 피하는 것입니다.

패턴 4) HashMap에서 get으로 읽은 뒤 entry로 수정

맵에서 값을 읽은 다음 같은 키를 entry로 갱신하려 하면 get이 만든 불변 빌림이 entry의 가변 빌림을 막습니다.

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, i32> = HashMap::new();
    m.insert("a".to_string(), 1);

    let v = m.get("a");          // 불변 빌림
    m.entry("a".to_string())     // 가변 빌림 필요
        .and_modify(|x| *x += 1);

    println!("{v:?}");
}

해결 1: entry로 읽기와 쓰기를 한 번에 처리

읽기/수정을 분리하지 말고 entry로 합치면 빌림이 단일화됩니다.

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, i32> = HashMap::new();

    let key = "a".to_string();
    let v_ref = m.entry(key).or_insert(0);
    *v_ref += 1;

    println!("{v_ref}");
}

해결 2: 필요 값만 복사해서 들고 있기

값이 Copy라면 copied()로 참조 생존을 끊어낼 수 있습니다.

use std::collections::HashMap;

fn main() {
    let mut m: HashMap<String, i32> = HashMap::new();
    m.insert("a".to_string(), 1);

    let old = m.get("a").copied().unwrap_or(0);
    *m.entry("a".to_string()).or_insert(0) = old + 1;
}

키가 String이면 entry에 넘길 때 소유권이 필요하므로, 실제 코드에서는 &str 키를 쓰거나(예: HashMap<&'static str, i32>), 키를 미리 만들어 재사용하는 식으로 할당을 줄이는 것도 고려합니다.

패턴 5) 클로저/메서드 체이닝이 빌림을 “생각보다 오래” 붙잡음

클로저는 캡처한 참조를 클로저가 살아있는 동안 유지할 수 있고, 메서드 체이닝은 임시 값의 드롭 시점 때문에 빌림이 예상보다 길어져 E0502를 유발합니다.

5-1) 클로저가 &mut를 캡처한 뒤, 같은 값을 다시 빌리기

fn main() {
    let mut s = String::from("hi");

    let mut add = || s.push('!'); // s를 가변으로 캡처

    let r = &s; // E0502: 이미 가변으로 빌린 상태
    println!("{r}");

    add();
}

해결: 클로저 수명 줄이기 또는 함수로 분리

fn main() {
    let mut s = String::from("hi");

    {
        let mut add = || s.push('!');
        add();
    } // add 드롭

    let r = &s;
    println!("{r}");
}

또는 클로저가 굳이 필요 없다면 함수로 빼서 캡처 자체를 없애는 게 더 명확합니다.

fn add_bang(s: &mut String) {
    s.push('!');
}

fn main() {
    let mut s = String::from("hi");
    add_bang(&mut s);
    println!("{s}");
}

5-2) 체이닝 중 만들어진 참조가 다음 단계까지 살아남음

예를 들어, 어떤 메서드가 내부적으로 참조를 반환하거나, 슬라이스/이터레이터를 만든 상태에서 원본을 수정하면 동일 문제가 납니다.

fn main() {
    let mut v = vec![1, 2, 3];

    let it = v.iter(); // v 불변 빌림
    v.push(4);         // E0502

    for x in it {
        println!("{x}");
    }
}

해결: 이터레이터 소비를 먼저 끝내거나, 필요한 값을 수집

fn main() {
    let mut v = vec![1, 2, 3];

    let sum: i32 = v.iter().copied().sum();
    v.push(4);

    println!("sum={sum}, v={v:?}");
}

디버깅 체크리스트: E0502를 빠르게 푸는 순서

  1. 불변 참조(&)가 어디서 만들어졌는지 찾습니다. 보통 let r = &x, iter(), get(), [] 인덱싱이 출발점입니다.
  2. 그 참조가 언제까지 살아있는지 봅니다. println! 한 번 때문에 스코프 끝까지 붙잡히는 경우가 많습니다.
  3. 해결은 아래 중 하나로 귀결됩니다.
    • 스코프를 쪼개서 참조를 빨리 드롭
    • 참조 대신 Copy 값/clone()한 소유 값을 들고 있기
    • entry/split_at_mut 등으로 “겹치지 않음”을 구조적으로 증명
    • 순회와 수정을 2단계로 분리

마무리: E0502를 “컴파일러와 협상”하는 관점

E0502는 러스트가 까다로워서가 아니라, **동시성/안전성에서 가장 위험한 형태(읽는 중에 쓰기)**를 원천 봉쇄하기 때문에 자주 보입니다. 실무에서는 “참조를 최대한 짧게”, “읽기와 쓰기를 한 함수/한 단계로”, “컬렉션 수정은 순회와 분리”라는 3원칙만 지켜도 발생 빈도가 크게 줄어듭니다.

추가로, 참조 생존 범위가 직관과 다르게 느껴진다면 NLL(Non-Lexical Lifetimes)과 임시 값 드롭 규칙을 함께 공부하는 것이 도움이 됩니다. 러스트의 이런 제약은 결국 런타임 락/GC 없이도 안전성을 얻는 비용이며, 한 번 패턴이 눈에 익으면 오히려 리팩터링의 방향을 명확히 잡아줍니다.

관련해서 “제약을 우회하려고 unsafe나 내부 가변성(RefCell)로 덮기” 전에, 구조적으로 해결할 수 있는지 먼저 확인하세요. 대부분은 이 글의 5패턴 중 하나로 설명되고, 안전한 형태로 정리할 수 있습니다.