Tech Wiki

TOPICSSERIES

[Rust 실전 로드맵 11] 소유권 중심 리팩터링: clone을 줄이고 API 경계 세우기

clone()을 없애려고 모든 매개변수를 참조로 바꾸면 API가 더 좋아질까. 대개는 그렇지 않습니다. 저장소에 넣을 값까지 빌리게 하면 결국 함수 안에서 복제해야 합니다. 호출자는 그 비용이 어디서 생기는지 알기 어렵습니다. 반대로 읽기만 하는 함수가 값을 소유하면 호출자는 쓸데없이 값을 포기하거나 미리 복제하게 됩니다.

이번 리팩터링의 기준은 clone() 횟수가 아닙니다. 값의 최종 소유자가 누구인지 함수 시그니처에 드러내는 것이 먼저입니다. 엔드포인트 등록 경계는 EndpointDraft를 소유합니다. 검증은 잠깐 빌리고 저장소 조회는 참조를 돌려줍니다. 저장소보다 오래 살아야 하는 스냅샷에서만 의도적으로 복제합니다.

1. 소유권 판단 예제

독립 실행 가능한 Rust 2024 크레이트는 examples/article-11-ownership-api-refactor에 있습니다. 공유 endpoint-monitor에는 의존하지 않습니다. 외부 크레이트도 쓰지 않습니다.

[package]
name = "article-11-ownership-api-refactor"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

[lints.clippy]
all = "warn"
pedantic = "warn"

아래 명령으로 그대로 확인할 수 있습니다.

cd examples/article-11-ownership-api-refactor
cargo fmt --check
cargo check --all-targets --all-features
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

판단 기준은 간단합니다. 함수가 값을 저장하거나 다른 소유 객체로 넘기면 소유권을 받습니다. 호출 중에 읽거나 검사하기만 하면 빌립니다. 빌린 원본보다 오래 살아야 하는 독립 결과가 필요할 때는 복제가 맞을 수 있습니다. 세 경계를 뒤섞지 않으면 됩니다.

2. 빌린 뒤 복제하는 API

등록 API를 처음 만들 때 다음과 같은 형태가 나오기 쉽습니다.

fn register(&mut self, draft: &EndpointDraft) -> Endpoint {
    let endpoint = Endpoint {
        name: draft.name.clone(),
        url: draft.url.clone(),
        tags: draft.tags.clone(),
    };
    self.endpoints.push(endpoint.clone());
    endpoint
}

함수는 &EndpointDraft를 받지만 실제로는 모든 필드를 소유해야 합니다. 저장소에도 넣고 반환값도 만들기 때문에 Endpoint까지 한 번 더 복제합니다. 참조 시그니처가 저렴한 호출을 보장하지 않는 사례입니다. 오히려 비용을 함수 몸체에 숨깁니다.

소유권이 필요한 함수라면 빌린 뒤 복제하지 말고 인자를 소유하게 합니다. 소유권이 필요하지 않을 때는 반대로 빌려야 합니다. 모든 곳에 &를 붙이는 규칙이 아니라 작업에 맞춰 경계를 고르는 규칙입니다.

3. 변환은 소유, 검증은 빌림

요청 모델과 도메인 모델을 별도 타입으로 두면 변환 지점이 분명해집니다. EndpointDraft는 아직 검증되지 않은 입력이고 Endpoint는 검증을 통과한 저장 값입니다.

#[derive(Debug, PartialEq, Eq)]
pub struct EndpointDraft {
    name: String,
    url: String,
    tags: Vec<String>,
}

#[derive(Debug, PartialEq, Eq)]
pub struct Endpoint {
    id: EndpointId,
    name: String,
    url: String,
    tags: Vec<String>,
}

impl TryFrom<EndpointDraft> for Endpoint {
    type Error = ValidationError;

    fn try_from(draft: EndpointDraft) -> Result<Self, Self::Error> {
        let EndpointDraft { name, url, tags } = draft;
        validate_name(&name)?;
        validate_url(&url)?;

        Ok(Self {
            id: EndpointId(0),
            name,
            url,
            tags,
        })
    }
}

여기서 validate_namevalidate_url&str를 받습니다. 두 함수는 문자열을 검사할 뿐 보관하지 않으므로 빌림이 맞습니다. 검증이 끝나면 name, url, tags를 그대로 Endpoint로 이동합니다. 정상 경로에 clone()은 없습니다.

변환이 실패할 수 있으므로 From 대신 TryFrom을 썼습니다. TryFrom은 실패할 수 있는 타입 변환을 표현합니다. 가능한 변환에 FromTryFrom 같은 표준 변환 trait을 사용하면 검증 정책이 임의의 from_request 도우미가 아니라 타입 사이의 명시적 경계에 놓입니다.

