Published on

Rust 소유권 에러 E0502/E0499 한방 해결

Authors

서버/CLI/라이브러리 무엇을 만들든 Rust를 쓰다 보면 결국 한 번은 마주치는 벽이 있습니다. 바로 소유권/빌림 규칙이 강제하는 동시 참조 불가 상황에서 터지는 컴파일 에러입니다. 그중에서도 빈도가 압도적으로 높은 것이 E0502(불변 빌림 중 가변 빌림)와 E0499(가변 빌림 중복)입니다.

이 글은 “규칙 설명”보다 실제 코드에서 어떻게 한 번에 고치는지에 집중합니다. 에러 메시지를 읽는 법부터, 스코프를 자르는 법, 컬렉션을 안전하게 둘로 나누는 법, 인덱스/핸들로 접근을 분리하는 법, 최후의 수단인 내부 가변성까지 한 흐름으로 정리합니다.

참고로 이런 류의 문제는 Go에서 context/select로 누수를 잡는 것처럼, 원인을 패턴화해두면 해결 속도가 급격히 빨라집니다. 동시성/리소스 관련 패턴이 궁금하면 Go goroutine 누수 잡기 - context·select 패턴도 함께 보면 좋습니다.

E0502/E0499를 “한 문장”으로 이해하기

  • E0502: 어떤 값에 대한 불변 참조(&T)가 살아있는 동안, 같은 값에 대한 가변 참조(&mut T)를 만들려고 해서 실패
  • E0499: 어떤 값에 대한 가변 참조(&mut T)가 살아있는 동안, 같은 값에 대한 또 다른 가변 참조를 만들려고 해서 실패

핵심은 “같은 값”과 “살아있는 동안(수명, lifetime)”입니다. 대부분의 해결은 결국 아래 둘 중 하나로 귀결됩니다.

  1. 참조가 살아있는 범위를 줄인다(스코프/수명 단축)
  2. ‘같은 값’이 아니게 만든다(데이터 구조/접근 방식 변경)

에러 메시지에서 먼저 볼 포인트 3가지

Rust 컴파일러는 보통 아래 정보를 제공합니다.

  1. 첫 번째 빌림이 발생한 위치
  2. 두 번째 빌림(충돌)이 발생한 위치
  3. 첫 번째 빌림이 끝나지 않았다고 판단한 범위

실전에서는 3번이 가장 중요합니다. “내가 보기엔 이미 안 쓰는데?” 싶은 경우가 많고, 그때는 대개 변수에 바인딩된 참조가 스코프 끝까지 살아있기 때문입니다.

패턴 1) 가장 빠른 해결: 스코프 쪼개기(수명 단축)

전형적인 E0502 예시

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

    let first = &v[0];
    v.push(4); // E0502: immutable borrow occurs here, mutable borrow later

    println!("{first}");
}

first&v[0]로 바인딩되어 있고, println!에서 사용되므로 컴파일러는 first의 수명이 push 이후까지 이어진다고 봅니다.

해결 1: 사용을 앞당기거나, 블록으로 감싸서 참조 수명 종료

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

    {
        let first = &v[0];
        println!("{first}");
    } // 여기서 `first` 수명 종료

    v.push(4);
}

해결 2: 필요한 값만 복사(특히 Copy 타입)

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

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

    println!("{first}");
}

이 방식은 “참조”를 들고 있지 않게 만들어 충돌을 제거합니다.

패턴 2) E0499의 단골: 같은 컬렉션에서 두 원소를 동시에 &mut로 잡기

다음은 실무에서 가장 자주 보는 형태입니다.

fn swap(v: &mut Vec<i32>, i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j]; // E0499: cannot borrow `*v` as mutable more than once

    std::mem::swap(a, b);
}

인덱스가 다르더라도, 컴파일러 입장에서는 v 전체에 대한 &mut가 두 번 생긴 것으로 취급됩니다(서로 다른 원소라는 것을 일반적으로 증명할 수 없기 때문).

