Tech Wiki

TOPICSSERIES

[Rust 실전 로드맵 14] Rust 제네릭과 트레이트: 재사용성과 추상화의 경계

제네릭, 트레이트 객체, impl Trait은 모두 여러 타입에 같은 동작을 적용합니다. 하지만 서로 바꿔 써도 되는 표기법은 아닙니다. 컴파일할 때 구체 타입이 정해지는가, 실행 중에 구현을 골라야 하는가, 호출자에게 어떤 타입 정보를 공개할 것인가에 따라 선택이 달라집니다.

이 글의 예제는 경계를 둘로 나눕니다. 저장소는 호출마다 하나의 구체 타입을 쓰므로 제네릭으로 연결합니다. 검사기는 서로 다른 구현을 한 컬렉션에 담아 실행해야 하므로 트레이트 객체를 씁니다. impl Trait은 짧은 제네릭 인자와 숨겨진 구체 반환 타입에만 사용합니다. 이 구조는 규칙을 설명하기에 충분하면서도 작은 프로그램에 불필요한 추상화 계층을 더하지 않습니다.

1. 트레이트 예제

이 예제는 Rust 2024를 사용합니다. 외부 의존성은 없습니다.

[package]
name = "article-14-generics-traits"
version = "0.1.0"
edition = "2024"
publish = false

[lints.rust]
unsafe_code = "forbid"

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

다음 명령은 포맷, 전체 타깃 검사, 경고를 오류로 처리하는 Clippy, 테스트, 실행까지 확인합니다.

cd examples/article-14-generics-traits
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. 트레이트는 동작의 계약이다

Repository는 저장 방식이 아니라 필요한 동작을 적습니다. 지금 구현은 Vec<Endpoint>를 쓰지만 호출 코드는 추가와 조회만 압니다. Checker도 URL 검사 방식의 공통 계약만 공개합니다.

pub trait Repository {
    fn add(&mut self, endpoint: Endpoint);
    fn all(&self) -> &[Endpoint];
}

pub trait Checker {
    fn label(&self) -> &'static str;
    fn check(&self, endpoint: &Endpoint) -> CheckResult;
}

트레이트를 만들었다는 이유만으로 모든 값에 dyn이 필요한 것은 아닙니다. 구현을 하나만 쓰는 함수라면 타입 매개변수와 트레이트 바운드가 더 직접적입니다. 반대로 서로 다른 검사기 구현을 같은 벡터에 보관하려면 하나의 정적 타입으로 묶을 방법이 필요합니다. 이때 dyn Checker가 맞습니다.

3. 제네릭과 구체 타입

run_static은 저장소 타입 R과 검사기 타입 C를 타입 매개변수로 받습니다. 각 호출에서 RC는 하나의 구체 타입으로 정해집니다.

pub fn run_static<R, C>(repository: &R, checker: &C) -> Vec<CheckResult>
where
    R: Repository,
    C: Checker,
{
    enabled_endpoints(repository)
        .map(|endpoint| checker.check(endpoint))
        .collect()
}

제네릭 코드는 단형화됩니다. 컴파일러는 실제로 사용된 구체 타입에 맞는 코드를 만들고 정적 디스패치를 수행합니다. 여기서 안전하게 말할 수 있는 것은 메서드 대상을 컴파일 시간에 안다는 점입니다. 이 사실만으로 제네릭 버전이 언제나 더 빠르다고 단정할 수는 없습니다. 인라이닝 기회, 코드 크기, 명령어 캐시, 최적화 결과와 실제 입력이 모두 영향을 줍니다.

저장소 경계가 제네릭인 이유도 속도 선전이 아닙니다. 이 프로그램은 실행 중에 저장소 구현을 섞지 않습니다. 테스트에서는 InMemoryRepository를 넘깁니다. 다른 호출에서는 다른 구현을 넘길 수 있지만 한 호출의 구체 저장소 타입은 정해져 있습니다. 필요한 유연성만 표현한 셈입니다.

4. 트레이트 객체와 런타임 구현

검사기는 조건이 다릅니다. HTTPS 검사기와 이름 길이 검사기를 한 목록에 담아 순서대로 실행합니다. Vec<HttpsChecker>에는 NameLengthChecker를 넣을 수 없으므로 포인터 뒤의 구체 타입을 지우고 dyn Checker로 다룹니다.

