Skip to main content

9. 트레이트, 제네릭, 패턴 매칭의 힘

이 장부터 Rust가 갑자기 한 단계 더 똑똑해 보이기 시작합니다. 앞 장들에서 우리는 소유권, 참조, 구조체, 열거형, 컬렉션을 배웠습니다. 이제는 그 재료들을 더 우아하게 조합할 차례입니다.

제네릭은 타입을 일반화해서 중복을 줄여 줍니다. 트레이트는 "이 타입은 이런 행동을 할 수 있다"를 표현합니다. 패턴 매칭은 복잡한 분기를 읽기 쉬운 코드로 바꿔 줍니다. 이 셋이 합쳐지면 Rust 코드는 단단하면서도 유연해집니다.12

쉽게 말하면 이렇습니다.

  • 제네릭은 "타입 자리에 빈칸을 만든다"입니다.

  • 트레이트는 "이 빈칸에 들어올 수 있는 타입의 자격 조건"입니다.

  • 패턴 매칭은 "들어온 값을 모양대로 분해해서 처리한다"입니다.

이번 장에서는 이 세 가지를 초보자 눈높이에서 차근차근 연결해 보겠습니다.

트레이트, 제네릭, 패턴 매칭의 힘

왜 이 장이 중요한가

초보자 시절에는 보통 이런 코드를 많이 씁니다.

fn biggest_i32(list: &[i32]) -> &i32 {
    let mut biggest = &list[0];

    for item in list {
        if item > biggest {
            biggest = item;
        }
    }

    biggest
}

fn biggest_char(list: &[char]) -> &char {
    let mut biggest = &list[0];

    for item in list {
        if item > biggest {
            biggest = item;
        }
    }

    biggest
}

뭔가 이상하죠. 로직은 똑같은데 타입만 다릅니다. 이런 순간이 바로 제네릭이 등장할 타이밍입니다.1

또 이런 생각도 들 수 있습니다.

"게시글도 요약할 수 있고, 댓글도 요약할 수 있고, 알림도 요약할 수 있는데, 그 공통점을 어떻게 표현하지?"

이때 트레이트가 필요합니다.3

그리고 마지막으로, Option, Result, enum을 다루다 보면 분기가 자꾸 늘어납니다. if, else if, else if, else if... 코드가 길어질수록 사람이 틀리기 쉬워집니다. 이때 Rust는 패턴 매칭으로 "값의 모양"을 직접 읽게 해 줍니다.24

제네릭: 타입을 위한 빈칸 만들기

제네릭은 구체적인 타입 이름 대신 임시 이름을 붙여 두고, 나중에 실제 타입을 채워 넣는 방식입니다. 보통 T, U, K, V 같은 이름을 많이 씁니다.

가장 단순한 형태는 이런 느낌입니다.

fn echo<T>(value: T) -> T {
    value
}

여기서 T는 "아직 정해지지 않은 어떤 타입"입니다. 컴파일할 때 실제 호출에 맞춰 타입이 결정됩니다.

fn main() {
    let a = echo(10);
    let b = echo("hello");

    println!("{a}");
    println!("{b}");
}

같은 함수인데 i32도 받고 &str도 받습니다. 함수 몸체가 타입에 크게 의존하지 않을 때 아주 유용합니다.

중복 코드를 제네릭으로 바꾸기

아까의 biggest_i32, biggest_char를 하나로 합쳐 봅시다.

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in &list[1..] {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    let chars = vec!['y', 'm', 'a', 'q'];

    println!("largest number: {}", largest(&numbers));
    println!("largest char: {}", largest(&chars));
}

여기서 핵심은 T: PartialOrd입니다.

PartialOrd는 크기 비교가 가능한 타입이라는 뜻입니다. 즉, > 연산을 쓸 수 있는 타입만 largest에 넣을 수 있습니다. 아무 타입이나 받게 두면 비교 자체가 불가능할 수도 있으니, 컴파일러가 "비교 가능한 타입만 허용하자"라고 조건을 거는 것입니다.13

초보자 입장에서는 이렇게 기억하면 됩니다.

"제네릭은 아무 타입이나 받는 게 아니라, 필요하면 조건을 붙여서 받는다."

제네릭 함수에서 자주 보는 문법

제네릭 문법은 처음 보면 괄호가 많아서 겁나지만, 사실 읽는 순서만 익히면 됩니다.

fn print_value<T>(value: T) {
    // ...
}

이 코드는 이렇게 읽으면 됩니다.

