Published on

Rust 소유권·빌림으로 라이프타임을 숨기는 API 패턴 7

Authors

서로 다른 모듈이 데이터를 공유하고, 캐시하고, 비동기로 흘려보내는 순간 Rust의 라이프타임은 API 표면으로 튀어나오기 쉽습니다. 하지만 대부분의 경우 라이프타임을 “정면으로 노출”하지 않고도 동일한 안전성을 얻을 수 있습니다. 핵심은 소유권 이전(owning), 빌림(borrowing), 복사/공유(share), 지연 복사(clone-on-write), 핸들(handle) 기반 접근 같은 도구를 적절히 조합해 사용자 입장에서 단순한 타입 시그니처를 제공하는 것입니다.

이 글은 “라이프타임을 아예 쓰지 말자”가 아니라, 라이프타임을 API 경계에서 최소화해 유지보수성과 사용성을 높이는 패턴을 다룹니다. 특히 라이브러리/SDK/사내 공용 크레이트처럼 호출자가 다양한 환경에서 쓰는 API일수록 효과가 큽니다.

참고로, 성능과 메모리 전략은 결국 트레이드오프입니다. C++에서 할당자 전략을 API 설계로 끌어올리는 논의가 있듯이, Rust에서도 소유권 전략이 API의 인체공학을 좌우합니다. 비슷한 맥락의 글로는 C++20 PMR로 할당자 지옥 끝내는 패턴 5도 함께 보면 관점 정리에 도움이 됩니다.

패턴 1) 입력은 impl AsRef 혹은 impl Into로 받고 내부는 소유한다

라이프타임이 가장 쉽게 새는 지점은 “입력으로 &str 을 받고 그 참조를 구조체에 저장”할 때입니다. 이때 구조체가 struct Foo<'a> { s: &'a str } 처럼 라이프타임을 강제하게 됩니다.

대신 입력은 유연하게 받고, 내부는 String 같은 소유 타입으로 들고 가면 API에서 라이프타임이 사라집니다.

pub struct Client {
    base_url: String,
}

impl Client {
    // 호출자는 &str, String 모두 가능
    pub fn new(base_url: impl Into<String>) -> Self {
        Self { base_url: base_url.into() }
    }
}

AsRef 는 보통 “읽기 전용으로 잠깐 참조”할 때 유리합니다.

pub fn is_https(url: impl AsRef<str>) -> bool {
    url.as_ref().starts_with("https://")
}

언제 쓰나

  • 구성 값, 설정, 키, 경로처럼 구조체가 오래 들고 있어야 하는 값
  • 호출자에게 문자열 소유권 정책을 강요하기 싫을 때

주의점

  • 무조건 소유하면 불필요한 할당이 생길 수 있습니다. 이때는 아래 Cow 패턴으로 완화합니다.

패턴 2) Cow 로 “필요할 때만 복사”하는 입력 설계

Cow 는 빌림과 소유를 동시에 표현합니다. 입력이 이미 &str 이면 빌려 쓰고, 필요해지는 순간에만 to_owned 로 복사합니다. 이 패턴은 라이프타임을 타입 하나로 캡슐화하는 데 유용합니다.

use std::borrow::Cow;

pub struct Query {
    // 외부 입력을 그대로 빌릴 수도, 소유할 수도 있음
    q: Cow<'static, str>,
}

impl Query {
    // 라이프타임을 API에 노출하지 않기 위해 'static 으로 고정하고
    // 들어오는 값은 항상 소유(String)로 승격시키는 선택도 가능
    pub fn new(q: impl Into<String>) -> Self {
        Self { q: Cow::Owned(q.into()) }
    }
}

위처럼 Cow<'static, str> 를 쓰면 호출자 입장에서는 라이프타임이 사라지지만, 대신 내부에서 소유로 승격됩니다. 반대로, 라이프타임을 조금 허용할 수 있으면 아래처럼 더 효율적입니다.

use std::borrow::Cow;

pub struct Query<'a> {
    q: Cow<'a, str>,
}

impl<'a> Query<'a> {
    pub fn new(q: impl Into<Cow<'a, str>>) -> Self {
        Self { q: q.into() }
    }
}

언제 쓰나

  • “대부분은 빌려도 되는데, 일부 케이스에서만 소유가 필요”한 API
  • 파서/토크나이저/템플릿 엔진처럼 입력을 부분적으로 가공하는 로직

주의점

  • Cow 는 만능이 아닙니다. 내부에서 자주 수정한다면 결국 Owned 로 가는 비율이 높아집니다.

패턴 3) 출력은 &T 대신 소유 타입(또는 공유 포인터)로 돌려준다

