Published on

Rust 이터레이터 체인 lifetime 오류 6가지 해결법

Authors

서로 다른 어댑터를 길게 체인하다 보면, Rust 컴파일러가 말하는 lifetime 오류는 대개 같은 몇 가지 패턴으로 수렴합니다. 특히 map/filter/flat_map/collect/for_each 같은 조합에서 “임시값이 너무 빨리 drop 된다”, “빌린 값이 충분히 오래 살지 않는다”, “클로저가 참조를 반환할 수 없다” 류의 메시지가 반복됩니다.

이 글은 이터레이터 체인에서 자주 만나는 lifetime 오류 6가지를, 왜 발생하는지와 어떻게 고치는지(소유권 이동, 중간 바인딩, collect 타이밍, Cow, Arc, String/&str 전략 등)로 나눠 설명합니다.

추가로, 러스트에서 “참조가 스스로를 가리키는 구조”까지 가면 난이도가 급상승하는데, 그 영역은 Rust self-referential struct, Pin으로 안전하게 글이 더 직접적인 해법을 다룹니다.

준비: 예제 데이터와 전제

아래 예제는 문자열을 파싱하고, 여러 변환을 거쳐 결과를 모으는 전형적인 체인을 기반으로 합니다.

use std::collections::HashMap;

fn sample_input() -> Vec<String> {
    vec![
        "user=alice,role=admin".to_string(),
        "user=bob,role=dev".to_string(),
        "user=carol,role=dev".to_string(),
    ]
}

fn parse_kv(line: &str) -> HashMap<&str, &str> {
    line.split(',')
        .filter_map(|pair| pair.split_once('='))
        .collect()
}

이제부터의 문제는 대부분 “어디까지 &str로 버티고, 어디서 String으로 소유권을 가져올지”의 선택에서 발생합니다.

1) 임시값에서 &str를 뽑아 반환하려는 경우

증상

체인 중간에서 String을 만들고, 거기서 &str를 뽑아 밖으로 내보내려 하면 실패합니다.

fn bad() -> Vec<&'static str> {
    let out: Vec<&str> = sample_input()
        .into_iter()
        .map(|s| s.to_uppercase())
        .map(|s| s.as_str())
        .collect();
    out
}

이 코드는 두 가지가 동시에 문제입니다.

  • to_uppercase()가 새 String을 만들고, 그 String은 다음 어댑터로 이동하면서 임시로 취급되기 쉽습니다.
  • as_str()로 만든 &str는 그 String이 살아있는 동안만 유효합니다. 그런데 collect()Vec<&str>를 만들면, 참조의 대상(String)이 어디에도 저장되지 않습니다.

해결 1: Vec<String>으로 수집하고 필요할 때 &str로 보기

fn fix_collect_owned() -> Vec<String> {
    sample_input()
        .into_iter()
        .map(|s| s.to_uppercase())
        .collect()
}

fn use_as_str(v: &[String]) -> Vec<&str> {
    v.iter().map(|s| s.as_str()).collect()
}

해결 2: 중간 결과를 구조로 보관

참조를 내보내려면 “참조 대상”을 어딘가에 같이 보관해야 합니다.

struct Upper {
    owned: Vec<String>,
}

impl Upper {
    fn as_slices(&self) -> Vec<&str> {
        self.owned.iter().map(|s| s.as_str()).collect()
    }
}

fn fix_store_owner() -> Upper {
    Upper {
        owned: sample_input().into_iter().map(|s| s.to_uppercase()).collect(),
    }
}

핵심은 &str 자체를 결과로 “소유”하려 하지 말고, String을 소유한 뒤 필요할 때 빌려 쓰는 구조로 바꾸는 것입니다.

2) flat_map에서 지역 벡터의 iter()를 반환하는 경우

증상

flat_map 클로저 내부에서 Vec를 만들고 iter()를 반환하면, 그 Vec는 클로저가 끝나면서 drop 됩니다.

fn bad_flat_map() -> Vec<&'static str> {
    let v = sample_input();

    let out: Vec<&str> = v.iter()
        .flat_map(|line| {
            let parts: Vec<&str> = line.split(',').collect();
            parts.iter().copied()
        })
        .collect();

    out
}

해결 1: 지역 컬렉션을 만들지 말고, 원본에서 바로 이터레이터를 반환

split 자체가 이터레이터를 반환하므로 굳이 Vec로 만들 필요가 없습니다.

