Published on

Rust 빌림체커 E0502/E0506 5분 해결법

Authors

서버나 CLI를 Rust로 짜다 보면, 컴파일러가 “너 지금 동시에 빌렸어” 혹은 “빌린 상태에서 값을 바꾸려 했어”라고 단호하게 막는 순간이 옵니다. 그 대표가 E0502(immutable과 mutable 동시 빌림)와 E0506(빌린 값을 대입으로 덮어씀)입니다.

이 글은 원리를 길게 설명하기보다, 5분 안에 고치는 패턴을 중심으로 정리합니다. 에러 메시지에 적힌 “borrowed here / later used here”를 어떻게 읽고, 어떤 리팩터링을 적용하면 되는지에 집중합니다.

관련해서 동시성 코드에서 Mutexspawn을 잘못 쓰면 비슷한 체감의 “막힘”이 생기는데, Tokio 쪽 이슈는 별도 글인 Rust Tokio join! 교착? spawn·Mutex 오용 해결도 참고하면 좋습니다.

0분 진단: E0502/E0506 한 줄 요약

  • E0502: 불변 참조(&T)가 살아있는 동안 같은 값을 가변 참조(&mut T)로 빌리려 함
  • E0506: 어떤 값이 참조로 빌려져 살아있는 동안, 그 값에 대입(재할당)해서 덮어씀

둘 다 핵심은 같습니다.

  • “참조가 살아있는 스코프”가 생각보다 길다
  • “참조를 만든 뒤에” 그 참조가 마지막으로 사용되는 지점까지가 생존 범위다

이제부터는 가장 흔한 케이스별로 “바로 고치는 레시피”를 봅니다.

1) E0502: 읽고(&) 나서 쓰려고(&mut) 할 때

재현 코드

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

    let first = &v[0];
    v.push(4);

    println!("{}", first);
}

대략 이런 형태로 E0502가 납니다. 이유는 first&v[0]을 가리키는 동안 push가 벡터 재할당을 일으킬 수 있어서(메모리 이동 가능) 안전하지 않기 때문입니다.

5분 해결법 A: 값을 복사하거나 클론해서 “참조 수명”을 끊기

Copy 타입이면 가장 빠릅니다.

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

    let first = v[0]; // i32는 Copy
    v.push(4);

    println!("{}", first);
}

String 같은 비 Copy 타입이면 clone으로 끊습니다.

fn main() {
    let mut v = vec![String::from("a"), String::from("b")];

    let first = v[0].clone();
    v.push(String::from("c"));

    println!("{}", first);
}

트레이드오프는 복사 비용이지만, “일단 컴파일”이 목표라면 가장 단순합니다.

5분 해결법 B: 스코프를 줄여 참조가 빨리 죽게 만들기

참조를 만든 뒤 바로 쓰고, 그 다음에 변경하면 됩니다.

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

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 first가 drop

    v.push(4);
}

이 패턴은 특히 “로그 찍으려고 잠깐 빌린 것” 때문에 막힐 때 유용합니다.

5분 해결법 C: 불변/가변 빌림을 분리하는 API로 바꾸기

같은 컨테이너를 동시에 다루는 경우, 표준 라이브러리의 “분할 빌림” API를 쓰면 해결됩니다.

예: split_at_mut로 슬라이스를 안전하게 두 조각으로 나누기

fn main() {
    let mut a = [10, 20, 30, 40];

    let (left, right) = a.split_at_mut(2);
    left[0] += 1;
    right[0] += 1;

    println!("{:?}", a);
}

leftright는 겹치지 않으니 동시에 &mut가 가능합니다.

예: HashMap::get_mutHashMap::get을 섞지 말고 흐름을 바꾸기

get으로 불변 참조를 잡아둔 채 insertget_mut을 하려 하면 E0502가 자주 납니다. 이때는 entry API로 “한 번에” 처리합니다.

use std::collections::HashMap;

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

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

핵심은 “읽기와 쓰기를 분리하지 말고, 원자적 흐름으로 합치기”입니다.

2) E0506: 빌린 상태에서 값을 덮어쓰는 패턴

재현 코드

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

    let r = &s;
    s = String::from("world");

    println!("{}", r);
}

sr이 빌린 상태에서 s = ...로 아예 다른 String으로 교체하려 하니 E0506이 납니다.

5분 해결법 A: 대입을 참조 사용 이후로 옮기기

가장 단순한 해결은 “순서 바꾸기”입니다.

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

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

    s = String::from("world");
    println!("{}", s);
}

5분 해결법 B: 덮어쓰기 대신 내부를 수정하는 메서드 사용

대입이 아니라 clear, push_str, replace_range 같은 “in-place 변경”을 쓰면, 참조와 충돌하는 지점을 줄일 수 있습니다(물론 여전히 &mut가 필요하면 스코프 조정이 필요합니다).

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

    // r을 오래 들고 있지 않게 먼저 사용
    println!("{}", &s);

    s.clear();
    s.push_str("world");

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

