Published on

Rust E0502, NLL로 소유권 충돌 풀기

Authors

Rust를 쓰다 보면 초반 러닝커브의 상당 부분이 소유권과 대여 규칙에서 옵니다. 그중에서도 E0502는 “불변으로 빌려둔 상태에서 가변으로 빌리려 했다”는 전형적인 충돌을 알려주는 에러라서, 자료구조를 조금만 만지기 시작하면 자주 만나게 됩니다.

다행히 Rust 컴파일러는 NLL(Non-Lexical Lifetimes) 이후로 대여의 생명주기를 더 정밀하게 추론합니다. 즉 “변수가 스코프에 남아있다”와 “참조가 실제로 마지막으로 사용된다”를 구분해, 불필요한 충돌을 많이 줄였습니다.

이 글에서는 E0502가 왜 발생하는지, NLL이 무엇을 바꿨는지, 그리고 실무에서 바로 써먹는 해결 패턴을 코드로 정리합니다.

E0502 에러 메시지 해석하기

E0502의 핵심은 하나입니다.

  • 같은 값에 대해 불변 참조(&T)가 살아있는 동안
  • 가변 참조(&mut T)를 만들 수 없다

Rust의 규칙을 한 줄로 요약하면 다음과 같습니다.

  • &T는 여러 개 가능
  • &mut T는 오직 하나만 가능
  • 그리고 &T&mut T는 동시에 공존 불가

이 규칙은 데이터 레이스를 컴파일 타임에 차단하기 위한 장치입니다.

가장 흔한 재현 예제

아래 코드는 컬렉션에서 값을 읽고, 같은 컬렉션을 수정하려다 E0502를 유발하는 대표 케이스입니다.

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

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

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

왜 문제일까요?

  • firstv 내부를 가리키는 불변 참조입니다.
  • 그런데 v.push(4)는 재할당(reallocation)을 유발할 수 있어, 내부 버퍼가 이동하면 first가 댕글링 참조가 될 수 있습니다.
  • Rust는 이 가능성을 원천 차단합니다.

NLL이 바꾼 것: 스코프가 아니라 “마지막 사용 지점”

NLL 이전에는 “참조는 변수가 속한 스코프 끝까지 살아있다”처럼 보수적으로 판단하는 경우가 많았습니다. NLL 이후에는 참조가 마지막으로 사용된 지점 이후에는 더 이상 살아있지 않다고 판단할 수 있습니다.

예를 들어 아래처럼 firstpush 이전에 마지막으로 사용하도록 순서를 바꾸면, 컴파일러는 first의 대여가 push 전에 끝난다고 추론할 수 있습니다.

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

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

    // first를 더 이상 사용하지 않으므로, 여기서는 불변 대여가 끝난 것으로 추론 가능
    v.push(4);
}

핵심은 “스코프를 줄여라”가 아니라, 참조를 더 빨리 소모하고 끝내라입니다.

패턴 1: 참조를 값으로 복사하거나 복제해서 대여를 끊기

원소가 Copy라면 가장 간단합니다. 참조를 오래 들고 있지 말고 값을 꺼내면 됩니다.

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은 비용이 있으니, 데이터 크기와 hot path 여부를 보고 선택해야 합니다.

패턴 2: 블록으로 대여 범위를 명시적으로 줄이기

NLL이 좋아졌다고 해도, 코드가 복잡해지면 “마지막 사용 지점”이 멀리 있거나 조건문/클로저에 섞여 추론이 기대만큼 깔끔하지 않을 수 있습니다. 이럴 땐 블록으로 수명을 확실히 끊어주는 게 효과적입니다.

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

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

    v.push(4);
}

이 패턴은 “컴파일러가 이해하기 쉬운 형태로” 의도를 표현한다는 점에서 디버깅 시간도 줄여줍니다.

패턴 3: 읽기와 쓰기를 분리하는 API 사용하기

동일한 컨테이너를 한 함수에서 읽고 쓰려다 충돌이 생기는 경우가 많습니다. 이때는 표준 라이브러리의 “분할 대여” 계열 API를 쓰면 해결되는 경우가 많습니다.

대표적으로 슬라이스의 split_at_mut는 서로 겹치지 않는 두 구간을 가변으로 동시에 빌릴 수 있게 해줍니다.

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

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

    assert_eq!(a, [11, 20, 130, 40]);
}

이건 단순 편의가 아니라, “두 참조가 절대 겹치지 않는다”는 사실을 타입 시스템에 증명해주는 도구입니다.

패턴 4: 인덱스 기반으로 접근하고, 참조는 짧게 유지

벡터를 순회하면서 수정해야 하는데 참조가 길게 잡혀 충돌하는 경우, 인덱스를 사용해 참조 생성을 최소화하는 방식이 유용합니다.

