Published on

Rust E0502/E0507 한방에 끝내는 소유권·빌림

Authors

서버 장애를 보면 로그 한 줄이 원인을 말해주듯, Rust에선 E0502/E0507이 소유권과 빌림의 충돌 지점을 정확히 찍어줍니다. 문제는 메시지를 읽고도 "그래서 뭘 어떻게 바꾸라는 거지"에서 막힌다는 점입니다. 이 글은 E0502(가변/불변 빌림 충돌)와 E0507(빌린 값에서 이동)만 집중해서, 어떤 코드가 왜 깨지고 어떻게 고치면 되는지 패턴으로 정리합니다.

실무 디버깅 관점의 글이 필요하다면, 비슷하게 "증상-원인-해결"로 파고드는 글로 systemd 서비스 재시작 루프 진단 - 로그·유닛·쉘도 함께 참고하면 접근 방식이 비슷해서 도움이 됩니다.

E0502: 불변으로 빌려놓고 가변으로 또 빌릴 수 없다

에러 요약

  • 이미 &T(불변 참조)로 빌린 값이 살아있는 동안
  • 같은 값을 &mut T(가변 참조)로 빌리려 하면
  • 컴파일러가 데이터 레이스 가능성을 차단하며 E0502를 냅니다.

Rust의 핵심 규칙을 한 줄로 줄이면 다음입니다.

  • 같은 스코프에서 &T는 여러 개 가능
  • &mut T는 오직 하나만 가능
  • &T&mut T는 동시에 공존 불가

흔한 실패 예제 1: 불변 참조를 잡아둔 채로 push

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

    let first = &v[0];
    v.push(4); // E0502

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

왜 실패하나

firstv 내부를 가리키는 불변 참조입니다. 그런데 v.push(4)는 벡터의 재할당(reallocation)을 일으킬 수 있고, 그러면 first가 가리키던 주소가 무효가 될 수 있습니다. Rust는 이 가능성 자체를 금지합니다.

해결 1: 참조의 생존 범위를 줄이기

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

    {
        let first = &v[0];
        println!("{}", first);
    }

    v.push(4);
}

핵심은 "불변 참조가 더 이상 필요 없을 때 빨리 떨어뜨리기"입니다. 중괄호 블록은 가장 단순한 방법입니다.

해결 2: 값 복사(또는 복제)로 참조 자체를 없애기

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

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

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

Copy 타입이면 참조 대신 값을 복사해두는 편이 가장 깔끔합니다. String 같은 비 Copy 타입이면 clone()을 고려할 수 있습니다.

