Published on

Rust 소유권 - E0502/E0499 대출 충돌 해결

Authors

Rust를 쓰다 보면 컴파일러가 친절하게도 빌림 규칙을 어긴 지점을 정확히 찔러줍니다. 그중에서도 가장 자주 보는 에러가 E0502(불변/가변 대출 충돌)과 E0499(가변 대출 중복)입니다.

이 글은 두 에러를 “왜 나는지”보다 “어떻게 빨리 고치느냐”에 초점을 맞춥니다. 특히 실제 코드에서 자주 등장하는 컬렉션 조작, HashMap 엔트리 업데이트, 반복문 내부에서의 참조 유지 같은 상황을 패턴별로 정리합니다.

참고로, 이런 문제는 네트워크 403이나 시스템 장애처럼 겉으로는 단순 증상이지만 원인이 여러 갈래로 갈라지는 유형입니다. 진단 체크리스트를 먼저 세우는 접근은 다른 분야에서도 유효합니다. 예를 들어 systemd 서비스 무한 재시작 10분 진단 체크리스트 같은 글의 방식이 Rust 에러 트러블슈팅에도 그대로 통합니다.

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

E0502: 불변 참조가 살아있는 동안 가변 참조를 만들었다

  • 이미 &T(불변 참조)를 빌려서 사용 중인데
  • 같은 값에 대해 &mut T(가변 참조)를 만들려고 하면
  • “읽는 중인데 동시에 쓰려 한다”로 간주되어 거절됩니다.

E0499: 가변 참조를 동시에 두 번 만들었다

  • &mut T는 “유일해야” 합니다.
  • 같은 값에 대해 &mut를 두 개 만들거나
  • 첫 번째 &mut가 아직 살아있는데 두 번째 &mut를 만들면
  • 컴파일러가 즉시 차단합니다.

핵심은 “참조가 살아있는 범위(scope)”입니다. 대부분의 해결책은 참조의 생존 범위를 줄이거나, 한 번에 하나의 참조만 존재하도록 코드 구조를 바꾸는 방식으로 귀결됩니다.

먼저 해볼 것: 에러 메시지에서 ‘살아있는 참조’ 범위를 찾기

Rust 에러는 보통 다음 정보를 줍니다.

  • 첫 번째 빌림이 발생한 위치
  • 두 번째(충돌하는) 빌림이 발생한 위치
  • 첫 번째 빌림이 언제까지 사용되는지(즉, 살아있는지)

이때 “첫 번째 빌림이 생각보다 오래 살아있다”가 대부분의 함정입니다. 특히 다음이 자주 원인입니다.

  • println!/format!/dbg! 같은 매크로가 참조를 더 오래 잡고 있는 경우
  • 반복문에서 참조를 변수에 저장해 루프 바깥까지 이어지는 경우
  • 이터레이터 체인(예: map, filter)이 참조를 캡처한 채로 뒤에서 소비되는 경우

패턴 1) 스코프를 쪼개서 참조 생존 범위 줄이기

가장 간단하고, 비용도 거의 없는 해결법입니다.

문제 코드(E0502)

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

    let first = &v[0]; // 불변 대출

    v.push(4); // 가변 대출이 필요 (재할당 가능성)

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

v.push(4)v에 대한 가변 접근이 필요합니다. 그런데 firstv를 불변으로 빌린 채 살아있으니 충돌합니다.

해결: 불변 참조를 더 빨리 끝내기

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

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 first의 스코프 종료

    v.push(4);
}

또는 “참조를 값으로 복사”할 수 있다면 더 간단합니다.

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

    let first = v[0]; // i32는 Copy라서 값 복사
    v.push(4);
    println!("{}", first);
}

Copy 가능한 타입이면 참조를 유지할 이유가 없습니다.

패턴 2) 읽기와 쓰기 단계를 분리(2-phase)하기

