Published on

Rust E0502·E0499 소유권 오류 5분 해결법

Authors

Rust를 처음(혹은 오랜만에) 쓰면 가장 빨리 마주치는 벽이 빌림 검사기(borrow checker)입니다. 그중에서도 E0502(불변 빌림과 가변 빌림 충돌), E0499(가변 빌림이 동시에 두 번 발생)만 잡아도 체감 난이도가 확 내려갑니다.

이 글은 “왜 안 되는지”를 길게 설명하기보다, 에러를 5분 안에 해결하는 사고 순서와 코드 패턴을 제공합니다. 아래 예제는 모두 바로 복붙해서 cargo run 혹은 cargo test로 확인할 수 있게 구성했습니다.

참고로 이런 류의 문제는 원인이 명확한데도 로그/에러 메시지 때문에 길을 잃기 쉽습니다. CI에서 재현이 어려울 때는 캐시/동시성 같은 환경 요인도 함께 정리해두면 좋습니다. 예: GitHub Actions 캐시로 Node.js CI 2배 빠르게, 실패 디버깅

1) E0502·E0499 한 줄 정의(해석부터)

E0502: immutable borrow + mutable borrow가 겹침

  • 이미 어떤 값이 불변으로 빌려진 상태에서
  • 같은 값에 대해 가변 빌림을 시도할 때
  • 혹은 그 반대 순서로도 동일하게 발생

에러 메시지에서 핵심은 보통 이런 문장입니다.

  • cannot borrow ... as mutable because it is also borrowed as immutable

E0499: mutable borrow가 동시에 2개

  • 어떤 값에 대해 가변 빌림은 동시에 하나만 허용
  • 두 번째 &mut가 생기는 순간 E0499

에러 메시지에서 핵심은 보통 이런 문장입니다.

  • cannot borrow ... as mutable more than once at a time

2) 5분 해결 체크리스트(순서대로 보면 대부분 끝)

  1. 빌림의 “수명”이 어디까지 이어지는지 먼저 찾기
    • Rust는 “변수 스코프 끝”이 아니라 “마지막 사용 지점”까지 빌림이 이어질 수 있습니다(특히 NLL 이전 감각으로 보면 헷갈림).
  2. 충돌하는 참조가 있다면, 둘 중 하나를 스코프 밖으로 밀어내기
    • { ... } 블록으로 참조를 빨리 끝내기
  3. 참조 대신 값을 쓰도록 복사/클론/추출
    • Copy 타입이면 값 복사로 끝
    • 아니면 clone() 혹은 to_owned()로 소유권 있는 값 만들기
  4. 컨테이너를 동시에 만지면, 인덱싱을 줄이고 API를 바꾸기
    • Vecsplit_at_mut 같은 안전한 분할 API 사용
    • HashMapget_mut/entry로 한 번에 처리
  5. 정말로 “동시 가변 접근”이 필요하면, 내부 가변성(RefCell/Mutex/RwLock) 고려
    • 단, 이건 마지막 카드(런타임 비용/패닉/락 비용)

3) E0502 대표 패턴 4가지와 즉시 해결법

패턴 A: 불변 참조를 잡아둔 채로 수정

문제 코드

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

    let r = &s;        // 불변 빌림
    s.push('!');       // 가변 빌림 시도 -> E0502

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

해결 1: 불변 참조를 더 빨리 끝내기(스코프 분리)

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

    {
        let r = &s;
        println!("{}", r);
    } // 여기서 r의 빌림이 끝남

    s.push('!');
    println!("{}", s);
}

해결 2: 필요한 값만 복사/복제해서 참조를 없애기

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

    let snapshot = s.clone();
    s.push('!');

    println!("before: {}, after: {}", snapshot, s);
}

패턴 B: 불변 참조를 만든 뒤 같은 변수에 &mut를 만듦

문제 코드

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

    let a = &v[0];     // 불변 빌림
    let b = &mut v[1]; // 가변 빌림 -> E0502

    *b += 10;
    println!("{}", a);
}

해결: 인덱싱 대신 “분할” API 사용

Vec에서 서로 다른 원소를 동시에 가변/불변으로 다루려면, 슬라이스를 분리해 서로 다른 영역임을 컴파일러가 알게 해야 합니다.

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

    let (left, right) = v.split_at_mut(1);
    let a = left[0];        // 값 복사(i32는 Copy)
    let b = &mut right[0];  // 원래 v[1]

    *b += 10;
    println!("{}", a);
    println!("{:?}", v);
}

패턴 C: println!/로그 때문에 빌림이 길어짐

로그가 참조를 잡고 있는 동안, 아래에서 가변 접근을 하면 E0502가 납니다.

문제 코드

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

    let r = &s;
    // 디버깅하다가 아래에서 수정하려고 하면 충돌
    // println!("debug: {}", r);

    s.push_str(" world");
    println!("{}", r);
}

해결: 로그를 먼저 찍고 참조 사용을 끝내기

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

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

    s.push_str(" world");
    println!("{}", s);
}

패턴 D: HashMap에서 getget_mut를 섞음

문제 코드

use std::collections::HashMap;

