Published on

Rust E0502, NLL로 빌림 충돌 풀기

Authors

서버나 CLI를 Rust로 짜다 보면 어느 순간 마주치는 고전이 있습니다. cannot borrow ... as mutable because it is also borrowed as immutable 류의 E0502입니다. 처음엔 “분명 여기서 불변으로 한 번 읽었고, 그 다음에 변경하려는 건데 왜 안 되지?” 같은 느낌이 들죠.

이 글은 E0502를 소유권/빌림 규칙으로만 설명하지 않고, Rust 컴파일러가 수명을 계산하는 방식인 NLL(Non-Lexical Lifetimes) 관점으로 문제를 쪼개서, 실제 코드에서 어떤 식으로 고치면 되는지 패턴 중심으로 정리합니다.

참고: async 코드에서 Send 관련 오류를 자주 겪는다면, 빌림이 await 경계를 넘어가면서 문제가 커지는 경우가 많습니다. 이 글과 함께 Rust async에서 Send 트레잇 오류 원인과 해결도 같이 보면 연결이 됩니다.

E0502는 왜 뜨나: 규칙 자체는 단순하다

E0502는 요약하면 이 규칙 위반입니다.

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

그 반대도 마찬가지로, &mut가 살아있는 동안 &를 만들 수 없습니다. 이유는 동시성 때문이 아니라 단일 스레드에서도 “읽는 중에 쓰기”가 일어나면 참조가 가리키는 데이터의 일관성이 깨질 수 있기 때문입니다.

문제는 “살아있다”의 의미입니다. Rust는 예전에는 “변수 스코프 끝까지” 참조가 살아있다고 보는 경우가 많았고, 지금은 NLL 덕분에 “실제로 마지막으로 사용되는 지점까지”로 더 정밀하게 줄였습니다.

그런데도 E0502가 계속 뜬다면, 대개는 내가 생각한 것보다 참조가 더 오래 살아있거나, 혹은 컴파일러가 보수적으로 수명을 길게 잡는 형태로 코드를 작성했기 때문입니다.

NLL(Non-Lexical Lifetimes)로 보는 핵심: 수명은 블록이 아니라 사용 지점

NLL의 핵심은 “수명은 {} 블록 경계(lexical)만으로 결정되지 않고, 참조가 마지막으로 사용되는 지점까지로 계산된다”는 겁니다.

즉, 아래처럼 불변 참조를 만든 뒤 더 이상 쓰지 않으면, 스코프가 남아 있어도 수명이 끝날 수 있습니다.

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

    let first = &v[0];
    println!("{first}"); // 여기서 마지막 사용

    // NLL 덕분에 여기서는 first의 수명이 끝난 것으로 계산될 수 있음
    v.push(4);
}

하지만 “마지막 사용”이 생각보다 뒤로 밀리면 E0502가 납니다. 특히 다음 패턴에서 많이 터집니다.

  • 참조를 변수에 담아두고 뒤에서 다시 쓰는 경우
  • match/if let/클로저로 참조를 캡처하는 경우
  • 반복문에서 참조가 다음 반복까지 살아있는 형태
  • 함수 호출이 참조를 계속 들고 있을 수 있다고 컴파일러가 판단하는 경우

가장 흔한 E0502 예제: 읽어둔 뒤 같은 컨테이너를 수정

아래 코드는 직관적으로는 “먼저 읽고, 그 다음 수정”처럼 보이지만 E0502가 발생할 수 있습니다.

fn main() {
    let mut v = vec![10, 20, 30];

    let x = v.get(0).unwrap();
    v.push(40);

    println!("{x}");
}

xv 내부를 가리키는 불변 참조입니다. 그런데 pushv를 가변으로 빌립니다. 그리고 println!에서 x를 다시 쓰므로, 컴파일러는 x의 수명이 push 이후까지 이어진다고 판단합니다. 따라서 불변 빌림과 가변 빌림이 겹쳐서 실패합니다.