"print_value는 T라는 타입 매개변수를 가진 함수이고, value의 타입은 T다."

조건이 붙으면 이렇게 읽습니다.

fn show<T: std::fmt::Display>(value: T) {
    println!("{value}");
}

"show는 Display를 구현한 타입 T만 받을 수 있다."

Display는 {}로 사람이 읽기 좋은 출력이 가능한 타입을 뜻합니다.

제네릭 구조체 만들기

제네릭은 함수뿐 아니라 구조체에도 붙일 수 있습니다.

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let p1 = Point { x: 3, y: 7 };
    let p2 = Point { x: 1.5, y: 9.2 };
}

여기서 Point<T>는 x와 y가 같은 타입일 때 사용합니다. 그래서 Point { x: 3, y: 1.5 }는 안 됩니다. x는 i32, y는 f64라서 서로 다르기 때문입니다.

서로 다른 타입을 넣고 싶다면 타입 매개변수를 두 개 쓰면 됩니다.

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let p = Point { x: 3, y: 1.5 };
    let q = Point { x: "left", y: true };
}

이제 x와 y가 꼭 같은 타입일 필요가 없습니다.1

제네릭 impl과 특정 타입 전용 impl

구조체가 제네릭이면 메서드도 제네릭하게 만들 수 있습니다.

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

이 x() 메서드는 모든 Point<T>에서 사용할 수 있습니다. 그런데 어떤 메서드는 특정 타입일 때만 가능할 수 있습니다.

struct Point {
    x: f64,
    y: f64,
}

impl Point {
    fn distance_from_origin(&self) -> f64 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

혹은 제네릭 구조체에 대해 특정 타입만 따로 구현할 수도 있습니다.

struct Point<T> {
    x: T,
    y: T,
}

impl Point<f64> {
    fn distance_from_origin(&self) -> f64 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

이 경우 Point<f64>만 이 메서드를 갖습니다. Point<i32>는 이 메서드를 못 씁니다.3

이 패턴은 꽤 중요합니다. "공통 동작은 모두에게, 특수 동작은 특정 타입에게"라는 설계를 깔끔하게 할 수 있기 때문입니다.

Rust의 제네릭은 느리지 않을까?

처음엔 "이렇게 추상적으로 써도 성능이 괜찮을까?"라는 걱정이 듭니다. Rust는 보통 이런 걱정을 덜어 줍니다. 제네릭 코드는 컴파일 시점에 실제 타입에 맞는 코드로 펼쳐지는 방식으로 최적화되기 때문에, 보통 손으로 타입별 함수를 따로 쓴 것과 비슷한 성능을 기대할 수 있습니다.1

즉, 제네릭은 편의성과 성능을 둘 다 챙기려는 Rust다운 도구입니다.

트레이트: 공통 행동을 이름 붙이기

이제 제네릭에 성격을 부여해 봅시다.

트레이트는 "어떤 타입이 제공해야 하는 기능의 목록"입니다. 다른 언어의 인터페이스와 비슷하게 느껴질 수 있지만, Rust답게 더 유연하게 쓰입니다.13

예를 들어 여러 종류의 콘텐츠를 "요약"하고 싶다고 해 봅시다.

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

이제 이 트레이트를 여러 타입이 구현할 수 있습니다.

struct Article {
    title: String,
    author: String,
}

struct Comment {
    user: String,
    text: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} - {}", self.title, self.author)
    }
}

impl Summary for Comment {
    fn summarize(&self) -> String {
        format!("{}: {}", self.user, self.text)
    }
}

이제 Article과 Comment는 서로 전혀 다른 타입이지만, 둘 다 summarize()를 호출할 수 있습니다.

fn main() {
    let article = Article {
        title: String::from("Rust 입문기"),
        author: String::from("Kim"),
    };

    let comment = Comment {
        user: String::from("lee"),
        text: String::from("좋은 글이네요!"),
    };

    println!("{}", article.summarize());
    println!("{}", comment.summarize());
}

이게 바로 공통 행동의 추상화입니다.

트레이트의 기본 구현

트레이트는 메서드의 기본 구현도 제공할 수 있습니다.

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

    fn summarize(&self) -> String {
        format!("더 읽어 보기: {}", self.author())
    }
}

struct Post {
    author_name: String,
    title: String,
}

impl Summary for Post {
    fn author(&self) -> String {
        self.author_name.clone()
    }
}

