DaleSchool

제네릭: 타입을 변수처럼

중급15분

학습 목표

  • 제네릭 함수를 정의하고 호출할 수 있다
  • 제네릭 구조체를 정의할 수 있다
  • 트레이트 바운드로 제네릭 타입을 제한할 수 있다
  • Vec, Option, Result가 모두 제네릭임을 이해한다

동작하는 코드

지금까지 Vec<i32>, Option<String>, Result<String, io::Error>를 써왔어요. 꺾쇠 괄호 안에 타입을 넣는 이 문법, 사실 전부 제네릭이었어요!

먼저, 제네릭이 없다면 어떤 일이 생기는지 볼게요.

fn print_i32(value: i32) {
    println!("값: {value}");
}

fn print_str(value: &str) {
    println!("값: {value}");
}

fn print_f64(value: f64) {
    println!("값: {value}");
}

fn main() {
    print_i32(42);
    print_str("안녕");
    print_f64(3.14);
}

같은 일을 하는 함수를 타입마다 하나씩 만들어야 해요. 타입이 10개면 함수도 10개?!

직접 해보기

  1. print_bool(value: bool) 함수를 추가해보세요. 점점 귀찮아지죠?
  2. 아래 제네릭 버전과 비교해보세요 — 함수 하나로 전부 처리할 수 있어요!

제네릭 함수 — 한 번만 쓰고, 모든 타입에

use std::fmt::Display;

fn print_value<T: Display>(value: T) {
    println!("값: {value}");
}

fn main() {
    print_value(42);
    print_value("안녕");
    print_value(3.14);
    print_value(true);
}

<T: Display>는 "T라는 타입 변수를 쓸게요. 단, Display 자격증이 있는 타입만요"라는 뜻이에요. 모듈 20에서 배운 트레이트 바운드가 여기서 쓰이죠!

T는 관례적인 이름이에요. Type의 첫 글자를 딴 거예요. U, V 등도 자주 쓰여요.

타입 추론 — 대부분 생략 가능

Rust가 타입을 알아서 추론해줘요. 명시적으로 쓸 수도 있지만, 보통 생략해요.

use std::fmt::Display;

fn print_value<T: Display>(value: T) {
    println!("값: {value}");
}

fn main() {
    print_value::<i32>(42);    // 명시적 — 보통 불필요
    print_value(42);           // 추론 — 이렇게 쓰세요
}

여러 제네릭 타입

제네릭 타입을 여러 개 쓸 수도 있어요.

use std::fmt::Display;

fn print_pair<T: Display, U: Display>(first: T, second: U) {
    println!("{first}, {second}");
}

fn main() {
    print_pair(1, "사과");
    print_pair(3.14, 42);
}

제네릭 구조체

구조체에도 제네릭을 쓸 수 있어요.

#[derive(Debug)]
struct Pair<T> {
    first: T,
    second: T,
}

impl<T> Pair<T> {
    fn new(first: T, second: T) -> Self {
        Pair { first, second }
    }
}

fn main() {
    let integers = Pair::new(1, 2);
    let strings = Pair::new("안녕", "세상");

    println!("{:?}", integers); // Pair { first: 1, second: 2 }
    println!("{:?}", strings);  // Pair { first: "안녕", second: "세상" }
}

Pair<T>에서 firstsecond는 같은 타입 T여야 해요. 다른 타입을 넣고 싶다면?

#[derive(Debug)]
struct MixedPair<T, U> {
    first: T,
    second: U,
}

fn main() {
    let mix = MixedPair { first: 42, second: "안녕" };
    println!("{:?}", mix);
}

이미 써본 제네릭들

사실 지금까지 제네릭을 계속 써왔어요!

fn main() {
    // Vec<T> — 어떤 타입이든 담는 벡터
    let numbers: Vec<i32> = vec![1, 2, 3];
    let names: Vec<&str> = vec!["지수", "민수"];

    // Option<T> — 값이 있거나 없거나
    let some_number: Option<i32> = Some(42);
    let no_number: Option<i32> = None;

    // Result<T, E> — 성공이거나 실패이거나
    let ok: Result<i32, String> = Ok(42);
    let err: Result<i32, String> = Err("에러!".into());

    println!("{:?}, {:?}", numbers, names);
    println!("{:?}, {:?}", some_number, no_number);
    println!("{:?}, {:?}", ok, err);
}