해결 1: split_at_mut로 “같은 값이 아님”을 증명

fn swap(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])
    };

    std::mem::swap(a, b);
}

split_at_mut는 슬라이스를 서로 겹치지 않는 두 조각으로 나누므로, 각 조각에서 &mut를 꺼내도 안전합니다.

해결 2: get_many_mut(버전에 따라 사용 가능)로 다중 가변 참조 획득

Rust 버전에 따라 표준 라이브러리에서 slice::get_many_mut가 제공됩니다. 환경에 따라 안정화 여부가 다를 수 있으니, 가능하면 split_at_mut를 먼저 떠올리는 것이 안전합니다.

패턴 3) 반복문에서 E0502/E0499가 터지는 이유: “루프 바디 전체”로 수명이 늘어남

예를 들어, 맵을 순회하면서 동시에 수정하려고 하면 자주 막힙니다.

use std::collections::HashMap;

fn bump(map: &mut HashMap<String, i32>) {
    for (k, v) in map.iter() {
        if k.starts_with("a") {
            *map.get_mut(k).unwrap() += 1; // E0502/E0499 류 충돌
        }
        println!("{k} {v}");
    }
}

iter()map에 대한 불변 빌림을 유지하는 동안, 루프 안에서 get_mut로 가변 빌림을 만들려 하니 충돌합니다.

해결 1: 2패스(키 목록 수집 후 수정)

use std::collections::HashMap;

fn bump(map: &mut HashMap<String, i32>) {
    let keys: Vec<String> = map
        .keys()
        .filter(|k| k.starts_with("a"))
        .cloned()
        .collect();

    for k in keys {
        *map.get_mut(&k).unwrap() += 1;
    }
}

메모리를 조금 더 쓰지만, 의도가 명확하고 가장 예측 가능하게 컴파일됩니다.

해결 2: retain/entry 같은 “빌림 친화 API”로 재구성

HashMap::entry는 수정 흐름을 한 번의 가변 접근으로 묶어주기 때문에, 설계를 바꾸면 소유권 문제가 사라지는 경우가 많습니다.

use std::collections::HashMap;

fn bump_one(map: &mut HashMap<String, i32>, k: String) {
    let e = map.entry(k).or_insert(0);
    *e += 1;
}

패턴 4) 함수 경계에서 빌림이 꼬일 때: “참조를 반환하지 말고 결과를 반환”

E0502/E0499는 종종 “헬퍼 함수가 참조를 반환”하면서 시작됩니다.

fn pick_first(v: &Vec<String>) -> &String {
    &v[0]
}

fn main() {
    let mut v = vec!["a".to_string(), "b".to_string()];
    let r = pick_first(&v);

    v.push("c".to_string()); // E0502
    println!("{r}");
}

해결: 참조 대신 소유값(복제/이동) 또는 인덱스 반환

  • 값이 작거나 Clone 비용이 허용되면 String을 복제
  • 복제가 부담되면 “어디를 가리키는지”만 반환(인덱스/키)
fn pick_first_index(v: &[String]) -> usize {
    assert!(!v.is_empty());
    0
}

fn main() {
    let mut v = vec!["a".to_string(), "b".to_string()];
    let idx = pick_first_index(&v);

    v.push("c".to_string());
    println!("{}", v[idx]);
}

이 패턴은 특히 파서/컴파일러/게임 ECS처럼 “참조를 들고 오래 살아야 하는” 구조에서 효과적입니다.

패턴 5) 구조체 메서드에서 자기 자신을 두 번 빌리는 문제: 필드 분해(destructuring)

다음은 self를 가변으로 빌린 상태에서, 다른 필드를 또 빌리며 충돌하는 형태입니다.

struct App {
    buf: Vec<u8>,
    pos: usize,
}

impl App {
    fn push_byte(&mut self, b: u8) {
        let p = &mut self.pos;
        self.buf.push(b); // E0499 류로 이어질 수 있음(상황에 따라)
        *p += 1;
    }
}

