Published on

Rust 소유권·빌림 - E0502 해결 패턴 7가지

Authors

서로 다른 레퍼런스를 안전하게 공존시키는 것이 Rust의 핵심이지만, 그만큼 자주 마주치는 컴파일 에러가 있습니다. 바로 E0502: cannot borrow ... as mutable because it is also borrowed as immutable 입니다. 한국어로 풀면 “불변으로 빌려둔 상태에서 같은 값을 가변으로 다시 빌릴 수 없다”는 뜻이죠.

이 글에서는 E0502가 나는 전형적인 상황을 먼저 이해한 뒤, 실무에서 바로 꺼내 쓸 수 있는 해결 패턴 7가지를 코드로 정리합니다. NLL(Non-Lexical Lifetimes)이 도입된 이후에도 여전히 E0502는 자주 등장하는데, 대부분은 **레퍼런스의 생존 범위를 좁히거나(스코프 분리), 데이터 흐름을 바꾸거나(값 복사/이동), 소유권 구조를 바꾸는 것(내부 가변성/분리 소유)**으로 해결됩니다.

관련해서 NLL 관점의 설명이 더 필요하면 다음 글도 함께 보면 좋습니다: Rust E0502, NLL로 빌림 충돌 풀기

E0502가 나는 전형적인 형태

가장 흔한 패턴은 아래처럼 “읽기용 불변 빌림을 잡아둔 채로” 같은 컨테이너를 “쓰기용 가변 빌림”하려는 경우입니다.

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

    let first = &v[0];      // 불변 빌림
    v.push(4);              // 가변 빌림 시도 (재할당/재배치 가능)

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

Vecpush 과정에서 capacity가 부족하면 재할당이 발생할 수 있고, 그 순간 기존 요소의 주소가 바뀔 수 있습니다. 그래서 Rust는 first 같은 레퍼런스가 살아 있는 동안 v를 가변으로 빌리는 것을 금지합니다.

이제부터는 이 문제를 푸는 “선택지”를 7가지로 나눠 설명합니다. 상황에 따라 정답은 달라집니다.


패턴 1) 스코프를 쪼개 레퍼런스 생존 범위 줄이기

가장 먼저 시도할 옵션입니다. 불변 빌림을 더 빨리 끝내면 가변 빌림이 가능해집니다.

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

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 first의 불변 빌림 종료

    v.push(4); // 이제 OK
}

핵심은 “레퍼런스 변수를 가능한 한 짧게 유지”하는 것입니다. 특히 함수가 길어질수록, 중간에 let r = ...;로 레퍼런스를 저장해두는 습관이 E0502를 만들기 쉽습니다.

언제 유용한가

  • 단순히 출력/검증 등 읽기 작업 후 바로 쓰기 작업을 해야 할 때
  • 불변 참조를 오래 들고 있을 이유가 없을 때

패턴 2) 필요한 값만 복사하거나 clone해서 레퍼런스 제거

레퍼런스가 문제라면 레퍼런스를 만들지 않으면 됩니다. 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);
}

트레이드오프

  • 장점: 코드가 단순해지고 빌림 문제가 사라짐
  • 단점: clone은 비용이 들 수 있음(문자열/큰 구조체)

패턴 3) 인덱스/키만 저장하고, 가변 작업 후 다시 접근

레퍼런스 대신 “어디를 볼지”만 저장해두는 방법입니다. 특히 Vec에서 흔히 쓰입니다.

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

    let idx = 0usize;
    v.push(40);

    println!("{}", v[idx]);
}

이 패턴은 “가변 작업이 재할당을 일으켜도, 인덱스는 유효하다”는 점을 활용합니다. 다만 중간에 removeinsert로 인덱스 의미가 바뀌면 위험해집니다.

언제 유용한가

  • 읽고 싶은 위치가 고정되어 있고, 구조 변경이 인덱스를 깨지 않을 때
  • 레퍼런스를 오래 들고 있을 필요가 없을 때

패턴 4) split_at_mut 등으로 “서로 다른 영역”임을 증명하기

E0502의 본질은 “같은 값에 대한 불변/가변 빌림 충돌”인데, 실제로는 서로 다른 요소를 다루는 경우가 많습니다. 이때 Rust에게 “겹치지 않는다”를 증명해주면 됩니다.

대표적으로 Vecsplit_at_mut를 제공합니다.

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

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

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

fn main() {
    let mut v = vec![1, 2, 3, 4];
    bump_two(&mut v, 0, 3);
    println!("{:?}", v);
}

이 방식은 “한 번에 두 개의 &mut를 얻고 싶다” 같은 상황에서 특히 강력합니다.

언제 유용한가

  • 같은 슬라이스/벡터의 서로 다른 요소를 동시에 수정해야 할 때
  • 컴파일러가 aliasing 불가능함을 추론하지 못할 때