5분 해결법 C: std::mem::take 또는 std::mem::replace로 소유권을 분리

“기존 값을 꺼내서 다른 곳에 쓰고, 원래 변수엔 새 값을 넣고 싶다”면 takereplace가 깔끔합니다.

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

    let old = std::mem::take(&mut s); // s는 빈 문자열로 바뀜
    // 여기서 old를 마음껏 사용
    let _len = old.len();

    s = String::from("world");
    println!("{}", s);
}

이 패턴은 구조체 필드를 빼서 가공한 뒤 다시 넣을 때도 자주 씁니다.

3) “참조가 생각보다 오래 살아있다”를 잡는 법

E0502/E0506을 자주 내는 진짜 원인은 문법이 아니라 수명 범위 착각입니다.

체크리스트

  1. 참조를 만든 줄과, 그 참조를 “마지막으로 사용한 줄”을 찾기
  2. 그 사이에 push, insert, 대입, swap, sort 같은 “구조 변경”이 있는지 보기
  3. 참조를 더 짧게 만들 수 있는지(스코프 블록, 즉시 사용, 값 복사) 판단

특히 Rust는 NLL(Non-Lexical Lifetimes) 덕분에 “블록 끝까지”가 아니라 “마지막 사용 지점까지”로 수명이 줄어들긴 하지만, 다음 같은 경우는 여전히 길어집니다.

  • 참조가 println! 같은 매크로 인자로 넘어가며 사용 지점이 뒤로 밀림
  • 이터레이터 체인에서 참조가 클로저에 캡처되어 생존 범위가 늘어남
  • 구조체에 참조를 저장해 두어 스코프가 함수 전체로 확장됨

4) 실전 패턴: 반복문에서 E0502가 나는 경우

가장 흔한 실전 케이스는 “순회하면서 수정”입니다.

문제 코드: 인덱스로 읽고, 같은 벡터를 수정

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

    for i in 0..v.len() {
        let x = &v[i];
        if *x % 2 == 0 {
            v.push(*x); // E0502 가능
        }
    }
}

해결법 A: 먼저 수집하고 나중에 반영(2-phase update)

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

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

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

이 방식은 빌림체커를 “속이는” 게 아니라, 데이터 흐름을 더 안전하게 만드는 정석입니다.

해결법 B: retain/drain_filter 계열로 의도를 API에 맡기기

필터링/삭제 같은 작업은 전용 API가 빌림 규칙을 만족하도록 설계되어 있습니다.

fn main() {
    let mut v = vec![1, 2, 3, 4, 5, 6];
    v.retain(|x| x % 2 == 0);
    println!("{:?}", v);
}

5) 실전 패턴: 구조체 메서드에서 self를 빌려놓고 self를 또 수정

self.field를 불변으로 빌린 다음 self.other_field를 수정하려다 E0502가 나는 경우가 많습니다.

문제 코드

struct App {
    name: String,
    count: usize,
}

impl App {
    fn bump_if_named(&mut self) {
        let n = &self.name;
        if n == "admin" {
            self.count += 1; // E0502가 날 수 있는 전형
        }
    }
}

해결법: 필요한 값만 미리 복사하거나, 비교를 즉시 끝내기

struct App {
    name: String,
    count: usize,
}

impl App {
    fn bump_if_named(&mut self) {
        let is_admin = self.name == "admin"; // 여기서 비교 끝
        if is_admin {
            self.count += 1;
        }
    }
}

비교에 필요한 정보만 뽑아 “불변 빌림”을 짧게 끝내는 게 포인트입니다.

6) 빠른 결론: 5분 안에 적용하는 우선순위

  1. 순서 변경: 참조 사용을 먼저 끝내고 변경은 나중에
  2. 스코프 축소: { ... } 블록으로 참조 수명 단축
  3. 값 복사/클론: 참조 대신 소유 값으로 끊기
  4. 2-phase update: 먼저 읽어 수집, 그 다음에 한 번에 수정
  5. 전용 API 사용: entry, split_at_mut, retain, extend 등으로 의도를 표현

E0502/E0506은 “Rust가 불친절해서”가 아니라, 실제로 위험한 메모리 상황을 컴파일 타임에 차단하는 신호입니다. 위 패턴들 중 하나로 코드를 정리하면, 대개 성능과 가독성도 함께 좋아집니다.

동시성 코드에서 빌림 문제와 함께 락 범위가 길어져 교착이나 병목이 생기는 경우도 많으니, Tokio 기반이라면 앞서 언급한 Rust Tokio join! 교착? spawn·Mutex 오용 해결도 같이 보면 문제를 더 빨리 분리할 수 있습니다.