fn main() {
    let mut v = vec![String::from("a"), String::from("b")];

    let first = v[0].clone();
    v.push(String::from("c"));

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

clone()은 비용이 있으니, 데이터 크기와 호출 빈도를 보고 판단하세요.

흔한 실패 예제 2: 불변으로 읽고, 같은 스코프에서 가변으로 수정

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

    let len = s.len();
    let r = &s;

    s.push('!'); // E0502

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

해결: 읽기와 쓰기를 단계로 분리

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

    let len = s.len();
    s.push('!');

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

"읽기 단계"와 "쓰기 단계"를 분리하면 대부분의 E0502는 사라집니다. Rust는 특히 "참조가 살아있는 기간"을 기준으로 판단하므로, 변수 선언 순서와 스코프가 곧 해결책인 경우가 많습니다.

실전 패턴: iterator와 collect로 빌림 충돌을 없애기

다음처럼 한 컬렉션을 순회하면서 동시에 수정하려 하면 E0502가 자주 납니다.

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

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

해결은 "읽기 대상"과 "쓰기 대상"을 분리하는 것입니다.

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

    let to_add: Vec<i32> = v.iter().copied().filter(|x| x % 2 == 0).collect();
    v.extend(to_add);

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

이 패턴은 성능도 예측 가능하고, 코드 리뷰에서도 의도가 명확합니다.

E0507: 빌린 값에서 move 할 수 없다

에러 요약

  • 어떤 값을 &T 또는 &mut T로 "빌려" 쓰는 중인데
  • 그 값을 소유권 이동(move)하려고 하면
  • Rust가 "너 그거 소유자 아니잖아"라며 E0507을 냅니다.

흔한 실패 예제 1: &T에서 String을 꺼내 move

fn take(s: String) {
    println!("{}", s);
}

fn main() {
    let s = String::from("hi");
    let r = &s;

    take(*r); // E0507
}

*rString 값을 "복사"가 아니라 "이동"하려는 시도입니다. StringCopy가 아니므로, 소유권이 필요합니다.

해결 1: 참조로 받도록 함수 시그니처 변경

fn take_ref(s: &str) {
    println!("{}", s);
}

fn main() {
    let s = String::from("hi");
    take_ref(&s);
}

가능하다면 가장 좋은 해결은 "소유권을 요구하지 않게" API를 바꾸는 것입니다. 특히 출력, 조회, 검증 같은 읽기 전용 로직은 &str, &T로 받는 편이 좋습니다.

해결 2: clone()으로 명시적으로 소유권을 만들어 move

fn take(s: String) {
    println!("{}", s);
}

fn main() {
    let s = String::from("hi");
    let r = &s;

    take(r.clone());
}

복제가 의도에 맞고 비용이 감당 가능할 때 쓰는 해결책입니다. 중요한 건 "move가 필요해서 clone을 한다"는 의도를 코드로 드러낸다는 점입니다.

흔한 실패 예제 2: &mut Option<T>에서 T를 꺼내 move

아래 코드는 Option 안의 값을 꺼내서 쓰고 싶을 때 자주 시도합니다.

fn main() {
    let mut opt = Some(String::from("token"));

    let r = &mut opt;
    let v = r.unwrap(); // E0507

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

unwrap()Option<T>를 소비(consuming)합니다. 그런데 지금 가진 건 &mut Option<T>라서 소비할 수 없습니다.

해결 1: take()로 안전하게 빼기

fn main() {
    let mut opt = Some(String::from("token"));

    let v = opt.take().unwrap();
    // opt는 이제 None

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

take()는 내부 값을 빼고 자리를 None으로 남깁니다. "자리 비우고 가져가기"가 의도라면 정답에 가깝습니다.

해결 2: as_ref() 또는 as_mut()로 참조로만 다루기

값을 "꺼내서 소유"할 필요가 없고, 읽기만 하면 된다면 참조로 바꾸세요.

fn main() {
    let opt = Some(String::from("token"));

    let v: &String = opt.as_ref().unwrap();
    println!("{}", v);
}

흔한 실패 예제 3: 구조체 필드를 빌린 상태에서 필드를 move

#[derive(Debug)]
struct User {
    name: String,
    email: String,
}

fn main() {
    let mut u = User {
        name: String::from("a"),
        email: String::from("a@example.com"),
    };

    let name_ref = &u.name;
    let email = u.email; // E0507 또는 관련 move 에러

    println!("{} {}", name_ref, email);
}

이 상황은 "한 필드를 참조로 잡아둔 채" 다른 필드를 move 하려 해서 구조체 전체가 부분적으로 이동(partial move)되면서 꼬이는 전형적인 케이스입니다.

해결 1: 스코프를 분리해 참조를 먼저 끝내기

#[derive(Debug)]
struct User {
    name: String,
    email: String,
}

fn main() {
    let mut u = User {
        name: String::from("a"),
        email: String::from("a@example.com"),
    };

    {
        let name_ref = &u.name;
        println!("{}", name_ref);
    }

    let email = std::mem::take(&mut u.email);
    println!("{}", email);
}

여기서 std::mem::take는 해당 필드를 기본값으로 대체하고(이 경우 빈 문자열), 원래 값을 소유권으로 가져옵니다.

해결 2: 구조를 바꿔서 소유권 이동을 더 명시적으로

필드를 자주 "꺼내" 써야 한다면, 애초에 필드를 Option<String>으로 두고 take()를 쓰는 설계가 더 자연스러운 경우가 많습니다.

#[derive(Debug)]
struct User {
    name: String,
    email: Option<String>,
}

fn main() {
    let mut u = User {
        name: String::from("a"),
        email: Some(String::from("a@example.com")),
    };

    let email = u.email.take().unwrap();
    println!("{}", email);
    println!("{:?}", u);
}

E0502/E0507을 빠르게 푸는 체크리스트

1) "참조가 살아있는 범위"부터 줄여라

  • 중괄호 블록으로 스코프를 분리
  • println! 같은 사용 지점을 앞당겨 참조를 빨리 소멸
  • 불변 참조를 잡아둔 채로 push, insert, remove, retain 같은 가변 작업을 하지 않기

2) "소유권이 필요한가"를 먼저 결정하라

  • 읽기 전용이면 &T, &str로 API를 바꾸는 것이 최선
  • 정말 소유권이 필요하면 clone()을 의도적으로 사용
  • 컨테이너에서 꺼내야 하면 take(), mem::take, mem::replace를 우선 검토

3) 읽기와 쓰기를 두 단계로 나눠라

  • 1단계: 필요한 데이터만 추출(collect, map, filter)
  • 2단계: 원본을 변경(extend, push, splice)

