Rust 트레이트 | Trait, 제네릭, 트레이트 바운드

이 글의 핵심

트레이트는 여러 타입이 공유하는 행동을 정의하는 Rust의 인터페이스입니다. 도형 시스템 예제로 정적 디스패치(제네릭)와 동적 디스패치(dyn Trait)의 차이를 보고, 연관 타입과 제네릭 매개변수 중 무엇을 고를지, 표준 트레이트를 직접 구현하거나 derive로 붙이는 실전 패턴까지 정리합니다.

시리즈 안내

#05 | 📋 전체 목차 | 이전: #04 에러 처리 · 다음: #06 컬렉션


들어가며

트레이트(trait)는 “이 타입은 이런 메서드를 구현한다”는 공통 행동 묶음입니다. 제네릭과 함께 쓰면 여러 타입에 같은 틀을 씌우되, 소유권·참조 규칙은 그대로 유지할 수 있습니다.

Java나 C#의 인터페이스와 비슷해 보이지만 중요한 차이가 두 가지 있습니다. 첫째, 트레이트 구현은 타입 정의와 분리되어 있어서, 이미 존재하는 타입(i32, Vec<T> 등)에 나중에 새 트레이트를 구현할 수 있습니다. 둘째, 트레이트는 런타임 다형성뿐 아니라 컴파일 타임 제약으로도 쓰입니다. 제네릭 함수의 트레이트 바운드는 C++ 템플릿처럼 타입마다 특수화된 코드를 만들되, “이 타입이 무엇을 할 수 있어야 하는지”를 시그니처에 선언하게 해서 템플릿의 난해한 오류 메시지 문제를 피합니다. 같은 트레이트를 정적 디스패치(제네릭)와 동적 디스패치(dyn Trait) 양쪽으로 쓸 수 있다는 점이 이 글 전체를 관통하는 주제입니다.


트레이트 정의와 기본 구현

기본 트레이트

trait Drawable {
    fn draw(&self);
}
struct Circle {
    radius: f64,
}
struct Rectangle {
    width: f64,
    height: f64,
}
impl Drawable for Circle {
    fn draw(&self) {
        println!("원 그리기: 반지름 {}", self.radius);
    }
}
impl Drawable for Rectangle {
    fn draw(&self) {
        println!("사각형 그리기: {}x{}", self.width, self.height);
    }
}
fn main() {
    let circle = Circle { radius: 5.0 };
    let rect = Rectangle { width: 10.0, height: 20.0 };
    
    circle.draw();
    rect.draw();
}

circle.draw()처럼 트레이트 메서드를 호출하려면 그 트레이트가 스코프에 있어야 합니다. 같은 파일에 정의되어 있으면 문제없지만, 다른 모듈의 트레이트라면 use shapes::Drawable;을 해야 하며 빠뜨리면 no method named 'draw' found for struct 'Circle'과 함께 “items from traits can only be used if the trait is in scope”라는 도움말이 나옵니다. 처음 Rust를 쓸 때 use std::io::Write; 없이 write_all을 호출하려다 이 오류를 만나는 경우가 많습니다. 메서드가 타입이 아니라 트레이트에 속해 있다는 점을 기억하면 이 규칙이 자연스럽게 이해됩니다.

기본 구현

trait Summary {
    fn summarize(&self) -> String {
        String::from("(더 읽기...)")
    }
}
struct Article {
    title: String,
    content: String,
}
// 방법 1: 기본 구현 사용
impl Summary for Article {}

// 방법 2: 직접 구현 (방법 1과 동시에 쓰면 E0119 "conflicting implementations" 오류)
// impl Summary for Article {
//     fn summarize(&self) -> String {
//         format!("{} - {}", self.title, &self.content[..50])
//     }
// }

같은 타입에 같은 트레이트를 두 번 구현할 수는 없으므로, 위 두 impl은 둘 중 하나를 고르는 대안으로 읽어야 합니다. 기본 구현은 트레이트의 다른 메서드를 호출할 수도 있어서, 예를 들어 summarize_author()만 필수로 두고 summarize()의 기본 구현이 그것을 이용하게 하면 구현하는 쪽의 부담을 크게 줄일 수 있습니다. 표준 라이브러리의 Iterator가 next() 하나만 구현하면 map, filter, sum 등 수십 개의 메서드를 기본 구현으로 얻는 구조가 바로 이 방식입니다.

