Published on

Rust 소유권 때문에 E0502 뜰 때 리팩토링 7가지

Authors

서로 다른 곳을 읽고 쓰는 것처럼 보이는데도 Rust가 E0502를 내면, 대부분은 “빌림의 수명(lifetime)이 생각보다 길게 잡혔다”거나 “같은 컨테이너를 통째로 빌려버렸다”는 신호입니다. E0502불변 참조가 살아있는 동안 같은 값을 가변으로 빌리려 할 때 발생합니다.

이 글은 E0502가 뜨는 코드를 컴파일러 관점에서 다시 쪼개는 7가지 리팩토링을 제공합니다. 단순히 clone()으로 덮는 대신, 성능·가독성·확장성을 같이 잡는 방향을 우선합니다.

관련해서 E0502E0499를 빠르게 감 잡고 싶다면 아래 글도 함께 보면 좋습니다.

E0502를 “상황”으로 이해하기

전형적인 패턴은 이렇습니다.

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

    let first = &v[0];      // 불변 빌림 시작
    v.push(4);              // 가변 빌림 필요
    println!("{}", first); // 불변 빌림이 여기까지 살아있다고 판단
}

사람 눈에는 push 전에 first를 읽기만 했고, push는 끝난 뒤 first를 출력하니 “괜찮지 않나?” 싶습니다. 하지만 push는 재할당(reallocation)으로 메모리 주소가 바뀔 수 있고, 그 사이 &v[0]가 무효화될 수 있습니다. Rust는 이를 컴파일 타임에 차단합니다.

이제부터는 이런 충돌을 구조적으로 제거하는 방법들입니다.

1) 빌림 범위를 줄이기: 스코프를 쪼개거나 drop()

가장 먼저 시도할 것은 “불변 빌림이 생각보다 오래 살아있는 구간”을 줄이는 것입니다.

스코프 블록으로 끊기

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

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

    v.push(4);
}

명시적으로 drop()

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

    let first = &v[0];
    println!("{}", first);
    std::mem::drop(first); // 불변 빌림 종료를 명확히

    v.push(4);
}
  • 장점: 최소 수정, 의도가 명확
  • 주의: drop()은 참조 자체를 버리는 것이지, 값을 해제하는 게 아닙니다

2) “참조” 대신 “값”을 들고 있기: Copy/clone을 전략적으로

참조가 문제면, 필요한 순간에 값을 복사해 참조 수명을 없애는 것이 가장 간단합니다.

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

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

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()은 비용이 있을 수 있으니 “정말 필요한 데이터만” 복사

3) 인덱싱 대신 split_at_mut로 “서로 다른 구간”을 증명하기

벡터의 서로 다른 원소를 동시에 다루고 싶은데 E0502/E0499가 뜨는 경우가 많습니다. 이때 핵심은 컴파일러가 두 참조가 겹치지 않음을 증명할 수 있어야 한다는 점입니다.

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

split_at_mut는 슬라이스를 두 조각으로 나누며, 두 조각이 겹치지 않는다는 사실을 타입 시스템으로 보장합니다.

  • 장점: 복사 없이 안전하게 다중 가변 참조
  • 적용처: 배열/벡터에서 “서로 다른 인덱스”를 동시에 수정하는 로직

4) 읽기-계산-쓰기 3단계로 분리: 중간 결과를 먼저 만들기

E0502는 “읽으면서 동시에 쓰는” 형태에서 많이 납니다. 해결은 보통 읽기 단계에서 필요한 정보를 모두 수집하고, 그 다음에 쓰기를 수행하는 구조로 바꾸는 것입니다.

문제 예시

use std::collections::HashMap;

fn bump(map: &mut HashMap<String, i32>, key: &str) {
    let v = map.get(key).unwrap(); // 불변 빌림
    map.insert(key.to_string(), v + 1); // 가변 빌림
}

리팩토링: 계산을 먼저 끝내기

use std::collections::HashMap;

fn bump(map: &mut HashMap<String, i32>, key: &str) {
    let next = map.get(key).copied().unwrap_or(0) + 1;
    map.insert(key.to_string(), next);
}
  • 장점: 로직이 “데이터 흐름”으로 읽혀 유지보수에 유리
  • 팁: copied()/cloned()로 참조를 값으로 바꿔 수명을 끊기

