Published on

Rust 소유권 에러 E0502/E0507 10분 해결법

Authors

Rust를 쓰다 보면 초반 러닝커브의 대부분은 소유권(ownership)과 빌림(borrowing) 규칙에 부딪히는 경험으로 채워집니다. 그중에서도 E0502(불변/가변 빌림 충돌)와 E0507(빌린 값에서 move 시도)는 빈도가 높고, 에러 메시지는 친절하지만 “어떻게 고칠지”는 감이 안 잡히는 경우가 많습니다.

이 글은 두 에러를 원인 유형으로 빠르게 분류하고, 그 유형별로 가장 짧은 수정 패턴을 제시합니다. 목표는 “이해를 곁들인 10분 해결”입니다.

참고로 이런 류의 문제는 원인을 좁히는 체크리스트가 있으면 빠르게 해결됩니다. 인증/환경 문제를 체크리스트로 푸는 방식이 익숙하다면, 비슷한 접근을 다른 글에서도 참고할 수 있습니다: OpenAI Responses API 401 403 인증오류 점검 가이드

0. 10분 디버깅 루틴(공통)

아래 순서대로만 보면 대부분의 E0502/E0507은 바로 방향이 잡힙니다.

  1. 에러가 가리키는 “첫 번째 빌림 지점”과 “두 번째 충돌 지점”을 각각 확인
  2. 변수의 타입이 T인지 &T인지 &mut T인지, 혹은 스마트 포인터(Rc, RefCell, Arc, Mutex)인지 확인
  3. “빌림이 끝나는 시점”을 코드 상에서 의도적으로 앞당길 수 있는지 확인
  4. move가 필요한지, clone으로 충분한지, 혹은 참조로 바꾸면 되는지 결정

이제 본격적으로 E0502E0507을 분해합니다.

1. E0502: 불변으로 빌린 상태에서 가변으로 빌릴 수 없음

1-1. E0502의 의미를 한 문장으로

E0502같은 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들려고 해서 발생합니다.

Rust 규칙은 단순합니다.

  • &T는 여러 개 OK
  • &mut T는 단 하나만 OK
  • &T&mut T의 공존은 불가(동일 스코프/동일 생명주기에서)

1-2. 가장 흔한 패턴: 읽고 있는 동안 수정하려고 함

다음 코드는 전형적인 E0502입니다.

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

    let first = &v[0]; // 불변 빌림 시작

    v.push(4); // 가변 빌림 필요 -> E0502

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

해결 패턴 A: 불변 빌림의 생명주기를 짧게(스코프 분리)

가장 “Rust다운” 해결은 불변 참조를 더 일찍 끝내는 것입니다.

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

    let first_val = v[0]; // 복사 타입(i32)이므로 값 복사

    v.push(4);

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

i32처럼 Copy인 타입은 참조를 잡지 말고 값을 복사하는 게 최단 해결입니다.

만약 String처럼 Copy가 아니라면, 스코프를 쪼갭니다.

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

    {
        let first = &v[0];
        println!("{}", first);
    } // 여기서 불변 빌림 종료

    v.push(String::from("c"));
}

해결 패턴 B: 인덱싱 대신 split_at_mut로 “서로 다른 조각”을 가변 빌림

서로 다른 원소를 동시에 만지고 싶은데 빌림이 꼬이면, split_at_mut가 정석입니다.

fn swap_first_two(v: &mut [i32]) {
    let (left, right) = v.split_at_mut(1);
    let a = &mut left[0];
    let b = &mut right[0];
    std::mem::swap(a, b);
}

split_at_mut는 컴파일러가 “서로 다른 메모리 구간”임을 확신할 수 있게 해주므로, 안전하게 복수의 &mut를 만들 수 있습니다.

1-3. 루프에서 자주 터지는 E0502: 순회하면서 수정

다음은 순회(iter)는 불변 빌림인데, 내부에서 수정하려 해서 터집니다.

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

    for x in v.iter() { // v를 불변 빌림
        if *x == 2 {
            v.push(4); // 가변 빌림 시도 -> E0502
        }
    }
}

해결 패턴 C: 2단계 처리(읽기 단계와 쓰기 단계 분리)

실무에서 가장 많이 쓰는 패턴입니다.

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

    let need_push = v.iter().any(|x| *x == 2);

    if need_push {
        v.push(4);
    }
}

