[Rust 시리즈 13편] 제네릭과 트레이트로 재사용 설계하기

English version

모듈을 나눈 뒤에는 "서로 다른 타입이지만 같은 행동을 공유하는 경우"가 자주 생깁니다. Rust의 제네릭과 트레이트는 이 문제를 해결하는 기본 도구입니다. 이번 편에서는 제네릭 함수·구조체를 작성하고, trait을 통해 공통 행동을 정의한 뒤, [[impl|impl]] Trait와 trait bound를 읽는 연습을 합니다.

제네릭이 필요한 순간

다음 코드는 Vec<i32>만 정렬합니다.

fn pick_max(a: i32, b: i32) -> i32 {
    if a > b { a } else { b }
}

문자열이나 사용자 정의 타입에도 같은 비교 로직을 적용하려면 제네릭 함수가 필요합니다.

fn pick_max<T: PartialOrd>(a: T, b: T) -> T {
    if a > b { a } else { b }
}
  • T는 타입 매개변수입니다.
  • T: PartialOrd는 "T가 비교 연산을 지원해야 한다"는 제약(trait bound)입니다.
  • 컴파일 시점에 각 타입별로 실제 함수가 생성되므로, 런타임 비용 없이 재사용을 늘릴 수 있습니다.

구조체와 열거형에 제네릭 적용하기

struct Pair<T> {
    left: T,
    right: T,
}

impl<T> Pair<T> {
    fn new(left: T, right: T) -> Self {
        Self { left, right }
    }
}

impl<T: PartialOrd> Pair<T> {
    fn max(&self) -> &T {
        if self.left >= self.right { &self.left } else { &self.right }
    }
}
  • impl<T> Pair<T> 블록은 모든 T에 대해 유효합니다.
  • [[impl|impl]]: PartialOrd> 블록처럼 추가 제약이 필요할 때는 별도의 impl 블록을 만듭니다. 즉, new는 모든 타입에서 쓸 수 있지만 max는 비교 가능한 타입에서만 열립니다.
  • enum Option<T>가 대표적인 제네릭 열거형의 예시입니다.

Trait으로 행동 정의하기

Trait은 "이 타입이 어떤 메서드를 제공하는가"를 설명합니다.

trait Printable {
    fn format(&self) -> String;
}

struct User {
    name: String,
}

impl Printable for User {
    fn format(&self) -> String {
        format!("사용자: {}", self.name)
    }
}

fn print_item(item: &impl Printable) {
    println!("{}", item.format());
}
  • impl Printable for UserUserPrintable을 충족한다는 선언입니다.
  • &impl Printable은 trait을 만족하는 모든 타입을 받아들입니다.
  • fn print_item<T: Printable>(item: &T) 형태로도 동일한 제약을 표현할 수 있습니다.

입문 단계에서는 이렇게 기억하면 충분합니다.

  • impl Trait: 짧고 읽기 쉬운 함수 시그니처를 원할 때
  • T: Trait + where: 제약이 많아지거나 여러 타입을 함께 다룰 때

Trait bound 읽기 연습

fn log_all<T, I>(iter: I)
where
    T: Printable,
    I: IntoIterator<Item = T>,
{
    for item in iter {
        println!("{}", item.format());
    }
}
  • where 절은 제약이 길어질 때 가독성을 높여줍니다.
  • IntoIterator<Item = T>는 "iter가 반복되면 T 값을 내놓는다"는 뜻입니다.
  • 이러한 제약을 명시하면, 나중에 모듈 경계에서 의도치 않은 타입이 들어오는 일을 막을 수 있습니다.

기본 메서드와 연관 타입

Trait은 기본 구현과 연관 타입도 제공합니다.

trait Repository {
    type Item;

    fn find_all(&self) -> Vec<Self::Item>;

    fn count(&self) -> usize {
        self.find_all().len()
    }
}
  • type Item;은 trait을 구현하는 타입이 스스로 채워야 하는 자리입니다. 제네릭이 호출 시점에 타입을 바꾸는 느낌이라면, 연관 타입은 "이 구현에서는 이 타입을 쓴다"고 미리 정해 두는 방식에 가깝습니다.
  • 기본 메서드는 impl에서 재정의하지 않아도 자동으로 사용할 수 있습니다.

Blanket impl과 표준 trait

  • impl<T: Display> Printable for T { ... }처럼 조건에 맞는 모든 타입에 trait을 구현하는 패턴을 blanket impl이라고 합니다.
  • 표준 trait (Display, Debug, Default, Iterator 등)을 먼저 익히면, 직접 trait을 만들 때 어떤 API 표면을 제공해야 할지 감이 잡힙니다.

CodeSandbox로 이어서 실습하기

아래 샌드박스는 CodeSandbox의 Rust starter입니다. 이번 글의 핵심 코드를 src/main.rs에 옮기고, cargo checkcargo run 결과를 나란히 보면서 컴파일 메시지와 실행 출력을 비교해 보세요.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. starter를 fork한 뒤 src/main.rs를 연다
  2. 본문 예제를 붙여 넣고 cargo check와 cargo run을 차례로 실행한다
  3. 타입, 값, 참조 흐름을 바꿔 컴파일 피드백과 출력 차이를 비교한다

Rust 실습은 브라우저 미리보기보다 터미널 피드백이 더 중요합니다. 여러 파일 구조나 추가 crate가 필요한 예제는 파일 배치를 조금 더 손봐야 할 수 있습니다.

실습 과제

  1. Config<T> 제네릭 구조체를 만들고, T: Clone일 때만 duplicate 메서드를 제공해 보세요.
  2. trait Cache를 정의하고, 메모리 캐시와 파일 캐시 구조체가 공통 인터페이스를 구현하도록 작성해 보세요.
  3. impl<T: Printable> Printable for Vec<T>처럼 컬렉션에도 trait을 구현해 보고, blanket impl의 장단점을 정리해 보세요.

완료 기준

  • 제네릭 함수와 trait bound의 문법을 직접 작성할 수 있다.
  • trait을 사용해 코드 재사용 경계를 설계할 수 있다.
  • impl Traitwhere 절을 읽고 의미를 설명할 수 있다.

다음 편에서는 이 추상화 위에 lifetime 개념을 얹어, 참조가 얽힌 함수에서도 안전성을 유지하는 방법을 익힙니다.

💬 댓글

이 글에 대한 의견을 남겨주세요