트레이트 객체(Trait Object)

트레이트 객체는 러스트에서 런타임에 동적 디스패치를 수행하기 위한 메커니즘이다. 제너릭과 달리 컴파일 시점에 구체 타입으로 확정되지 않으면서, 동일한 트레이트를 구현하는 여러 타입을 하나로 묶어 추상화된 인터페이스처럼 다룰 수 있게 해준다. 제너릭은 일반적으로 단형화(monomorphization)를 통해 각 타입마다 다른 구현 코드를 생성하지만, 트레이트 객체를 사용하면 실제 실행 시점에 어떤 타입이 들어오는지에 따라 함수를 호출하는 동적 디스패치가 이루어진다.

러스트는 정적 타입 언어이지만, 트레이트 객체를 활용하면 다양한 타입을 담아두고 동일한 인터페이스로 접근하는 전형적인 OOP 스타일의 유연함을 어느 정도 흉내낼 수 있다. 예를 들어 Box 형태로 묶어두면 여러 타입이 하나의 ‘Trait’라는 공통 분모를 통해서만 상호 작용하도록 만들 수 있다. 이때 중요한 점은 트레이트 객체가 구현되어야 할 트레이트가 ‘객체 안전(object safe)’해야 한다는 것이다. 객체 안전성을 만족하지 못하는 트레이트(예: 제네릭 메서드가 있거나, Self 타입을 반환해야 하는 메서드가 있는 트레이트)는 트레이트 객체로 만들 수 없다.

트레이트 객체를 만드는 대표적인 방법은 &dyn Trait처럼 참조자에 붙이거나, Box처럼 힙에 저장해 사용하는 것이다. 표면적으로 &dyn Trait와 &Trait는 비슷해 보일 수 있지만, &Trait는 더 이상 쓰이지 않고 러스트 2018 에디션 이후로는 &dyn Trait 형태가 권장된다. 이는 dyn 키워드가 동적 디스패치를 의도적으로 사용하는 것임을 명시해주어 가독성과 의도를 명확히 하려는 목적이다.

트레이트 객체는 내부적으로 두 가지 포인터 정보를 담고 있다고 볼 수 있다. 하나는 실제 데이터(구현 타입)의 메모리 주소이고, 다른 하나는 가상 테이블(vtable) 주소다. 이 vtable에는 트레이트 메서드를 호출할 때 필요한 함수 포인터들이 들어가 있다. 즉, &dyn Trait나 Box 타입 안에는 “실제 데이터의 주소” + “vtable 주소”가 묶여 있다. 메서드를 호출하면 러스트는 vtable을 참조하여 적절한 구현을 찾아간다. 이렇게 이루어지는 함수를 동적 디스패치(dynamic dispatch)라고 부른다.

트레이트 객체는 다음과 같이 코드에서 활용할 수 있다.

trait Draw {
    fn draw(&self);
}

struct Circle {
    radius: f32,
}

impl Draw for Circle {
    fn draw(&self) {
        println!("Circle with radius: {}", self.radius);
    }
}

struct Rectangle {
    width: f32,
    height: f32,
}

impl Draw for Rectangle {
    fn draw(&self) {
        println!("Rectangle {} x {}", self.width, self.height);
    }
}

fn draw_shape(shape: &dyn Draw) {
    shape.draw();
}

fn main() {
    let circle = Circle { radius: 5.0 };
    let rect = Rectangle { width: 3.0, height: 4.0 };

    draw_shape(&circle);
    draw_shape(&rect);
}

위 예제에서 draw_shape 함수는 &dyn Draw 타입의 파라미터 하나만 받는다. 이 함수는 어떠한 구체 타입이 들어올지 알 필요가 없으며, 오직 Draw 트레이트에 정의된 draw 메서드만 호출한다. &dyn Draw에 대한 함수 호출은 내부적으로 Circle이나 Rectangle의 draw 메서드로 동적 디스패치되어 실행된다.

때로는 트레이트 객체를 스택에 둘 수 없고 힙에 저장해야 할 필요가 있다. 이때는 Box 형태로 사용한다. 예를 들어 많은 객체를 Vector에 담아두고 싶거나, 실행 중에 다른 타입의 객체로 교체해야 한다면 Box를 사용해 힙에 저장하고 그 포인터를 이동시키거나 참조로 넘기는 방식을 쓸 수 있다.

이 코드에서는 Circle, Rectangle 두 구조체의 값을 하나의 Vec에 담고 있다. 벡터 입장에서는 draw 메서드를 호출할 수 있는 어떤 타입이든 모두 수용 가능하며, 실제 draw 호출 시점에는 해당 타입이 어떤 것인지 vtable을 통해 자동으로 찾아간다.

트레이트 객체를 사용할 때 주의해야 할 점 중 하나는 객체 안전성(object safety)이다. 특정 트레이트가 트레이트 객체로서 동작하려면 다음과 같은 조건을 만족해야 한다.

Self가 메서드 시그니처에 직접 노출되지 않아야 한다. 예를 들어 fn clone(&self) -> Self 같은 메서드를 가지면 안 된다. 모든 제네릭 메서드는 사용될 수 없다. 예를 들어 fn foo(&self, x: T)처럼 제네릭 매개변수가 있으면 트레이트 객체로 만들 수 없다.

추가로 트레이트 객체를 통해 얻는 유연성은 컴파일 타임 최적화를 제한한다는 트레이드오프가 있다. 제너릭과 달리 monomorphization이 이루어지지 않으므로, 함수 호출 때마다 가상 테이블을 조회하는 비용이 따른다. 또한 컴파일러가 타입 정보를 정확하게 알 수 없으므로 인라인 최적화 등의 적용이 제한적이다. 따라서 크리티컬한 성능이 요구되는 경우에는 트레이트 객체보다 제너릭을 사용하는 것이 더 빠른 실행 코드를 얻을 수 있다.

결과적으로 트레이트 객체는 러스트에서 동적 디스패치를 통한 다형성을 표현하기 위한 중요한 수단이다. 여러 타입을 하나의 인터페이스로 추상화하고, 그 인터페이스만을 바탕으로 코드를 작성할 수 있게 해준다. 객체지향적 스타일을 어느 정도 모사하고 싶을 때, 혹은 컴파일 시점에 타입이 확정되지 않아도 문제없는 경우에 트레이트 객체를 활용하면 편리하다. 반면, 런타임 비용이 늘어날 수 있으며, 트레이트의 객체 안전성 제한을 준수해야 한다는 점에 유의해야 한다.

Last updated