대출 충돌은 “읽으면서 동시에 쓰기”에서 터집니다. 해결은 읽기 단계에서 필요한 정보를 미리 계산해 값으로 들고 나오고, 쓰기 단계에서 변경을 수행하는 것입니다.

문제 코드(불변 참조를 잡은 채로 수정)

fn bump_if_contains(v: &mut Vec<i32>, x: i32) {
    let found = v.iter().find(|n| **n == x); // found는 &i32를 포함
    if found.is_some() {
        v.push(x + 1);
    }
}

해결: 불변 참조 대신 불리언/인덱스 등으로 “요약값”을 얻기

fn bump_if_contains(v: &mut Vec<i32>, x: i32) {
    let exists = v.iter().any(|n| *n == x);
    if exists {
        v.push(x + 1);
    }
}

또는 인덱스를 구한 뒤, 그 인덱스로 수정합니다.

fn inc_first_match(v: &mut [i32], x: i32) {
    if let Some(i) = v.iter().position(|n| *n == x) {
        v[i] += 1;
    }
}

여기서 중요한 포인트는 position이 반환하는 것은 usize(값)라서 참조를 붙잡지 않는다는 점입니다.

패턴 3) 같은 슬라이스에서 두 개의 가변 참조가 필요하면 split_at_mut

E0499는 특히 배열/슬라이스에서 “서로 다른 원소를 동시에 바꾸고 싶다”에서 많이 터집니다.

문제 코드(E0499)

fn swap(v: &mut [i32], i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j];
    std::mem::swap(a, b);
}

컴파일러는 ij가 다르다는 걸 일반적으로 증명할 수 없어서, 두 &mut가 같은 위치를 가리킬 가능성을 배제하지 못합니다.

해결: 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는 Rust 표준 라이브러리가 “겹치지 않음”을 보장해 주는 안전한 API라서, 컴파일러가 안심하고 두 &mut를 허용합니다.

패턴 4) HashMap 업데이트는 entry로 해결하는 경우가 많다

HashMap에서 값을 읽고, 조건에 따라 같은 키에 다시 쓰는 흐름은 대출 충돌의 단골입니다.

문제 코드(읽은 참조를 들고 다시 수정)

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");
    if v.is_some() {
        m.insert("a".to_string(), 2);
    }
}

해결: entry로 한 번에 처리

use std::collections::HashMap;

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

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

entry는 내부적으로 “키 탐색”과 “삽입/수정”을 한 흐름으로 묶어 대출 충돌을 피하게 설계되어 있습니다.

패턴 5) 반복문에서 ‘현재 원소 참조’를 오래 들고 있지 않기

다음 형태는 루프가 커질수록 자주 터집니다.

  • 어떤 원소를 &mut로 잡아둔 채
  • 같은 컬렉션을 다시 순회하거나 push/pop을 하려는 경우

문제 코드(개념 예시)

fn normalize(v: &mut Vec<i32>) {
    for i in 0..v.len() {
        let cur = &mut v[i];
        if *cur < 0 {
            v.push(-*cur); // cur이 살아있어서 충돌 가능
        }
    }
}

해결 1: 값을 복사해 판단하고, 수정은 짧게

fn normalize(v: &mut Vec<i32>) {
    let mut to_push = Vec::new();

    for i in 0..v.len() {
        let val = v[i];
        if val < 0 {
            to_push.push(-val);
        }
    }

    v.extend(to_push);
}

해결 2: retain/drain/extend 같은 고수준 API로 재구성

가능하면 “순회하면서 수정” 대신, 표준 라이브러리의 안전한 변환 API를 조합해 두 단계로 처리하세요.

패턴 6) 정말로 ‘동시에 여러 곳에서 바꿔야’ 한다면 내부 가변성(RefCell, Mutex)

빌림 규칙은 컴파일 타임에 안전성을 확보하기 위한 장치입니다. 하지만 구조적으로 “여러 곳에서 같은 데이터를 공유하면서 필요할 때 바꾸는” 모델이 더 자연스러운 경우가 있습니다.

  • 단일 스레드에서 런타임 검사로 충분하면 RefCell/Rc
  • 멀티스레드 공유면 Mutex/RwLock/Arc

