Published on

Rust E0502 소유권 충돌 6패턴 해결법

Authors

서버 사이드 Rust를 쓰다 보면 가장 자주 마주치는 에러 중 하나가 E0502 입니다. 메시지는 대개 다음 형태로 나타납니다.

  • cannot borrow ... as mutable because it is also borrowed as immutable
  • 또는 반대로, 가변 대여가 잡힌 상태에서 불변 대여를 시도했다는 내용

핵심은 간단합니다.

  • 어떤 값에 대해 불변 대여(&T)가 살아있는 동안 같은 값을 가변 대여(&mut T) 할 수 없습니다.
  • 반대로 가변 대여가 살아있는 동안 같은 값에 대한 다른 대여(불변/가변 모두)도 불가능합니다.

문제는 이 규칙이 단순해 보여도, 실제 코드에서는 반복문, 인덱싱, 클로저, 메서드 체이닝 같은 “표현식의 수명” 때문에 의도치 않게 충돌이 생긴다는 점입니다. 이 글에서는 E0502를 유발하는 대표 6패턴과, 실무에서 가장 많이 쓰는 해결책을 코드로 정리합니다.

참고로, 같은 “6가지 해법” 형식으로 문제를 쪼개 해결하는 접근은 다른 언어에서도 유효합니다. 예를 들어 Java에서 groupByNPE를 패턴별로 잡는 방식이 비슷합니다: Java Stream groupBy NPE 6가지 해법

E0502 빠르게 진단하는 체크리스트

아래 중 하나라도 해당하면 E0502가 날 확률이 높습니다.

  1. 한 줄에서 get 같은 불변 접근과 push 같은 가변 변경을 같이 함
  2. let x = &v[i]; v.push(...) 처럼 참조를 잡아둔 뒤 컬렉션 변경
  3. iter()로 돌면서 같은 컬렉션을 수정
  4. self.foo()로 불변 대여된 상태에서 self.bar_mut() 호출
  5. 클로저가 외부 참조를 캡처한 채로 내부에서 변경
  6. 인덱스 두 개로 같은 Vec 원소를 동시에 가변 참조

이제 패턴별로 해결해보겠습니다.

패턴 1) 참조를 오래 잡아두고 나중에 수정

가장 흔한 형태입니다.

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

    let first = &v[0];
    // 여기서 first가 살아있음
    v.push(4); // E0502 가능

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

해결 1: 값 복사 또는 소유로 가져오기

Copy 타입이면 가장 간단합니다.

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

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

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

String 같은 비-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 drop

    v.push(4);
}

패턴 2) iter()로 순회하면서 같은 컬렉션 수정

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

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

iter()v를 불변 대여합니다. 그 상태에서 push는 가변 대여가 필요하니 충돌합니다.

해결 1: 2단계로 나누기(읽기 단계, 쓰기 단계)

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

    let should_push = v.iter().any(|x| *x == 2);
    if should_push {
        v.push(99);
    }
}

해결 2: 인덱스로 순회하고 변경은 별도 수행

다만 push로 길이가 바뀌면 인덱스 루프가 위험해질 수 있으니, 보통은 “추가할 것들을 모아두고 마지막에 append”가 안전합니다.

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);
}

패턴 3) 한 표현식에서 불변 접근과 가변 접근이 섞임

예를 들어, 아래는 “읽고 나서 그 값을 기반으로 수정”을 한 줄에 쓰려다 터집니다.

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

    // v.last()가 불변 대여를 만들고,
    // 그 대여가 표현식 끝까지 살아있다고 판단되면 충돌
    if v.last().is_some() {
        v.push(40); // E0502가 나는 케이스가 있음
    }
}

해결: 중간 변수로 끊어서 수명 단축

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

    let has_last = v.last().is_some();
    if has_last {
        v.push(40);
    }
}

이 테크닉은 “메서드 체이닝이 길어질수록 임시 참조가 생각보다 오래 산다”는 Rust의 수명 추론 특성과 잘 맞습니다.

패턴 4) 같은 구조체에서 self 불변 메서드와 가변 메서드가 충돌

다음은 실제 서비스 코드에서 흔합니다.

struct Store {
    items: Vec<i32>,
}

impl Store {
    fn first(&self) -> Option<&i32> {
        self.items.first()
    }

    fn add(&mut self, x: i32) {
        self.items.push(x)
    }