또는 “추가할 목록”을 따로 모아 마지막에 반영합니다.

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

    for x in v.iter() {
        if *x == 2 {
            to_add.push(4);
        }
    }

    v.extend(to_add);
}

1-4. HashMap에서 E0502: get하고 insert하려는 순간

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"); // 불변 빌림
    m.insert("b".to_string(), 2); // 가변 빌림 -> E0502

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

해결 패턴 D: entry API로 “읽기+쓰기”를 한 번에

use std::collections::HashMap;

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

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

entry는 소유권/빌림을 컴파일러가 추론하기 쉬운 형태로 묶어줘서, getinsert 같은 충돌을 피할 수 있습니다.

2. E0507: 빌린 컨텍스트에서 move 할 수 없음

2-1. E0507의 의미를 한 문장으로

E0507&T 또는 &mut T 같은 “빌린 값”을 통해 그 내부의 값을 move(소유권 이전)하려고 해서 발생합니다.

즉, “내 것이 아닌데 가져가려고” 할 때 나는 에러입니다.

2-2. 가장 흔한 패턴: &Option<T>에서 T를 꺼내려고 함

fn take_name(user_name: &Option<String>) -> String {
    user_name.unwrap() // E0507: &Option<String>에서 String을 move하려 함
}

해결 패턴 A: 참조로 꺼내기(as_ref)

소유권이 아니라 “보기만” 하면 되는 경우가 많습니다.

fn name_len(user_name: &Option<String>) -> usize {
    user_name.as_ref().map(|s| s.len()).unwrap_or(0)
}

as_ref()Option<String>Option<&String>으로 바꿔서 move를 피합니다.

해결 패턴 B: 정말로 가져가야 한다면, 소유한 쪽에서 take()

가져가려면 원본이 소유하고 있어야 합니다. 그래서 함수 시그니처를 바꾸거나, &mut Option<T>를 받아 take()를 씁니다.

fn take_name(user_name: &mut Option<String>) -> Option<String> {
    user_name.take()
}

take()는 내부 값을 꺼내고 원본을 None으로 바꿉니다. 이 패턴은 상태 머신, 세션/캐시 핸들링에 특히 자주 등장합니다.

2-3. &String에서 String을 move하려는 실수

fn into_upper(s: &String) -> String {
    *s // E0507
}

해결 패턴 C: clone 또는 to_owned

소유한 String이 필요하면 복제 비용을 지불해야 합니다.

fn into_upper(s: &String) -> String {
    s.to_owned().to_uppercase()
}

더 나은 선택지는, 애초에 &str을 받는 것입니다.

fn into_upper(s: &str) -> String {
    s.to_uppercase()
}

API 설계에서 &String보다 &str이 범용적이고 빌림 규칙도 단순해지는 경우가 많습니다.

2-4. 구조체 필드를 move하려다 E0507: &self 메서드에서 필드 꺼내기

struct User {
    name: String,
}

impl User {
    fn name(self: &User) -> String {
        self.name // E0507
    }
}

해결 패턴 D: 반환을 참조로 바꾸기

impl User {
    fn name(&self) -> &str {
        &self.name
    }
}

해결 패턴 E: 소유권을 넘기고 싶으면 self를 소비하기

impl User {
    fn into_name(self) -> String {
        self.name
    }
}

이건 “이 메서드를 호출하면 User는 더 이상 못 쓴다”는 명확한 신호가 됩니다.

해결 패턴 F: 부분 move가 필요하면 mem::take 또는 replace

&mut self가 있고 필드를 비워도 된다면 다음이 실전에서 매우 유용합니다.

use std::mem;

struct User {
    name: String,
}

impl User {
    fn take_name(&mut self) -> String {
        mem::take(&mut self.name)
    }
}

mem::take는 해당 타입의 Default 값으로 교체합니다(String은 빈 문자열). Default가 없다면 mem::replace로 원하는 값으로 교체하세요.

3. E0502와 E0507을 “원인별로” 고르는 치트시트