RefCell 예시(단일 스레드)

use std::cell::RefCell;

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

    {
        let mut borrow = v.borrow_mut();
        borrow.push(4);
    }

    let sum: i32 = v.borrow().iter().sum();
    println!("{}", sum);
}

주의할 점은, RefCell은 컴파일 타임이 아니라 런타임에 빌림 규칙을 검사한다는 것입니다. 규칙을 어기면 패닉이 납니다. 즉, “컴파일 에러를 런타임 리스크로 옮기는” 선택이므로 꼭 필요한 곳에만 사용하세요.

패턴 7) 함수 시그니처를 바꿔서 소유권/대출을 명확히 만들기

대출 충돌이 반복된다면, 함수 경계에서 소유권이 애매하게 흐르고 있을 가능성이 큽니다.

  • &T로 받아놓고 내부에서 결국 수정이 필요하면 &mut T로 바꾸기
  • 참조로 길게 들고 있어야 한다면 “필요한 값만” 복사/클론해서 반환하기
  • 복잡한 구조를 한 번에 빌리기보다, 필요한 필드만 빌리도록 구조를 쪼개기

예시: 필요한 데이터만 반환

struct User {
    name: String,
    age: u32,
}

fn user_name_len(u: &User) -> usize {
    u.name.len()
}

fn bump_age(u: &mut User) {
    u.age += 1;
}

읽기와 쓰기 API를 분리하면 호출부에서도 자연스럽게 스코프가 분리됩니다.

빠른 진단 체크리스트

에러를 보면 아래 순서로 확인하면 해결 속도가 확 올라갑니다.

  1. 충돌하는 첫 번째 참조가 “생각보다 오래” 살아있지 않은가
  2. 참조 대신 값(Copy, 인덱스, 불리언, 키 복사 등)으로 바꿀 수 없는가
  3. 읽기 단계와 쓰기 단계를 분리할 수 없는가
  4. 같은 컬렉션의 서로 다른 부분을 동시에 바꾸는가
    • 그렇다면 split_at_mut/chunks_mut/iter_mut 패턴으로 재구성
  5. HashMap이면 entry로 바꿀 수 없는가
  6. 공유/순환 참조 구조가 필요한가
    • 필요하다면 Rc/RefCell 또는 Arc/Mutex 고려

문제 해결을 “타입 시스템과의 싸움”으로 보면 끝이 없습니다. 대신 “참조의 생존 범위를 설계한다”로 관점을 바꾸면, 대부분은 구조적으로 깔끔해집니다.

또한 이런 디버깅은 결국 ‘증상-원인-해결책’을 빠르게 매핑하는 능력입니다. 다른 영역의 트러블슈팅 글도 함께 보면 사고방식이 단단해집니다. 예를 들어 런타임 메모리 문제를 체계적으로 쪼개는 Go gRPC 메모리 누수? pprof로 잡는 7단계 같은 접근은 Rust에서 “어떤 참조가 언제까지 살아있나”를 추적할 때도 도움이 됩니다.

마무리: 컴파일러가 요구하는 건 ‘증명’이다

E0502E0499는 Rust가 까다로워서가 아니라, 컴파일러가 안전성을 위해 “동시에 성립할 수 없는 조건”을 코드로 증명하라고 요구하는 것입니다.

  • 스코프를 줄이고
  • 읽기/쓰기 단계를 분리하고
  • 표준 라이브러리의 안전한 분할 API를 쓰고
  • 필요하면 내부 가변성으로 모델을 바꾸면

대부분의 대출 충돌은 깔끔하게 정리됩니다.

다음 단계로는, 실제 프로젝트에서 자주 쓰는 패턴(예: 그래프 구조, 캐시, ECS, 파서)에서 어떤 설계가 대출 충돌을 줄이는지까지 확장해 보면 좋습니다.