Vec, Option, Result 모두 제네릭 타입이에요. 꺾쇠 괄호 안에 넣는 i32, String 등이 바로 타입 인자예요.

where 절 — 트레이트 바운드를 깔끔하게

트레이트 바운드가 길어지면 where 절로 분리할 수 있어요.

꺾쇠 괄호 안에 전부 쓰면 복잡해요

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

fn compare_and_print<T: Display + PartialOrd + Debug>(a: T, b: T) {
    if a > b {
        println!("{a} > {b}");
    } else {
        println!("{a} <= {b}");
    }
}

where 절로 분리하면 깔끔해요

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

fn compare_and_print<T>(a: T, b: T)
where
    T: Display + PartialOrd + Debug,
{
    if a > b {
        println!("{a} > {b}");
    } else {
        println!("{a} <= {b}");
    }
}

fn main() {
    compare_and_print(10, 20);
    compare_and_print("사과", "바나나");
}

기능은 완전히 같아요. where는 읽기 편하게 만드는 도구예요.

더 알아보기: 단형화(monomorphization)

"제네릭을 쓰면 느려지지 않나요?" 걱정하지 마세요! Rust는 단형화라는 기법을 써요.

컴파일할 때 제네릭 함수를 실제 사용된 타입별로 복사해요.

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

fn main() {
    print_value(42);     // i32 버전이 생성됨
    print_value("안녕");  // &str 버전이 생성됨
}

컴파일러가 내부적으로 이렇게 만들어요.

fn print_value_i32(value: i32) {
    println!("값: {value}");
}

fn print_value_str(value: &str) {
    println!("값: {value}");
}

제네릭은 코드를 줄이면서도 성능은 그대로예요. 런타임 비용이 전혀 없어요.

연습 1 (쉬움). 아래 제네릭 함수를 완성하세요. 두 값 중 큰 값을 반환해야 해요.

fn max_of<T: PartialOrd>(a: T, b: T) -> T {
    // 여기를 완성하세요
}

fn main() {
    println!("{}", max_of(10, 20));           // 20
    println!("{}", max_of("apple", "banana")); // banana
}

연습 2 (보통). 제네릭 구조체 Stack<T>을 구현하세요.

struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Self {
        // 여기를 완성하세요
    }

    fn push(&mut self, item: T) {
        // 여기를 완성하세요
    }

    fn pop(&mut self) -> Option<T> {
        // 여기를 완성하세요
        // 힌트: Vec의 pop() 메서드를 쓰세요
    }

    fn is_empty(&self) -> bool {
        // 여기를 완성하세요
    }
}

fn main() {
    let mut stack = Stack::new();
    stack.push(1);
    stack.push(2);
    stack.push(3);

    while let Some(value) = stack.pop() {
        println!("{value}"); // 3, 2, 1 순서로 출력
    }
}

연습 3 (도전). where 절을 사용해서 Display + Clone 바운드가 있는 제네릭 함수를 작성하세요. 값을 복제해서 두 번 출력해야 해요.

use std::fmt::Display;

fn print_cloned<T>(value: T)
where
    // 여기에 바운드를 작성하세요
{
    let cloned = value.clone();
    println!("원본: {value}");
    println!("복제: {cloned}");
}

fn main() {
    print_cloned(String::from("Rust"));
    print_cloned(42);
}

Q1. 제네릭의 주요 목적은?

  • A) 프로그램을 더 빠르게 만든다
  • B) 하나의 코드로 여러 타입을 처리할 수 있게 한다
  • C) 메모리 사용량을 줄인다
  • D) 에러 처리를 자동화한다

Q2. 다음 중 fn foo<T: Display + Clone>(x: T)와 같은 의미인 것은?

  • A) fn foo<T>(x: T) where T: Display
  • B) fn foo<T>(x: T) where T: Display + Clone
  • C) fn foo<T>(x: T) where T: Clone
  • D) fn foo(x: Display + Clone)

Q3. Rust의 제네릭이 런타임 성능에 미치는 영향은?

  • A) 약간 느려진다
  • B) 비용이 없다 (단형화로 컴파일 시점에 구체 타입으로 변환)
  • C) 메모리를 2배 사용한다
  • D) 가비지 컬렉션이 필요해진다