fn fix_flat_map_no_temp_vec() -> Vec<&str> {
    let v = sample_input();

    v.iter()
        .flat_map(|line| line.split(','))
        .collect()
}

해결 2: 정말로 Vec가 필요하면 소유권을 밖으로 올려라

중간 캐시가 필요하다면, flat_map 밖에서 소유하게 만들어야 합니다.

fn fix_flat_map_cache() -> Vec<String> {
    let v = sample_input();

    let cached: Vec<Vec<String>> = v.iter()
        .map(|line| line.split(',').map(|s| s.to_string()).collect())
        .collect();

    cached.into_iter().flatten().collect()
}

3) filter/retain에서 외부 참조를 캡처한 뒤, 동시에 같은 값에 변경을 가하는 경우

증상

이터레이터가 어떤 컬렉션을 빌려 순회하는 동안, 같은 스코프에서 그 컬렉션을 변경하려 하면 borrow 충돌이 나고, 메시지는 종종 lifetime/borrow로 뭉뚱그려 보입니다.

fn bad_mutate_while_iterating() {
    let mut v = sample_input();

    let it = v.iter().filter(|s| s.contains("dev"));

    // 여기서 v를 변경하려 하면 컴파일 에러
    v.push("user=dave,role=dev".to_string());

    let _collected: Vec<&String> = it.collect();
}

해결 1: 순회 결과를 먼저 수집하고 나서 변경

fn fix_collect_then_mutate() {
    let mut v = sample_input();

    let collected: Vec<String> = v.iter()
        .filter(|s| s.contains("dev"))
        .cloned()
        .collect();

    v.push("user=dave,role=dev".to_string());

    // collected는 v와 독립
    assert!(!collected.is_empty());
}

해결 2: 인덱스 기반으로 분리하거나, drain_filter 계열 사용 고려

표준 라이브러리에서 안정적으로 쓸 수 있는지(버전)와 요구사항에 따라 다르지만, 핵심은 “빌림과 변경을 동시에 하지 않기”입니다.

4) HashMap 조회 결과 참조를 체인 밖으로 들고 나가려는 경우

증상

get이 주는 값은 HashMap을 빌린 참조입니다. 맵이 drop 되거나 변경되면 참조는 무효가 됩니다. 이때 체인이 길면 원인이 가려져 lifetime 에러처럼 보입니다.

fn bad_map_get() -> Vec<&str> {
    let v = sample_input();

    v.iter()
        .map(|line| parse_kv(line))
        .filter_map(|m| m.get("user").copied())
        .collect()
}

위 코드는 parse_kv가 반환하는 HashMap<&str, &str>의 키와 값이 line을 빌립니다. 그런데 map(|line| parse_kv(line))에서 만들어진 HashMap은 각 이터레이션에서 임시로 생성되고 곧 drop 됩니다. 그 안에서 get으로 꺼낸 &strcollect하려 하니 당연히 참조가 남지 않습니다.

해결 1: 파싱 결과를 소유하도록 바꾸기

가장 단순하고 실무에서 많이 쓰는 방식입니다.

fn parse_kv_owned(line: &str) -> HashMap<String, String> {
    line.split(',')
        .filter_map(|pair| pair.split_once('='))
        .map(|(k, v)| (k.to_string(), v.to_string()))
        .collect()
}

fn fix_map_get_owned() -> Vec<String> {
    let v = sample_input();

    v.iter()
        .map(|line| parse_kv_owned(line))
        .filter_map(|m| m.get("user").cloned())
        .collect()
}

해결 2: 파싱 결과를 먼저 보관하고, 그 다음 참조를 뽑기

참조를 꼭 써야 한다면 “소유자”를 먼저 만들고 수명을 늘립니다.

fn fix_store_maps_then_borrow() -> Vec<String> {
    let v = sample_input();

    let maps: Vec<HashMap<String, String>> = v.iter().map(|line| parse_kv_owned(line)).collect();

    maps.iter()
        .filter_map(|m| m.get("user"))
        .cloned()
        .collect()
}

5) collect 타입을 Vec<&str>로 강제해 버리는 경우

증상

체인 마지막에서 타입 주석을 Vec<&str>로 박아두면, 컴파일러는 전체 체인을 “참조를 만들 수 있는가” 관점으로 강제 추론합니다. 그 결과, 원래는 소유로 풀어야 하는 문제를 lifetime 에러로 만나게 됩니다.

fn bad_type_annotation() {
    let v = sample_input();

    let out: Vec<&str> = v.into_iter()
        .map(|s| s.trim().to_string())
        .map(|s| s.as_str())
        .collect();

    let _ = out;
}

