Published on

Rust E0502 소유권 충돌, NLL로 해결하기

Authors

Rust를 쓰다 보면 가장 자주 마주치는 컴파일 에러 중 하나가 E0502입니다. 메시지는 대체로 “불변으로 빌린 뒤 가변으로 빌릴 수 없다” 혹은 그 반대 형태로 나오죠. 이 에러는 소유권(ownership) 그 자체보다는 대여(borrow) 규칙을 위반했을 때 발생합니다.

다행히 Rust는 NLL(Non-Lexical Lifetimes) 도입 이후, 과거보다 훨씬 많은 E0502 상황을 “사람이 기대하는 방식”으로 통과시켜 줍니다. 하지만 여전히 NLL이 해결해주지 못하는 케이스도 있고, 반대로 NLL을 이해하면 코드 구조를 조금만 바꿔서 컴파일러가 대여 범위를 더 좁게 추론하도록 유도할 수 있습니다.

이 글에서는

  • E0502가 왜 발생하는지(대여 규칙 관점)
  • NLL이 무엇을 바꿨는지(대여 범위 계산 방식)
  • NLL을 “활용”해서 E0502를 해결하는 실전 패턴

을 예제로 정리합니다.

관련해서 E0502를 패턴별로 빠르게 훑고 싶다면 이 글도 함께 보세요: Rust E0502 소유권 충돌 6패턴 해결법


E0502의 본질: 불변 대여와 가변 대여의 동시성

Rust의 핵심 규칙은 단순합니다.

  • 어떤 값에 대해 불변 대여(&T)는 여러 개 가능
  • 어떤 값에 대해 가변 대여(&mut T)는 오직 하나만 가능
  • 그리고 불변 대여가 살아있는 동안 가변 대여는 불가, 반대도 마찬가지

E0502는 이 규칙을 컴파일러가 위반으로 판단할 때 발생합니다.

가장 흔한 형태

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

    let first = &v[0]; // 불변 대여
    v.push(4);         // 가변 대여 필요

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

이 코드는 직관적으로 “firstv[0]만 보고 있는데 왜 push가 안 돼?”라고 느낄 수 있습니다. 하지만 push는 벡터의 재할당(reallocation)을 유발할 수 있어, 기존 요소의 메모리 위치가 바뀔 수 있습니다. 그 상태에서 first가 계속 유효하다고 보장할 수 없으니 Rust는 막습니다.

여기서 중요한 포인트는 불변 대여가 얼마나 오래 살아있다고 컴파일러가 판단하느냐입니다.


NLL(Non-Lexical Lifetimes)이 바꾼 것

Lexical lifetime(옛날 방식)

과거에는 대여의 생존 범위를 “변수가 선언된 스코프의 끝”까지로 보는 경우가 많았습니다. 즉, let r = &x;라고 쓰면, r이 이후에 실제로 쓰이지 않더라도 스코프 끝까지 대여가 유지되는 것처럼 취급되곤 했습니다.

NLL(현재 방식)

NLL은 이름 그대로 대여의 생존 범위를 문법적(lexical) 스코프가 아니라, 실제 사용 지점 기반으로 줄이는 방향입니다.

  • “이 참조가 마지막으로 사용되는 지점” 이후에는 대여가 끝난 것으로 취급
  • 그 결과, 같은 스코프 안에서도 대여가 더 빨리 종료되어 E0502가 줄어듦

다만 NLL은 “마법”이 아니라, 여전히 안전성 증명을 해야 하므로 제한이 남습니다.


NLL로 해결되는 케이스 vs 안 되는 케이스

1) NLL로 해결되는 전형: 마지막 사용 이후에 변경

아래 코드는 NLL 덕분에 통과하는 형태입니다.

fn main() {
    let mut x = 10;

    let r = &x;            // 불변 대여
    println!("{}", r);    // 여기서 마지막 사용

    x += 1;                // 그 다음 변경은 OK (NLL이 대여 종료로 판단)
    println!("{}", x);
}

핵심은 rx += 1 이후에 다시 사용되지 않는다는 점입니다.

2) NLL로도 안 되는 전형: 참조가 살아있는 동안 구조 변경

앞서 본 Vecpush 예시는 NLL로도 해결되지 않습니다.

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

    let first = &v[0];
    v.push(4); // 여전히 불가

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

이건 단순히 “대여 범위가 길어서”가 아니라, push가 참조 무효화를 일으킬 수 있기 때문에 구조적으로 막히는 케이스입니다.


NLL을 활용해 E0502를 푸는 실전 패턴

여기서부터는 “NLL이 더 잘 추론하게 만드는” 방향으로 코드를 바꾸는 방법들입니다. 포인트는 대체로 대여를 짧게 만들기입니다.

패턴 A: 참조를 더 빨리 소비(마지막 사용을 앞당기기)

참조를 만든 뒤 오래 들고 있지 말고, 필요한 작업을 먼저 끝내면 NLL이 대여 종료를 더 빨리 판단합니다.

fn main() {
    let mut x = 10;

    // 참조를 만든 즉시 사용
    println!("{}", &x);

    // 이후 변경
    x += 1;
    println!("{}", x);
}

“참조를 변수로 저장하지 않는다”는 작은 변화가, 대여 범위를 눈에 띄게 줄여줍니다.

패턴 B: 스코프 블록으로 대여 범위 강제 축소

