- Published on
Rust 소유권·빌림으로 라이프타임을 숨기는 API 패턴 7
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 모듈이 데이터를 공유하고, 캐시하고, 비동기로 흘려보내는 순간 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를 만족해야 하는 핸들 타입
주의점
- 내부 가변 상태가 필요하면
Mutex나RwLock를 추가해야 합니다. 이때는 락 경합이 설계의 핵심이 됩니다.
패턴 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) 컬렉션 요소 참조를 반환하지 말고 “인덱스/핸들”을 반환
Vec 나 HashMap 내부 요소의 참조를 반환하면, 그 참조는 컨테이너의 라이프타임에 묶입니다. 게다가 컨테이너가 변경되면(재할당, 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가지 패턴을 조합하면, 복잡한 라이프타임 표기를 대부분 “구현 내부”로 밀어 넣으면서도 성능과 안전성을 함께 잡을 수 있습니다.