- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — Rust의 추상화 도구
- 트레잇 — 인터페이스, 그리고 그 이상
- 제네릭과 트레잇 경계
- 정적 디스패치 — 제로 코스트 다형성
- 동적 디스패치 — dyn 트레잇 객체
- 정적 vs 동적 — 무엇을 언제
- 연관 타입 — 트레잇에 딸린 타입
- 블랭킷 impl — 조건을 만족하는 모든 타입에
- derive — 트레잇 자동 구현
- 손으로 익히기
- 마치며
- 참고 자료
들어가며 — Rust의 추상화 도구
객체지향 언어는 상속과 인터페이스로 추상화를 조직합니다. Rust에는 클래스 상속이 없습니다. 대신 **트레잇(trait)**과 **제네릭(generic)**이라는 두 축으로 코드 재사용과 다형성을 얻습니다. 이 둘을 이해하면 Rust 라이브러리의 시그니처가 갑자기 읽히기 시작합니다. T: Display + Clone 같은 표기가 무슨 뜻인지, impl Iterator와 Box<dyn Iterator>가 왜 다른지 말이죠.
핵심 직관은 이렇습니다. 트레잇은 "이 타입이 무엇을 할 수 있는가"를 정의하고, 제네릭은 "그 능력을 요구하는 코드"를 씁니다. 그리고 그 요구를 실행하는 방식이 두 가지(정적 디스패치 / 동적 디스패치)라는 것이 이 글의 하이라이트입니다. 하나씩 쌓아 봅시다.
트레잇 — 인터페이스, 그리고 그 이상
트레잇은 타입이 구현할 수 있는 메서드의 집합입니다. 여기까지는 자바·C#의 인터페이스, Go의 인터페이스와 비슷합니다.
trait Summary {
fn summarize(&self) -> String; // 시그니처만 (구현 없음)
fn preview(&self) -> String { // 기본 구현 제공
format!("{}...", &self.summarize()[..5.min(self.summarize().len())])
}
}
struct Article {
title: String,
body: String,
}
impl Summary for Article { // Article에 Summary를 구현
fn summarize(&self) -> String {
format!("{}: {}", self.title, self.body)
}
// preview는 기본 구현을 그대로 씀
}
trait Summary가 계약을 정의하고, impl Summary for Article이 Article이 그 계약을 만족하도록 구현합니다. 기본 구현(preview)을 제공할 수 있다는 점은 인터페이스보다 한 걸음 나아간 부분입니다(자바의 default 메서드와 비슷).
인터페이스와의 결정적 차이는 두 가지입니다.
첫째, 타입을 정의한 곳과 트레잇을 구현하는 곳이 분리됩니다. 자바에서는 클래스를 선언할 때 implements Comparable을 미리 적어야 합니다. Rust에서는 이미 존재하는 타입(내가 만들지 않은 타입 포함)에 나중에 트레잇을 구현할 수 있습니다. 표준 라이브러리의 i32에 내 트레잇을 구현하는 것도 가능합니다.
둘째, 그 자유에는 **고아 규칙(orphan rule)**이라는 제약이 붙습니다. 트레잇을 구현하려면 트레잇이거나 타입이거나 둘 중 하나는 내 크레이트 소유여야 합니다. 남의 트레잇을 남의 타입에 구현하는 건 금지입니다. 이유는 일관성입니다. 만약 두 크레이트가 각자 Display를 Vec에 구현하면 어느 쪽을 써야 할지 충돌이 나니까요. 이 규칙이 "한 (트레잇, 타입) 쌍에 구현은 최대 하나"를 보장합니다.
제네릭과 트레잇 경계
이제 트레잇을 요구하는 코드를 씁니다. 제네릭 함수는 "어떤 타입 T든 받는다"고 쓰지만, 대개 T가 특정 능력을 갖길 요구합니다. 그 요구가 **트레잇 경계(trait bound)**입니다.
use std::fmt::Display;
// T는 Display를 구현하는 어떤 타입이든 될 수 있다
fn announce<T: Display>(item: T) {
println!("공지: {item}"); // Display 덕분에 {} 포맷 가능
}
fn main() {
announce(42); // i32는 Display 구현
announce("hello"); // &str도 Display 구현
announce(3.14); // f64도
}
<T: Display>가 "T는 반드시 Display를 구현해야 한다"는 경계입니다. 이게 없으면 announce 본문에서 {item}으로 출력할 수 없습니다(컴파일러가 "T가 Display인지 모른다"고 거부). 경계는 제네릭 코드가 T에 대해 무엇을 가정해도 되는지를 컴파일러에게(그리고 독자에게) 알려 줍니다.
여러 경계는 +로 잇고, 많아지면 where 절로 깔끔하게 뺍니다.
use std::fmt::{Debug, Display};
// 인라인 경계 — 짧을 때
fn show<T: Display + Clone>(x: T) { /* ... */ }
// where 절 — 경계가 많을 때 가독성이 좋음
fn process<T, U>(t: T, u: U) -> String
where
T: Display + Clone,
U: Debug + Default,
{
format!("{t} {u:?}")
}
여기서 자바 제네릭과의 근본적 차이 하나. 자바 제네릭은 **타입 소거(type erasure)**로 런타임엔 타입 정보가 지워지고, 경계는 대개 캐스팅으로 처리됩니다. Rust 제네릭은 **단형화(monomorphization)**됩니다. 컴파일러가 announce(42)와 announce("hello")를 위해 각각 i32용, &str용 함수를 따로 생성합니다. 그래서 제네릭인데도 런타임 비용이 0입니다. 이게 다음 절의 "정적 디스패치"입니다.
정적 디스패치 — 제로 코스트 다형성
단형화의 결과, 제네릭 함수 호출은 **정적 디스패치(static dispatch)**됩니다. 어떤 구현을 호출할지 컴파일 타임에 결정되고, 각 타입에 특화된 코드가 생성되며, 함수는 인라인될 수도 있습니다. 런타임에 "어느 메서드지?"를 찾는 비용이 없습니다.
트레잇을 인자로 받는 관용적 방법은 impl Trait 문법입니다. 이건 제네릭의 문법 설탕입니다.
use std::fmt::Display;
// 아래 둘은 사실상 동일 (impl Trait는 제네릭의 축약)
fn v1(item: impl Display) { println!("{item}"); }
fn v2<T: Display>(item: T) { println!("{item}"); }
반환 위치에서도 impl Trait를 쓸 수 있는데, 이건 특히 유용합니다. 이터레이터나 클로저처럼 이름 붙이기 힘든 구체 타입을 반환할 때, "이 트레잇을 만족하는 어떤 구체 타입"이라고만 밝히면 됩니다.
// 반환 타입의 진짜 이름은 복잡한 Map<...> 이지만, 감출 수 있다
fn evens(max: u32) -> impl Iterator<Item = u32> {
(0..max).filter(|n| n % 2 == 0)
}
fn main() {
for n in evens(10) {
print!("{n} "); // 0 2 4 6 8
}
}
정적 디스패치의 트레이드오프는 코드 크기입니다. 단형화는 타입마다 별도 코드를 찍어 내므로 바이너리가 커질 수 있습니다(코드 부풀림, code bloat). 하지만 속도가 최우선인 대부분의 경우, 이 트레이드오프는 남는 장사입니다.
동적 디스패치 — dyn 트레잇 객체
그런데 서로 다른 타입들을 한 컬렉션에 섞어 담고 싶을 때가 있습니다. "그리기 가능한 도형들의 리스트"처럼요. 원, 사각형, 삼각형이 모두 Draw 트레잇을 구현한다 해도, 제네릭 Vec<T>는 하나의 구체 타입만 담을 수 있습니다. 이럴 때 필요한 것이 트레잇 객체(trait object), 문법으로는 dyn Trait입니다.
trait Draw {
fn draw(&self) -> String;
}
struct Circle;
struct Square;
impl Draw for Circle { fn draw(&self) -> String { "○".into() } }
impl Draw for Square { fn draw(&self) -> String { "□".into() } }
fn main() {
// 서로 다른 타입을 하나의 Vec에! (박스에 담아 트레잇 객체로)
let shapes: Vec<Box<dyn Draw>> = vec![
Box::new(Circle),
Box::new(Square),
Box::new(Circle),
];
for s in &shapes {
print!("{} ", s.draw()); // 실행 시점에 실제 타입의 draw 호출
}
}
Box<dyn Draw>는 "힙에 있는, Draw를 구현하는 어떤 타입"입니다. 여기서 s.draw()를 호출하면 **동적 디스패치(dynamic dispatch)**가 일어납니다. 컴파일 타임엔 s의 진짜 타입을 모르므로, 런타임에 결정합니다.
작동 원리는 **가상 함수 테이블(vtable)**입니다. Box<dyn Draw>는 사실 두 개의 포인터입니다. 하나는 실제 데이터를 가리키고(data pointer), 하나는 그 타입의 Draw 메서드 구현 주소들이 담긴 vtable을 가리킵니다. s.draw()는 vtable에서 draw의 주소를 찾아 점프합니다. C++의 가상 함수와 같은 메커니즘이죠. 이래서 dyn Trait를 "뚱뚱한 포인터(fat pointer)"라 부릅니다. 보통 포인터의 두 배 크기니까요.
정적 vs 동적 — 무엇을 언제
두 디스패치를 나란히 두면 트레이드오프가 명확해집니다.
- 정적 디스패치(제네릭 /
impl Trait): 컴파일 타임에 해소. 인라인·최적화가 잘 됨. 런타임 오버헤드 0. 대신 타입마다 코드가 생성되어 바이너리가 커질 수 있고, 이종 컬렉션을 못 담음. - 동적 디스패치(
dyn Trait): 런타임에 vtable로 해소. 이종 타입을 한 컬렉션에 담을 수 있고, 바이너리가 작음(구현 하나만). 대신 호출마다 vtable 조회 비용(간접 호출, 인라인 어려움)이 있음.
실용적 결론은 이렇습니다. 기본은 정적 디스패치를 쓰세요. 대부분의 경우 그게 더 빠르고 자연스럽습니다. 동적 디스패치는 이종 컬렉션이 필요하거나(위 도형 리스트), 코드 부풀림을 피해야 하거나, 플러그인처럼 런타임에야 타입이 정해지는 경우에 씁니다. 대부분의 애플리케이션에서 vtable 조회 비용은 무시할 만하니, 성능 때문에 dyn을 피할 필요는 잘 없습니다. 표현력과 코드 구조를 보고 고르면 됩니다.
한 가지 제약: 모든 트레잇이 트레잇 객체가 될 수 있는 건 아닙니다. 객체 안전(object-safe) 규칙을 만족해야 합니다(대략, 메서드가 제네릭이 아니고 Self를 값으로 반환하지 않아야 함). 이 규칙을 어긴 트레잇을 dyn으로 쓰면 컴파일러가 이유와 함께 알려 줍니다.
연관 타입 — 트레잇에 딸린 타입
트레잇은 메서드뿐 아니라 **연관 타입(associated type)**도 가질 수 있습니다. 대표 예가 표준 Iterator입니다.
trait Iterator {
type Item; // 연관 타입: 이 이터레이터가 내놓는 것의 타입
fn next(&mut self) -> Option<Self::Item>;
}
struct Counter { count: u32 }
impl Iterator for Counter {
type Item = u32; // 여기서 Item을 u32로 확정
fn next(&mut self) -> Option<u32> {
if self.count < 3 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
type Item은 트레잇이 요구하는 "빈칸 타입"이고, 구현할 때 type Item = u32로 채웁니다. 그러면 next가 Option<u32>를 반환하게 되죠.
여기서 자연스러운 질문. "이거 그냥 제네릭 Iterator<Item>으로 하면 안 되나?" 됩니다만, 차이가 있습니다. 연관 타입은 한 타입에 대해 구현이 하나로 고정됩니다. Counter는 Item = u32인 이터레이터로 딱 하나만 될 수 있습니다. 반면 제네릭 파라미터였다면 Counter가 Iterator<u32>이면서 동시에 Iterator<String>일 수도 있어, 타입 추론이 모호해집니다. "이 타입에 대해 이 관계는 유일하다"를 표현할 때 연관 타입을 쓰고, "여러 관계가 가능하다"를 표현할 때 제네릭 파라미터를 씁니다. 이터레이터가 내놓는 원소 타입은 하나뿐인 게 자연스러우니 연관 타입이 맞습니다.
블랭킷 impl — 조건을 만족하는 모든 타입에
트레잇의 강력함이 폭발하는 지점이 **블랭킷 impl(blanket implementation)**입니다. "어떤 트레잇을 구현하는 모든 타입에 대해, 다른 트레잇을 자동으로 구현"하는 것입니다.
표준 라이브러리의 실제 예가 압권입니다. ToString 트레잇(.to_string() 메서드)은 이렇게 정의됩니다.
// 표준 라이브러리의 실제 (단순화)
impl<T: Display> ToString for T {
fn to_string(&self) -> String {
// Display의 포맷 기능을 이용해 문자열 생성
format!("{self}")
}
}
이 한 조각이 하는 일을 음미해 보세요. "Display를 구현하는 모든 타입 T는 자동으로 ToString도 구현한다." 그래서 여러분이 새 타입에 Display만 구현하면, .to_string()이 공짜로 딸려 옵니다. 아무도 각 타입마다 ToString을 손으로 구현하지 않습니다. 조건부 블랭킷 impl 하나가 생태계 전체를 덮는 것입니다.
블랭킷 impl은 "능력의 조합"을 표현하는 관용구이기도 합니다. impl<T: A + B> C for T는 "A와 B를 둘 다 갖춘 타입은 C도 된다"는 규칙을 선언합니다. 트레잇·제네릭·블랭킷 impl이 맞물리면, 상속 계층 없이도 대단히 표현력 있는 추상화가 조립됩니다.
derive — 트레잇 자동 구현
마지막으로 실전에서 매일 쓰는 편의 기능. 많은 표준 트레잇은 #[derive(...)] 어트리뷰트로 자동 구현할 수 있습니다. 컴파일러가 필드 구조를 보고 뻔한 구현을 대신 생성해 줍니다.
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1.clone(); // Clone 덕분
println!("{p1:?}"); // Debug 덕분: Point { x: 1, y: 2 }
println!("{}", p1 == p2); // PartialEq 덕분: true
}
#[derive(Debug, Clone, PartialEq)] 한 줄이, Debug(디버그 출력), Clone(복제), PartialEq(동등 비교) 세 트레잇의 구현을 자동 생성합니다. 손으로 쓰면 수십 줄이 될 보일러플레이트를 컴파일러가 필드 단위로 재귀 생성합니다. 자주 파생하는 트레잇은 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, PartialOrd, Ord 등입니다.
derive의 원리는 절차적 매크로(procedural macro)입니다. 즉 이건 마법이 아니라, 컴파일 타임에 코드를 생성하는 매크로일 뿐입니다. 그래서 직접 만든 트레잇에도 파생 매크로를 붙일 수 있고(예: serde의 #[derive(Serialize)]), 이게 Rust 생태계의 편의성을 떠받치는 큰 기둥입니다.
손으로 익히기
트레잇 경계, 정적/동적 디스패치, 연관 타입은 시그니처를 읽는 감각이 붙어야 편해집니다. Rust 학습 랩에서 트레잇을 정의하고 제네릭 함수에 경계를 걸어 보고, 같은 코드를 impl Trait(정적)와 Box<dyn Trait>(동적)로 각각 짜 보며 차이를 직접 만들어 보면, 라이브러리 문서의 복잡한 시그니처가 훨씬 편하게 읽힙니다.
마치며
Rust는 상속 없이도 풍부한 추상화를 제공합니다. 트레잇이 능력을 정의하고, 제네릭과 트레잇 경계가 그 능력을 요구하며, 정적 디스패치가 제로 코스트로 그것을 실행합니다. 서로 다른 타입을 한데 모아야 할 때는 dyn 트레잇 객체가 vtable을 통한 동적 디스패치로 유연함을 줍니다. 연관 타입이 트레잇에 딸린 타입 관계를 표현하고, 블랭킷 impl이 조건을 만족하는 모든 타입을 한 번에 덮으며, derive가 뻔한 구현을 공짜로 만들어 줍니다.
이 조각들이 맞물리는 방식을 이해하는 순간, Rust 코드의 시그니처가 소음이 아니라 정보로 읽히기 시작합니다. fn foo<T: Trait>(...)을 보면 "이 함수는 T에게 이 능력을 요구하는구나", Box<dyn Trait>을 보면 "여기선 이종 타입을 런타임 디스패치로 다루는구나"가 즉시 보입니다. 그게 Rust의 추상화를 손에 넣었다는 신호입니다.
참고 자료
- The Rust Programming Language, 10.2장 "Traits: Defining Shared Behavior": https://doc.rust-lang.org/book/ch10-02-traits.html
- The Rust Programming Language, 18.2장 "Using Trait Objects": https://doc.rust-lang.org/book/ch18-02-trait-objects.html
- Rust Reference — Traits: https://doc.rust-lang.org/reference/items/traits.html
- Rust by Example — Generics: https://doc.rust-lang.org/rust-by-example/generics.html
- The Rust Reference — Object safety (dyn compatibility): https://doc.rust-lang.org/reference/items/traits.html#object-safety