패턴 5) 연산 순서를 바꿔 ‘읽기 후 쓰기’로 정렬하기

의외로 많은 E0502는 “필요 없는 레퍼런스를 먼저 만들어서” 생깁니다. 즉, 쓰기 작업을 먼저 끝내고 그 다음 읽으면 해결됩니다.

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

    // 먼저 변경
    v.push(4);

    // 그 다음 읽기
    let first = &v[0];
    println!("{}", first);
}

실무에서는 다음 같은 형태가 많습니다.

  • 변경 전에 상태 점검을 하려고 참조를 잡아둠
  • 로그/메트릭을 찍으려고 참조를 잡아둠

이때는 “로그를 값으로 만들기” 혹은 “변경 이후에 로그 찍기”로 정리할 수 있습니다.


패턴 6) 소유권을 분리해 구조적으로 빌림 충돌을 없애기

E0502가 반복된다면 설계 신호일 수 있습니다. 한 구조체가 너무 많은 것을 품고 있거나, 한 컨테이너에 읽기/쓰기 대상이 섞여 있을 수 있습니다.

예를 들어, Vec 자체를 수정하면서 동시에 특정 요소를 참조해야 하는 경우라면 “요소를 빼서 작업한 뒤 다시 넣는” 식으로 소유권을 분리할 수 있습니다.

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

    // 첫 요소를 소유권 이동으로 꺼냄
    let mut first = std::mem::take(&mut v[0]);

    // 이제 v는 자유롭게 변경 가능
    v.push(String::from("c"));

    // first를 수정
    first.push('!');

    // 다시 넣기
    v[0] = first;

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

std::mem::take는 자리에 기본값을 남기고 값을 “꺼내오는” 패턴입니다(여기서는 String의 기본값은 빈 문자열).

언제 유용한가

  • 특정 필드를 길게 작업해야 해서 참조를 오래 잡아야 할 때
  • 컬렉션 구조 변경과 요소 변경이 강하게 얽혀 있을 때

패턴 7) 내부 가변성(RefCell, Mutex, RwLock)로 런타임 빌림으로 전환

정적 빌림 규칙이 너무 빡빡해서 구조적으로 풀기 어렵다면, 내부 가변성을 고려할 수 있습니다. 이는 “컴파일 타임”이 아니라 “런타임”에 빌림 규칙을 검사합니다.

단일 스레드라면 RefCell이 대표적입니다.

use std::cell::RefCell;

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

    // 불변 빌림
    {
        let r = v.borrow();
        println!("first={}", r[0]);
    }

    // 가변 빌림
    v.borrow_mut().push(4);

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

멀티스레드라면 Mutex/RwLock을 씁니다.

use std::sync::{Arc, Mutex};

fn main() {
    let v = Arc::new(Mutex::new(vec![1, 2, 3]));

    {
        let guard = v.lock().unwrap();
        println!("first={}", guard[0]);
    }

    v.lock().unwrap().push(4);
}

주의점

  • RefCell은 규칙을 어기면 런타임 패닉이 납니다
  • Mutex/RwLock은 데드락/경합/성능 이슈가 생길 수 있습니다
  • 즉, “최후의 선택지”로 두고, 먼저 스코프/구조 리팩터링을 시도하는 편이 좋습니다

E0502 디버깅 체크리스트

  1. 레퍼런스를 변수에 저장해 생존 범위를 불필요하게 늘리고 있지 않은가
  2. 읽기 참조가 꼭 레퍼런스여야 하는가(값 복사/clone 가능 여부)
  3. 인덱스/키만 들고 있다가 나중에 다시 접근할 수 있는가
  4. 동일 컨테이너의 서로 다른 영역이라면 split_at_mut 같은 API로 분할 가능한가
  5. 연산 순서를 바꿔서 쓰기를 먼저 끝낼 수 있는가
  6. 소유권을 분리(take, replace, drain, swap_remove 등)하는 편이 더 자연스러운가
  7. 정말로 내부 가변성이 필요한가(단일/멀티 스레드 구분)

마무리: “레퍼런스를 오래 잡지 말라”가 핵심

E0502는 소유권/빌림 모델을 제대로 이해하고 있다는 신호이기도 합니다. 중요한 건 규칙을 외우는 것이 아니라, 레퍼런스의 생존 범위를 최소화하고, 필요하면 값/인덱스/소유권 이동으로 데이터 흐름을 재구성하는 습관입니다.

실제로 Rust에서 성능과 안정성을 함께 잡는 코드는, 대개 “짧게 빌리고 빨리 놓는” 구조를 갖습니다. E0502를 만날 때마다 위 7가지 패턴을 체크리스트처럼 대입해보면, 대부분의 케이스는 깔끔하게 정리됩니다.