직접 구현 예제의 &self.content[..50]에는 실제로 자주 터지는 버그가 숨어 있습니다. 문자열 슬라이스는 바이트 단위라서, 내용이 50바이트보다 짧으면 범위 초과로 panic하고, 한글처럼 한 글자가 3바이트인 문자열에서는 50번째 바이트가 글자 중간에 걸려 byte index 50 is not a char boundary로 panic합니다. 글자 수 기준으로 자르려면 self.content.chars().take(50).collect::<String>()처럼 문자 단위로 다뤄야 합니다.


제네릭 함수와 구조체

제네릭 함수

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    
    largest
}
fn main() {
    let numbers = vec![10, 50, 25, 100, 75];
    let result = largest(&numbers);
    println!("최댓값: {}", result);
    
    let chars = vec!['a', 'z', 'm', 'b'];
    let result = largest(&chars);
    println!("최댓값: {}", result);
}

T: PartialOrd 바운드가 없으면 item > largest에서 “binary operation > cannot be applied to type &T” 오류가 납니다. 컴파일러는 T가 무엇이든 될 수 있다고 보므로, 비교가 가능하다는 약속을 바운드로 명시해야 합니다. Ord가 아니라 PartialOrd를 쓴 이유는 f64처럼 NaN 때문에 전순서가 없는 타입도 받기 위해서입니다. 이 함수는 빈 슬라이스를 받으면 &list[0]에서 panic하므로, 실제 코드라면 Option<&T>를 반환하거나 표준 라이브러리의 iter().max()(Ord 필요)나 max_by를 쓰는 편이 낫습니다. 참조를 반환하기 때문에 T가 Copy일 필요가 없다는 점도 이 버전의 장점입니다.

제네릭 구조체

struct Point<T> {
    x: T,
    y: T,
}
impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point { x, y }
    }
}
// 특정 타입에만 메서드 추가
impl Point<f64> {
    fn distance_from_origin(&self) -> f64 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}
fn main() {
    let int_point = Point::new(5, 10);
    let float_point = Point::new(1.0, 4.0);
    
    println!("거리: {}", float_point.distance_from_origin());
}

impl Point<f64> 블록은 T가 f64일 때만 존재하는 메서드를 추가합니다. 그래서 int_point.distance_from_origin()을 호출하면 컴파일 오류가 납니다. f64에 고정하는 대신 impl<T: Float> Point<T>처럼 트레이트 바운드로 “제곱근을 구할 수 있는 모든 타입”에 열어 둘 수도 있지만, 표준 라이브러리에는 그런 트레이트가 없어 num-traits 같은 크레이트가 필요합니다. 표준 라이브러리만으로 수치 타입을 추상화하기가 번거롭다는 점은 Rust 제네릭에서 자주 부딪히는 현실적인 한계입니다.

여러 타입 매개변수

struct Pair<T, U> {
    first: T,
    second: U,
}
impl<T, U> Pair<T, U> {
    fn new(first: T, second: U) -> Self {
        Pair { first, second }
    }
}
fn main() {
    let pair = Pair::new(1, "hello");
    println!("{}, {}", pair.first, pair.second);
}

트레이트 바운드, where 절, impl Trait

기본 트레이트 바운드

use std::fmt::{Display, Debug};
fn print_info<T: Display + Debug>(value: T) {
    println!("Display: {}", value);
    println!("Debug: {:?}", value);
}
fn main() {
    print_info(42);
    print_info("hello");
}

where 절

use std::fmt::{Debug, Display};