해결 1: Vec<String>으로 수집

fn fix_collect_string() -> Vec<String> {
    sample_input()
        .into_iter()
        .map(|s| s.trim().to_string())
        .collect()
}

해결 2: 빌림이 목적이면 입력을 &[String]으로 받고, 결과도 그 입력에 묶기

입력이 소유한 String을 오래 들고 있고, 그에 대한 슬라이스가 필요하다면 함수 시그니처부터 그렇게 설계합니다.

fn borrow_views<'a>(v: &'a [String]) -> Vec<&'a str> {
    v.iter().map(|s| s.as_str()).collect()
}

이 패턴은 “이터레이터 체인의 lifetime 오류”를 “API 설계 문제”로 돌려놓는 가장 확실한 방법입니다.

6) map에서 참조를 반환하는 클로저가 캡처/이동과 섞여 깨지는 경우

증상

클로저가 외부 값을 move로 캡처하거나, 내부에서 새 값을 만들면서 그 참조를 반환하려 하면 lifetime이 꼬입니다. 특히 unwrap_or/unwrap_or_else/then_some 같은 편의 메서드와 섞일 때 흔합니다.

fn bad_return_ref_from_closure() -> Vec<&str> {
    let v = sample_input();

    v.iter()
        .map(|line| {
            let owned = format!("{}", line);
            owned.as_str()
        })
        .collect()
}

owned는 클로저 스코프가 끝나면 drop 되므로 &str를 반환할 수 없습니다.

해결 1: 참조 대신 소유 값을 반환

fn fix_return_owned() -> Vec<String> {
    let v = sample_input();

    v.iter()
        .map(|line| format!("{}", line))
        .collect()
}

해결 2: 조건부로 빌리거나 소유해야 한다면 Cow 고려

입력에서 그대로 빌릴 수 있으면 빌리고, 변환이 필요하면 소유하도록 만들면 체인이 깔끔해집니다.

use std::borrow::Cow;

fn fix_cow(v: &[String]) -> Vec<Cow<'_, str>> {
    v.iter()
        .map(|s| {
            if s.contains("admin") {
                Cow::Owned(s.to_uppercase())
            } else {
                Cow::Borrowed(s.as_str())
            }
        })
        .collect()
}

Cow는 결과 타입이 하나로 고정되면서도, 불필요한 할당을 줄일 수 있어 “체인에서 lifetime 때문에 억지로 String만 쓰는 상황”을 완화합니다.

디버깅 체크리스트: lifetime 오류를 체인에서 빨리 쪼개는 법

이터레이터 체인은 한 줄이 길수록 원인을 찾기 어렵습니다. 아래 순서로 쪼개면 대부분 빨리 해결됩니다.

  1. collect 직전까지를 let tmp = ...;로 바인딩해서 타입을 분리한다.
  2. tmp의 타입을 cargo check에서 확인한다. 필요하면 let tmp: Vec<_> = ...collect();로 한 번 강제 수집한다.
  3. 참조(&str, &T)를 수집하려는지, 소유(String, T)를 수집하려는지 목적을 명확히 한다.
  4. 클로저 안에서 만든 지역 변수의 참조를 밖으로 반환하고 있지 않은지 본다.
  5. flat_map에서 “지역 컬렉션의 iter() 반환” 패턴이 있는지 본다.
  6. 입력이 소유한 데이터를 오래 들고 있어야 참조를 반환할 수 있다. 그렇지 않으면 소유로 전환한다.

마무리: 결론은 “참조의 소유자”를 명확히 하는 것

Rust 이터레이터 체인에서 lifetime 오류가 나는 이유는 대부분 단순합니다. 참조를 만들었는데, 그 참조의 소유자가 체인 어딘가에서 임시로 생성되거나 너무 빨리 drop 되는 것입니다. 해결도 대부분 두 갈래입니다.

  • 참조 대신 소유(String, Vec<T>, HashMap<String, String>)로 수집해 수명을 단순화한다.
  • 참조를 꼭 써야 한다면, 참조 대상(소유자)을 먼저 구조적으로 보관하고 그 위에서 빌린다.

특히 자기 자신을 참조하는 형태의 데이터 구조로 가면, 단순한 이터레이터 체인 문제를 넘어 Pin/프로젝션 같은 주제가 필요해집니다. 그 경우는 Rust self-referential struct, Pin으로 안전하게에서 더 깊게 다룬 방식이 도움이 됩니다.