Rust의 dyn 키워드와 동적 디스패치(Dynamic Dispatch)
트레이트를 사용하다 보면 어느 순간 벽에 부딪히는 상황이 옵니다. 서로 다른 타입의 값을 하나의 벡터에 담고 싶은데 컴파일러가 허락하지 않는 거죠 😅
trait Animal {
fn speak(&self) -> &str;
}
struct Dog;
struct Cat;
impl Animal for Dog {
fn speak(&self) -> &str {
"멍멍!"
}
}
impl Animal for Cat {
fn speak(&self) -> &str {
"야옹~"
}
}
fn main() {
let animals = vec![Dog, Cat]; // 컴파일 오류!
}
Dog과 Cat은 둘 다 Animal을 구현하지만 엄연히 서로 다른 타입입니다.
Rust의 벡터는 모든 원소가 같은 타입이어야 하니까 이 코드는 동작하지 않죠.
이 문제를 해결하는 열쇠가 바로 dyn 키워드입니다.
그럼 dyn이 뭔지, 그리고 어떻게 쓰는 건지 알아볼까요?
정적 디스패치
Rust는 기본적으로 정적 디스패치(static dispatch)를 씁니다.
dyn을 보기 전에 이쪽부터 짚고 넘어갈게요.
제네릭이나 impl Trait을 사용하면 컴파일러가 호출 시점의 구체적인 타입을 보고 각 타입 전용 코드를 생성합니다.
이 과정을 단형화(monomorphization)라고 불러요.
fn greet(animal: &impl Animal) {
println!("{}", animal.speak());
}
fn main() {
let dog = Dog;
let cat = Cat;
greet(&dog);
greet(&cat);
}
멍멍!
야옹~
이 코드를 컴파일하면 greet 함수가 실제로는 두 개로 복제됩니다.
greet::<Dog>와 greet::<Cat>처럼 타입별로 전용 함수가 만들어지는 거죠.
// 컴파일러가 내부적으로 생성하는 코드 (개념적)
fn greet_dog(animal: &Dog) {
println!("{}", animal.speak());
}
fn greet_cat(animal: &Cat) {
println!("{}", animal.speak());
}
이렇게 하면 런타임에 “이 값의 타입이 뭐지?” 같은 판단이 필요 없으니 함수 호출이 매우 빠릅니다. 컴파일러가 어떤 메서드를 호출할지 이미 알고 있으니까요.
하지만 단점도 있습니다. 타입이 10개면 함수도 10개가 만들어지니 바이너리 크기가 커질 수 있어요. 그리고 무엇보다 글 서두에서 본 것처럼 서로 다른 타입을 하나의 컬렉션에 모을 수 없습니다.
동적 디스패치와 dyn
동적 디스패치(dynamic dispatch)는 이름 그대로 런타임에 어떤 메서드를 호출할지 결정하는 방식입니다. Java나 Python에서 다형성이 작동하는 방식과 비슷하다고 생각하면 됩니다.
Rust에서 동적 디스패치를 사용하려면 dyn 키워드를 트레이트 이름 앞에 붙여서 트레이트 객체(trait object)를 만듭니다.
fn greet_dynamic(animal: &dyn Animal) {
println!("{}", animal.speak());
}
&impl Animal이 “컴파일 시점에 타입이 결정되는 Animal 구현체”라면, &dyn Animal은 “런타임에 타입이 결정되는 Animal 구현체”입니다.
이제 서로 다른 타입을 하나의 벡터에 담을 수 있어요.
fn main() {
let dog = Dog;
let cat = Cat;
let animals: Vec<&dyn Animal> = vec![&dog, &cat];
for animal in &animals {
println!("{}", animal.speak());
}
}
멍멍!
야옹~
Vec<&dyn Animal>은 Animal 트레이트를 구현한 어떤 타입이든 참조로 담을 수 있는 벡터입니다.
Dog이든 Cat이든 &dyn Animal이라는 같은 형태의 참조로 취급되니까 하나의 벡터에 넣을 수 있게 되는 겁니다.
vtable의 비밀
dyn이 어떻게 런타임에 올바른 메서드를 호출하는지 궁금하지 않으신가요?
그 비밀은 vtable(가상 메서드 테이블)에 있습니다.
트레이트 객체는 내부적으로 두 개의 포인터로 이루어져 있어요. 하나는 실제 데이터를 가리키는 포인터이고, 다른 하나는 해당 타입의 vtable을 가리키는 포인터입니다. 이렇게 포인터 두 개로 구성되어 있다고 해서 “팻 포인터(fat pointer)“라고도 부릅니다.
// &dyn Animal의 내부 구조 (개념적)
struct TraitObject {
data: *const (), // 실제 데이터 (Dog 또는 Cat)
vtable: *const (), // vtable 포인터
}
vtable은 해당 타입이 트레이트의 각 메서드를 어떻게 구현했는지를 담은 함수 포인터 테이블입니다.
// Dog의 vtable (개념적)
static DOG_VTABLE: Vtable = Vtable {
speak: Dog::speak, // Dog의 speak 구현을 가리킴
drop: Dog::drop,
size: size_of::<Dog>(),
align: align_of::<Dog>(),
};
그래서 animal.speak()을 호출하면 Rust는 vtable에서 speak 함수 포인터를 찾아 호출합니다.
Dog의 트레이트 객체라면 Dog::speak이 호출되고, Cat이라면 Cat::speak이 호출되는 식이죠.
이 간접 호출 때문에 정적 디스패치보다 약간의 오버헤드가 있지만, 대부분의 애플리케이션에서는 체감하기 어려운 수준입니다.
Box와 함께 사용하기
참조 대신 소유권을 가진 트레이트 객체가 필요하다면 Box를 사용합니다.
fn main() {
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog),
Box::new(Cat),
];
for animal in &animals {
println!("{}", animal.speak());
}
}
멍멍!
야옹~
&dyn Animal은 데이터를 빌려 쓰는 거라서 원본이 살아 있어야 합니다.
반면 Box<dyn Animal>은 데이터의 소유권을 가지고 있어서 함수에서 생성해 반환하거나 구조체 필드에 저장할 수 있죠.
fn create_animal(name: &str) -> Box<dyn Animal> {
match name {
"dog" => Box::new(Dog),
"cat" => Box::new(Cat),
_ => panic!("모르는 동물이에요!"),
}
}
fn main() {
let animal = create_animal("dog");
println!("{}", animal.speak());
}
멍멍!
함수의 반환 타입으로 Box<dyn Animal>을 쓰면 호출자는 내부에 어떤 구체적인 타입이 들어 있는지 알 필요가 없습니다.
그저 Animal 트레이트의 메서드를 호출하면 되니까요.
객체 안전성
모든 트레이트가 dyn과 함께 사용할 수 있는 건 아닙니다.
트레이트 객체로 만들려면 해당 트레이트가 “객체 안전(object safe)“해야 하는데요.
객체 안전하지 않은 트레이트의 대표적인 예가 Clone입니다.
fn try_clone(animal: &dyn Clone) { // 컴파일 오류!
let _copied = animal.clone();
}
왜 안 될까요?
clone() 메서드는 Self를 반환합니다.
그런데 dyn Clone은 런타임에 실제 타입이 뭔지 모르니까 반환값의 크기를 알 수 없어요.
컴파일러가 스택에 얼마만큼의 공간을 확보해야 하는지 결정할 수가 없어요.
객체 안전성 규칙은 크게 두 가지입니다.
우선 메서드가 Self를 반환하면 안 됩니다.
Self는 구현 타입의 크기에 따라 달라지는데 트레이트 객체를 통해서는 그 크기를 알 수 없으니까요.
trait NotObjectSafe {
fn returns_self(&self) -> Self; // Self 반환 → 객체 안전 X
}
또한 메서드에 제네릭 타입 매개변수가 있으면 안 됩니다. 제네릭이 있으면 호출 시마다 다른 함수가 필요한데 vtable에 무한한 변형을 담아둘 수는 없잖아요.
trait AlsoNotSafe {
fn process<T>(&self, item: T); // 제네릭 매개변수 → 객체 안전 X
}
반환 타입을 Box<dyn Trait>으로 바꾸면 Self 반환 제약을 우회할 수 있습니다.
trait Cloneable {
fn clone_box(&self) -> Box<dyn Cloneable>;
}
impl Cloneable for Dog {
fn clone_box(&self) -> Box<dyn Cloneable> {
Box::new(Dog)
}
}
이렇게 하면 반환값의 크기가 항상 Box의 크기(포인터 하나)로 고정되니까 객체 안전성 문제가 사라집니다.
정적 vs 동적, 언제 뭘 쓸까?
정적 디스패치와 동적 디스패치 중 어떤 걸 선택해야 하는지 고민되는 경우가 많을 텐데요. 각각 잘 맞는 상황이 있습니다.
정적 디스패치(impl Trait / 제네릭)는 타입이 컴파일 시점에 결정되는 경우에 적합합니다.
함수에 넘기는 타입이 하나로 고정되어 있거나 성능이 매우 중요한 핫 루프에서는 정적 디스패치가 낫습니다.
// 정적 디스패치: 컴파일 시점에 타입이 정해짐
fn feed(animal: &impl Animal) {
println!("{}에게 밥을 줍니다", animal.speak());
}
동적 디스패치(dyn Trait)는 다음과 같은 상황에서 빛을 발합니다.
서로 다른 타입을 하나의 컬렉션에 담아야 할 때가 가장 대표적입니다.
앞에서 계속 본 것처럼 Vec<Box<dyn Animal>> 패턴이죠.
함수가 런타임 조건에 따라 다른 타입을 반환해야 할 때도 유용합니다.
아까 본 create_animal 함수가 좋은 예시인데요, 입력값에 따라 Dog을 반환할 수도 있고 Cat을 반환할 수도 있으니까 반환 타입을 Box<dyn Animal>로 해야 합니다.
플러그인 시스템이나 전략 패턴처럼 동작을 런타임에 교체해야 하는 설계에서도 dyn이 자연스럽습니다.
struct Zoo {
animals: Vec<Box<dyn Animal>>,
}
impl Zoo {
fn new() -> Self {
Zoo { animals: Vec::new() }
}
fn add(&mut self, animal: Box<dyn Animal>) {
self.animals.push(animal);
}
fn roll_call(&self) {
for animal in &self.animals {
println!("{}", animal.speak());
}
}
}
fn main() {
let mut zoo = Zoo::new();
zoo.add(Box::new(Dog));
zoo.add(Box::new(Cat));
zoo.roll_call();
}
멍멍!
야옹~
Zoo 구조체는 어떤 종류의 Animal이든 받아들일 수 있어요.
새로운 동물 타입을 추가해도 Zoo의 코드를 수정할 필요가 없어요.
열거형 vs 트레이트 객체
사실 Rust에서 여러 타입을 하나로 묶는 방법이 dyn만 있는 건 아닙니다.
열거형으로도 비슷한 걸 할 수 있거든요.
enum AnimalKind {
Dog,
Cat,
}
impl AnimalKind {
fn speak(&self) -> &str {
match self {
AnimalKind::Dog => "멍멍!",
AnimalKind::Cat => "야옹~",
}
}
}
fn main() {
let animals = vec![AnimalKind::Dog, AnimalKind::Cat];
for animal in &animals {
println!("{}", animal.speak());
}
}
멍멍!
야옹~
겉보기에는 비슷해 보이지만 둘의 성격은 꽤 다릅니다.
열거형은 가능한 타입이 정해져 있고 자주 바뀌지 않을 때 잘 맞습니다.
모든 변형이 한 곳에 정의되니 컴파일러가 누락된 케이스를 잡아줄 수 있고, vtable 없이 패턴 매칭으로 처리하니 성능도 좋습니다.
하지만 새로운 변형을 추가하려면 열거형 정의와 모든 match 문을 수정해야 하죠.
트레이트 객체는 반대입니다. 새로운 타입을 추가할 때 기존 코드를 건드릴 필요가 없어요. 라이브러리를 사용하는 쪽에서 트레이트만 구현하면 돼요. 대신 vtable을 통한 간접 호출 비용이 있고, 컴파일러가 모든 케이스를 검사해줄 수 없습니다.
간단하게 정리하면 변형이 고정적이라면 열거형을, 확장 가능해야 한다면 dyn을 쓰는 게 좋습니다.
마치며
처음에 벡터에 서로 다른 타입을 담지 못해 당황했던 문제, dyn으로 깔끔하게 풀 수 있게 됐죠.
정적 디스패치가 기본이지만 타입을 하나로 묶거나 런타임에 동작을 바꿔야 할 때는 dyn을 꺼내 쓰면 됩니다.
트레이트가 아직 익숙하지 않다면 먼저 읽어보시고, Box<dyn Trait> 패턴이 더 궁금하다면 Box로 힙에 데이터 저장하기도 참고해보세요.
더 자세한 내용은 dyn keyword - Rust 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0