해결: 필요한 필드를 먼저 로컬로 꺼내거나, 필드 분해로 “서로 다른 필드”임을 명확히

struct App {
    buf: Vec<u8>,
    pos: usize,
}

impl App {
    fn push_byte(&mut self, b: u8) {
        let App { buf, pos } = self;
        buf.push(b);
        *pos += 1;
    }
}

필드 분해는 컴파일러가 “동일한 self에 대한 중복 빌림”이 아니라 “서로 다른 필드에 대한 독립적 빌림”으로 추론할 여지를 줍니다.

패턴 6) 정말로 동시에 읽고/써야 한다면: 내부 가변성(RefCell, Mutex, RwLock)

소유권 규칙은 기본적으로 컴파일 타임에 안전을 증명하려고 합니다. 하지만 프로그램 구조상 “동시에 잡아야만” 하는 경우가 있습니다.

  • 단일 스레드에서 런타임 검사로 충분: RefCell<T>
  • 멀티 스레드 공유: Mutex<T> 또는 RwLock<T>

RefCell로 E0502/E0499를 우회(런타임 borrow 체크)

use std::cell::RefCell;

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

    {
        let first = v.borrow()[0]; // 불변 borrow
        println!("{first}");
    } // 불변 borrow 종료

    v.borrow_mut().push(4); // 가변 borrow
}

RefCell은 규칙을 없애는 게 아니라 검사를 런타임으로 옮기는 것입니다. 잘못 쓰면 패닉이 날 수 있으니, “구조적으로 컴파일 타임에 풀 수 없는가?”를 먼저 확인한 뒤 선택하세요.

실무 체크리스트: E0502/E0499가 뜨면 이 순서로 본다

  1. 참조 변수(let r = &...)가 스코프 끝까지 살아있는지 확인하고, 블록으로 감싸 수명 단축
  2. 불변 참조가 필요하다면 값 복사(Copy) 또는 clone으로 참조 자체를 없애기
  3. 컬렉션에서 두 원소를 &mut로 잡아야 하면 split_at_mut로 비겹침을 증명
  4. 순회하면서 수정하려면 2패스(키 수집 후 수정) 또는 entry/전용 API로 재구성
  5. 구조체 메서드에서 꼬이면 필드 분해로 빌림 단위를 필드로 좁히기
  6. 정말 불가피하면 RefCell/Mutex/RwLock로 내부 가변성(대신 런타임 비용/데드락/패닉 리스크 관리)

디버깅 팁: “컴파일러가 생각하는 수명”을 눈으로 확인하기

  • 참조를 만든 줄과, 마지막으로 사용한 줄 사이에 가변 접근이 있는지 찾습니다.
  • 참조가 마지막으로 사용되는 지점을 앞당길 수 있는지(출력/계산 순서 변경) 봅니다.
  • “참조를 반환하는 함수”가 있다면, 인덱스/키/핸들 반환로 바꿀 수 있는지 검토합니다.

이 과정은 시스템 장애 원인을 추적할 때 로그/스택을 좁혀가는 것과 유사합니다. 운영 환경에서 원인 추적 루틴을 정리해둔 글로는 systemd 서비스 자동 재시작 원인 추적 가이드도 참고할 만합니다.

마무리: “빌림 충돌”은 버그가 아니라 설계 피드백이다

E0502/E0499는 처음엔 귀찮지만, 익숙해지면 코드의 데이터 흐름을 더 명확하게 만들라는 신호로 읽힙니다. 특히 아래 두 가지를 습관화하면 체감 난이도가 급격히 내려갑니다.

  • 참조는 짧게 들고, 필요하면 값/인덱스로 바꾼다
  • 동시에 &mut가 필요하면 API(split_at_mut, entry)로 안전을 증명한다

다음에 같은 에러를 만나면, 에러 메시지를 “규칙 위반”으로 보지 말고 “수명/접근 단위를 줄여라”라는 리팩터링 힌트로 받아들이면 해결 속도가 훨씬 빨라집니다.