fn main() {
    let mut m = HashMap::from([(String::from("a"), 1)]);

    let cur = m.get("a");        // 불변 빌림
    let cur_mut = m.get_mut("a"); // 가변 빌림 -> E0502

    if let Some(v) = cur_mut {
        *v += 1;
    }

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

해결: entry로 한 번에 처리(가장 깔끔)

use std::collections::HashMap;

fn main() {
    let mut m = HashMap::new();

    *m.entry(String::from("a")).or_insert(0) += 1;

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

4) E0499 대표 패턴 4가지와 즉시 해결법

패턴 A: 같은 값에 &mut를 두 번

문제 코드

fn main() {
    let mut x = 0;

    let a = &mut x;
    let b = &mut x; // E0499

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

해결: 한 번에 끝내거나, 값을 합쳐서 한 번만 빌리기

fn main() {
    let mut x = 0;

    {
        let a = &mut x;
        *a += 1;
    }

    {
        let b = &mut x;
        *b += 1;
    }

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

패턴 B: Vec의 두 원소를 동시에 &mut로 잡기

문제 코드

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

    let a = &mut v[0];
    let b = &mut v[1]; // E0499 (인덱싱은 분리 증명이 안 됨)

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

해결: split_at_mut로 안전 분할

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

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

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

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

패턴 C: 반복문에서 가변 참조를 오래 들고 있음

반복 중에 &mut를 잡고, 같은 컨테이너를 또 접근하려 하면 흔히 E0499가 납니다.

문제 코드

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

    for i in 0..v.len() {
        let x = &mut v[i];
        // 여기서 v를 또 건드리면(예: push) 충돌 가능
        // v.push(4);
        *x += 1;
    }
}

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

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

    // 쓰기만 필요하면 iter_mut가 가장 단순
    for x in v.iter_mut() {
        *x += 1;
    }

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

패턴 D: 구조체 메서드에서 self를 두 번 가변 대여

문제 코드

struct App {
    a: i32,
    b: i32,
}

impl App {
    fn bump_both(&mut self) {
        let x = &mut self.a;
        let y = &mut self.b; // E0499 (self가 이미 가변 대여됨)
        *x += 1;
        *y += 1;
    }
}

fn main() {
    let mut app = App { a: 0, b: 0 };
    app.bump_both();
}

해결: 한 번에 구조 분해로 필드를 분리

struct App {
    a: i32,
    b: i32,
}

impl App {
    fn bump_both(&mut self) {
        let App { a, b } = self;
        *a += 1;
        *b += 1;
    }
}

fn main() {
    let mut app = App { a: 0, b: 0 };
    app.bump_both();
    println!("{} {}", app.a, app.b);
}

5) 그래도 안 풀릴 때: “소유권 설계”를 바꾸는 3가지 옵션

옵션 1: 값을 함수 밖으로 빼서 반환으로 합치기

&mut를 여기저기 전달하기보다, 변경 결과를 반환해 합치는 방식이 더 Rust스럽고 안전합니다.

fn add_suffix(mut s: String) -> String {
    s.push_str("!");
    s
}

fn main() {
    let s = String::from("hello");
    let s = add_suffix(s);
    println!("{}", s);
}

옵션 2: 내부 가변성 RefCell (단일 스레드)

컴파일 타임이 아니라 런타임에 빌림 규칙을 검사합니다. 규칙을 깨면 패닉이 나므로, “정말 필요한 경우만” 쓰는 게 좋습니다.

use std::cell::RefCell;

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

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

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

옵션 3: Mutex/RwLock (멀티 스레드)

공유 상태가 필요하면 락이 정답인 경우도 많습니다. 다만 성능/데드락 설계가 과제가 됩니다.

6) 에러 메시지에서 “딱 여기만” 보면 빨리 끝난다

  • first borrowed here / borrowed here 라인이 첫 빌림 시작점
  • second borrow occurs here 라인이 충돌 지점
  • borrow later used here 라인이 빌림이 끝나지 않는 이유(마지막 사용)

즉, 해결은 보통 아래 셋 중 하나입니다.

  • 마지막 사용을 앞당긴다(로그/출력/참조 사용을 먼저 끝냄)
  • 스코프를 쪼갠다(블록으로 참조 수명 단축)
  • 동시에 접근하지 않게 API/자료구조 사용법을 바꾼다(split_at_mut, entry 등)

7) 마무리: E0502·E0499는 “버그”가 아니라 설계 힌트

E0502/E0499는 Rust가 괴롭히는 게 아니라, 데이터 경쟁/유효하지 않은 참조로 이어질 수 있는 코드를 미리 차단하는 신호입니다. 위 패턴(스코프 분리, 값 스냅샷, 컨테이너 분할, entry 활용)만 익혀도 대부분의 소유권 오류는 5분 안에 정리됩니다.

성능 튜닝이나 빌드/배포 파이프라인에서 문제를 줄이는 것도 결국 같은 결입니다. 작은 규칙을 자동화하고, 충돌 지점을 줄이면 전체 개발 속도가 올라갑니다. CI 최적화가 필요하다면 GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress 같은 글도 함께 참고해두면 도움이 됩니다.