Rust 트레이트 객체 (dyn)
트레이트 페이지에서는 “Speak를 구현한 타입이면 무엇이든” 받는 함수를 만들어 봤어요. 제네릭 페이지에서는 Rust가 제네릭 함수를 타입마다 따로 복사해 만든다는 것도 봤고요. 둘 다 컴파일할 때 타입을 알고 있으면 아주 잘 동작해요. 그런데 서로 다른 타입을 리스트 하나에 담고 싶다면 어떻게 할까요?
그럴 때 쓰는 게 트레이트 객체(trait object)예요. 학생들이 가득한 음악 교실을 떠올려 보세요. 누구는 피아노를, 누구는 바이올린을, 누구는 드럼을 연주해요. 선생님은 학생마다 어떤 악기를 가졌는지 알 필요가 없어요. “연주 시작!“이라고만 하면 각자 자기 방식대로 연주하니까요. 트레이트 객체를 쓰면 코드도 똑같이 할 수 있어요. “정확한 타입은 모르지만, 이 일을 할 수 있다는 건 알아.”
문제: Vec 하나에 서로 다른 타입
섹션 제목: “문제: Vec 하나에 서로 다른 타입”도형이 두 가지 있고, 이걸 Vec 하나에 담고 싶다고 해 볼게요.
struct Circle { radius: f64 }struct Square { side: f64 }
fn main() { let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }];}이 코드는 컴파일되지 않아요.
error[E0308]: mismatched types --> src/main.rs:5:47 |5 | let shapes = vec![Circle { radius: 1.0 }, Square { side: 2.0 }]; | ^^^^^^^^^^^^^^^^^^^^ expected `Circle`, found `Square`Vec은 한 가지 타입만 담을 수 있어요. Rust가 아이템을 메모리에 나란히 줄 세우려면 모든 아이템의 크기가 같아야 하거든요. 에러 메시지는 “Circle을 기대했는데 Square가 왔다”는 뜻이에요. Circle과 Square는 서로 다른 타입이라서 평범한 Vec 하나에 함께 들어갈 수 없어요.
Box<dyn Trait>: 서로 다른 도형의 리스트
섹션 제목: “Box<dyn Trait>: 서로 다른 도형의 리스트”해결 방법은 두 단계예요. 먼저 두 도형에 공통 트레이트를 하나 줘요. 그다음 각 도형을 Box에 넣고, 타입을 Box<dyn Shape>라고 적어요.
trait Shape { fn name(&self) -> String; fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn name(&self) -> String { String::from("원") } fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn name(&self) -> String { String::from("정사각형") } fn area(&self) -> f64 { self.side * self.side }}
fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 2.0 }), Box::new(Square { side: 3.0 }), Box::new(Circle { radius: 1.0 }), ];
for shape in &shapes { println!("{}의 넓이: {:.2}", shape.name(), shape.area()); }}실행 결과:
원의 넓이: 12.57정사각형의 넓이: 9.00원의 넓이: 3.14Box<dyn Shape>를 한 조각씩 읽어 볼게요.
dyn Shape는 “Shape를 구현한 어떤 타입. 정확히 무엇인지는 프로그램이 실행될 때 알게 될 거예요”라는 뜻이에요.dyn은 dynamic(동적)의 줄임말이에요.Box는 값을 힙에 두고 그 값을 가리키는 포인터를 들고 있어요(스마트 포인터 페이지에서 만나 봤죠). 안에 무엇이 들었든Box는 모두 크기가 같아요. 그래서 이제Vec이 문제없이 받아 줘요.
상자 안의 값(그리고 “이건 Circle이에요”라고 알려 주는 숨은 포인터)을 합쳐서 트레이트 객체라고 불러요.
&dyn Trait: 상자에 넣는 대신 빌려 쓰기
섹션 제목: “&dyn Trait: 상자에 넣는 대신 빌려 쓰기”항상 Box가 필요한 건 아니에요. 값이 이미 다른 곳에 있다면 참조자로도 충분해요. &dyn Shape는 “Shape를 구현한 어떤 타입을 빌린 참조자”라는 뜻이에요.
trait Shape { fn area(&self) -> f64;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn area(&self) -> f64 { 3.14159 * self.radius * self.radius }}
impl Shape for Square { fn area(&self) -> f64 { self.side * self.side }}
fn print_area(shape: &dyn Shape) { println!("넓이: {:.1}", shape.area());}
fn main() { let circle = Circle { radius: 1.0 }; let square = Square { side: 4.0 };
print_area(&circle); print_area(&square);
let both: Vec<&dyn Shape> = vec![&circle, &square]; println!("도형이 {}개 있어요", both.len());}실행 결과:
넓이: 3.1넓이: 16.0도형이 2개 있어요간단한 규칙이 있어요. 리스트가 값을 소유해야 하면 Box<dyn Trait>를, 잠깐 빌려 쓰기만 하면 &dyn Trait를 쓰세요.
정적 디스패치와 동적 디스패치
섹션 제목: “정적 디스패치와 동적 디스패치”“디스패치”(dispatch)는 그냥 “어떤 메서드를 호출할지 정하는 것”이에요. Rust는 두 가지 방법으로 이걸 해요.
정적 디스패치(static dispatch)는 제네릭과 impl Trait를 쓸 때 일어나요. 컴파일러가 정확한 타입을 알기 때문에, 타입마다 함수를 따로 복사해 만들어요(제네릭 페이지에서 본 것처럼요). 호출은 곧장 알맞은 메서드로 가요. 아주 빨라요.
동적 디스패치(dynamic dispatch)는 dyn Trait를 쓸 때 일어나요. 컴파일러는 정확한 타입을 몰라요. 그래서 트레이트 객체마다 “이 값의 area는 여기 있어요”라고 적힌 작은 숨은 표(vtable이라고 해요)를 들고 다녀요. 실행 중에 Rust가 그 표에서 메서드를 찾은 다음 호출해요. 이 찾는 과정에 아주 작은 비용이 더 들어요.
// 정적 디스패치: Rust가 타입마다 이 함수를 하나씩 복사해 만들어요fn static_area(shape: &impl Shape) -> f64 { shape.area()}
// 동적 디스패치: 함수는 하나, 메서드는 실행 중에 찾아요fn dynamic_area(shape: &dyn Shape) -> f64 { shape.area()}같은 도형이라면 두 함수 모두 같은 답을 줘요. 다른 점은 Rust가 알맞은 area 메서드를 어떻게 찾느냐 하나뿐이에요. 그럼 어느 쪽을 골라야 할까요?
제네릭 / impl Trait를 쓸 때 |
dyn Trait를 쓸 때 |
|---|---|
| 호출할 때마다 알려진 타입 하나만 다룰 때 | 서로 다른 타입을 컬렉션 하나에 섞어야 할 때 |
| 가능한 한 가장 빠른 코드를 원할 때 | 타입을 실행 중에 정할 때(예: 사용자 입력에 따라) |
| 프로그램이 조금 커져도 괜찮을 때 | 코드를 작게, 컴파일을 빠르게 하고 싶을 때 |
초보자에게 좋은 기본값은 이거예요. 제네릭이나 impl Trait로 시작하세요. 그러다 “서로 다른 타입을 리스트 하나에” 담아야 하는 문제를 만나면 그때 dyn을 꺼내면 돼요. 속도 차이는 너무 걱정하지 마세요. 대부분의 프로그램에서는 전혀 느낄 수 없어요.
함수에서 Box<dyn Trait> 반환하기
섹션 제목: “함수에서 Box<dyn Trait> 반환하기”함수가 어떤 타입을 돌려줄지 실행 중에 정해야 할 때가 있어요. -> impl Shape를 써 보고 싶겠지만, 그건 함수가 항상 같은 타입을 돌려줄 때만 동작해요. 분기마다 다른 타입을 돌려준다면 대신 Box<dyn Shape>를 돌려주세요.
trait Shape { fn describe(&self) -> String;}
struct Circle { radius: f64 }struct Square { side: f64 }
impl Shape for Circle { fn describe(&self) -> String { format!("반지름이 {}인 원", self.radius) }}
impl Shape for Square { fn describe(&self) -> String { format!("한 변이 {}인 정사각형", self.side) }}
fn make_shape(kind: &str, size: f64) -> Box<dyn Shape> { if kind == "circle" { Box::new(Circle { radius: size }) } else { Box::new(Square { side: size }) }}
fn main() { let a = make_shape("circle", 1.5); let b = make_shape("square", 2.0); println!("{}을 만들었어요", a.describe()); println!("{}을 만들었어요", b.describe());}실행 결과:
반지름이 1.5인 원을 만들었어요한 변이 2인 정사각형을 만들었어요“팩토리” 함수, 플러그인 시스템, 설정 파일에 따라 움직이는 코드에서 흔히 보는 패턴이에요. 정확한 타입이 프로그램을 실행해야만 알 수 있는 데이터에 달려 있는 곳이라면 어디든요.
실전 예제: 알림 보내기
섹션 제목: “실전 예제: 알림 보내기”실제 앱에 더 가까운 예를 볼게요. 프로그램이 사용자에게 알림을 보내야 하는데, 사용자마다 고른 채널이 달라요. 이메일, 문자(SMS), 아니면 둘 다요. 채널은 각자 자기 타입이고, 모두 Notifier 트레이트를 공유해요.
trait Notifier { fn send(&self, message: &str) -> String;}
struct Email { address: String }struct Sms { phone: String }
impl Notifier for Email { fn send(&self, message: &str) -> String { format!("이메일 → {}: {}", self.address, message) }}
impl Notifier for Sms { fn send(&self, message: &str) -> String { format!("문자 → {}: {}", self.phone, message) }}
fn notify_all(channels: &[Box<dyn Notifier>], message: &str) { for channel in channels { println!("{}", channel.send(message)); }}
fn main() { let channels: Vec<Box<dyn Notifier>> = vec![ Box::new(Sms { phone: String::from("+1-555-0100") }), ];
notify_all(&channels, "주문하신 상품이 발송됐어요!");}실행 결과:
이메일 → [email protected]: 주문하신 상품이 발송됐어요!문자 → +1-555-0100: 주문하신 상품이 발송됐어요!좋은 점이 있어요. 나중에 Push 알림을 추가하고 싶다면 새 구조체 하나와 impl Notifier for Push 하나만 쓰면 돼요. notify_all 함수는 하나도 바꿀 필요가 없어요.
한계: dyn 호환성
섹션 제목: “한계: dyn 호환성”모든 트레이트가 트레이트 객체가 될 수 있는 건 아니에요. 트레이트는 dyn 호환(dyn compatible)이어야 해요(예전 책이나 블로그 글에서는 객체 안전성(object safety)이라고 불러요). 전체 규칙은 길지만, 실제로 마주치는 경우는 대부분 다음 두 가지예요.
- 메서드가
Self를 반환하면 안 돼요. (dyn뒤에서는 Rust가Self가 무엇인지 모르니까, 반환값이 얼마나 큰지도 알 수 없어요.) - 메서드가
fn process<T>(&self, item: T)처럼 자기만의 제네릭 타입 매개변수를 가지면 안 돼요.
첫 번째 규칙을 어기면 이렇게 돼요.
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self;}// ... 나중에:let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })];error[E0038]: the trait `Shape` is not dyn compatible |14 | let shapes: Vec<Box<dyn Shape>> = vec![Box::new(Circle { radius: 1.0 })]; | ^^^^^ `Shape` is not dyn compatible | 1 | trait Shape { | ----- this trait is not dyn compatible... 2 | fn area(&self) -> f64; 3 | fn duplicate(&self) -> Self; | ^^^^ ...because method `duplicate` references the `Self` type in its return type = help: consider moving `duplicate` to another trait에러 메시지가 솔직하고 친절해요. “duplicate 메서드가 반환 타입에서 Self를 쓰기 때문에 Shape는 dyn 호환이 아니다”라고, 어느 메서드가 문제인지 정확히 알려 주거든요. 해결 방법은 그 메서드를 다른 트레이트로 옮기거나, 메서드에 where Self: Sized를 붙이는 거예요. 이렇게 하면 Rust에게 “이 메서드는 트레이트 객체가 아니라, 실제로 알려진 타입에만 있어요”라고 알려 주는 셈이에요.
trait Shape { fn area(&self) -> f64; fn duplicate(&self) -> Self where Self: Sized;}Clone 같은 일부 표준 트레이트를 dyn Clone으로 쓸 수 없는 이유도 이거예요. clone()이 Self를 반환하거든요.
자주 하는 실수
섹션 제목: “자주 하는 실수”Box를 빼먹기. Vec<dyn Shape>라고 쓰고 싶어지기 쉬워요.
let shapes: Vec<dyn Shape> = Vec::new();error[E0277]: the size for values of type `dyn Shape` cannot be known at compilation time --> src/main.rs:3:17 |3 | let shapes: Vec<dyn Shape> = Vec::new(); | ^^^^^^^^^^^^^^ doesn't have a size known at compile-time | = help: the trait `Sized` is not implemented for `dyn Shape`“dyn Shape 타입 값의 크기를 컴파일할 때 알 수 없다”는 에러예요. dyn Shape는 어떤 크기든 될 수 있어요. 작은 Square일 수도 있고, 필드가 잔뜩 있는 큰 구조체일 수도 있어요. Rust는 고정된 크기가 필요하니까, 트레이트 객체는 언제나 포인터 뒤에 있어야 해요. Box<dyn Shape>, &dyn Shape, Rc<dyn Shape>처럼요.
어디에나 dyn 쓰기. 함수가 한 번에 한 가지 타입만 받는다면 impl Trait나 제네릭이 더 간단하고 빨라요(impl Trait는 고급 트레이트 페이지에서 더 다뤄요). dyn은 정말로 타입을 섞어야 할 때를 위해 아껴 두세요.
연습 문제
섹션 제목: “연습 문제”1. Vec 하나에 담은 동물원
섹션 제목: “1. Vec 하나에 담은 동물원”fn name(&self) -> String과 fn sound(&self) -> String을 가진 트레이트 Animal을 정의하세요. 이걸 Dog, Cow, Duck에 구현하고, 하나씩 Vec<Box<dyn Animal>>에 넣은 다음, 반복문으로 각 동물의 울음소리를 출력해 보세요. 힌트: 구조체는 비어 있어도 돼요(struct Dog;). 각각 Box::new(...)로 넣으면 돼요.
정답 보기
trait Animal { fn name(&self) -> String; fn sound(&self) -> String;}
struct Dog;struct Cow;struct Duck;
impl Animal for Dog { fn name(&self) -> String { String::from("강아지") } fn sound(&self) -> String { String::from("멍멍") }}
impl Animal for Cow { fn name(&self) -> String { String::from("소") } fn sound(&self) -> String { String::from("음메") }}
impl Animal for Duck { fn name(&self) -> String { String::from("오리") } fn sound(&self) -> String { String::from("꽥꽥") }}
fn main() { let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cow), Box::new(Duck)];
for animal in &zoo { println!("{}는 {} 하고 울어요", animal.name(), animal.sound()); }}실행 결과:
강아지는 멍멍 하고 울어요소는 음메 하고 울어요오리는 꽥꽥 하고 울어요2. 실행 중에 인사 담당 고르기
섹션 제목: “2. 실행 중에 인사 담당 고르기”fn hello(&self, name: &str) -> String을 가진 트레이트 Greeter와 두 타입 English, Spanish를 정의하세요. "es"이면 Spanish를, 그 밖에는 English를 돌려주는 fn make_greeter(lang: &str) -> Box<dyn Greeter>를 작성하고, ["en", "es", "fr"]의 각 코드로 "Mia"에게 인사해 보세요. 힌트: if의 두 분기는 같은 타입을 돌려줘야 하니까, 각각 Box::new(...)로 감싸 주세요.
정답 보기
trait Greeter { fn hello(&self, name: &str) -> String;}
struct English;struct Spanish;
impl Greeter for English { fn hello(&self, name: &str) -> String { format!("Hello, {name}!") }}
impl Greeter for Spanish { fn hello(&self, name: &str) -> String { format!("¡Hola, {name}!") }}
fn make_greeter(lang: &str) -> Box<dyn Greeter> { if lang == "es" { Box::new(Spanish) } else { Box::new(English) }}
fn main() { for lang in ["en", "es", "fr"] { let greeter = make_greeter(lang); println!("{lang}: {}", greeter.hello("Mia")); }}실행 결과:
en: Hello, Mia!es: ¡Hola, Mia!fr: Hello, Mia!이 문제는 영어 인사와 스페인어 인사를 고르는 예제라서, 인사말은 그대로 두었어요.
Vec은 한 가지 타입만 담을 수 있어요. 여러 타입을 섞으려면Box<dyn Trait>같은 트레이트 객체를 쓰세요.Box<dyn Trait>는 값을 소유하고,&dyn Trait는 빌려요. 트레이트 객체는 언제나 포인터 뒤에 있어요.- 제네릭과
impl Trait는 정적 디스패치(빠르고, 컴파일할 때 정해져요)를 써요.dyn Trait는 동적 디스패치(실행 중에 vtable에서 찾아요)를 써요. - 함수가 실행 중에 서로 다른 타입 중 하나를 골라 돌려줘야 한다면
Box<dyn Trait>를 반환하세요. - dyn 호환 트레이트만 트레이트 객체가 될 수 있어요.
Self를 반환하는 메서드나 제네릭 메서드가 없어야 해요(where Self: Sized가 붙어 있다면 괜찮아요).
이해도 점검
섹션 제목: “이해도 점검”이해도 점검
1. Circle과 Square를 왜 같은 평범한 Vec에 넣을 수 없을까요?
2. Box<dyn Shape>의 dyn은 Rust에게 무엇을 알려 줄까요?
3. 다음 중 정적 디스패치를 쓰는 것은 무엇일까요?
4. Box 없이 쓴 Vec<dyn Shape>는 왜 에러일까요?
5. 다음 중 트레이트를 dyn 호환이 아니게 만드는 메서드는 무엇일까요?
점수: 0 / 5
더 알아보기
섹션 제목: “더 알아보기”- 트레이트 객체를 사용하여 다른 타입의 값 허용하기 — The Rust Programming Language 한국어판:
Box<dyn Draw>로 GUI 컴포넌트 리스트를 만들어 보는 공식 장이에요. - Returning Traits with dyn — Rust by Example (영어):
Box<dyn Trait>를 반환하는 함수를 짧은 예제로 실행해 볼 수 있어요. - Trait objects — The Rust Reference (영어): 라이프타임과
Send같은 추가 마커 트레이트를 포함해,dyn Trait타입의 정확한 규칙이에요. - Dyn compatibility — The Rust Reference (영어): 트레이트를
dyn으로 쓰려면 따라야 하는 규칙 전체 목록이에요. dyn키워드 — 표준 라이브러리 문서 (영어):dyn, vtable, 그리고 제네릭과 비교한 장단점을 짧게 설명해요.