> [!abstract] Introduction
> Rust에는 클래스도 상속도 없지만 다형성을 구현할 수 있습니다. 그 비밀은 바로 트레이트*trait*입니다. 트레이트는 "이 타입은 이런 동작을 할 수 있다"는 능력*capability*을 정의하는 추상화 도구로, Java의 인터페이스*interface*와 비슷하지만 더 강력한 기능을 제공합니다. 이 글에서는 트레이트의 기본 문법부터 정적·동적 디스패치의 차이, 그리고 연관 타입·슈퍼트레이트·블랭킷 구현 같은 고급 패턴까지 단계적으로 살펴봅니다.
# 트레이트란 무엇인가
트레이트는 타입이 구현해야 할 메서드 시그니처의 집합이다. [[Object-Oriented Programming|객체지향 프로그래밍]]에서 살펴본 것처럼, Rust는 클래스 상속 대신 트레이트로 다형성과 추상화를 달성한다. 핵심 아이디어는 간단하다 — 데이터(구조체)와 행위(트레이트)를 분리하고, 필요한 행위를 타입에 자유롭게 부착하는 것이다.
Java의 인터페이스가 "이 클래스는 이 계약을 이행한다"는 선언이라면, Rust의 트레이트는 "이 타입은 이 능력을 보유한다"는 선언이다. 미묘한 차이지만, 상속 계층 없이도 타입에 능력을 부여할 수 있다는 점에서 트레이트의 설계 철학이 드러난다.
# 트레이트 정의와 구현
## 정의
`trait` 키워드로 메서드 시그니처를 선언한다.
```rust
trait Summary {
fn summarize(&self) -> String;
}
```
`Summary` 트레이트는 `summarize`라는 메서드를 요구한다. 이 트레이트를 구현하는 모든 타입은 `summarize`의 본체를 반드시 제공해야 한다.
## 구현
`impl Trait for Type` 문법으로 특정 타입에 트레이트를 구현한다.
```rust
struct Article {
title: String,
author: String,
content: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{} — {}", self.title, self.author)
}
}
struct Tweet {
username: String,
content: String,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("@{}: {}", self.username, self.content)
}
}
```
`Article`과 `Tweet`은 완전히 다른 구조체지만, 둘 다 `Summary`를 구현하므로 `summarize()`를 호출할 수 있다. 데이터 구조의 형태와 관계없이 동일한 능력을 보유하게 되는 것이다.
## 기본 구현
트레이트를 정의할 때 메서드의 기본 본체*default implementation*를 제공할 수 있다. 기본 구현이 있는 메서드는 구현체에서 재정의하지 않아도 된다.
```rust
trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("({}의 글 더 보기...)", self.summarize_author())
}
}
impl Summary for Tweet {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
// summarize()는 기본 구현을 그대로 사용
}
```
`Tweet`은 `summarize_author()`만 구현하면 `summarize()`는 기본 구현이 자동으로 적용된다. 기본 구현 안에서 같은 트레이트의 다른 메서드를 호출할 수 있다는 점이 중요하다. 이를 통해 구현체는 핵심 메서드 하나만 제공하고 나머지는 트레이트가 조합하는 구조를 만들 수 있다.
# 트레이트 바운드
함수의 매개변수로 "특정 트레이트를 구현한 타입"만 받고 싶을 때 트레이트 바운드*trait bound*를 사용한다.
## 기본 문법
```rust
// 축약 문법
fn notify(item: &impl Summary) {
println!("속보! {}", item.summarize());
}
// 정식 문법 (트레이트 바운드)
fn notify<T: Summary>(item: &T) {
println!("속보! {}", item.summarize());
}
```
두 문법은 동일한 의미다. `impl Summary`는 트레이트 바운드의 축약형*syntactic sugar*으로, "Summary를 구현한 어떤 타입이든 받겠다"는 뜻이다.
## 복수 트레이트 바운드
`+` 연산자로 여러 트레이트를 동시에 요구할 수 있다.
```rust
fn notify(item: &(impl Summary + Display)) {
println!("속보! {}", item.summarize());
println!("전문: {}", item); // Display 트레이트의 fmt() 사용
}
```
## `where`
트레이트 바운드가 복잡해지면 `where` 로 가독성을 높인다.
```rust
fn process<T, U>(t: &T, u: &U) -> String
where
T: Summary + Clone,
U: Display + Debug,
{
format!("{} / {:?}", t.summarize(), u)
}
```
`where` 는 함수 시그니처에서 바운드를 분리하여, "이 함수가 무엇을 하는가"와 "어떤 제약이 있는가"를 명확히 구분해 준다.
# 정적 디스패치와 동적 디스패치
트레이트를 사용할 때 Rust는 두 가지 방식으로 메서드 호출을 해소*resolve*한다. 이것이 트레이트 시스템에서 가장 중요한 설계 결정이다.
## 정적 디스패치 — 단형화
`impl Trait`이나 제네릭을 사용하면 컴파일러가 **단형화*monomorphization***를 수행한다. 호출 시점에 구체 타입이 결정되어, 각 타입별로 함수의 복사본이 생성된다.
```rust
fn notify(item: &impl Summary) {
println!("{}", item.summarize());
}
// 컴파일러가 내부적으로 생성하는 코드 (개념적)
fn notify_article(item: &Article) {
println!("{}", item.summarize());
}
fn notify_tweet(item: &Tweet) {
println!("{}", item.summarize());
}
```
`notify(&article)`을 호출하면 `notify_article`이, `notify(&tweet)`을 호출하면 `notify_tweet`이 호출된다. 런타임에 어떤 함수를 호출할지 판단할 필요가 없으므로 함수 호출이 직접 호출*direct call*이 되고, 인라인 최적화까지 가능하다.
단형화의 대가는 바이너리 크기다. 제네릭 함수를 10개의 타입으로 호출하면 함수의 복사본이 10개 생긴다. 이것이 Rust 바이너리가 커지는 주요 원인 중 하나다[^monomorphization-bloat].
## 동적 디스패치 — 트레이트 객체
`dyn Trait`을 사용하면 런타임에 vtable[^vtable]을 통해 메서드를 호출한다. 이것을 트레이트 객체*trait object*라 부른다.
```rust
fn notify(item: &dyn Summary) {
println!("{}", item.summarize());
}
```
트레이트 객체는 내부적으로 두 개의 포인터로 구성된 팻 포인터*fat pointer*다.
![[traitObjectFatPointer.svg]]
첫 번째 포인터(`data_ptr`)는 실제 데이터를 가리키고, 두 번째 포인터(`vtable_ptr`)는 해당 타입의 vtable을 가리킨다. `item.summarize()`를 호출하면 vtable에서 `summarize_fn`의 주소를 읽어 간접 호출*indirect call*을 수행한다. 이것이 [[Object-Oriented Programming|객체지향 프로그래밍]]에서 Java의 가상 메서드 테이블과 C의 함수 포인터 테이블로 설명한 메커니즘과 동일한 원리다.
## 정적 디스패치 vs 동적 디스패치
| | 정적 디스패치 (`impl Trait`) | 동적 디스패치 (`dyn Trait`) |
| :-------------------------------- | :--------------------- | :------------------------------ |
| 해소 시점 | 컴파일 타임 | 런타임 |
| 메커니즘 | 단형화 (함수 복제) | vtable 간접 호출 |
| 성능 | 직접 호출 + 인라인 가능 | 간접 호출 오버헤드[^indirect-call-cost] |
| 바이너리 크기 | 타입 수에 비례하여 증가 | 함수 하나만 존재 |
| 이종 컬렉션[^heterogeneous-collection] | 불가능 | 가능 (`Vec<Box<dyn Trait>>`) |
| 컴파일 시간 | 타입 수에 비례하여 증가 | 영향 적음 |
선택 기준은 명확하다. 성능이 중요하고 타입이 컴파일 시점에 결정되면 정적 디스패치를, 서로 다른 타입을 하나의 컬렉션에 담아야 하거나 바이너리 크기를 줄여야 하면 동적 디스패치를 사용한다.
```rust
// 이종 컬렉션: 동적 디스패치가 필수
let items: Vec<Box<dyn Summary>> = vec![
Box::new(Article { title: "제목".into(), author: "저자".into(), content: "본문".into() }),
Box::new(Tweet { username: "user".into(), content: "트윗".into() }),
];
for item in &items {
println!("{}", item.summarize());
}
```
`Vec<Box<dyn Summary>>`는 서로 다른 타입의 객체를 하나의 벡터에 담는다. 제네릭의 `T`는 하나의 구체 타입으로만 결정되기 때문에 정적 디스패치로는 불가능하다.
# 고급 패턴
## 연관 타입
연관 타입*associated type*은 트레이트 내부에서 플레이스홀더 타입을 선언하는 기능이다. 구현체가 이 타입을 구체적으로 지정한다.
```rust
trait Iterator {
type Item; // 연관 타입
fn next(&mut self) -> Option<Self::Item>;
}
struct Counter {
count: u32,
}
impl Iterator for Counter {
type Item = u32; // Counter의 Item은 u32
fn next(&mut self) -> Option<u32> {
self.count += 1;
if self.count <= 5 { Some(self.count) } else { None }
}
}
```
이런 의문이 들 수 있다.
>[!question] Question
>제네릭 트레이트로도 같은 일을 할 수 있지 않나?
차이는 다음과 같다.
```rust
// 제네릭 트레이트: 한 타입이 여러 번 구현 가능
trait Convert<T> {
fn convert(&self) -> T;
}
impl Convert<String> for u32 { /* ... */ }
impl Convert<f64> for u32 { /* ... */ }
// 연관 타입: 한 타입당 정확히 하나의 구현
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
// Counter에 대해 Iterator는 단 하나만 구현된다
```
제네릭 트레이트는 하나의 타입이 같은 트레이트를 여러 타입 매개변수로 중복 구현할 수 있지만, 연관 타입은 한 타입당 정확히 하나의 구현만 허용한다. `Iterator`의 `Item`이 연관 타입인 이유는 "`Counter`의 반복자가 산출하는 타입은 하나로 고정"이라는 의미를 표현하기 위해서다.
## 슈퍼트레이트
트레이트가 다른 트레이트를 전제 조건으로 요구할 수 있다. 이를 슈퍼트레이트*supertrait*라 한다.
```rust
use std::fmt::Display;
trait PrettyPrint: Display {
fn pretty_print(&self) {
println!("╔══════════════╗");
println!("║ {}", self); // Display 트레이트의 기능을 사용
println!("╚══════════════╝");
}
}
```
`PrettyPrint`를 구현하려면 `Display`도 반드시 구현해야 한다. 슈퍼트레이트는 상속이 아니다 — "이 능력을 갖추려면 저 능력이 선행되어야 한다"는 의존성 선언이다. 계층 관계가 아니라 요구사항 관계인 것이다.
## 블랭킷 구현
블랭킷 구현*blanket implementation*은 특정 트레이트를 구현한 모든 타입에 대해 일괄적으로 다른 트레이트를 구현하는 기법이다.
```rust
// 표준 라이브러리의 실제 코드
impl<T: Display> ToString for T {
fn to_string(&self) -> String {
format!("{}", self)
}
}
```
이 한 줄의 구현으로 `Display`를 구현한 **모든** 타입이 자동으로 `to_string()` 메서드를 갖게 된다. 개별 타입마다 `ToString`을 일일이 구현할 필요가 없다. 표준 라이브러리가 이 기법을 광범위하게 활용하여, 적은 수의 핵심 트레이트 구현만으로 풍부한 기능을 제공한다[^std-blanket-impls].
## 오펀 룰
Rust는 트레이트 구현에 대해 일관성*coherence*을 보장하기 위한 오펀 룰*orphan rule*을 강제한다. 규칙은 다음과 같다: 트레이트를 타입에 구현하려면, **트레이트 또는 타입 중 최소 하나가 현재 크레이트*crate*에 정의되어 있어야 한다**.
```rust
// 가능: 내 타입에 외부 트레이트 구현
struct MyType;
impl Display for MyType { /* ... */ }
// 가능: 외부 타입에 내 트레이트 구현
trait MyTrait { /* ... */ }
impl MyTrait for Vec<i32> { /* ... */ }
// 불가능: 외부 타입에 외부 트레이트 구현
impl Display for Vec<i32> { /* ... */ } // 컴파일 에러
```
이 규칙이 없다면, 서로 다른 크레이트가 같은 타입에 같은 트레이트를 다르게 구현할 수 있고, 어떤 구현을 사용할지 결정할 수 없게 된다. 오펀 룰은 이 충돌을 원천적으로 방지한다.
제약을 우회해야 할 때는 뉴타입 패턴*newtype pattern*을 사용한다.
```rust
// 래퍼 타입으로 오펀 룰 우회
struct Wrapper(Vec<String>);
impl Display for Wrapper {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
```
`Wrapper`는 현재 크레이트에 정의된 타입이므로, 외부 트레이트인 `Display`를 구현할 수 있다. 런타임 비용은 없다 — 컴파일러가 래퍼를 최적화로 제거한다[^newtype-zero-cost].
---
## 출처
- Klabnik, S. and Nichols, C. (2023). *The Rust Programming Language* (2nd ed.). No Starch Press. Chapters 10, 17.
- Blandy, J., Orendorff, J. and Tindall, L. (2021). *Programming Rust* (2nd ed.). O'Reilly Media. Chapters 11, 12.
- Gjengset, J. (2021). *Rust for Rustaceans*. No Starch Press. Chapter 3: Designing Interfaces.
- Gamma, E. et al. (1994). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley.
[^monomorphization-bloat]: 단형화 팽창*monomorphization bloat*. 제네릭 함수가 많은 타입으로 인스턴스화되면 바이너리 크기가 급격히 증가하는 현상. `serde` 같은 직렬화 라이브러리에서 특히 두드러진다. Rust 컴파일러는 동일한 함수 본체를 공유하는 등의 최적화를 수행하지만, 완전히 해결하지는 못한다.
[^vtable]: 가상 메서드 테이블*Virtual Method Table*. 동적 디스패치에서 사용하는 함수 포인터 배열로, 각 트레이트 메서드의 구현 주소를 저장한다. C++의 vtable과 동일한 메커니즘이며, 트레이트 객체마다 하나의 vtable이 생성된다. 자세한 내용은 [[Object-Oriented Programming]]의 다형성 섹션을 참고.
[^indirect-call-cost]: 간접 호출의 성능 비용은 포인터 역참조 한 번(수 나노초)이지만, 진짜 비용은 CPU의 분기 예측*branch prediction*을 어렵게 만들고 인라인 최적화를 차단하는 것이다. 핫 루프에서는 이 차이가 유의미할 수 있다.
[^heterogeneous-collection]: 이종 컬렉션*heterogeneous collection*. 서로 다른 타입의 객체를 하나의 컬렉션에 담는 것. `Vec<i32>`는 동종 컬렉션(모든 원소가 `i32`)이고, `Vec<Box<dyn Summary>>`는 이종 컬렉션(`Article`, `Tweet` 등 다양한 타입의 객체를 담음)이다.
[^std-blanket-impls]: 대표적인 블랭킷 구현 사례: `impl<T: Display> ToString for T`, `impl<T> From<T> for T` (모든 타입은 자기 자신으로부터 변환 가능), `impl<T: Into<U>> From<T> for U`의 역방향 구현 등.
[^newtype-zero-cost]: Rust의 뉴타입 패턴은 런타임 오버헤드가 없다. `#[repr(transparent)]`를 적용하면 래퍼 타입이 내부 타입과 동일한 메모리 레이아웃을 가지며, 컴파일러가 래퍼를 완전히 제거한다.