Introduction

Rust에는 클래스도 상속도 없지만 다형성을 구현할 수 있습니다. 그 비밀은 바로 트레이트trait입니다. 트레이트는 "이 타입은 이런 동작을 할 수 있다"는 능력capability을 정의하는 추상화 도구로, Java의 인터페이스interface와 비슷하지만 더 강력한 기능을 제공합니다. 이 글에서는 트레이트의 기본 문법부터 정적·동적 디스패치의 차이, 그리고 연관 타입·슈퍼트레이트·블랭킷 구현 같은 고급 패턴까지 단계적으로 살펴봅니다.

트레이트란 무엇인가

트레이트는 타입이 구현해야 할 메서드 시그니처의 집합이다. 객체지향 프로그래밍에서 살펴본 것처럼, Rust는 클래스 상속 대신 트레이트로 다형성과 추상화를 달성한다. 핵심 아이디어는 간단하다 — 데이터(구조체)와 행위(트레이트)를 분리하고, 필요한 행위를 타입에 자유롭게 부착하는 것이다.

Java의 인터페이스가 "이 클래스는 이 계약을 이행한다"는 선언이라면, Rust의 트레이트는 "이 타입은 이 능력을 보유한다"는 선언이다. 미묘한 차이지만, 상속 계층 없이도 타입에 능력을 부여할 수 있다는 점에서 트레이트의 설계 철학이 드러난다.

트레이트 정의와 구현

정의

trait 키워드로 메서드 시그니처를 선언한다.

trait Summary {
    fn summarize(&self) -> String;
}

Summary 트레이트는 summarize라는 메서드를 요구한다. 이 트레이트를 구현하는 모든 타입은 summarize의 본체를 반드시 제공해야 한다.

구현

impl Trait for Type 문법으로 특정 타입에 트레이트를 구현한다.

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)
    }
}

ArticleTweet은 완전히 다른 구조체지만, 둘 다 Summary를 구현하므로 summarize()를 호출할 수 있다. 데이터 구조의 형태와 관계없이 동일한 능력을 보유하게 되는 것이다.

기본 구현

트레이트를 정의할 때 메서드의 기본 본체default implementation를 제공할 수 있다. 기본 구현이 있는 메서드는 구현체에서 재정의하지 않아도 된다.

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()는 기본 구현을 그대로 사용
}

Tweetsummarize_author()만 구현하면 summarize()는 기본 구현이 자동으로 적용된다. 기본 구현 안에서 같은 트레이트의 다른 메서드를 호출할 수 있다는 점이 중요하다. 이를 통해 구현체는 핵심 메서드 하나만 제공하고 나머지는 트레이트가 조합하는 구조를 만들 수 있다.

트레이트 바운드

함수의 매개변수로 "특정 트레이트를 구현한 타입"만 받고 싶을 때 트레이트 바운드trait bound를 사용한다.

기본 문법

// 축약 문법
fn notify(item: &impl Summary) {
    println!("속보! {}", item.summarize());
}

// 정식 문법 (트레이트 바운드)
fn notify<T: Summary>(item: &T) {
    println!("속보! {}", item.summarize());
}

두 문법은 동일한 의미다. impl Summary는 트레이트 바운드의 축약형syntactic sugar으로, "Summary를 구현한 어떤 타입이든 받겠다"는 뜻이다.

복수 트레이트 바운드

+ 연산자로 여러 트레이트를 동시에 요구할 수 있다.

fn notify(item: &(impl Summary + Display)) {
    println!("속보! {}", item.summarize());
    println!("전문: {}", item);  // Display 트레이트의 fmt() 사용
}

where

트레이트 바운드가 복잡해지면 where 로 가독성을 높인다.

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를 수행한다. 호출 시점에 구체 타입이 결정되어, 각 타입별로 함수의 복사본이 생성된다.

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 바이너리가 커지는 주요 원인 중 하나다1.

동적 디스패치 — 트레이트 객체

dyn Trait을 사용하면 런타임에 vtable2을 통해 메서드를 호출한다. 이것을 트레이트 객체trait object라 부른다.

fn notify(item: &dyn Summary) {
    println!("{}", item.summarize());
}

트레이트 객체는 내부적으로 두 개의 포인터로 구성된 팻 포인터fat pointer다.

첫 번째 포인터(data_ptr)는 실제 데이터를 가리키고, 두 번째 포인터(vtable_ptr)는 해당 타입의 vtable을 가리킨다. item.summarize()를 호출하면 vtable에서 summarize_fn의 주소를 읽어 간접 호출indirect call을 수행한다. 이것이 객체지향 프로그래밍에서 Java의 가상 메서드 테이블과 C의 함수 포인터 테이블로 설명한 메커니즘과 동일한 원리다.

정적 디스패치 vs 동적 디스패치

정적 디스패치 (impl Trait)동적 디스패치 (dyn Trait)
해소 시점컴파일 타임런타임
메커니즘단형화 (함수 복제)vtable 간접 호출
성능직접 호출 + 인라인 가능간접 호출 오버헤드3
바이너리 크기타입 수에 비례하여 증가함수 하나만 존재
이종 컬렉션4불가능가능 (Vec<Box<dyn Trait>>)
컴파일 시간타입 수에 비례하여 증가영향 적음

선택 기준은 명확하다. 성능이 중요하고 타입이 컴파일 시점에 결정되면 정적 디스패치를, 서로 다른 타입을 하나의 컬렉션에 담아야 하거나 바이너리 크기를 줄여야 하면 동적 디스패치를 사용한다.