pub fn run_dynamic<R>(
    repository: &R,
    checkers: &[Box<dyn Checker>],
) -> Vec<CheckResult>
where
    R: Repository,
{
    enabled_endpoints(repository)
        .flat_map(|endpoint| checkers.iter().map(move |checker| checker.check(endpoint)))
        .collect()
}

트레이트 객체는 dyn Checker 자체가 아니라 &dyn CheckerBox<dyn Checker> 같은 포인터를 통해 사용합니다. 이런 포인터에는 값의 데이터 포인터와 해당 구현의 메서드를 찾는 가상 메서드 테이블(vtable) 정보가 들어 있습니다. 메서드 호출은 실행 중 vtable을 거치는 동적 디스패치입니다. 예제는 검사기를 소유해 벡터에 보관하므로 Box를 썼습니다. 잠깐 빌리기만 한다면 &dyn Checker도 선택지가 됩니다.

동적 디스패치에는 간접 호출이 있고 이 예제의 Box는 각 검사기를 힙에 할당합니다. 그렇다고 체감할 성능 저하를 주장할 근거는 없습니다. 이 크레이트는 벤치마크하지 않았고 검사 작업의 실제 비용도 없습니다. 검사기가 네트워크 I/O를 수행하는 서비스라면 디스패치 비용의 비중은 더 작을 수 있습니다. 하지만 그것도 측정 전에는 가정일 뿐입니다.

5. dyn 호환성과 트레이트 객체

모든 트레이트를 dyn Trait로 쓸 수 있는 것은 아닙니다. 현재 Reference는 예전의 object safety를 dyn compatibility라고 부릅니다. 기본 트레이트와 상위 트레이트가 dyn 호환이어야 합니다. 트레이트 자체가 Self: Sized를 요구해서도 안 됩니다. 디스패치할 메서드에는 타입 매개변수가 없어야 하고 허용된 수신자 형태가 필요합니다. 연관 상수, 제네릭 연관 타입, Self를 반환하는 메서드 같은 요소도 제한을 받습니다.

이유는 vtable 항목을 생각하면 이해하기 쉽습니다. check(&self, endpoint: &Endpoint) -> CheckResult는 구체 검사기 타입을 몰라도 같은 호출 형태를 가집니다. 반면 fn convert<T>(&self, value: T)는 호출마다 T에 맞는 별도 코드가 필요하므로 하나의 vtable 항목으로 표현할 수 없습니다. 그런 메서드가 트레이트 객체에서 호출될 필요가 없다면 where Self: Sized를 붙여 객체 디스패치 대상에서 제외할 수 있습니다.

Checker의 두 메서드는 &self를 받고 제네릭 메서드나 Self 반환을 쓰지 않으므로 Box<dyn Checker>로 사용할 수 있습니다. 객체 호환성을 먼저 얻으려고 트레이트 기능을 억지로 줄일 필요는 없습니다. 런타임 다형성이 실제 요구일 때만 이 제약을 설계에 반영하면 됩니다.

6. 인자 위치 impl Trait

저장소를 채우는 함수는 타입 이름을 본문이나 다른 인자에서 다시 쓸 필요가 없습니다. 이런 단순한 인자는 impl Repository로 줄여 쓸 수 있습니다.

pub fn seed_repository(repository: &mut impl Repository) {
    repository.add(Endpoint::new(
        "api",
        "https://api.example.com/health",
        true,
    ));
}

인자 위치의 impl Repository는 익명 타입 매개변수처럼 동작하며 정적 디스패치를 사용합니다. &mut dyn Repository의 줄임말이 아닙니다. 명시적인 <R: Repository>가 더 나은 때도 있습니다. 같은 타입이 여러 인자에 반복되거나 반환 타입, where 절에서 그 타입을 다시 가리켜야 할 때입니다.

둘은 소스 호환성까지 완전히 같은 것은 아닙니다. Reference는 명시적 타입 매개변수라면 호출자가 function::<ConcreteType>(...)처럼 타입 인자를 지정할 수 있지만 impl Trait 인자에는 그 이름이 없다고 설명합니다. 공개 API에서 한 형태를 다른 형태로 바꾸면 호출자의 명시적 제네릭 인자 개수에 영향을 줄 수 있습니다.