Post는 author()만 구현했고, summarize()는 트레이트의 기본 구현을 그대로 사용합니다.3

이 기능은 반복을 줄이는 데 좋습니다. "대부분 비슷하지만 약간씩 다른 동작"을 만들 때 특히 편합니다.

트레이트를 매개변수로 받기

트레이트의 진짜 재미는 "이 행동을 할 수 있는 아무 타입이나 받아라"라고 말할 때 시작됩니다.

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

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

&impl Summary는 "Summary를 구현한 어떤 타입의 참조"라는 뜻입니다.3

같은 의미를 제네릭으로도 쓸 수 있습니다.

fn notify<T: Summary>(item: &T) {
    println!("알림: {}", item.summarize());
}

둘은 초보자 단계에서는 거의 같은 뜻으로 생각해도 됩니다.

다만 이런 차이를 알아 두면 좋습니다.

fn pair_notify(item1: &impl Summary, item2: &impl Summary) {
    println!("{} / {}", item1.summarize(), item2.summarize());
}

이 함수는 두 인자가 서로 다른 타입이어도 괜찮습니다. 둘 다 Summary만 구현하면 됩니다.

반면 아래 코드는 두 인자가 반드시 같은 타입이어야 합니다.

fn pair_notify_same<T: Summary>(item1: &T, item2: &T) {
    println!("{} / {}", item1.summarize(), item2.summarize());
}

이 차이는 실무에서 꽤 자주 중요합니다.

트레이트 바운드: 타입의 자격 조건 붙이기

제네릭에 조건을 붙이는 것을 트레이트 바운드라고 합니다.

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

fn print_info<T: Display + Debug>(item: &T) {
    println!("display: {item}");
    println!("debug: {item:?}");
}

T: Display + Debug는 둘 다 구현한 타입만 받겠다는 뜻입니다.

조건이 길어지면 where를 쓰면 읽기 편해집니다.

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

fn compare_and_show<T, U>(left: &T, right: &U) -> String
where
    T: Display + Debug,
    U: Display,
{
    format!("left = {left}, right = {right}")
}

초보자라면 일단 이렇게 기억하면 충분합니다.

  • T: SomeTrait는 "T는 SomeTrait를 구현해야 한다"

  • +는 여러 조건을 동시에 건다

  • where는 조건이 길 때 줄바꿈해서 읽기 좋게 만든다

자주 보는 표준 트레이트들

Rust에서는 우리가 직접 만드는 트레이트도 중요하지만, 표준 라이브러리에 이미 준비된 트레이트도 정말 많이 씁니다. 다음 네 개는 특히 자주 만납니다.3

  • Debug: {:?}로 디버그 출력할 수 있게 해 줍니다.

  • Clone: .clone()으로 복제할 수 있게 해 줍니다.

  • PartialEq: ==, != 비교를 가능하게 합니다.

  • Default: 기본값을 만들 수 있게 해 줍니다.

많은 경우 derive로 자동 구현할 수 있습니다.

#[derive(Debug, Clone, PartialEq, Default)]
struct Config {
    host: String,
    port: u16,
}

이 한 줄이 생각보다 엄청난 노동력을 아껴 줍니다. Rust의 derive는 초보자에게도 매우 든든한 도우미입니다.

패턴 매칭: 값의 모양으로 분기하기

이제 분기를 이야기해 봅시다.

Rust의 패턴 매칭은 단순한 switch보다 훨씬 강력합니다. 값이 무엇인지뿐 아니라, 값이 어떤 구조를 가지고 있는지도 함께 볼 수 있습니다. 특히 enum, Option, Result, 튜플, 구조체를 다룰 때 빛이 납니다.24

가장 기본은 match입니다.

fn describe_number(n: i32) -> &'static str {
    match n {
        0 => "zero",
        1 => "one",
        2 => "two",
        _ => "many",
    }
}

여기서 _는 "나머지 전부"라는 뜻입니다.

중요한 점 하나. match는 반드시 모든 경우를 다뤄야 합니다. 이를 포괄성 검사라고 생각하면 쉽습니다. 컴파일러가 "빠진 경우 없지?"를 체크해 줍니다.2

이건 귀찮은 규칙이 아니라 아주 큰 안전장치입니다.

match는 값을 반환하는 표현식이다

match는 단순한 제어문이 아니라 표현식입니다. 즉, 값을 만들 수 있습니다.

fn grade(score: u32) -> &'static str {
    match score {
        90..=100 => "A",
        80..=89 => "B",
        70..=79 => "C",
        _ => "F",
    }
}

