Published on

Rust 소유권 때문에 E0502 뜰 때 리팩터링 6가지

Authors

서버 사이드 Rust를 조금만 깊게 쓰다 보면 한 번은 반드시 마주치는 에러가 E0502입니다. 메시지는 대개 다음 형태로 나옵니다.

  • cannot borrow ... as mutable because it is also borrowed as immutable
  • 혹은 그 반대(가변 빌림이 살아 있는데 불변 빌림 시도)

핵심은 단순합니다. 동일한 값에 대해 불변 참조(&T)가 살아있는 동안 가변 참조(&mut T)를 만들 수 없고, 반대로도 마찬가지입니다. 문제는 “내가 보기엔 이미 안 쓰는데?”라고 생각하는 참조가 스코프/수명(lifetime) 상으로는 아직 살아있게 잡히는 경우가 많다는 점입니다.

이 글에서는 E0502가 자주 터지는 전형적인 패턴을 먼저 짚고, 실전에서 가장 효과가 컸던 리팩터링 6가지를 코드로 정리합니다. (에러를 억지로 피해가는 게 아니라, 코드의 의도를 더 명확하게 만드는 방향입니다.)

비슷한 결의 디버깅 글로는 경고를 안전하게 고치는 접근을 다룬 pandas SettingWithCopyWarning 안전 수정 7가지도 참고할 만합니다. “경고/에러를 억누르기보다 구조를 바꿔 안전하게 만든다”는 관점이 유사합니다.

E0502가 나는 대표 상황

예를 들어 아래 코드는 매우 흔합니다. Vec에서 값을 읽어 조건을 판단하고, 같은 Vec를 수정하려는 코드입니다.

fn bump_if_positive(v: &mut Vec<i32>) {
    let first = &v[0];

    if *first > 0 {
        v[0] += 1;
    }
}

firstv불변으로 빌린 참조인데, 그 참조가 if 블록을 지나기 전까지 살아있다고 컴파일러가 판단하면 v[0] += 1에서 가변 빌림이 충돌합니다.

이제부터는 이런 충돌을 “소유권 규칙을 존중하는 형태”로 정리하는 6가지 방법을 봅니다.

1) 불변 참조를 값으로 복사/클론해서 수명 줄이기

가장 단순하면서 효과적인 방법은 참조를 오래 들고 있지 않는 것입니다. Copy 타입이면 값 복사로 끝나고, 아니면 필요한 만큼만 clone합니다.

fn bump_if_positive(v: &mut Vec<i32>) {
    let first = v[0]; // i32는 Copy

    if first > 0 {
        v[0] += 1;
    }
}

first가 더 이상 &v[0]가 아니라 값이므로, 이후 v를 가변으로 빌려도 충돌이 없습니다.

  • 장점: 가장 빠르게 해결
  • 단점: 큰 구조체를 clone하면 비용이 커질 수 있음

이 패턴은 특히 HashMap에서 get으로 꺼낸 참조를 들고 있다가 같은 맵에 insert하는 코드에서 자주 씁니다.

2) “읽기 단계”와 “쓰기 단계”를 분리하기(두 단계 계산)

E0502는 흔히 읽으면서 동시에 수정하려는 흐름에서 발생합니다. 이때는 로직을 두 단계로 쪼개면 깔끔해집니다.

fn normalize(v: &mut Vec<i32>) {
    // 1) 읽기 단계: 필요한 정보만 계산
    let max = v.iter().copied().max().unwrap_or(1);

    // 2) 쓰기 단계: 이제는 v를 마음껏 수정
    for x in v.iter_mut() {
        *x /= max;
    }
}

여기서 포인트는 max를 계산하는 동안에는 v를 불변으로만 빌리고, 계산이 끝나면 불변 빌림이 종료되도록 만든다는 점입니다.

  • 장점: 의도가 명확해지고 테스트도 쉬워짐
  • 단점: 한 번에 처리하던 것을 두 번 순회할 수 있음(필요하면 인덱스 기반으로 최적화)

비슷하게 “원인-결과를 분리해 재현 가능하게 만든다”는 점에서 장애 진단 글인 systemd 서비스가 계속 재시작될 때 7단계 진단 같은 접근과도 통합니다.

3) 스코프를 인위적으로 줄여 참조를 빨리 drop 시키기

컴파일러는 참조가 “마지막으로 사용된 지점”까지 살아있다고 봅니다(비어있는 것처럼 보여도, 코드 구조상 살아있을 수 있음). 이때는 블록 스코프로 참조의 생존 범위를 딱 잘라버리면 됩니다.

fn bump_if_positive(v: &mut Vec<i32>) {
    let should_bump = {
        let first_ref = &v[0];
        *first_ref > 0
    }; // 여기서 first_ref의 스코프 종료

    if should_bump {
        v[0] += 1;
    }
}
  • 장점: 로직을 크게 바꾸지 않고 해결
  • 단점: 블록이 많아지면 가독성이 떨어질 수 있음

팁: Rust는 NLL(Non-Lexical Lifetimes) 덕분에 예전보다 수명 추론이 좋아졌지만, 클로저/이터레이터 체인/매크로가 섞이면 여전히 “생각보다 오래 살아있게” 잡히는 경우가 있습니다.

4) 컬렉션 내부에서 서로 다른 부분을 동시에 빌려야 한다면 split_at_mut/get_many_mut 사용

