Published on

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

Authors
Binance registration banner

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 같은 글도 함께 참고해두면 도움이 됩니다.