라이프타임이 사용자에게 가장 불편하게 다가오는 지점은 “반환값이 참조”일 때입니다. 예를 들어 fn get(&self) -> &str 는 간단해 보이지만, 반환 참조의 유효기간이 self 에 묶입니다. 호출자가 값을 오래 잡고 싶으면 곧바로 소유로 복사해야 합니다.

이때는 반환을 소유 타입으로 바꾸거나, 공유 포인터로 바꾸면 됩니다.

선택지 A: String 을 반환

pub struct Store {
    name: String,
}

impl Store {
    pub fn name(&self) -> String {
        self.name.clone()
    }
}

선택지 B: Arc<str> 혹은 Arc<String> 반환

큰 문자열을 자주 반환하면 clone 비용이 부담입니다. 공유 소유권을 주면 복사 대신 참조 카운트 증가로 해결됩니다.

use std::sync::Arc;

pub struct Store {
    name: Arc<str>,
}

impl Store {
    pub fn name(&self) -> Arc<str> {
        Arc::clone(&self.name)
    }
}

언제 쓰나

  • 캐시, 레지스트리, 설정 저장소처럼 “꺼내 쓴 값이 오래 살아야” 하는 API
  • 비동기 태스크로 값을 넘겨야 하는 API

주의점

  • Arc 는 원자적 카운팅 비용이 있습니다. 단일 스레드라면 Rc 도 고려하세요.

패턴 4) 내부 공유는 Arc 로 고정하고 API는 Clone 으로 단순화

라이프타임이 복잡해지는 대표적 상황이 “여러 컴포넌트가 같은 상태를 참조”할 때입니다. 이때 구조체들이 서로 & 로 얽히면 라이프타임 매개변수가 전염됩니다.

가장 실용적인 해결책은 내부 상태를 Arc 로 감싸고, 외부에는 Clone 가능한 핸들을 제공하는 것입니다.

use std::sync::Arc;

struct Inner {
    endpoint: String,
}

#[derive(Clone)]
pub struct Api {
    inner: Arc<Inner>,
}

impl Api {
    pub fn new(endpoint: impl Into<String>) -> Self {
        let inner = Inner { endpoint: endpoint.into() };
        Self { inner: Arc::new(inner) }
    }

    pub fn endpoint(&self) -> &str {
        &self.inner.endpoint
    }
}

호출자는 Api 를 마음껏 복제해 스레드/태스크로 넘길 수 있고, 라이프타임은 표면에서 사라집니다.

언제 쓰나

  • 클라이언트 SDK, DB 풀, 설정/메트릭 핸들
  • 비동기 환경에서 Send + Sync 를 만족해야 하는 핸들 타입

주의점

  • 내부 가변 상태가 필요하면 MutexRwLock 를 추가해야 합니다. 이때는 락 경합이 설계의 핵심이 됩니다.

패턴 5) “빌림 기반 빌더, 소유 기반 최종 타입”으로 경계를 분리

빌더 패턴은 입력을 많이 받습니다. 이 입력들을 모두 소유로 받으면 불필요한 할당이 생기고, 모두 빌림으로 받으면 빌더와 최종 타입 전체가 라이프타임에 오염됩니다.

타협안은 빌더는 빌림을 허용하고, build 시점에만 소유로 승격하는 것입니다.

pub struct Client {
    base_url: String,
    token: String,
}

pub struct ClientBuilder<'a> {
    base_url: Option<&'a str>,
    token: Option<&'a str>,
}

impl<'a> ClientBuilder<'a> {
    pub fn new() -> Self {
        Self { base_url: None, token: None }
    }

    pub fn base_url(mut self, v: &'a str) -> Self {
        self.base_url = Some(v);
        self
    }

    pub fn token(mut self, v: &'a str) -> Self {
        self.token = Some(v);
        self
    }

    pub fn build(self) -> Result<Client, &'static str> {
        let base_url = self.base_url.ok_or("missing base_url")?.to_owned();
        let token = self.token.ok_or("missing token")?.to_owned();
        Ok(Client { base_url, token })
    }
}

최종 산출물 Client 는 라이프타임이 없고, 빌더는 짧은 생명주기에서만 라이프타임을 씁니다. API 사용자는 대부분 Client 만 들고 다니므로 체감 복잡도가 크게 줄어듭니다.

언제 쓰나

  • 옵션이 많은 설정 객체
  • CLI/환경변수/설정파일을 조합해 구성하는 초기화 단계

패턴 6) 컬렉션 요소 참조를 반환하지 말고 “인덱스/핸들”을 반환

VecHashMap 내부 요소의 참조를 반환하면, 그 참조는 컨테이너의 라이프타임에 묶입니다. 게다가 컨테이너가 변경되면(재할당, rehash) 참조 안정성 문제가 생기기 때문에 API 제약이 커집니다.