// 이종 컬렉션: 동적 디스패치가 필수
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은 트레이트 내부에서 플레이스홀더 타입을 선언하는 기능이다. 구현체가 이 타입을 구체적으로 지정한다.

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

차이는 다음과 같다.

// 제네릭 트레이트: 한 타입이 여러 번 구현 가능
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는 단 하나만 구현된다

제네릭 트레이트는 하나의 타입이 같은 트레이트를 여러 타입 매개변수로 중복 구현할 수 있지만, 연관 타입은 한 타입당 정확히 하나의 구현만 허용한다. IteratorItem이 연관 타입인 이유는 "Counter의 반복자가 산출하는 타입은 하나로 고정"이라는 의미를 표현하기 위해서다.

슈퍼트레이트

트레이트가 다른 트레이트를 전제 조건으로 요구할 수 있다. 이를 슈퍼트레이트supertrait라 한다.

use std::fmt::Display;

trait PrettyPrint: Display {
    fn pretty_print(&self) {
        println!("╔══════════════╗");
        println!("║ {}",  self);  // Display 트레이트의 기능을 사용
        println!("╚══════════════╝");
    }
}

PrettyPrint를 구현하려면 Display도 반드시 구현해야 한다. 슈퍼트레이트는 상속이 아니다 — "이 능력을 갖추려면 저 능력이 선행되어야 한다"는 의존성 선언이다. 계층 관계가 아니라 요구사항 관계인 것이다.

블랭킷 구현

블랭킷 구현blanket implementation은 특정 트레이트를 구현한 모든 타입에 대해 일괄적으로 다른 트레이트를 구현하는 기법이다.

// 표준 라이브러리의 실제 코드
impl<T: Display> ToString for T {
    fn to_string(&self) -> String {
        format!("{}", self)
    }
}

이 한 줄의 구현으로 Display를 구현한 모든 타입이 자동으로 to_string() 메서드를 갖게 된다. 개별 타입마다 ToString을 일일이 구현할 필요가 없다. 표준 라이브러리가 이 기법을 광범위하게 활용하여, 적은 수의 핵심 트레이트 구현만으로 풍부한 기능을 제공한다5.

오펀 룰

Rust는 트레이트 구현에 대해 일관성coherence을 보장하기 위한 오펀 룰orphan rule을 강제한다. 규칙은 다음과 같다: 트레이트를 타입에 구현하려면, 트레이트 또는 타입 중 최소 하나가 현재 크레이트crate에 정의되어 있어야 한다.

// 가능: 내 타입에 외부 트레이트 구현
struct MyType;
impl Display for MyType { /* ... */ }

// 가능: 외부 타입에 내 트레이트 구현
trait MyTrait { /* ... */ }
impl MyTrait for Vec<i32> { /* ... */ }

// 불가능: 외부 타입에 외부 트레이트 구현
impl Display for Vec<i32> { /* ... */ }  // 컴파일 에러

이 규칙이 없다면, 서로 다른 크레이트가 같은 타입에 같은 트레이트를 다르게 구현할 수 있고, 어떤 구현을 사용할지 결정할 수 없게 된다. 오펀 룰은 이 충돌을 원천적으로 방지한다.

제약을 우회해야 할 때는 뉴타입 패턴newtype pattern을 사용한다.

// 래퍼 타입으로 오펀 룰 우회
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를 구현할 수 있다. 런타임 비용은 없다 — 컴파일러가 래퍼를 최적화로 제거한다6.


출처

  • 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.

Footnotes

  1. 단형화 팽창monomorphization bloat. 제네릭 함수가 많은 타입으로 인스턴스화되면 바이너리 크기가 급격히 증가하는 현상. serde 같은 직렬화 라이브러리에서 특히 두드러진다. Rust 컴파일러는 동일한 함수 본체를 공유하는 등의 최적화를 수행하지만, 완전히 해결하지는 못한다.

  2. 가상 메서드 테이블Virtual Method Table. 동적 디스패치에서 사용하는 함수 포인터 배열로, 각 트레이트 메서드의 구현 주소를 저장한다. C++의 vtable과 동일한 메커니즘이며, 트레이트 객체마다 하나의 vtable이 생성된다. 자세한 내용은 Object-Oriented Programming의 다형성 섹션을 참고.

  3. 간접 호출의 성능 비용은 포인터 역참조 한 번(수 나노초)이지만, 진짜 비용은 CPU의 분기 예측branch prediction을 어렵게 만들고 인라인 최적화를 차단하는 것이다. 핫 루프에서는 이 차이가 유의미할 수 있다.

  4. 이종 컬렉션heterogeneous collection. 서로 다른 타입의 객체를 하나의 컬렉션에 담는 것. Vec<i32>는 동종 컬렉션(모든 원소가 i32)이고, Vec<Box<dyn Summary>>는 이종 컬렉션(Article, Tweet 등 다양한 타입의 객체를 담음)이다.

  5. 대표적인 블랭킷 구현 사례: impl<T: Display> ToString for T, impl<T> From<T> for T (모든 타입은 자기 자신으로부터 변환 가능), impl<T: Into<U>> From<T> for U의 역방향 구현 등.

  6. Rust의 뉴타입 패턴은 런타임 오버헤드가 없다. #[repr(transparent)]를 적용하면 래퍼 타입이 내부 타입과 동일한 메모리 레이아웃을 가지며, 컴파일러가 래퍼를 완전히 제거한다.