fn complex_function<T, U>(t: T, u: U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{
    println!("{}", t);
    println!("{:?}", u);
    0  // 반환 타입이 i32이므로 값이 필요 (없으면 "mismatched types" 오류)
}

where 절은 기능적으로 <T: Display + Clone, U: Clone + Debug>와 같지만, 바운드가 길어질 때 함수 이름과 매개변수를 한눈에 읽을 수 있게 해 줍니다. 또 where Vec<T>: Debug처럼 타입 매개변수 자체가 아닌 다른 타입에 대한 제약은 where 절로만 쓸 수 있습니다. 바운드는 필요한 만큼만 거는 것이 원칙입니다. 이 예제의 Clone처럼 본문에서 쓰지 않는 바운드는 호출하는 쪽의 선택지만 줄이므로, 실제로는 지워도 됩니다.

impl Trait

fn returns_summarizable() -> impl Summary {
    Article {
        title: String::from("제목"),
        content: String::from("내용"),
    }
}

반환 위치의 impl Trait는 “이 트레이트를 구현하는 어떤 하나의 타입”을 반환한다는 뜻입니다. 호출하는 쪽은 구체 타입이 Article이라는 사실을 알 수 없지만, 컴파일러는 알고 있으므로 정적 디스패치와 인라이닝이 그대로 적용됩니다. 그래서 if 분기에서 한쪽은 Article, 다른 쪽은 Tweet을 반환하면 `if` and `else` have incompatible types 오류가 납니다. 분기마다 다른 타입이 필요하면 Box<dyn Summary>를 반환하거나 두 타입을 담는 열거형을 만들어야 합니다. impl Trait가 가장 빛나는 곳은 클로저와 반복자 체인처럼 이름을 쓸 수 없거나 너무 긴 타입을 반환할 때입니다. fn evens() -> impl Iterator<Item = u32>는 Filter<Range<u32>, [closure@...]> 같은 타입을 직접 적지 않아도 되게 해 줍니다.


Clone·Copy·Debug·Display 표준 트레이트

Clone과 Copy

#[derive(Clone)]
struct User {
    name: String,
    age: u32,
}
let user1 = User {
    name: String::from("홍길동"),
    age: 25,
};
let user2 = user1.clone();  // 명시적 복사

User에 Copy를 derive하지 않은 이유는 String 필드 때문입니다. Copy는 “비트 단위 복사만으로 안전한 타입”에만 허용되므로, 힙 메모리를 소유하는 String이 들어 있으면 #[derive(Copy)]에서 the trait Copy cannot be implemented for this type 오류가 납니다. Clone은 깊은 복사를 명시적으로 호출하는 것이고 Copy는 대입만으로 암묵적으로 복사되는 것이라, 비용이 드는 복사가 코드에 .clone()으로 드러나게 만드는 설계입니다. 성능 문제를 찾을 때 코드에서 .clone()을 검색해 보는 것만으로도 불필요한 복사를 꽤 찾을 수 있는 이유입니다.

Debug와 Display

use std::fmt;
#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}
impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}
fn main() {
    let p = Point { x: 10, y: 20 };
    println!("{:?}", p);  // Debug
    println!("{}", p);    // Display
}

예제: 트레이트로 만드는 도형 시스템

trait Shape {
    fn area(&self) -> f64;
    fn perimeter(&self) -> f64;
}
struct Circle {
    radius: f64,
}
struct Rectangle {
    width: f64,
    height: f64,
}
impl Shape for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * self.radius * self.radius
    }
    
    fn perimeter(&self) -> f64 {
        2.0 * std::f64::consts::PI * self.radius
    }
}
impl Shape for Rectangle {
    fn area(&self) -> f64 {
        self.width * self.height
    }
    
    fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }
}
fn print_shape_info<T: Shape>(shape: &T) {
    println!("넓이: {:.2}", shape.area());
    println!("둘레: {:.2}", shape.perimeter());
}
fn main() {
    let circle = Circle { radius: 5.0 };
    let rect = Rectangle { width: 10.0, height: 20.0 };

    print_shape_info(&circle);
    print_shape_info(&rect);
}

print_shape_info는 제네릭 함수이므로 컴파일러가 print_shape_info::<Circle>과 print_shape_info::<Rectangle> 두 벌을 만듭니다. 호출은 빠르지만, 이 방식으로는 Circle과 Rectangle을 한 벡터에 섞어 담을 수 없습니다. Vec<T>의 T는 하나의 구체 타입이어야 하기 때문입니다. 도형 목록처럼 여러 타입을 한 컨테이너에 모아야 하는 순간이 8절의 Vec<Box<dyn Shape>>가 필요해지는 지점입니다. 이 선택은 “타입 집합이 닫혀 있는가”로 정리할 수 있습니다. 도형 종류가 고정되어 있다면 enum Shape { Circle(..), Rectangle(..) }와 match가 가장 빠르고 단순하며, 라이브러리 사용자가 새 도형을 추가할 수 있어야 한다면 트레이트 객체가 맞습니다.


바운드와 단일화: 정적 디스패치의 비용

제네릭 T에 어떤 트레이트를 구현해야 하는지 적으면 그게 트레이트 바운드입니다. 컴파일러는 호출 시점에 구체 타입을 단일화(monomorphization)해 코드를 생성하므로, 정적 디스패치에 가깝고 런타임 비용이 작습니다.

// 단일 바운드
fn show<T: std::fmt::Display>(x: T) {
    println!("{x}");
}
// 여러 바운드: + 로 나열하거나 where로 정리
fn clone_and_debug<T>(x: &T) -> String
where
    T: Clone + std::fmt::Debug,
{
    format!("{:?}", x.clone())
}
  • T: Trait: T는 해당 트레이트를 구현해야 함.
  • T: ?Sized: 크기가 정해지지 않은 타입(예: str, 트레이트 객체)까지 받을 때 T: ?Sized와 함께 &T 패턴을 씁니다.
  • for<'a> (HRTB): 고급 주제로, “모든 수명 'a에 대해” 같은 제약을 적을 때 사용합니다.