fn bump_evens(v: &mut Vec<i32>) {
    for i in 0..v.len() {
        if v[i] % 2 == 0 {
            v[i] += 1;
        }
    }
}

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

반대로, 아래처럼 이터레이터로 불변 대여를 길게 유지한 채 수정하려 하면 충돌이 나기 쉽습니다.

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

    // v.iter()가 v를 불변으로 빌림
    for x in v.iter() {
        if *x == 2 {
            // 같은 v를 가변으로 빌리려 해서 충돌 가능
            v.push(4);
        }
    }
}

이 경우는 로직 자체가 “순회 중 구조 변경”이라서 안전하게 재설계하는 게 좋습니다. 예를 들어 push할 값을 따로 모았다가 루프 후에 반영하는 식입니다.

패턴 5: Option::take로 소유권을 잠깐 빼내기

구조체 필드를 참조한 뒤 같은 구조체를 수정해야 하는 패턴에서 E0502가 자주 뜹니다. 그럴 때 Option 필드를 take로 비워두고 소유권을 꺼내 처리하면 깔끔하게 풀립니다.

#[derive(Debug)]
struct Job {
    name: Option<String>,
    retries: u32,
}

impl Job {
    fn run(&mut self) {
        // name을 소유권으로 꺼내오면서 self에 대한 대여 충돌을 피함
        let name = self.name.take().unwrap_or_else(|| String::from("unknown"));

        self.retries += 1;
        println!("run: {} (#{})", name, self.retries);

        // 필요하면 다시 넣어줌
        self.name = Some(name);
    }
}

fn main() {
    let mut j = Job { name: Some(String::from("build")), retries: 0 };
    j.run();
    println!("{:?}", j);
}

이 방식은 “필드를 잠깐 비워도 되는가”가 설계적으로 허용될 때 특히 강력합니다.

NLL로도 해결 안 되는 경우: 진짜로 동시에 필요할 때

NLL은 “불필요하게 길게 잡힌 대여”를 줄여줄 뿐, 동시에 존재해야 하는 참조 관계 자체를 허용해주진 않습니다.

예를 들어 다음 요구사항은 구조적으로 충돌합니다.

  • v[0]에 대한 참조를 계속 들고 있어야 하고
  • 동시에 vpush를 해야 한다

이건 Vec의 재할당 가능성 때문에 안전하게 공존할 수 없습니다. 해결책은 보통 다음 중 하나입니다.

  • 인덱스를 들고 있고 필요할 때마다 다시 접근하기
  • Vec 대신 재할당이 덜 문제되는 다른 구조 선택(예: VecDeque, SlotMap, arena, Rc로 간접화)
  • 미리 reserve로 재할당 가능성을 줄이기(단, 논리적 해결은 아님)
fn main() {
    let mut v = vec![1, 2, 3];
    v.reserve(10);

    let first = &v[0];
    // reserve로 재할당 가능성을 낮췄더라도, 규칙 자체는 바뀌지 않음
    // v.push(4); // 여전히 컴파일 에러 가능

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

reserve는 성능 최적화에는 유용하지만, 대여 규칙을 우회하는 수단은 아닙니다.

디버깅 체크리스트

E0502를 보면 아래 순서로 점검하면 빠릅니다.

  1. 불변 참조(&)가 “마지막으로 사용되는 줄”이 어디인지 찾기
  2. 그 이후에 같은 값에 대한 가변 작업(&mut, push, insert, remove, 필드 변경 등`)이 있는지 확인
  3. 해결 전략 선택
    • 참조를 더 빨리 소모하도록 코드 순서 변경
    • 값 복사/복제로 대여 끊기
    • 블록으로 수명 단축
    • 분할 대여 API(split_at_mut 등) 사용
    • 구조 변경이 필요하면 로직 재설계(두 단계 처리)

이 과정은 성능 문제를 원인 추적하듯 “대여가 오래 잡혀 있는 지점”을 좁혀가는 느낌과 유사합니다. 복잡한 문제를 추적하는 방법론이 필요하다면, 원인 분해 방식은 systemd 서비스 무한 재시작 원인과 journalctl 추적 같은 글의 접근과도 닮아 있습니다.

마무리

E0502는 Rust가 불친절해서가 아니라, “동시에 안전하게 존재할 수 없는 참조 관계”를 정확히 짚어주는 신호입니다. NLL 덕분에 많은 경우는 코드 순서 조정만으로 해결되지만, Vec 재할당처럼 근본적으로 위험한 패턴은 설계를 바꾸거나 소유권을 재배치해야 합니다.

정리하면 다음 한 문장이 실전에서 가장 도움이 됩니다.

  • 참조를 오래 들고 있지 말고, 필요한 만큼만 빌리고 빨리 끝내라

이 원칙을 기준으로 위 패턴들을 적용하면 E0502는 “막히는 벽”이 아니라 “안전한 설계로 유도하는 가드레일”로 바뀝니다.