해결 1: 값을 복사하거나 소유권으로 빼기

불변 참조가 꼭 필요하지 않다면, 값을 복사(Copy) 하거나 클론해서 참조 수명을 끊는 게 가장 간단합니다.

fn main() {
    let mut v = vec![10, 20, 30];

    let x = v[0]; // i32는 Copy
    v.push(40);

    println!("{x}");
}

String처럼 Copy가 아닌 타입이면 clone()으로 끊을 수 있습니다.

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

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

    println!("{x}");
}

성능이 민감하면 무조건 clone을 하라는 뜻이 아닙니다. 다만 “참조를 오래 들고 있을 이유가 없다면” 가장 명확한 해결책입니다.

해결 2: 참조의 스코프를 강제로 끝내기(블록 활용)

NLL이 있어도, 코드 구조상 참조가 뒤에서 다시 쓰이면 수명은 줄어들 수 없습니다. 이럴 때는 참조 사용을 별도 블록으로 묶어서 “여기서 끝”을 명확히 해줍니다.

fn main() {
    let mut v = vec![10, 20, 30];

    {
        let x = v.get(0).unwrap();
        println!("{x}");
    } // x 수명 종료

    v.push(40);
}

이 패턴은 특히 “로그 한 번 찍고 수정” 같은 상황에서 가장 많이 씁니다.

해결 3: 참조 대신 인덱스/키를 들고 있기

참조를 들고 있으면 컨테이너 전체가 묶이는 경우가 많습니다. 대신 “어디를 볼지”를 나타내는 인덱스나 키만 들고 있다가, 필요할 때 다시 접근하면 빌림이 짧아집니다.

fn main() {
    let mut v = vec![10, 20, 30];

    let idx = 0;
    v.push(40);

    println!("{}", v[idx]);
}

단, push로 재할당이 일어나도 인덱스는 유효하지만, 삭제/정렬처럼 구조가 바뀌면 인덱스 의미가 바뀔 수 있으니 주의해야 합니다.

“NLL이면 다 해결되는 거 아냐?”가 아닌 이유

NLL은 “불필요하게 길게 잡힌 수명”을 줄여주는 기능입니다. 하지만 아래 상황은 NLL로도 해결되지 않습니다.

  • 참조를 실제로 나중에 다시 사용한다
  • 참조가 클로저에 캡처되어, 클로저의 수명만큼 살아있다
  • match에서 한 분기만 참조를 쓰는 것처럼 보여도, 전체 표현식의 수명 때문에 길어지는 경우

즉, NLL은 마법이 아니라 정밀한 분석일 뿐이고, 코드 구조가 “참조를 오래 들고 있는 형태”면 그대로 막힙니다.

클로저 캡처로 수명이 길어지는 케이스

클로저는 참조를 캡처할 수 있고, 그 순간 수명이 생각보다 길어집니다.

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

    let show_first = || println!("{}", v[0]);

    // 여기서 v를 가변으로 빌리고 싶어도
    v.push(4);

    show_first();
}

show_first 클로저가 v를 불변으로 캡처합니다. 그리고 show_first()를 뒤에서 호출하니, 그 캡처된 불변 빌림은 push까지 살아있다고 판단됩니다.

해결: 필요한 값만 캡처하거나, 캡처 시점에 복사/클론

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

    let first = v[0];
    let show_first = move || println!("{first}");

    v.push(4);
    show_first();
}

여기서는 firstmove로 클로저에 넘기므로 v를 캡처하지 않습니다.

match/if let에서 참조가 길어지는 케이스와 해결

옵션/결과 타입을 다루다 보면 아래처럼 작성하기 쉽습니다.

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

    let r = s.get(0..1).unwrap();

    // r을 나중에 또 쓰고 싶어서 남겨둠
    s.push('!');

    println!("{r}");
}

