Published on

Rust NLL이 허용하는 빌림 범위 이해하기

Authors

Rust의 소유권 시스템을 처음 접하면, 빌림 규칙 자체보다도 “왜 이 코드는 안 되지?”가 더 크게 다가옵니다. 특히 예전(2018 이전)에는 빌림의 수명이 블록 스코프와 강하게 결합되어 있어, 논리적으로는 안전해 보이는 코드도 거절되는 일이 잦았습니다.

NLL(Non-Lexical Lifetimes)은 이 지점을 크게 개선합니다. 핵심은 “변수의 스코프(lexical scope)”가 아니라 “참조가 실제로 마지막으로 사용되는 지점”을 기준으로 빌림의 끝을 더 정밀하게 계산한다는 점입니다. 이 글에서는 NLL이 빌림 범위를 어떻게 줄이고(정확히는 불필요하게 길게 잡히던 범위를 단축) 그 결과 어떤 코드가 새로 허용되는지, 그리고 여전히 막히는 케이스는 왜 막히는지까지 코드로 정리합니다.

참고로 동시성 코드에서 빌림과 락의 범위가 엮이면서 교착으로 이어지는 패턴도 자주 나오는데, Tokio 환경에서의 실전 이슈는 Rust Tokio join! 교착? spawn·Mutex 오용 해결 글이 함께 도움이 됩니다.

NLL이 해결한 문제: “스코프 끝까지 빌린다”의 과잉 보수

NLL 이전의 직관적(하지만 과하게 보수적인) 모델은 다음과 같습니다.

  • let r = &x; 같은 참조가 만들어지면
  • r이 선언된 스코프가 끝날 때까지 x는 빌려진 것으로 간주

하지만 실제 안전성 관점에서 중요한 건 “r을 마지막으로 사용한 이후에도 x를 빌린 것으로 볼 필요가 있느냐”입니다. NLL은 이 “마지막 사용 지점(last use)”을 기반으로 빌림 종료 지점을 앞당깁니다.

예제 1: 불필요하게 길던 불변 빌림이 짧아지는 경우

아래 코드는 NLL이 빌림 범위를 줄여주는 대표 패턴입니다.

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

    let r = &s;           // 불변 빌림 시작
    println!("{r}");      // 여기서 r의 마지막 사용

    s.push('!');          // NLL: 여기서는 이미 불변 빌림이 끝났다고 판단 가능
    println!("{s}");
}

NLL의 관점에서 rprintln! 이후 더 이상 사용되지 않으므로, 그 지점에서 불변 빌림을 종료시킬 수 있습니다. 그래서 뒤의 s.push('!') 같은 가변 접근이 허용됩니다.

여기서 중요한 포인트는 “참조 변수 r이 아직 스코프 안에 존재하더라도” 빌림은 끝날 수 있다는 점입니다. 즉, “변수의 생존”과 “빌림의 유효성”이 분리됩니다.

NLL의 핵심 개념: 수명은 블록이 아니라 제약(constraint)으로 결정

NLL을 이해할 때 도움이 되는 관점은 다음입니다.

  • 컴파일러는 각 참조(빌림)에 대해 “어디서 생성되고, 어디까지 유효해야 하는가”를 제약으로 만든다
  • 그 제약을 만족하는 최소 범위를 계산해 빌림의 끝을 가능한 앞당긴다

이때 범위를 결정하는 실질적인 트리거는 “사용(use)”입니다. 참조가 사용되는 지점까지는 빌림이 유지되어야 하며, 그 이후는 필요 없다면 종료됩니다.

예제 2: 같은 스코프여도 NLL이 허용하는 전형적인 패턴

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

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

    v.push(4); // NLL이 빌림 종료를 앞당기면 가능
    println!("v = {v:?}");
}

이 패턴은 “읽고 출력한 다음에 수정”이라는 흔한 흐름인데, NLL 전에는 종종 막히던 형태였습니다.

그래도 막히는 케이스: 참조가 실제로 더 오래 필요할 때

NLL은 마법이 아니라 “필요한 만큼만” 빌림을 유지하는 최적화에 가깝습니다. 참조가 이후에도 사용된다면 빌림은 당연히 계속됩니다.

예제 3: 마지막 사용이 뒤에 있으면 가변 접근은 여전히 불가

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

    let r = &s;

    // r을 나중에 사용하므로, 여기서 빌림은 끝날 수 없다
    // s.push('!'); // 컴파일 에러

    println!("{r}");
}

이 코드는 논리적으로도 안전하지 않습니다. s.push는 내부 버퍼 재할당이 발생할 수 있고, 그 사이에 r이 가리키는 메모리 안정성이 깨질 수 있습니다.

NLL이 특히 빛나는 곳: 조건 분기와 조기 반환

NLL의 “제약 기반” 특성은 분기와 조기 반환에서 큰 체감이 납니다. 사람이 보기엔 “이 분기에서는 참조를 더 안 쓰는데?” 같은 상황이 많기 때문입니다.

예제 4: 분기에서 참조 사용이 끝나는 지점이 갈리는 경우

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

    let r = &s;

    if s.len() > 0 {
        println!("{r}"); // 이 분기에서만 r 사용
    }

    // if 블록 이후에는 r이 더 이상 사용되지 않으므로
    // NLL은 여기서 불변 빌림이 끝났다고 판단 가능
    s.push('!');

    println!("{s}");
}