이런 방식은 코드가 아주 깔끔합니다. 단, 모든 팔(arm)이 같은 종류의 값을 반환해야 합니다. 위 예제는 모두 &'static str을 반환하므로 문제없습니다.

enum과 패턴 매칭은 찰떡궁합

패턴 매칭이 가장 멋지게 보이는 순간은 enum을 분해할 때입니다.

enum Command {
    Quit,
    Echo(String),
    Move { x: i32, y: i32 },
}

fn run(cmd: Command) {
    match cmd {
        Command::Quit => println!("종료합니다."),
        Command::Echo(text) => println!("메시지: {text}"),
        Command::Move { x, y } => println!("({x}, {y})로 이동"),
    }
}

이 코드는 정말 Rust스럽습니다. Command가 어떤 변형인지 확인하면서, 그 안의 데이터까지 바로 꺼내 씁니다.4

if cmd == ... 같은 비교를 길게 쓰지 않아도 됩니다. 값의 모양 자체를 읽으니까 훨씬 자연스럽습니다.

구조체와 튜플도 분해할 수 있다

패턴 매칭은 enum만의 전유물이 아닙니다.

struct Point {
    x: i32,
    y: i32,
}

fn print_point(point: Point) {
    match point {
        Point { x: 0, y: 0 } => println!("원점입니다."),
        Point { x, y: 0 } => println!("x축 위의 점: {x}"),
        Point { x: 0, y } => println!("y축 위의 점: {y}"),
        Point { x, y } => println!("좌표: ({x}, {y})"),
    }
}

튜플도 비슷합니다.

fn describe(pair: (i32, i32)) {
    match pair {
        (0, 0) => println!("원점"),
        (x, 0) => println!("x축: {x}"),
        (0, y) => println!("y축: {y}"),
        (x, y) => println!("({x}, {y})"),
    }
}

데이터를 "읽으면서 동시에 풀어낸다"는 감각을 익히는 것이 중요합니다.

if let: 한 가지만 보고 싶을 때

모든 경우를 다 나누는 match가 좋은 건 맞지만, 어떤 때는 딱 한 경우만 관심 있을 수 있습니다.

let maybe_name = Some("rust".to_string());

if let Some(name) = maybe_name {
    println!("name = {name}");
}

이 코드는 아래 match를 짧게 쓴 것과 비슷합니다.

match maybe_name {
    Some(name) => println!("name = {name}"),
    None => {}
}

즉, if let은 "한 패턴만 보고 나머지는 무시"할 때 좋습니다.4

while let: 패턴이 맞는 동안 계속 반복

컬렉션을 하나씩 꺼내 처리할 때 아주 편리합니다.

fn main() {
    let mut stack = vec![1, 2, 3];

    while let Some(top) = stack.pop() {
        println!("{top}");
    }
}

pop()은 값이 있으면 Some, 비어 있으면 None을 반환합니다. 그래서 값이 나오는 동안만 반복합니다.4

이 코드는 "스택이 빌 때까지 꺼내기"를 정말 자연스럽게 표현합니다.

let else: 꺼내거나, 아니면 빨리 나가기

최근 Rust를 배울수록 자주 보게 되는 문법이 let ... else입니다.

fn greet(name: Option<&str>) {
    let Some(name) = name else {
        println!("이름이 없습니다.");
        return;
    };

    println!("안녕하세요, {name}!");
}

이 문법의 장점은 중첩을 줄여 준다는 점입니다. Some일 때만 아래 코드를 계속 읽으면 되니, 코드가 한결 평평해집니다.4

초보자에게는 "안 되면 빨리 빠져나가는 문법"이라고 기억해도 좋습니다.

패턴 매칭의 고급 맛보기: |, 가드, @

이제 패턴 매칭의 표현력을 살짝만 더 맛봅시다.

여러 패턴을 한 번에 매칭하기

fn is_vowel(ch: char) -> bool {
    match ch {
        'a' | 'e' | 'i' | 'o' | 'u' => true,
        _ => false,
    }
}

|는 "또는"입니다.

가드 사용하기

패턴이 맞은 뒤 추가 조건을 더 붙일 수 있습니다.

fn describe(n: i32) {
    match n {
        x if x < 0 => println!("음수"),
        0 => println!("0"),
        x if x % 2 == 0 => println!("양수 짝수: {x}"),
        x => println!("양수 홀수: {x}"),
    }
}

