Rust의 Trait: 공유 동작 정의하기
1. 개요
Rust에서 trait은 여러 데이터 타입이 공통으로 가질 수 있는 동작을 정의하는 방법입니다. Java의 interface와 비슷하지만, default implementation, generics에 대한 trait bound, 그리고 static dispatch와 dynamic dispatch 중 선택할 수 있는 등 추가 기능이 있습니다.
이 글을 읽은 후, trait을 정의하는 방법, struct에 구현하는 방법, trait을 파라미터와 반환값으로 사용하는 방법, dyn Trait으로 여러 타입의 객체 목록을 만드는 방법을 알게 될 것입니다. 또한 컴파일러가 때때로 여러분의 코드를 거부하는 이유도 이해하게 될 것입니다.
모든 예제는 Rust stable(edition 2021 이상)에서 실행되며 외부 라이브러리가 필요하지 않습니다.
2. Trait 정의 및 구현
판매 시스템에 Order와 Customer 두 가지 타입이 있다고 가정합시다. 우리는 이 둘이 모두 자신을 짧은 문자열로 “요약”할 수 있기를 원합니다.
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Order {
pub id: u32,
pub total: u64, // 단위: 원
}
pub struct Customer {
pub name: String,
pub email: String,
}
impl Summary for Order {
fn summarize(&self) -> String {
format!("주문 #{} - {} 원", self.id, self.total)
}
}
impl Summary for Customer {
fn summarize(&self) -> String {
format!("고객 {} <{}>", self.name, self.email)
}
}
fn main() {
let order = Order { id: 42, total: 350_000 };
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
println!("{}", order.summarize());
println!("{}", customer.summarize());
}
설명:
pub trait Summary는summarize메서드를 가진 trait을 선언합니다. 여기서는 메서드 시그니처만 있고, 본문은 없습니다.impl Summary for Order는Order에 대한 구체적인 구현을 제공합니다. 각 타입은 trait에서 본문이 없는 모든 메서드를 구현해야 합니다.&self는 메서드가 객체를 빌려온다는 의미이며, 소유권을 가져가지 않습니다.
결과:
주문 #42 - 350000 원
고객 An <an@example.com>
주의: 다른 모듈에서
order.summarize()를 호출하려면, traitSummary이 스코프 내에 있어야 합니다 (use crate::Summary;). 이는 매우 흔한 오류입니다. 올바르게 구현했는데도 컴파일러가 “no method namedsummarizefound”라고 보고합니다.
3. 기본 구현
Trait은 미리 메서드 본문을 제공할 수 있습니다. trait을 구현하는 타입은 기본값을 사용하거나 필요시 재정의할 수 있습니다.
pub trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("[요약] {}", self.title())
}
}
impl Summary for Order {
fn title(&self) -> String {
format!("주문 #{}", self.id)
}
// 기본값을 재정의합니다
fn summarize(&self) -> String {
format!("{} - {} 원", self.title(), self.total)
}
}
impl Summary for Customer {
fn title(&self) -> String {
format!("고객 {}", self.name)
}
// summarize를 재정의하지 않음 -> 기본값 사용
}
2번 섹션의 main을 다시 실행하면, 결과는:
주문 #42 - 350000 원
[요약] 고객 An
주목할 점은 기본 메서드 summarize가 trait에서 본문이 없는 title()을 호출할 수 있다는 것입니다. 이는 유용한 패턴입니다. 구체적인 타입에는 최소 부분만 (title) 구현하도록 요구하고, trait이 그 위에 나머지를 구축합니다.
이제부터 이 글의 나머지 예제에서는 이 버전의 trait을 사용합니다.
4. 파라미터로서의 Trait
4.1. impl Trait 문법
“Summary를 가진 어떤 것”을 받는 함수를 쓰는 가장 간단한 방법:
fn print_summary(item: &impl Summary) {
println!("공지: {}", item.summarize());
}
fn main() {
let order = Order { id: 7, total: 120_000 };
print_summary(&order);
}
공지: 주문 #7 - 120000 원
4.2. Trait Bound
&impl Summary는 실제로는 trait bound가 있는 generic의 축약 문법입니다:
fn print_summary<T: Summary>(item: &T) {
println!("공지: {}", item.summarize());
}
여러 파라미터가 있을 때 두 가지 방식이 다릅니다:
// a와 b는 서로 다른 타입일 수 있습니다
fn compare_loose(a: &impl Summary, b: &impl Summary) { /* ... */ }
// a와 b는 반드시 같은 타입 T여야 합니다
fn compare_strict<T: Summary>(a: &T, b: &T) { /* ... */ }
compare_strict(&order, &customer)를 호출하면 Order와 Customer가 같은 타입이 아니기 때문에 컴파일 오류가 발생합니다.
4.3. 여러 Bound와 where 절
여러 trait이 필요할 때는 +로 연결합니다. 목록이 길 때는 가독성을 위해 where로 변경합니다:
use std::fmt::{Debug, Display};
fn log_item<T>(item: &T)
where
T: Summary + Display + Debug,
{
println!("{item} | {item:?} | {}", item.summarize());
}
이 함수는 세 가지 trait을 모두 구현하는 타입만 받습니다. trait이 하나라도 없으면 컴파일러가 함수 호출 지점에서 즉시 오류를 보고하며, 런타임을 기다리지 않습니다.
Rust의 generics은 monomorphization을 사용합니다. 컴파일러는 각 구체적인 타입에 대해 별도의 log_item 버전을 생성합니다. 따라서 함수 호출에 런타임 오버헤드가 없습니다(static dispatch). 대신 바이너리가 더 커질 수 있습니다.
5. impl Trait 반환
“Summary를 구현하는 어떤 타입”을 구체적인 타입 이름을 명시하지 않고 반환할 수도 있습니다:
fn latest_order() -> impl Summary {
Order { id: 100, total: 999_000 }
}
fn main() {
println!("{}", latest_order().summarize());
}
주문 #100 - 999000 원
이 방식은 closure와 iterator에 특히 유용합니다. 실제 타입이 매우 길거나 쓸 수 없는 경우가 많기 때문입니다.
주의: 반환 위치의
impl Trait은 여전히 정확히 하나의 구체적인 타입입니다. 다음 코드는 컴파일되지 않습니다:fn pick(vip: bool) -> impl Summary { if vip { Customer { name: "An".into(), email: "an@example.com".into() } } else { Order { id: 1, total: 0 } // 오류: `if` and `else` have incompatible types } }여러 다른 타입을 반환하려면
Box<dyn Summary>이 필요합니다(섹션 6 참조).
6. dyn Trait을 이용한 Trait Object
Generics은 컴파일 시간에 타입이 결정되어야 합니다. 때때로 Order와 Customer를 모두 포함하는 목록이 필요합니다. 이때는 trait object를 사용합니다:
fn main() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 50_000 }),
Box::new(Customer {
name: "Binh".to_string(),
email: "binh@example.com".to_string(),
}),
];
for item in &items {
println!("{}", item.summarize());
}
}
주문 #1 - 50000 원
[요약] 고객 Binh
설명:
dyn Summary는 컴파일 시간에 크기를 알 수 없는 타입입니다. 따라서Box,&또는Rc같은 포인터 뒤에 있어야 합니다.- 각 trait object는 두 부분으로 구성됩니다: 데이터를 가리키는 포인터와 vtable을 가리키는 포인터입니다.
summarize()호출은 런타임에 vtable에서 조회됩니다(dynamic dispatch). - 섹션 5의
pick함수는 반환 타입을Box<dyn Summary>으로 변경하고 각 분기를Box::new(...)로 감싸면 고쳐집니다.
빠른 비교:
| 기준 | Generics / impl Trait |
dyn Trait |
|---|---|---|
| Dispatch | Static, inline 가능 | Dynamic via vtable |
| 여러 타입 목록 | 아니오 | 예 |
| 바이너리 크기 | 더 큼 | 더 작음 |
주의: 모든 trait이
dyn과 함께 사용될 수 있는 것은 아닙니다. Trait은 dyn-compatible이어야 합니다(이전에는 object safe라고 불림). Trait이fn send<T>(&self, t: T)같은 generic 메서드를 가지면 dyn-compatible이 아닙니다.where Self: Sized가 없으면서Self를 반환하는 메서드를 가져도 이 성질을 잃습니다. 이유는 vtable이 generic 메서드의 수많은 버전을 포함할 수 없고, 컴파일러가 실제Self가 무엇인지 알 수 없기 때문입니다.
7. Derive와 표준 Trait
표준 라이브러리에는 많은 친숙한 trait이 있습니다. 일부 trait은 #[derive]로 자동 생성됩니다:
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub struct Order {
pub id: u32,
pub total: u64,
}
impl fmt::Display for Order {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Order#{}", self.id)
}
}
fn main() {
let a = Order { id: 5, total: 10_000 };
let b = a.clone();
println!("{a}"); // Display
println!("{a:?}"); // Debug
println!("{}", a == b); // PartialEq
println!("{}", a.to_string().len());
}
Order#5
Order { id: 5, total: 10000 }
true
7
ToString을 구현하지 않았는데도 a.to_string()을 호출할 수 있습니다. 이는 표준 라이브러리의 blanket implementation 덕분입니다:
impl<T: Display + ?Sized> ToString for T { /* ... */ }
즉, Display를 가진 모든 타입은 자동으로 ToString을 갖게 됩니다. 여러분도 자신의 trait에 대해 blanket impl을 작성할 수 있습니다. 예를 들어 impl<T: Display> Summary for T처럼 말입니다. 하지만 조심해야 합니다. 다른 개별 구현과 충돌하기 때문입니다.
8. Orphan Rule과 Newtype Pattern
Rust에는 orphan rule이 있습니다. trait 또는 타입이 여러분의 crate에서 정의된 경우에만 impl Trait for Type을 작성할 수 있습니다. 이 규칙은 두 crate가 같은 타입에 대해 한 trait을 두 가지 다른 방식으로 구현하는 것을 방지합니다.
다음 예제는 거부됩니다. Display와 Vec 모두 표준 라이브러리에 속하기 때문입니다:
// 오류 E0117: 현재 crate에서 정의된 trait만 crate 외부에서 정의된 타입에 구현될 수 있습니다
impl std::fmt::Display for Vec<Order> { /* ... */ }
해결책은 newtype pattern입니다. 외부 타입을 자신의 struct로 감싸면 됩니다:
pub struct Cart(pub Vec<Order>);
impl fmt::Display for Cart {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let total: u64 = self.0.iter().map(|o| o.total).sum();
write!(f, "장바구니: {} 개, 총 {} 원", self.0.len(), total)
}
}
fn main() {
let cart = Cart(vec![
Order { id: 1, total: 100_000 },
Order { id: 2, total: 250_000 },
]);
println!("{cart}");
}
장바구니: 2 개, 총 350000 원
Cart는 로컬 타입이므로 Display를 구현할 수 있습니다. Newtype은 런타임 비용이 없습니다: Cart는 Vec<Order>와 같은 레이아웃을 가집니다.
9. Trait에 대한 Test 작성
Rust의 test는 같은 파일에 있으며 #[cfg(test)] 모듈 안에 있습니다. 재정의된 버전과 기본값, 그리고 trait object를 통한 호출 경우를 모두 확인합니다:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn order_overrides_default_summary() {
let order = Order { id: 42, total: 350_000 };
assert_eq!(order.summarize(), "주문 #42 - 350000 원");
}
#[test]
fn customer_uses_default_summary() {
let customer = Customer {
name: "An".to_string(),
email: "an@example.com".to_string(),
};
assert_eq!(customer.summarize(), "[요약] 고객 An");
}
#[test]
fn trait_objects_dispatch_to_correct_impl() {
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Order { id: 1, total: 0 }),
Box::new(Customer { name: "Binh".into(), email: "b@x.vn".into() }),
];
let result: Vec<String> = items.iter().map(|i| i.summarize()).collect();
assert_eq!(result, vec!["주문 #1 - 0 원", "[요약] 고객 Binh"]);
}
}
Test 실행:
cargo test
running 3 tests
test tests::customer_uses_default_summary ... ok
test tests::order_overrides_default_summary ... ok
test tests::trait_objects_dispatch_to_correct_impl ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
세 번째 test는 중요합니다. 이는 vtable을 통과할 때 각 요소가 여전히 실제 타입의 올바른 구현을 호출함을 확인합니다.
10. 결론
Trait은 Rust에서 동작 공유의 기초입니다. 주요 포인트:
trait으로 동작을 선언하고impl Trait for Type으로 구현하세요. 기본 메서드는 반복되는 코드를 줄이는 데 도움이 됩니다.- 타입이 컴파일 시간에 알려져 있을 때
impl Trait또는 trait bound(T: Trait,where)를 사용하세요. 이 방법은 static dispatch를 제공하며 런타임 비용이 없습니다. - 여러 다른 타입을 포함하거나 반환해야 할 때
Box<dyn Trait>을 사용하세요. dyn-compatible 요구 사항을 기억하세요. - trait의 메서드를 호출하기 전에
use로 trait을 스코프로 가져오는 것을 잊지 마세요. - orphan rule을 만나면 newtype pattern을 사용하세요.
언제 trait을 만들지 않아야 할까요? 동작이 오직 하나의 구현만 있고 test 시 대체할 필요가 없다면, struct의 일반 메서드로 충분합니다. Trait은 정말로 여러 타입이 동일한 동작을 공유하거나 시스템의 부분을 분리하기 위해 추상화가 필요할 때만 추가할 가치가 있습니다.