5) HashMapentry()로 끝내기: 불변 조회 후 가변 삽입 금지

HashMap에서 get()으로 읽고 insert()로 쓰는 조합은 자주 충돌합니다. entry() API는 이 패턴을 위해 존재합니다.

use std::collections::HashMap;

fn bump(map: &mut HashMap<String, i32>, key: &str) {
    *map.entry(key.to_string()).or_insert(0) += 1;
}
  • 장점: 더 짧고 빠른 경우가 많음(해시 1회)
  • 적용처: 카운팅, 누적, 기본값 설정

6) 컨테이너를 통째로 빌리지 말고 “필드 단위”로 쪼개기

구조체에서 self를 불변으로 잡아둔 채 self의 다른 필드를 가변으로 바꾸려 하면 E0502가 납니다. 해결은 필드 참조를 먼저 분리하거나, 아예 데이터 구조를 분리하는 것입니다.

문제 예시

struct App {
    cache: Vec<String>,
    log: Vec<String>,
}

impl App {
    fn do_work(&mut self) {
        let first = self.cache.first(); // 불변 빌림
        self.log.push(format!("first={:?}", first)); // 가변 빌림
    }
}

리팩토링 A: 필요한 값만 뽑아오기

impl App {
    fn do_work(&mut self) {
        let first = self.cache.first().cloned();
        self.log.push(format!("first={:?}", first));
    }
}

리팩토링 B: 필드를 먼저 분해하기

impl App {
    fn do_work(&mut self) {
        let App { cache, log } = self;
        let first = cache.first();
        log.push(format!("first={:?}", first));
    }
}
  • 장점: 구조체 메서드에서 특히 효과적
  • 주의: 분해 후에도 동일 필드를 동시에 가변/불변으로 잡으면 여전히 충돌

7) 내부 가변성으로 설계를 바꾸기: RefCell/RwLock/Mutex

어떤 경우는 “한 스레드에서 순차적으로” 접근하는데도, API 설계상 불변 참조로도 내부를 수정해야 할 때가 있습니다(캐시, 메모이제이션, 그래프 탐색의 방문 마킹 등). 이때는 내부 가변성(interior mutability) 패턴을 고려합니다.

단일 스레드: RefCell

use std::cell::RefCell;

struct Cache {
    hits: RefCell<u64>,
}

impl Cache {
    fn record_hit(&self) {
        *self.hits.borrow_mut() += 1; // 런타임에 빌림 검사
    }
}

멀티 스레드: Mutex 또는 RwLock

use std::sync::{Arc, Mutex};

fn main() {
    let counter = Arc::new(Mutex::new(0u64));

    {
        let mut g = counter.lock().unwrap();
        *g += 1;
    }
}
  • 장점: API를 &self로 유지하면서 내부 상태 변경 가능
  • 단점: RefCell은 런타임 패닉 가능, Mutex/RwLock은 락 비용과 데드락 설계 이슈
  • 권장: “정말로 필요한 곳”에만 최소 범위로 적용

체크리스트: 어떤 리팩토링을 먼저 고를까

  1. 불변 참조가 오래 살아있나? 스코프 분리 또는 drop()
  2. 참조가 꼭 필요한가? Copy/clone으로 값화
  3. 같은 컨테이너의 다른 부분을 동시에 만지나? split_at_mut 또는 데이터 분할
  4. 읽기와 쓰기가 섞였나? 읽기-계산-쓰기 단계 분리
  5. HashMap이면? entry() 우선
  6. self가 문제면? 필드 분해 또는 구조 재배치
  7. 설계상 불변 API로 수정해야 하면? 내부 가변성(RefCell/Mutex/RwLock)

마무리: E0502는 “컴파일러가 증명할 수 있냐”의 문제

E0502를 없애는 핵심은 Rust가 요구하는 규칙을 외우는 게 아니라, 동시에 존재하는 참조들의 관계를 컴파일러가 증명 가능하도록 코드 구조를 바꾸는 것입니다. 위 7가지는 그 증명을 쉽게 만드는 대표적인 리팩토링 도구들입니다.

비슷한 결의 문제를 다른 영역에서 디버깅하는 방식이 궁금하다면, 원인 후보를 체크리스트로 빠르게 좁히는 글도 도움이 됩니다.