이 방식은 대규모 데이터 처리에서도 디버깅이 쉽고, 장애 대응처럼 "원인 격리"가 잘 됩니다. 비슷한 사고방식으로 병목을 줄이는 튜닝 사례는 Rust+Qdrant RAG - HNSW 튜닝으로 지연 50%↓도 참고할 만합니다.

자주 쓰는 해결 도구 모음

Option<T>: as_ref, as_mut, take

fn main() {
    let mut opt = Some(String::from("x"));

    // 빌려서 보기
    let r: &String = opt.as_ref().unwrap();
    println!("{}", r);

    // 소유권으로 꺼내기
    let v: String = opt.take().unwrap();
    println!("{}", v);
    println!("{:?}", opt);
}

std::mem::takestd::mem::replace

#[derive(Debug)]
struct State {
    buf: String,
}

fn main() {
    let mut s = State { buf: String::from("payload") };

    let owned = std::mem::take(&mut s.buf);
    // s.buf는 이제 빈 문자열

    println!("owned={}", owned);
    println!("state={:?}", s);
}

split_at_mut: 슬라이스를 안전하게 둘로 쪼개 가변 참조 2개 만들기

E0502가 날 때 "같은 배열을 두 군데에서 동시에 &mut로 쓰고 싶다"는 요구가 숨어있는 경우가 많습니다. 이때는 인덱스 기반으로 안전 분할을 하세요.

fn main() {
    let mut a = [10, 20, 30, 40];

    let (left, right) = a.split_at_mut(2);
    left[0] += 1;
    right[0] += 2;

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

마무리: 에러를 없애는 게 아니라 설계를 정렬하는 과정

E0502는 "동시에 읽고 쓰려는" 설계 충돌을, E0507은 "소유권 없이 이동하려는" 설계 충돌을 드러냅니다. 그래서 해결책은 대개 문법 트릭이 아니라 다음 중 하나로 귀결됩니다.

  • 참조 수명을 줄여서 충돌 구간을 없앤다
  • 소유권이 필요한지 재정의하고 API를 바꾼다
  • 컨테이너에서 값을 꺼내는 정식 도구(take, mem::take, replace)를 쓴다
  • 읽기 단계와 쓰기 단계를 분리해 데이터 흐름을 단순화한다

이 네 가지를 습관처럼 적용하면 E0502/E0507은 "공포의 벽"이 아니라 "설계 가이드"가 됩니다.