해결은 결국 동일합니다.

  • r을 더 빨리 소비하고(출력 먼저)
  • 필요하면 r.to_string() 같은 소유 타입으로 변환
  • 또는 스코프를 분리
fn main() {
    let mut s = String::from("hello");

    let r = s.get(0..1).unwrap().to_string();
    s.push('!');

    println!("{r}");
}

컬렉션에서 “동시에 두 군데”를 빌리려다 터지는 E0502

E0502는 “불변 vs 가변”만이 아니라, 같은 컬렉션에서 여러 참조를 동시에 만들 때도 자주 얽힙니다. 예를 들어 같은 Vec에서 두 원소를 가변으로 동시에 잡고 싶을 때는 단순 인덱싱으로는 안 됩니다.

fn swap_bad(v: &mut Vec<i32>, i: usize, j: usize) {
    let a = &mut v[i];
    let b = &mut v[j];
    std::mem::swap(a, b);
}

이건 서로 다른 인덱스여도 컴파일러가 “겹칠 수 있다”고 보수적으로 판단합니다.

해결: split_at_mut로 슬라이스를 분할해 비겹침을 증명

fn swap_ok(v: &mut [i32], i: usize, j: usize) {
    assert!(i != j);

    let (a, b) = if i < j {
        let (left, right) = v.split_at_mut(j);
        (&mut left[i], &mut right[0])
    } else {
        let (left, right) = v.split_at_mut(i);
        (&mut right[0], &mut left[j])
    };

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

여기서 핵심은 split_at_mut가 “서로 다른 두 슬라이스는 메모리상 겹치지 않는다”를 API 레벨에서 보장해준다는 점입니다. 즉, 코드로 비겹침을 증명하면 빌림 충돌이 풀립니다.

실전 체크리스트: E0502를 빠르게 푸는 순서

  1. 참조의 마지막 사용 지점을 찾는다
    • “내가 생각한 마지막 사용”이 아니라, 실제로 참조 변수가 어디서 다시 쓰이는지 확인
  2. 가능하면 참조를 값으로 바꾼다
    • Copy면 그냥 대입
    • 아니면 clone()/to_owned()로 소유권 확보
  3. 참조가 꼭 필요하면 스코프를 쪼갠다
    • { ... } 블록으로 참조 사용 구간을 격리
  4. 컬렉션 내부를 여러 곳 빌리면 분할 API를 쓴다
    • 슬라이스는 split_at_mut
    • 맵/셋은 엔트리 API나 구조 재설계를 고려
  5. 클로저/이터레이터에서 캡처되면 캡처 대상을 최소화한다
    • 필요한 값만 move로 넘기기

디버깅 팁: 컴파일러 메시지를 “수명 그래프”로 읽기

E0502 에러에는 보통 다음 힌트가 같이 나옵니다.

  • 어디서 불변 빌림이 시작됐는지
  • 어디서 가변 빌림을 시도했는지
  • 어디서 불변 빌림이 “나중에 사용”되는지

이 3개를 선으로 이어서 보면, 참조가 길게 살아있는 이유가 거의 드러납니다. 그리고 해결은 보통 “그 선을 끊는 것”입니다.

마무리

E0502는 Rust가 까다로워서가 아니라, 참조가 살아있는 구간이 겹치는지를 컴파일 타임에 엄격히 검증하기 때문에 생깁니다. NLL 덕분에 예전보다 훨씬 자연스러운 코드가 통과하지만, 여전히 클로저 캡처, 나중 사용, 컬렉션 동시 빌림 같은 패턴에서는 수명이 길어져 충돌이 납니다.

해결의 본질은 하나입니다.

  • 참조를 더 짧게 만들거나(스코프/구조 변경)
  • 참조를 없애거나(복사/클론/소유권 이동)
  • 비겹침을 API로 증명하거나(split_at_mut 같은 분할)

이 3가지를 습관화하면 E0502는 “공포의 에러”가 아니라 “코드 구조를 더 명확하게 만드는 신호”로 바뀝니다.