    fn do_work(&mut self) {
        let f = self.first();
        // f가 살아있는 동안 self를 &mut로 쓰려 하니 충돌
        self.add(10); // E0502
        println!("{:?}", f);
    }
}

해결 1: 필요한 값만 복사하거나 소유로 만들기

impl Store {
    fn do_work(&mut self) {
        let f = self.items.first().copied();
        self.add(10);
        println!("{:?}", f);
    }
}

해결 2: 필드 단위로 분리 대여되게 구조를 바꾸기

서로 다른 필드를 동시에 빌리는 건 가능하지만, 같은 필드를 빌리면 안 됩니다. 따라서 “읽기 전용 캐시”와 “쓰기 대상”을 분리하면 해결되는 경우가 많습니다.

struct Store {
    cache: Option<i32>,
    items: Vec<i32>,
}

impl Store {
    fn do_work(&mut self) {
        self.cache = self.items.first().copied();
        self.items.push(10);
    }
}

패턴 5) 인덱스로 같은 컬렉션의 두 원소를 동시에 가변 참조

정렬, 스왑, 그래프/배열 알고리즘에서 자주 나옵니다.

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

    let a = &mut v[1];
    let b = &mut v[2]; // E0502 또는 E0499 계열로 막힘

    *a += *b;
}

Rust는 v[1]v[2]가 다르다는 것을 “일반적인 인덱싱”만으로는 안전하게 증명하지 못합니다.

해결: split_at_mut로 슬라이스를 쪼개서 증명

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

    let (left, right) = v.split_at_mut(2);
    let a = &mut left[1];
    let b = &mut right[0]; // 원래 v[2]

    *a += *b;
}

이 패턴은 “서로 겹치지 않는 두 구간”을 타입 시스템에 알려주는 정석입니다.

패턴 6) HashMap에서 값 참조를 잡은 채로 다시 수정

HashMap::get으로 값을 빌리고, 같은 맵에 insertentry를 쓰려다 E0502가 납니다.

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");
    // v가 살아있는 동안 m을 가변으로 쓰기
    m.insert("b".to_string(), 2); // E0502

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

해결 1: 필요한 값만 복사/클론 후 참조 해제

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").copied();
    m.insert("b".to_string(), 2);

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

해결 2: entry API로 한 번에 처리

읽기와 쓰기를 분리하지 말고, 애초에 “수정 의도”를 entry로 표현하면 대여 충돌이 사라집니다.

use std::collections::HashMap;

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

    *m.entry("a".to_string()).or_insert(0) += 1;
    *m.entry("b".to_string()).or_insert(0) += 2;
}

실무 팁으로는, 카운팅/집계는 getinsert로 쪼개기보다 entry가 거의 항상 더 깔끔합니다.

자주 쓰는 리팩토링 레시피 요약

E0502를 “컴파일러와 싸우는 문제”로 보지 말고, 코드의 의도를 더 명확히 만드는 신호로 보면 해결이 빨라집니다.

  • 참조를 오래 들고 있지 말고, 필요한 값만 copied() 또는 clone()
  • 불변 단계와 가변 단계를 2단계로 분리
  • 체이닝 결과를 let tmp = ...로 받아 수명 단축
  • 동일 컬렉션의 2개 가변 원소는 split_at_mut로 증명
  • HashMapentry로 읽기+쓰기 의도를 묶기

이런 “패턴 분해 후 해결” 방식은 성능 튜닝에서도 유사합니다. 예를 들어 빌드 캐시가 안 먹는 원인을 케이스로 쪼개는 접근은 다음 글과 결이 같습니다: Docker 빌드 캐시가 안 먹을 때 - BuildKit 원인 8가지

마무리: E0502를 줄이는 설계 습관

E0502는 대개 다음 중 하나를 요구합니다.

  1. 데이터의 “읽기”와 “쓰기” 타이밍을 분리하라
  2. 참조 대신 값(복사/클론)을 들고 다녀라
  3. 자료구조 접근을 더 원자적으로 표현하라(entry, split_at_mut)

처음에는 불편하지만, 이 규칙 덕분에 런타임에서 디버깅하기 어려운 데이터 레이스/유즈-애프터-프리 류의 버그가 구조적으로 사라집니다. E0502를 만날 때마다 위 6패턴 중 어디에 속하는지 먼저 분류하고, 해당 해법을 적용하면 대부분의 케이스는 빠르게 정리됩니다.