이런 if 조건을 매치 가드라고 부릅니다.4

@ 바인딩

값을 범위와 매칭하면서 동시에 변수에도 저장할 수 있습니다.

fn age_group(age: u8) {
    match age {
        n @ 0..=12 => println!("어린이: {n}"),
        n @ 13..=18 => println!("청소년: {n}"),
        n => println!("성인: {n}"),
    }
}

n @ 0..=12는 "0부터 12 사이면 매칭하고, 그 값을 n으로도 쓰겠다"는 뜻입니다.

초보자에게 필수는 아니지만, 읽을 줄은 알아 두면 좋습니다.

세 가지를 합쳐 보면 더 강력하다

이제 제네릭, 트레이트, 패턴 매칭을 한 예제로 묶어 봅시다.

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

struct News {
    title: String,
}

struct Alert {
    message: String,
}

impl Summary for News {
    fn summarize(&self) -> String {
        format!("뉴스: {}", self.title)
    }
}

impl Summary for Alert {
    fn summarize(&self) -> String {
        format!("알림: {}", self.message)
    }
}

enum Status {
    Draft,
    Published,
    Archived,
}

fn print_item<T: Summary>(item: &T, status: Status) {
    match status {
        Status::Draft => println!("[임시저장] {}", item.summarize()),
        Status::Published => println!("[게시됨] {}", item.summarize()),
        Status::Archived => println!("[보관됨] {}", item.summarize()),
    }
}

fn main() {
    let news = News {
        title: String::from("Rust 1.x 업데이트"),
    };

    let alert = Alert {
        message: String::from("서버 점검 예정"),
    };

    print_item(&news, Status::Published);
    print_item(&alert, Status::Draft);
}

이 코드에서

  • Summary는 공통 행동을 정의합니다.

  • T: Summary는 그 행동을 할 수 있는 타입만 받게 만듭니다.

  • match는 상태별 출력을 읽기 좋게 분기합니다.

이런 식으로 Rust 코드는 점점 "단순한 문법의 나열"이 아니라 "의도를 드러내는 설계"가 됩니다.

초보자가 자주 헷갈리는 지점

제네릭이면 아무 타입이나 되는 것 아닌가요?

아닙니다. 함수 안에서 어떤 연산을 하느냐에 따라 필요한 트레이트 조건을 붙여야 합니다. 비교하려면 PartialOrd, 출력하려면 Display, 복제하려면 Clone 같은 식입니다.

트레이트는 구조체에만 쓰나요?

아닙니다. enum에도 구현할 수 있고, 사실 타입이라면 꽤 폭넓게 적용할 수 있습니다.

match와 if let 중 뭘 써야 하나요?

경우를 여러 개 다뤄야 하면 match, 한 가지 경우만 깔끔하게 보고 싶으면 if let이 좋습니다.4

_를 많이 쓰면 편한데 괜찮나요?

너무 많이 쓰면 미래의 변경을 놓칠 수 있습니다. 특히 enum에 새 변형이 추가되었을 때 _가 전부 삼켜 버리면, 놓친 사실을 깨닫기 어려울 수 있습니다. 정말 나머지를 무시해도 될 때만 쓰는 습관이 좋습니다.2

이 장의 핵심 정리

제네릭은 중복되는 타입별 코드를 하나로 합칩니다. 트레이트는 타입들이 공유하는 행동을 이름 붙입니다. 패턴 매칭은 값의 구조를 기준으로 코드를 분기하게 해 줍니다. 이 셋을 익히면 Rust 코드가 훨씬 짧아지고, 읽기 쉬워지고, 안전해집니다.12

특히 꼭 기억할 문장은 이것입니다.

"제네릭은 재사용성을 만들고, 트레이트는 그 재사용에 규칙을 주고, 패턴 매칭은 데이터를 읽기 좋은 형태로 펼쳐 준다."

이 장을 넘기고 나면, 앞으로 배울 모듈, 크레이트, 테스트, 동시성에서도 Rust 코드의 모양이 왜 그렇게 생겼는지 훨씬 잘 보이기 시작할 것입니다. 이제 Rust는 단순히 안전한 언어가 아니라, 표현력이 높은 언어로 보이기 시작할 차례입니다.

참고

1The Rust Programming Language - Generic Types, Traits, and Lifetimes

2The Rust Programming Language - Patterns and Matching

3Traits & Generics - Luca Nerlich

4Pattern Matching - Luca Nerlich

책 목록

rust 학습

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기