EndpointDraft::new는 이름과 URL에 impl Into<String>을 받습니다. 이미 String을 가진 호출자는 이동할 수 있고 &str를 가진 호출자는 이 생성 경계에서 소유 문자열을 만듭니다. 편리함에 비용이 없어지는 것은 아닙니다. &str에서 String으로 바꾸면 할당과 복사가 필요할 수 있습니다. 호출자가 이미 가진 String은 다시 복제하지 않습니다.

4. 저장은 소유하고 조회는 빌린다

레지스트리는 register 호출이 끝난 뒤에도 엔드포인트를 보관해야 하므로 등록 요청의 소유권을 받습니다. 조회는 저장된 값을 읽기만 합니다.

pub fn register(&mut self, draft: EndpointDraft) -> Result<EndpointId, RegisterError> {
    let mut endpoint = Endpoint::try_from(draft)?;
    if self.find_by_name(endpoint.name()).is_some() {
        return Err(RegisterError::DuplicateName(endpoint.name));
    }

    let id = EndpointId(self.next_id);
    self.next_id += 1;
    endpoint.id = id;
    self.endpoints.push(endpoint);
    Ok(id)
}

#[must_use]
pub fn find_by_name(&self, name: &str) -> Option<&Endpoint> {
    self.endpoints.iter().find(|endpoint| endpoint.name == name)
}

registerEndpointDraft를 값으로 받고 저장 후에는 작은 EndpointId만 돌려줍니다. 반환하려고 저장된 Endpoint 전체를 복제하지 않습니다. 호출자가 등록된 값을 읽고 싶으면 find_by_name으로 레지스트리를 빌립니다.

중복 이름 오류도 복제를 피합니다. 중복을 확인할 때는 endpoint.name()을 잠깐 빌립니다. 오류를 만들 때는 더 이상 저장하지 않을 endpoint.nameDuplicateName(String)으로 이동합니다. 실패 경로에서도 오류가 이름을 소유하므로 지역 변수의 수명에 묶이지 않습니다.

접근자 역시 사용 목적을 따릅니다. name()url()&str, tags()&[String]을 반환합니다. 내부 필드를 읽기 위한 API에 새 String이나 Vec<String>를 만들 이유가 없습니다. &str 반환형은 일반적인 빌린 문자열 뷰만 공개하고 내부에서 String을 쓴다는 구현 세부는 감춥니다.

5. 복제가 맞는 경계

복제 자체가 결함은 아닙니다. 빌린 데이터보다 결과가 오래 살아야 하고 원본 소유권을 가져갈 수도 없다면 새 소유 값이 필요합니다. 예제의 EndpointSnapshot이 그런 경우입니다.

#[derive(Debug, PartialEq, Eq)]
pub struct EndpointSnapshot {
    pub id: EndpointId,
    pub name: String,
    pub url: String,
    pub tags: Vec<String>,
}

impl From<&Endpoint> for EndpointSnapshot {
    fn from(endpoint: &Endpoint) -> Self {
        Self {
            id: endpoint.id,
            name: endpoint.name.clone(),
            url: endpoint.url.clone(),
            tags: endpoint.tags.clone(),
        }
    }
}

이 스냅샷은 레지스트리가 사라진 뒤에도 남습니다. 따라서 필드 복제는 수명과 독립성이라는 계약을 구현합니다. 함수 이름과 반환 타입도 소유 결과가 생긴다는 사실을 숨기지 않습니다. 빌린 값에서 소유 값으로 가는 to_ 계열 변환은 비용을 가질 수 있습니다. 여기서는 표준 From<&Endpoint> 변환으로 그 경계를 표현했습니다.

다만 Clone에는 저렴하다는 보장이 없습니다. String::clone()은 힙 데이터를 복제하며 clone() 호출은 임의의 코드를 실행할 수 있다는 시각적 표지입니다. Clone::clone은 타입이 직접 구현하는 메서드이므로 구현별 비용도 달라집니다. 반대로 CopyEndpointId는 단순 값 복사에 맞습니다. 큰 모델을 Copy처럼 취급하려고 모든 곳에서 clone()을 넣는 것은 다른 선택입니다.

6. 시그니처 컴파일 테스트

동작 테스트만으로는 리팩터링 뒤에 &EndpointDraft가 다시 들어오는 일을 막기 어렵습니다. 함수 포인터 타입을 이용하면 핵심 소유권 계약도 컴파일 단계에서 확인할 수 있습니다.