3-1. E0502 치트시트

  • 읽기(&T)와 쓰기(&mut T)가 겹친다
    • 해결: 스코프 분리, 중간값을 복사/계산 후 빌림 종료, 읽기/쓰기 2단계 처리
  • 컬렉션에서 서로 다른 원소를 동시에 &mut로 잡고 싶다
    • 해결: split_at_mut, 또는 인덱스 설계를 바꿔 “서로 다른 조각”임을 증명
  • HashMap에서 조회 후 수정
    • 해결: entry 사용

3-2. E0507 치트시트

  • &T에서 T를 꺼내려 한다
    • 해결: 참조로 꺼내기(as_ref, iter, get 등)
  • 정말로 소유권이 필요하다
    • 해결: 함수가 T를 받게 바꾸기, 혹은 &mut Option<T>에서 take()
  • 구조체 필드를 빼고 싶다
    • 해결: 반환을 참조로, 또는 self 소비, 또는 mem::take/replace

4. 실전 예제: E0502/E0507이 연쇄로 터질 때(리팩터링 순서)

실무에서는 두 에러가 같이 나오는 경우가 많습니다. 예를 들어, 어떤 상태를 읽고(불변 빌림) 조건에 따라 상태를 업데이트(가변 빌림)하면서, 중간에 필드를 꺼내(move)려 할 때입니다.

아래는 의도적으로 꼬아둔 예시입니다.

#[derive(Default)]
struct State {
    last_msg: Option<String>,
    count: usize,
}

fn process(state: &mut State) {
    let msg_ref = state.last_msg.as_ref(); // 불변 빌림

    if let Some(m) = msg_ref {
        if m.contains("ok") {
            state.count += 1; // 가변 빌림 필요 -> E0502 가능
        }
    }

    // 그리고 나중에 last_msg를 꺼내고 싶어짐
    // let owned = state.last_msg.unwrap(); // E0507 가능
}

권장 리팩터링 순서

  1. 먼저 읽기 결과를 “값”으로 축약해서 불변 빌림을 끝냅니다.
fn process(state: &mut State) {
    let is_ok = state
        .last_msg
        .as_ref()
        .map(|m| m.contains("ok"))
        .unwrap_or(false);

    if is_ok {
        state.count += 1;
    }
}
  1. 그 다음 소유권이 필요하면 take()로 명확히 꺼냅니다.
fn process_and_take(state: &mut State) -> Option<String> {
    let is_ok = state
        .last_msg
        .as_ref()
        .map(|m| m.contains("ok"))
        .unwrap_or(false);

    if is_ok {
        state.count += 1;
    }

    state.last_msg.take()
}

이 흐름은 “읽기 판단을 먼저 끝내고, 쓰기/이동은 나중에”라는 원칙을 코드로 강제합니다.

5. 언제 RefCell 같은 내부 가변성을 써야 하나

E0502를 만날 때마다 RefCell로 도망가고 싶은 유혹이 있습니다. 하지만 내부 가변성은 컴파일 타임 보장런타임 체크로 바꾸는 선택입니다.

  • 단일 스레드에서, 구조상 빌림이 복잡하지만 논리적으로 안전함이 명확할 때: RefCell이 유용
  • 멀티 스레드에서 공유 갱신: Arc + Mutex/RwLock 고려
  • 단순히 스코프/데이터 흐름을 정리하면 해결되는 문제: 먼저 리팩터링이 우선

운영 환경에서 “원인과 해결을 분리해서 재현하고 고친다”는 태도는 다른 장애 대응에도 그대로 적용됩니다. 예를 들어 배포 동기화 실패를 원인별로 쪼개는 글도 같은 결입니다: Argo CD Sync 실패 comparisonError 원인·해결

6. 마무리: 10분 안에 끝내는 핵심만 다시

  • E0502불변 빌림이 살아있는 동안 가변 빌림을 만들었는지부터 본다
    • 스코프 분리, 2단계 처리, entry, split_at_mut가 주력
  • E0507빌린 값에서 move를 시도했는지를 본다
    • 참조로 바꾸기(as_ref), 소유권을 받도록 API 변경, take/mem::take가 주력

이 두 에러는 “Rust가 까다롭다”의 상징처럼 느껴지지만, 사실은 데이터 흐름을 명확히 만들라는 리팩터링 신호에 가깝습니다. 위 패턴들을 머릿속에 치트시트로 넣어두면, 실제로는 컴파일러가 가장 빠른 코드 리뷰어가 되어줍니다.