Vec나 슬라이스에서 서로 다른 원소를 동시에 바꾸고 싶은데 인덱싱으로 하면 E0502/E0499가 자주 납니다. 이때는 표준 라이브러리가 제공하는 “서로 다른 영역임을 증명하는 API”를 쓰는 게 정석입니다.

split_at_mut로 두 구간을 분리

fn swap_neighbors(v: &mut [i32], i: usize) {
    assert!(i + 1 < v.len());

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

    std::mem::swap(a, b);
}

get_many_mut로 여러 원소를 한 번에

Rust 버전에 따라 안정화 여부/시그니처가 다를 수 있지만, 취지는 같습니다. “서로 다른 인덱스”를 한 번에 빌려오도록 해서 중복 빌림을 막습니다.

fn add_pair(v: &mut [i32], i: usize, j: usize) {
    if i == j { return; }

    // 사용 중인 Rust 버전에 맞는 API를 확인하세요.
    let [a, b] = v.get_many_mut([i, j]).unwrap();
    *a += 1;
    *b += 1;
}
  • 장점: 안전하고 의도가 명확
  • 단점: 인덱스 검증/분기 처리가 필요

5) HashMap/BTreeMapentry API로 “읽기+쓰기”를 원자적으로 표현

맵에서 E0502가 나는 가장 흔한 패턴은 이겁니다.

use std::collections::HashMap;

fn bump_count(m: &mut HashMap<String, usize>, key: String) {
    // let cur = m.get(&key);  // 불변 빌림
    // m.insert(key, cur.unwrap_or(&0) + 1); // 가변 빌림 충돌
}

이럴 때는 entry를 쓰면, “키에 대한 접근과 갱신”을 한 흐름으로 표현할 수 있습니다.

use std::collections::HashMap;

fn bump_count(m: &mut HashMap<String, usize>, key: String) {
    *m.entry(key).or_insert(0) += 1;
}
  • 장점: 가장 Rust다운 해결, 성능도 좋음(불필요한 조회 감소)
  • 단점: entry 흐름에 익숙하지 않으면 처음엔 낯설 수 있음

맵뿐 아니라, 상태 업데이트가 얽힌 코드에서는 “API가 제공하는 안전한 갱신 경로”를 먼저 찾는 게 좋습니다.

6) 구조를 바꿔 소유권을 이동시키기: mem::take/mem::replace로 임시로 빼서 처리

복잡한 구조체에서 self의 한 필드를 읽어 판단하고, 다른 필드를 수정하거나, 같은 필드를 다시 수정해야 할 때 E0502가 자주 발생합니다. 이때는 아예 해당 필드를 통째로 꺼내서(소유권 이동) 로컬에서 처리한 뒤 다시 넣는 방식이 강력합니다.

use std::mem;

#[derive(Default)]
struct State {
    buf: Vec<String>,
    total: usize,
}

impl State {
    fn flush_if_large(&mut self) {
        // buf를 통째로 꺼내면, self에 대한 빌림 충돌이 크게 줄어듭니다.
        let mut buf = mem::take(&mut self.buf);

        if buf.len() > 10 {
            self.total += buf.len();
            buf.clear();
        }

        self.buf = buf;
    }
}

mem::take는 대상에 Default::default()를 넣고 값을 꺼내옵니다. mem::replace는 원하는 대체값을 넣고 꺼내옵니다.

  • 장점: 참조 수명 문제를 구조적으로 제거, 복잡한 메서드에서 특히 효과적
  • 단점: 큰 값을 이동시키는 비용이 있을 수 있음(대부분 Vec/String은 move가 포인터 이동이라 저렴)

보너스: 그래도 막힐 때 점검 체크리스트

  1. 불변 참조(&)를 변수에 담아 오래 들고 있지 않은가
  2. 이터레이터 체인에서 클로저가 외부를 캡처해 수명이 늘어나지 않았는가
  3. 같은 컬렉션의 서로 다른 부분을 동시에 바꾸려 하는가(그렇다면 split_at_mut 계열)
  4. 맵 갱신은 entry로 표현할 수 없는가
  5. 메서드가 너무 많은 일을 하고 있지 않은가(읽기/쓰기 단계를 분리)

에러를 해결하는 과정은 종종 “코드를 더 안전하게 만드는 방향”으로 이어집니다. 프레임워크/런타임 이슈를 다룬 글이지만, 원인과 해결을 체계적으로 쪼개는 관점은 Node.js ERR_REQUIRE_ESM - ESM/CJS 충돌 해결법 같은 트러블슈팅에도 그대로 적용됩니다.

마무리

E0502는 Rust가 불편해서가 아니라, 동시 접근(읽기/쓰기)의 경계를 코드로 명확히 하라는 요구에 가깝습니다. 실무에서 가장 재사용성이 높았던 해결 전략은 다음 6가지였습니다.

  1. 참조 대신 값으로 복사/클론
  2. 읽기 단계와 쓰기 단계 분리
  3. 스코프를 줄여 참조를 빨리 종료
  4. 슬라이스 분할/다중 가변 참조 API 사용
  5. 맵은 entry로 갱신
  6. mem::take/replace로 소유권을 이동해 구조적으로 해결

다음에 E0502를 보면 “컴파일러를 설득할 트릭”을 찾기보다, 위 6가지 중 하나로 데이터 흐름을 더 명확하게 리팩터링해 보세요. 대부분은 코드가 더 읽기 쉬워지고, 버그 가능성도 함께 줄어듭니다.