Published on

Rust lifetime 에러 E0597/E0716 해결 7패턴

Authors

서로 다른 언어에서 메모리 문제는 런타임 크래시나 미묘한 버그로 나타나는 경우가 많지만, Rust는 컴파일 타임에 이를 강하게 차단합니다. 그 과정에서 특히 자주 보게 되는 에러가 E0597(borrowed value does not live long enough)와 E0716(temporary value dropped while borrowed)입니다.

두 에러는 결이 비슷합니다.

  • E0597: “참조하고 있는 값이 더 빨리 drop 된다”는 의미입니다. 즉, 참조의 수명이 원본 값의 수명을 넘어섭니다.
  • E0716: “임시(temporary) 값에 대한 참조를 만들었는데, 임시 값이 표현식 끝에서 drop 된다”는 의미입니다. 즉, 참조가 붙잡고 있는 대상이 임시라서 너무 빨리 사라집니다.

이 글에서는 두 에러를 실무에서 가장 많이 만나는 형태로 나누고, 해결 패턴 7가지를 재현 코드와 함께 정리합니다.

E0597/E0716 빠른 감별법

E0716은 “임시 값”이 핵심

대표적인 패턴은 체이닝 결과에 바로 참조를 붙이는 경우입니다.

  • some_string().as_str() 결과를 &str로 오래 들고 있음
  • format!(...) 결과에 대한 참조를 반환하거나 저장함

E0597은 “스코프/소유자”가 핵심

대표적인 패턴은 로컬 변수/락 가드/컨테이너 내부 참조를 더 바깥으로 빼려는 경우입니다.

  • 함수 안에서 만든 String&str을 반환
  • MutexGuard에서 얻은 참조를 guard drop 이후에도 사용
  • Vec에서 꺼낸 참조를 push 이후에도 사용

패턴 1) 임시를 변수로 바인딩해 수명 연장하기 (E0716)

임시 값에 대한 참조는 표현식이 끝나는 순간 무효가 됩니다. 가장 간단한 해결은 “임시를 이름 있는 변수로 만들기”입니다.

문제 코드

fn main() {
    let s: &str = format!("user:{}", 42).as_str();
    println!("{}", s);
}

format!(...)이 만든 String은 임시이며, .as_str()은 그 임시에 대한 &str을 만듭니다. 임시는 그 줄이 끝나면 drop 되므로 s는 댕글링이 됩니다.

해결 코드

fn main() {
    let tmp = format!("user:{}", 42);
    let s: &str = tmp.as_str();
    println!("{}", s);
}

핵심은 tmp가 스코프 끝까지 살아있게 만들어 &str이 안전해지는 것입니다.

패턴 2) 참조 대신 소유 타입을 반환/저장하기 (E0597/E0716 공통)

“참조를 오래 들고 있어야 한다”는 요구가 진짜인지 점검해보면, 종종 소유 타입으로 바꾸는 게 가장 깔끔합니다.

문제 코드: 로컬 String&str 반환 (E0597)

fn make_name() -> &str {
    let s = String::from("alice");
    s.as_str()
}

로컬 변수 s는 함수가 끝나면 drop 되므로 그 내부를 가리키는 &str을 반환할 수 없습니다.

해결 코드: String 반환

fn make_name() -> String {
    String::from("alice")
}

fn main() {
    let name = make_name();
    println!("{}", name);
}

만약 호출자가 문자열 slice가 필요하면 호출자 쪽에서 name.as_str()로 빌리면 됩니다.

패턴 3) Cow로 필요할 때만 소유하기 (성능/유연성 패턴)

API가 “빌려도 되고, 필요하면 소유해도 된다”는 형태라면 std::borrow::Cow가 좋은 타협점입니다.

예시: 입력이 slice일 수도, 가공 결과는 소유일 수도

use std::borrow::Cow;

fn normalize(input: &str) -> Cow<'_, str> {
    if input.chars().all(|c| c.is_ascii_lowercase()) {
        Cow::Borrowed(input)
    } else {
        Cow::Owned(input.to_ascii_lowercase())
    }
}

fn main() {
    let a = normalize("abc");
    let b = normalize("AbC");
    println!("{} {}", a, b);
}

Cow는 lifetime 문제를 “무조건 참조로 해결”하려다 생기는 E0597을 피하면서도, 불필요한 할당을 줄일 수 있습니다.

패턴 4) 스코프를 재구성해 drop 시점을 앞/뒤로 옮기기 (E0597)

Rust의 drop은 스코프 기반입니다. 참조가 필요한 범위를 더 안쪽으로 밀어 넣거나, 소유자의 스코프를 더 바깥으로 빼면 해결되는 경우가 많습니다.

문제 코드: 참조가 너무 오래 살아있음

fn main() {
    let r: &str;

    {
        let s = String::from("hello");
        r = s.as_str();
    }

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

내부 블록이 끝날 때 s가 drop 되므로 r은 무효입니다.

해결 코드: 참조 사용 범위를 안쪽으로

fn main() {
    {
        let s = String::from("hello");
        let r = s.as_str();
        println!("{}", r);
    }
}

또는 정말로 바깥에서 써야 한다면, s를 바깥 스코프로 올려 소유자가 더 오래 살게 해야 합니다.

패턴 5) 컨테이너 변경 전, 빌림을 끊기 (Vec/HashMap에서 자주 발생)