7. 반환 위치 impl Trait

반환 위치에서는 의미가 다릅니다. 함수는 트레이트를 구현하는 하나의 구체 타입을 고르고 호출자에게 그 이름을 공개하지 않습니다.

pub fn enabled_endpoints(
    repository: &impl Repository,
) -> impl Iterator<Item = &Endpoint> {
    repository.all().iter().filter(|endpoint| endpoint.is_enabled())
}

#[must_use]
pub fn default_checker() -> impl Checker {
    HttpsChecker
}

enabled_endpointsslice::IterFilter가 겹친 긴 타입을 감춥니다. 호출자는 Iterator<Item = &Endpoint>라는 계약만 사용합니다. default_checker도 실제 반환형이 HttpsChecker라는 사실을 감춥니다. 둘 다 호출할 때마다 임의의 구현을 고르는 트레이트 객체는 아닙니다. 각 함수의 모든 반환 경로는 컴파일러가 정한 같은 구체 타입으로 귀결되어야 합니다.

실행 조건에 따라 HttpsChecker 또는 NameLengthChecker를 반환해야 한다면 단순한 -> impl Checker로는 부족합니다. 가능한 구현이 닫힌 집합이면 enum으로 감쌉니다. 실행 중 확장 가능한 서로 다른 타입을 돌려줘야 한다면 Box<dyn Checker>를 검토할 수 있습니다. 처음부터 모든 반환값을 박싱하는 것보다 요구를 먼저 구분하는 편이 낫습니다.

8. 알맞은 추상화

작은 프로그램에 트레이트를 많이 추가하면 구현보다 추상화가 더 커질 수 있습니다. 이 예제에서는 두 변화 지점만 경계로 잡았습니다. 저장소는 메모리 구현을 다른 구현으로 교체할 여지가 있습니다. 검사기는 실제로 두 구체 타입을 한 컬렉션에 섞습니다. EndpointCheckResult에는 별도 트레이트 계층을 만들지 않았습니다.

선택 기준은 다음처럼 정리할 수 있습니다.

  • 호출마다 한 구체 구현을 사용하고 타입 관계를 유지해야 하면 제네릭과 트레이트 바운드를 씁니다.
  • 서로 다른 구현을 한 컬렉션에 담거나 실행 중 구현을 선택해야 하면 트레이트 객체를 검토합니다.
  • 인자 타입 이름이 다시 필요하지 않으면 인자 위치의 impl Trait이 시그니처를 줄여줍니다.
  • 하나의 구체 반환 타입 이름만 숨기고 싶으면 반환 위치의 impl Trait을 씁니다.
  • 구현이 하나뿐이고 교체, 테스트 대역, 공개 계약이 필요하지 않다면 구체 타입을 그대로 쓰는 편이 낫습니다.

9. 경계점검

stable rustc 1.98.1, Cargo 1.98.1, Rust 2024 에디션에서 모든 명령이 성공해야 합니다. 첫 번째 테스트 실행에는 6개 테스트가 있습니다. 뒤이어 실행되는 바이너리와 문서 테스트는 각각 0개입니다.

running 6 tests
test tests::disabled_endpoints_are_not_checked ... ok
test tests::impl_iterator_hides_the_filter_type ... ok
test tests::repository_trait_keeps_storage_behind_a_boundary ... ok
test tests::static_dispatch_uses_one_checker_type ... ok
test tests::static_signature_preserves_the_generic_boundary ... ok
test tests::trait_objects_mix_checker_types_in_one_collection ... ok

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

cargo run --quiet의 표준 출력은 다음과 같습니다.

api https: pass
api name-length: fail

첫 줄은 HttpsChecker, 둘째 줄은 NameLengthChecker가 같은 Vec<Box<dyn Checker>>에서 실행됐음을 보여줍니다. 저장소 쪽은 끝까지 InMemoryRepository라는 구체 타입을 제네릭 경계에서 유지합니다. 정적 디스패치와 동적 디스패치를 프로젝트 전체의 단일 원칙으로 고를 필요는 없습니다. 변하는 축마다 다르게 고르면 됩니다.

전체 소스 코드

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

출처


답글 남기기

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

Tech Wiki

Built with WordPress · Learn in public.