NLL이 대부분 해결해주지만, 코드가 복잡해지면 “마지막 사용 지점”이 눈에 잘 안 보이거나, 의도치 않게 참조가 더 오래 살아있는 형태가 됩니다. 이때는 블록 스코프가 가장 명확합니다.

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

    {
        let r = &s; // 불변 대여
        println!("{}", r);
    } // 여기서 r 드롭, 대여 종료

    s.push_str(" world"); // 가변 대여 가능
    println!("{}", s);
}

이 방식은 팀 코드리뷰에서도 의도가 명확해서 실무에서 자주 씁니다.

패턴 C: 값 복사/클론으로 참조 자체를 없애기

참조를 유지해야 해서 충돌이 나는 경우, 작은 값이라면 복사해 버리는 게 더 낫습니다.

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

    let first = v[0]; // i32는 Copy라 참조가 아님
    v.push(40);

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

Copy 타입이면 사실상 “대여”가 발생하지 않습니다. String 같은 힙 타입이면 clone이 비용이 될 수 있으니, 데이터 크기와 빈도를 보고 판단하세요.

패턴 D: split_at_mut로 “서로 다른 부분”임을 증명

E0502의 상당수는 “같은 컬렉션의 서로 다른 요소를 동시에 만지고 싶은데” 컴파일러가 안전을 증명하지 못해서 생깁니다. 이때 Rust 표준 라이브러리의 분할 API를 쓰면 됩니다.

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

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

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

이 코드는 “왼쪽 슬라이스와 오른쪽 슬라이스가 겹치지 않는다”를 API가 보장해 주므로, 컴파일러가 안심하고 허용합니다.

패턴 E: Vec 재할당 위험을 피하려면 인덱스로 접근하거나 reserve

Vec에서 push가 문제가 되는 이유는 재할당 가능성입니다. 해결책은 두 갈래입니다.

  1. 참조를 오래 들고 있지 말고, 인덱스로 다시 접근
fn main() {
    let mut v = vec![1, 2, 3];

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

    println!("{}", first_val);
    println!("{}", v[0]);
}
  1. 재할당이 없도록 미리 공간 확보(reserve) 후, 참조를 잡는 순서를 조정
fn main() {
    let mut v = vec![1, 2, 3];
    v.reserve(10);

    // reserve 이후에도 참조와 push를 섞는 건 여전히 조심해야 하지만,
    // "push가 재할당을 일으킬 수 있다"는 근본 위험을 줄인다.
    v.push(4);

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

reserve는 안전성 증명을 대체하진 않습니다. 다만 설계 관점에서 재할당 가능성을 낮춰, 참조를 잡는 순서를 더 유연하게 만들 수 있습니다.


NLL 관점에서 코드를 읽는 법: “마지막 사용”을 찾기

NLL을 활용하려면, 컴파일러가 보는 관점으로 코드를 훑는 습관이 도움이 됩니다.

  1. 참조(& 또는 &mut)가 만들어지는 지점을 찾는다.
  2. 그 참조가 마지막으로 사용되는 지점을 찾는다.
  3. 그 사이에 같은 값에 대한 반대 성격의 대여(불변 vs 가변)가 있는지 본다.

이 3단계로 보면, 많은 E0502가 “참조를 너무 오래 들고 있었다”로 귀결됩니다.


자주 하는 오해 2가지

오해 1: “NLL이면 E0502가 거의 사라진다”

NLL은 대여 범위를 줄여주지만, Vec 재할당처럼 참조 무효화 자체가 가능한 연산은 여전히 막힙니다. 즉, NLL은 “허용 가능한 케이스를 더 많이 찾아주는” 최적화에 가깝고, 규칙을 완화하는 기능이 아닙니다.

오해 2: “스코프 블록은 꼼수다”

오히려 스코프 블록은 Rust에서 대여를 제어하는 가장 명시적이고 안전한 도구입니다. 특히 리뷰/유지보수 관점에서 “여기까지만 빌린다”를 코드로 문서화하는 효과가 큽니다.


실무 팁: E0502를 줄이는 코드 구조

  • 참조를 반환하거나 구조체 필드에 오래 저장하기 전에, 정말 장기 대여가 필요한지 재검토
  • 컬렉션을 수정해야 한다면, 읽기 단계와 쓰기 단계를 분리(예: 필요한 값만 먼저 수집)
  • “서로 다른 부분”을 동시에 만져야 하면 split_at_mut, chunks_mut 같은 분할 API를 우선 고려
  • 컴파일 에러가 길게 이어지면, 문제 지점을 작게 쪼개서(함수 추출) 대여 범위를 줄이기

참고로, 이런 식의 “원인 파악 후 범위를 줄이기” 접근은 네트워크 재시도/백오프 설계에서 실패 구간을 분리해 안정성을 올리는 방식과도 사고가 닮아 있습니다: Azure OpenAI 429/503 재시도·백오프 설계 가이드


정리

  • E0502는 불변 대여와 가변 대여가 겹칠 때 발생한다.
  • NLL은 대여 생존 범위를 “스코프 끝”이 아니라 “마지막 사용 지점” 기준으로 줄인다.
  • 따라서 해결 전략은 대개 “참조를 짧게” 만드는 것: 즉시 소비, 블록 스코프, 값 복사/클론, 분할 API 활용.
  • Vec 재할당처럼 참조 무효화가 가능한 연산은 NLL로도 해결되지 않으며, 설계를 바꾸거나 접근 방식을 바꿔야 한다.

E0502를 NLL 관점으로 읽기 시작하면, Rust가 단순히 까다로운 언어가 아니라 “안전성을 증명할 수 있는 코드 구조를 요구하는 언어”라는 감각이 훨씬 선명해집니다.