Vec에서 원소를 참조로 빌린 상태에서 push 같은 변경을 하면 재할당(reallocation) 가능성 때문에 빌림 규칙이 깨집니다. 이때 에러가 E0597 또는 borrow 관련 에러로 나타납니다.

문제 코드: 참조 유지한 채로 push

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

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

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

해결 1: 참조 사용을 먼저 끝내기

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

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

    v.push(String::from("c"));
}

해결 2: 필요한 값은 복제/소유로 확보

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 대신 인덱싱 전략을 바꾸거나, 미리 reserve로 재할당 가능성을 줄이는 방법도 고려합니다.

패턴 6) 락 가드/RefCell borrow의 수명을 명시적으로 관리하기 (E0597)

Mutex, RwLock, RefCell은 “가드(guard) 객체”가 살아있는 동안만 내부 참조가 유효합니다. 가드가 drop되면 내부 참조는 더 이상 안전하지 않습니다.

문제 코드: guard에서 얻은 참조를 밖으로 빼기

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(String::from("hello"));

    let s_ref: &str;
    {
        let guard = m.lock().unwrap();
        s_ref = guard.as_str();
    }

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

guard가 drop되는 순간 락이 풀리고, 그 이후 s_ref를 보장할 수 없습니다.

해결 1: guard 스코프 안에서만 사용

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(String::from("hello"));

    let guard = m.lock().unwrap();
    println!("{}", guard.as_str());
}

해결 2: 필요한 데이터는 소유로 복사

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(String::from("hello"));

    let owned: String = {
        let guard = m.lock().unwrap();
        guard.clone()
    };

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

동시성/내부 가변성 타입은 lifetime 에러가 아니라 “설계 힌트”로 받아들이는 편이 좋습니다. 참조를 오래 잡고 있으면 락 경합이나 borrow 충돌을 키우기 쉽습니다.

패턴 7) self 참조를 피하고, 인덱스/핸들로 설계하기 (구조적 해결)

Rust에서 가장 어려운 lifetime 문제 중 하나는 “자기 자신을 참조하는 구조체(self-referential struct)”를 만들려는 시도입니다. 예를 들어 String을 필드로 가지고, 동시에 그 String&str을 다른 필드에 저장하고 싶어집니다. 하지만 구조체 이동(move) 가능성 때문에 일반적인 안전 Rust로는 성립하기 어렵습니다.

안티패턴(개념 예시)

struct Bad {
    buf: String,
    view: /* buf를 가리키는 &str 같은 것 */
}

이런 형태는 값이 move될 때 view가 가리키는 주소 안정성이 깨질 수 있어, 컴파일러가 강하게 막습니다.

해결: 참조 대신 “핸들”을 저장

가장 흔한 대안은 &str을 저장하는 대신 범위를 나타내는 인덱스(슬라이스 범위)를 저장하고, 필요할 때 계산하는 방식입니다.

#[derive(Debug)]
struct Good {
    buf: String,
    range: (usize, usize),
}

impl Good {
    fn new(buf: String, start: usize, end: usize) -> Self {
        Self { buf, range: (start, end) }
    }

    fn view(&self) -> &str {
        let (s, e) = self.range;
        &self.buf[s..e]
    }
}

fn main() {
    let g = Good::new(String::from("hello world"), 0, 5);
    println!("{}", g.view());
}

이 패턴은 lifetime을 “저장”하지 않고 “계산”하기 때문에 E0597류 문제를 구조적으로 제거합니다.

자주 헷갈리는 포인트: lifetime은 ‘기간’이 아니라 ‘관계’

lifetime 표기는 시간의 길이를 직접 지정하는 게 아니라, “이 참조가 저 값보다 오래 살 수 없다”는 관계 제약을 표현합니다. 그래서 E0597/E0716을 만났을 때는 아래 질문으로 접근하면 빠릅니다.

  • 이 참조가 가리키는 실제 소유자는 누구인가?
  • 그 소유자는 언제 drop 되는가?
  • 참조가 필요한 범위를 더 줄일 수 있는가?
  • 참조를 꼭 유지해야 하는가, 아니면 소유/복사로 바꿀 수 있는가?

에러를 해결하는 과정은 종종 “설계 개선”으로 이어집니다. 타입/스코프/소유권을 명확히 하면, 런타임 버그를 컴파일 타임에 제거하는 Rust의 장점이 극대화됩니다.

마무리: 7가지 패턴 체크리스트

  • 임시 값을 변수로 바인딩해 수명 연장하기 (E0716 대표)
  • 참조 대신 소유 타입을 반환/저장하기
  • Cow로 빌림과 소유를 함께 지원하기
  • 스코프 재구성으로 drop 시점 조절하기
  • 컨테이너 변경 전 빌림을 끊기 (Vec/HashMap 등)
  • 락 가드/RefCell borrow의 스코프를 좁히거나 복사하기
  • self 참조 구조를 피하고 인덱스/핸들 기반으로 설계하기

비슷한 결의 “컴파일 에러를 설계로 푸는” 접근이 궁금하다면, 타입 시스템을 활용해 오류를 줄이는 사례로 TS 5.5+ satisfies로 타입 좁히기 오류 잡기도 함께 참고할 만합니다. 또한 런타임에서 터지는 문제를 체계적으로 진단하는 글로는 Node.js ESM/CJS 혼용 ERR_REQUIRE_ESM 해결 가이드처럼 “원인 분해 후 패턴화”하는 방식이 Rust 에러 접근에도 도움이 됩니다.