연관 타입과 제네릭 매개변수 중 무엇을 쓸까

구분연관 타입 (type Item)제네릭 (Iterator<Item = T> 스타일)
의미구현체가 하나의 연관 타입을 고정호출부가 타입 매개변수를 넘김
예Iterator::Item여러 Item을 동시에 쓰기 어려움
언제“이 트레이트당 출력 타입은 하나”일 때동일 트레이트를 다른 타입 인자로 여러 번 구현해야 할 때
// 연관 타입: 구현마다 Output이 하나로 정해짐
trait Processor {
    type Output;
    fn process(&self) -> Self::Output;
}
// 제네릭 트레이트: 동일 타입에 대해 다른 T로 여러 impl 가능 (일부 제약 하에서)
trait Convert<T> {
    fn convert(&self) -> T;
}

실무에서는 Iterator처럼 “결과 타입이 구현에 고정”이면 연관 타입, AsRef<T>처럼 타입 인자를 바꿔가며 쓰면 제네릭 트레이트 쪽이 자연스럽습니다.

이 선택은 사용하는 쪽의 코드에서 차이가 가장 크게 드러납니다. Iterator가 제네릭 트레이트 Iterator<T>였다면, 한 타입이 Iterator<i32>와 Iterator<String>을 동시에 구현할 수 있게 되어 for x in v.iter()에서 x의 타입을 컴파일러가 추론할 수 없고, 반복자를 받는 함수마다 fn f<I: Iterator<T>, T>(it: I)처럼 타입 매개변수를 하나 더 달아야 했을 것입니다. 연관 타입은 “구현 타입이 정해지면 결과 타입도 정해진다”는 관계를 컴파일러에 알려 주므로 추론이 자연스럽게 동작합니다. 반대로 From<T>는 String이 From<&str>, From<char>, From<Box<str>> 등을 모두 구현해야 하므로 제네릭 매개변수가 맞습니다.


dyn Trait와 동적 디스패치

동적 디스패치: 같은 슬롯에 서로 다른 구체 타입을 담을 때 dyn Trait + 포인터(&dyn Trait, Box<dyn Trait>)를 씁니다. vtable을 통해 메서드가 호출됩니다.

trait Event {
    fn name(&self) -> &str;
}
struct Click;
impl Event for Click {
    fn name(&self) -> &str {
        "click"
    }
}
fn print_events(events: &[Box<dyn Event + Send>]) {
    for e in events {
        println!("{}", e.name());
    }
}
  • 객체 안전(object-safe): 트레이트 객체로 쓰려면 객체 안전 규칙을 만족해야 합니다(예: Self: Sized가 아닌 연관 함수만 허용 등). 최근 Rust 문서에서는 같은 개념을 “dyn 호환성(dyn compatibility)“이라고 부릅니다.
  • dyn Trait + Send + Sync: 스레드 간 공유할 때 자주 붙입니다.

&dyn Trait와 Box<dyn Trait>는 두 개의 포인터로 이루어진 “뚱뚱한 포인터(fat pointer)“입니다. 하나는 데이터를, 다른 하나는 해당 타입의 vtable(메서드 주소 표)을 가리킵니다. 그래서 메서드 호출마다 vtable을 거치는 간접 호출이 생기고 인라이닝이 막히지만, 코드는 트레이트당 한 벌만 생성되므로 바이너리 크기와 컴파일 시간 면에서는 유리합니다.

객체 안전 규칙에 걸리는 가장 흔한 경우는 두 가지입니다. 메서드가 Self를 반환하거나(fn clone(&self) -> Self), 제네릭 타입 매개변수를 가지는 경우(fn process<T>(&self, t: T))입니다. 앞의 경우는 vtable만으로는 반환할 객체의 크기를 알 수 없고, 뒤의 경우는 가능한 모든 T마다 vtable 항목을 만들 수 없기 때문입니다. 그래서 Clone을 요구하는 트레이트는 Box<dyn Trait>로 쓸 수 없고, 컴파일러는 the trait cannot be made into an object(최신 버전에서는 dyn 호환성 관련 문구) 오류와 함께 어느 메서드가 문제인지 알려 줍니다. 해당 메서드에 where Self: Sized를 붙이면 트레이트 객체에서는 그 메서드만 제외하고 나머지를 쓸 수 있습니다.


#[derive]로 반복 구현 줄이기

#[derive(...)]는 컴파일러가 제공하는 도출 매크로로, 반복 구현을 줄여 줍니다. 자주 쓰는 것:

매크로효과
Clone, Copy복사 의미
Debug, PartialEq, Eq디버그·동등 비교
DefaultDefault::default()
Serialize/Deserializeserde 사용 시
#[derive(Debug, Clone, PartialEq, Eq, Default)]
// 타입 정의
struct Config {
    port: u16,
    host: String,
}

커스텀 Derive는 프로시저 매크로(derive 크레이트)로 만들며, 이 글 범위를 넘어서므로 The Rust Reference - Procedural Macros를 참고하면 됩니다.

derive는 모든 필드에 같은 트레이트가 구현되어 있다고 가정하고 필드별 구현을 조합합니다. 그래서 Config에 Eq를 derive할 수 있는 것은 u16과 String이 모두 Eq이기 때문이고, f64 필드를 추가하는 순간 Eq derive가 실패합니다(f64는 NaN 때문에 PartialEq만 구현). 제네릭 구조체에 derive하면 impl<T: Clone> Clone for Wrapper<T>처럼 모든 타입 매개변수에 같은 바운드가 붙는다는 점도 알아 둘 만합니다. T를 Arc<T>로만 담고 있어서 실제로는 T: Clone이 필요 없는데도 derive된 Clone이 그 바운드를 요구해 쓸 수 없는 경우가 있으며, 이럴 때는 직접 구현해야 합니다. Default derive는 숫자는 0, String은 빈 문자열로 채우므로 port가 0이 된다는 점도 설정 타입에서는 의도와 맞는지 확인해야 합니다.


Iterator, From/Into, Display/Debug 구현 패턴

Iterator

표준 라이브러리의 반복자 체인은 트레이트 조합의 대표 예입니다. map, filter, collect는 모두 Iterator 트레이트에 의존합니다.

let sum: i32 = [1, 2, 3].iter().copied().filter(|x| x % 2 == 1).sum();

From / Into

From<T>를 구현하면 Into<U>가 자동으로 따라옵니다(역은 성립하지 않을 수 있음). 타입 변환 API를 하나로 통일할 때 씁니다.

struct UserId(u64);
impl From<u64> for UserId {
    fn from(id: u64) -> Self {
        UserId(id)
    }
}
let id: UserId = 42.into();

From을 구현하면 Into가 자동으로 생기는 것은 표준 라이브러리에 impl<T, U: From<T>> Into<U> for T 같은 포괄 구현(blanket impl)이 있기 때문입니다. 그래서 직접 구현할 때는 항상 From 쪽을 구현하는 것이 관례입니다. 42.into()는 대상 타입을 추론할 정보가 필요하므로 let id: UserId처럼 타입을 명시해야 하고, 그렇지 않으면 type annotations needed 오류가 납니다. 이 변환은 실패하지 않는다는 약속이므로, 검증이 필요한 변환(음수를 거부하는 등)은 TryFrom을 구현해 Result를 돌려주는 것이 맞습니다. ? 연산자가 에러 타입을 자동으로 변환하는 것도 From 구현 덕분이라, 4편의 에러 처리와 이 절이 연결됩니다.

Display vs Debug

  • Debug ({:?}): 개발자용, 구조체 덤프. derive(Debug)로 충분한 경우가 많습니다.
  • Display ({}): 사용자-facing 문자열. 로그·UI에 맞게 직접 fmt 구현합니다.
use std::fmt;
struct Point(i32, i32);
impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "({}, {})", self.0, self.1)
    }
}

트레이트와 제네릭 요약

  1. Trait: 공통 동작 정의 (인터페이스)
  2. 제네릭: 타입 매개변수로 재사용성 향상
  3. 트레이트 바운드: 제네릭 타입 제약
  4. 표준 트레이트: Clone, Copy, Debug, Display
  5. impl Trait: 반환 타입 간소화
  6. 연관 타입 vs 제네릭: “구현당 하나의 연관 타입” vs “타입 인자를 바꿔가며 impl”
  7. dyn Trait: 동적 디스패치·객체 안전 규칙
  8. Derive: 반복 impl 축소; serde 등과 조합
  9. 실전: Iterator 체인, From/Into, Display/Debug 역할 분리

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 연관 타입과 제네릭 트레이트 매개변수 중 무엇을 골라야 하나요?

A. Iterator의 Item처럼 한 타입이 트레이트를 구현할 때 결과 타입이 하나로 정해진다면 연관 타입이 맞고, 호출부에서 타입을 매번 적을 필요도 없습니다. 같은 타입에 대해 Convert, Convert처럼 타입 인자를 바꿔 가며 여러 번 구현해야 한다면 제네릭 트레이트를 씁니다. AsRef가 후자의 대표적인 예입니다.