이때는 참조 대신 핸들(예: 정수 ID) 을 반환하고, 조회 함수에서만 빌림을 제공하는 방식이 안전하고 단순합니다.

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(u32);

pub struct Users {
    items: Vec<String>,
}

impl Users {
    pub fn new() -> Self {
        Self { items: Vec::new() }
    }

    pub fn insert(&mut self, name: impl Into<String>) -> UserId {
        let id = self.items.len() as u32;
        self.items.push(name.into());
        UserId(id)
    }

    pub fn get(&self, id: UserId) -> Option<&str> {
        self.items.get(id.0 as usize).map(|s| s.as_str())
    }
}

핸들 기반 설계는 라이프타임을 감추는 것뿐 아니라, 동시성/캐싱/직렬화에도 유리합니다.

언제 쓰나

  • 그래프/씬/에디터 모델처럼 객체가 많고 참조 관계가 복잡한 도메인
  • 내부 컨테이너 구현을 바꾸고 싶을 때(예: Vec 에서 슬롯맵으로)

주의점

  • 삭제가 필요하면 ID 재사용 정책(세대 카운터 등)을 설계해야 합니다.

패턴 7) 콜백/비동기는 FnOnce + 'static 으로 경계를 고정하고 캡처는 소유로

비동기 런타임이나 스레드로 작업을 넘길 때 라이프타임이 가장 자주 폭발합니다. 이유는 간단합니다. 태스크가 언제 끝날지 모르니, 런타임은 보통 'static 을 요구합니다.

이때 해법은 “참조를 캡처하지 말고 소유로 캡처”하도록 API를 유도하는 것입니다. 즉 콜백은 FnOnce() + Send + 'static 같은 형태로 받고, 사용자는 필요한 데이터를 move 로 넘깁니다.

use std::thread;

pub fn spawn_task(f: impl FnOnce() + Send + 'static) {
    thread::spawn(f);
}

pub fn example() {
    let msg = String::from("hello");

    // 참조(&msg)가 아니라 소유(msg)를 move로 캡처
    spawn_task(move || {
        println!("{msg}");
    });
}

이 패턴은 API 관점에서 라이프타임을 “사용자에게 설명할 필요가 없는 규칙”으로 바꿉니다. 호출자는 단지 move 를 쓰면 되고, 컴파일러가 소유권 이전을 강제합니다.

언제 쓰나

  • 워커 풀, 이벤트 루프, 스트리밍 처리
  • 네트워크 클라이언트가 내부적으로 태스크를 생성하는 구조

주의점

  • 'static 은 “프로그램 전체에서 살아야 한다”가 아니라 “참조를 포함하지 않는다”에 가깝습니다. String, Arc 같은 소유 타입은 'static 을 만족할 수 있습니다.

패턴 선택 가이드: 언제 어떤 전략이 좋은가

  • 단순함 최우선: 입력 Into<String), 내부 String, 출력 String 혹은 Arc.
  • 할당 최소화: 입력 AsRef 또는 Cow, 빌더에서 빌림 후 build 에서만 소유.
  • 공유/비동기: 상태는 Arc 로 고정하고 핸들 타입을 Clone 가능하게.
  • 복잡한 참조 그래프: 참조 반환 대신 ID/핸들 기반.

운영 환경에서의 문제는 종종 “API가 너무 엄격해서 우회 구현이 난립”할 때 커집니다. 예를 들어 배포 파이프라인도 재사용 가능한 경계를 잘 설계하면 복잡도가 줄어듭니다. 이런 관점에서 모노레포에서 GitHub Actions 재사용 워크플로우 설계·버전관리 같은 글도 “경계 설계”를 다른 도메인에서 연습하는 데 도움이 됩니다.

마무리: 라이프타임은 내부로, 소유권 정책은 외부로

좋은 Rust API는 대개 “라이프타임을 사용자가 이해해야만 쓸 수 있는 형태”를 피합니다. 대신 다음을 명확히 드러냅니다.

  • 이 API는 값을 소유하는가, 공유하는가
  • 호출자가 값을 넘겨야 하는가, 빌려주면 되는가
  • 반환값은 호출자가 오래 보관할 수 있는가

정리하면, 라이프타임을 줄이는 가장 강력한 방법은 라이프타임 트릭이 아니라 소유권 경계를 설계로 고정하는 것입니다. 위 7가지 패턴을 조합하면, 복잡한 라이프타임 표기를 대부분 “구현 내부”로 밀어 넣으면서도 성능과 안전성을 함께 잡을 수 있습니다.