여기서 포인트는 “r이 스코프상으로는 살아있지만, 제어 흐름 상 더 이상 사용되지 않는다”는 사실을 NLL이 반영한다는 점입니다.

NLL로도 해결되지 않는 대표 난관: 컬렉션 원소를 빌린 채로 같은 컬렉션 수정

NLL이 허용 범위를 넓혀주긴 했지만, 아래 류의 문제는 NLL로도 본질적으로 해결되지 않습니다.

  • 어떤 컬렉션의 원소에 대한 참조를 들고 있는 동안
  • 같은 컬렉션을 구조적으로 변경(push, insert 등)하려는 시도

예제 5: 벡터 원소 참조와 push의 충돌

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

    let x = &v[0];
    v.push(40); // 에러 가능 (재할당으로 x가 무효화될 수 있음)

    println!("{x}");
}

이건 NLL이 “빌림을 줄여서” 해결할 수 있는 문제가 아닙니다. x가 실제로 나중에 사용되므로 빌림이 유지되어야 하고, 그 상태에서 push는 재할당 가능성 때문에 안전하지 않습니다.

해결 전략은 보통 다음 중 하나입니다.

  • 참조 대신 값을 복사(가능한 타입이라면)
  • 인덱스를 저장하고 나중에 다시 접근
  • 구조 변경이 필요 없다면 push를 먼저 수행

예를 들어 복사가 가능한 타입이라면:

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

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

    println!("{x}");
}

“빌림 범위 줄이기”를 의도적으로 유도하는 코딩 습관

NLL이 알아서 해주는 부분이 많지만, 코드를 조금만 다듬어도 컴파일러가 더 쉽게 빌림 종료 지점을 추론하게 만들 수 있습니다.

1) 참조의 사용을 최대한 가까이 두기

참조를 만들고 한참 뒤에 쓰면, 그 사이에 가변 접근이 끼어들 여지가 커집니다.

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

    // 필요할 때 빌려서 바로 사용
    println!("{}", &s);

    s.push('!');
    println!("{s}");
}

2) 스코프 블록으로 “의도적 종료 지점” 만들기

NLL이 대부분 해결하지만, 사람이 의도를 명확히 하고 싶을 때는 블록이 여전히 유용합니다.

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

    {
        let r = &s;
        println!("{r}");
    } // 여기서 r 드롭, 빌림도 명확히 종료

    s.push('!');
    println!("{s}");
}

3) MutexGuard 같은 가드 타입은 “빌림 범위”가 곧 “락 범위”

Rust에서 흔한 실수는 참조(가드)를 오래 들고 있으면서 다른 작업을 하다가, 락 경합이나 교착을 만드는 것입니다. NLL이 빌림을 줄여주더라도, 가드가 실제로 사용되는 지점이 뒤에 있으면 락도 오래 잡힙니다.

Tokio에서 Mutex 가드를 잡은 채로 await를 만나면 문제가 커지기 쉬운데, 이 주제는 Rust Tokio join! 교착? spawn·Mutex 오용 해결에서 더 깊게 다룹니다.

컴파일 에러를 읽는 관점: “누가 누구를 얼마나 오래 빌리나”로 번역하기

빌림 에러 메시지는 복잡해 보이지만, NLL 환경에서는 다음 질문으로 정리하면 빠릅니다.

  • 어떤 값이 빌려졌나(예: s)
  • 어떤 형태로 빌려졌나(불변 참조 & 인지, 가변 참조 &mut 인지)
  • 그 참조의 마지막 사용 지점이 어디인가
  • 그 사이에 충돌하는 접근(특히 &mut 또는 구조 변경)이 끼어드는가

특히 “마지막 사용 지점”을 찾는 게 핵심입니다. println! 한 줄이 마지막 사용인지, 혹은 디버그 출력/로그/클로저 캡처 때문에 더 뒤까지 살아있는지에 따라 결과가 갈립니다.

클로저 캡처로 빌림이 길어지는 흔한 예

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

    let f = || {
        // 클로저가 s를 참조로 캡처할 수 있음
        println!("{s}");
    };

    // f가 이후에 호출될 수 있으니, 캡처 방식에 따라
    // 여기서 s를 가변으로 쓰는 것이 막힐 수 있다
    // s.push('!');

    f();
}

이럴 때는 캡처를 값으로 바꾸거나(가능하면), 호출 순서를 조정하거나, 필요한 데이터만 복제해 클로저에 넘기는 식으로 해결합니다.

정리: NLL은 “더 똑똑한 스코프”가 아니라 “더 정확한 마지막 사용”이다

  • NLL은 빌림 수명을 블록 스코프에 묶지 않고, 참조의 실제 사용 지점까지로 최소화합니다.
  • 그 결과, “읽고 나서 수정” 같은 자연스러운 코드가 더 많이 컴파일됩니다.
  • 다만 참조가 실제로 뒤에서 사용되면 빌림은 유지되므로, 컬렉션 원소 참조와 구조 변경 충돌 같은 본질적 제약은 그대로 남습니다.
  • 실전에서는 참조를 짧게 쓰는 습관(사용 직전 빌림, 작은 스코프, 가드 빨리 해제)이 NLL의 장점을 극대화합니다.

추가로, Rust에서 빌림 범위가 길어져 동시성 문제가 커지는 케이스를 다루고 싶다면 Rust Tokio join! 교착? spawn·Mutex 오용 해결도 함께 읽어보면 연결되는 인사이트가 많습니다.