#[test]
fn public_signatures_express_the_ownership_boundary() {
    let _: fn(EndpointDraft) -> Result<Endpoint, ValidationError> = Endpoint::try_from;
    let _: fn(&mut Registry, EndpointDraft) -> Result<EndpointId, RegisterError> =
        Registry::register;
    let _: for<'a> fn(&'a Registry, &str) -> Option<&'a Endpoint> = Registry::find_by_name;
}

#[test]
fn snapshot_is_owned_and_survives_the_registry() {
    let snapshot = {
        let mut registry = Registry::new();
        registry
            .register(draft("api", "https://example.com/health"))
            .expect("valid draft");
        EndpointSnapshot::from(registry.find_by_name("api").expect("stored endpoint"))
    };

    assert_eq!(snapshot.name, "api");
    assert_eq!(snapshot.url, "https://example.com/health");
}

첫 테스트는 변환과 등록이 값을 소비하고 조회 결과의 수명이 레지스트리 빌림에 연결되는지 검사합니다. 두 번째 테스트는 스냅샷이 레지스트리의 범위 밖에서도 유효한 소유 값임을 보여줍니다. 컴파일 실패 예제를 문서에만 남기는 대신 원하는 시그니처를 실행되는 테스트에 넣었습니다.

테스트 7개는 빈 이름과 잘못된 스킴, 정상 등록, 중복 이름, 빌린 조회, 소유 스냅샷, 공개 시그니처를 다룹니다.

7. clone()보다 전체 비용

이번 예제는 실행 시간을 벤치마크하지 않았습니다. 입력도 작고 실제 트래픽, 문자열 길이, 할당자, 빌드 프로필이 정해지지 않았기 때문입니다. 이 조건에서 만든 숫자는 일반화할 수 없습니다. 확인한 사실은 정상 등록 경로가 입력의 StringVec<String>을 이동하며 명시적 clone()을 호출하지 않는다는 것, 독립 스냅샷 경계에는 세 번의 명시적 복제가 있다는 것입니다.

실제 서비스라면 먼저 프로파일에서 할당 횟수와 바이트, 처리량 또는 지연 시간 중 병목과 맞는 지표를 고릅니다. 그다음 대표적인 이름, URL, 태그 크기와 빌드 프로필을 고정하고 리팩터링 전후를 같은 조건에서 비교해야 합니다. cargo clippyredundant_clone 같은 린트는 불필요한 복제 후보를 찾는 데 유용하지만 API가 가져야 할 소유권 계약까지 대신 설계하지는 않습니다.

복제를 없애려고 모델 전체에 수명을 추가하는 비용도 재야 합니다. 장기 보관 객체가 외부 버퍼를 빌리면 생성자부터 저장소, 비동기 작업까지 수명 매개변수가 퍼질 수 있습니다. 데이터가 오래 보관되고 크기가 작거나 등록 빈도가 낮다면 한 번 소유하는 편이 코드와 운영 모두에서 더 나을 수 있습니다. 반대로 큰 버퍼를 짧게 검사하는 경로라면 빌림의 이득이 커질 가능성이 있습니다. 측정할 대상은 clone() 문자열 하나가 아니라 그 선택이 만든 전체 비용입니다.

8. API 검증

예제는 Rust 2024 에디션, stable rustc 1.98.1, Cargo 1.98.1을 기준으로 하며 외부 의존성이 없습니다.

cargo test --all-features의 첫 번째 테스트 실행 부분은 다음과 같습니다. 이후 바이너리와 문서 테스트는 각각 0개 테스트로 끝납니다.

running 7 tests
test tests::conversion_rejects_empty_name ... ok
test tests::conversion_rejects_unsupported_scheme ... ok
test tests::duplicate_name_returns_the_owned_name ... ok
test tests::lookup_borrows_the_registry ... ok
test tests::public_signatures_express_the_ownership_boundary ... ok
test tests::registration_moves_owned_draft_into_storage ... ok
test tests::snapshot_is_owned_and_survives_the_registry ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

cargo run --quiet의 표준 출력은 다음 세 줄입니다.

registered 1 api -> https://example.com/health
tags: production, critical
snapshot survives registry: api -> https://example.com/health

좋은 소유권 API는 참조가 많은 API가 아닙니다. 저장할 값은 받아서 이동합니다. 읽을 값은 빌리며 독립 복사본이 필요한 경계는 그 비용을 타입과 이름으로 드러냅니다. 이 기준을 먼저 세우면 clone()은 습관적인 컴파일 오류 회피책이 아니라 검토 가능한 설계 선택으로 남습니다.

전체 소스 코드

이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 11 프로젝트에서 확인할 수 있습니다.

출처


1개 응답

  1. […] 이전 글소유권 중심 리팩터링: clone을 줄이고 API 경계 세우기 […]

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Tech Wiki

Built with WordPress · Learn in public.