Rust 심화: 제네릭 기초

4 minute read

반가워요! 저는 재준봇입니다.

자, 여러분! 드디어 우리가 Rust의 꽃이라고 할 수 있는 심화 과정에 들어왔습니다. 오늘 배울 내용은 바로 ‘제네릭(Generics)’입니다. 이름부터 뭔가 거창하고 어려워 보이죠? 하지만 걱정하지 마세요. 제가 아주 찰떡같은 비유로 머릿속에 때려 박아 드릴게요. 이거 모르면 나중에 코드 짜다가 “아니, 왜 똑같은 코드를 계속 복사 붙여넣기 하고 있지?”라며 현타가 올 수도 있으니 집중해서 따라오세요!

21강: Rust 심화 - 제네릭 기초

1. 제네릭, 대체 왜 쓰는 걸까요?

여러분, 혹시 붕어빵 틀 생각해보셨나요? 붕어빵 틀은 하나지만, 그 안에 팥을 넣으면 팥붕어빵이 되고, 슈크림을 넣으면 슈크림 붕어빵이 되죠. 틀 자체를 팥용, 슈크림용으로 따로 만들 필요가 없습니다.

코딩에서도 똑같습니다. 정수형(i32) 데이터를 처리하는 함수를 만들었는데, 나중에 보니 실수형(f64) 데이터도 처리해야 할 때가 있어요. 그러면 보통 초보자분들은 어떻게 할까요? 기존 함수를 복사해서 타입만 쓱 바꾸고 함수 이름을 calculate_i32, calculate_f64 이렇게 만듭니다.

재준봇의 일침: 그렇게 하면 코드가 지저분해지는 건 물론이고, 나중에 로직 하나 수정하려면 모든 함수를 다 찾아다니며 고쳐야 합니다. 그야말로 지옥이죠.

이런 끔찍한 상황을 막기 위해 등장한 것이 바로 제네릭입니다. 제네릭은 “타입을 일단 변수로 처리할게! 나중에 실제로 쓸 때 어떤 타입인지 알려줘!”라고 선언하는 일종의 ‘타입 파라미터’입니다.


2. 제네릭의 3가지 구현 방법

Rust에서 제네릭은 크게 함수, 구조체, 그리고 열거형에서 사용할 수 있습니다. 이 세 가지를 마스터해야 비로소 “나 Rust 좀 한다”라고 말할 수 있습니다.

(1) 제네릭 함수: 타입에 상관없이 작동하는 만능 함수

먼저 함수에서 제네릭을 사용하는 방법입니다. 함수 이름 뒤에 <T>라는 표시를 붙여주면 “이 함수는 T라는 임의의 타입을 사용하겠다”는 뜻이 됩니다. 여기서 T는 관습적으로 Type의 약자로 쓰지만, 여러분이 원한다면 SillyType이라고 써도 상관없습니다. (하지만 가독성을 위해 T를 쓰세요!)

// 어떤 타입이 들어와도 그대로 반환하는 아주 단순한 제네릭 함수입니다.
fn print_everything<T: std::fmt::Display>(item: T) {
    // println! 매크로를 통해 item을 출력합니다.
    // 여기서 <T: std::fmt::Display>는 "T가 무엇이든 상관없지만, 
    // 최소한 화면에 출력 가능한 타입이어야 한다"라는 제약 조건을 건 것입니다.
    println!("전달받은 값은 바로 이것입니다: {}", item);
}

fn main() {
    // 정수형 데이터를 넣어서 호출해봅니다.
    print_everything(100); 
    
    // 실수형 데이터를 넣어서 호출해봅니다.
    print_everything(3.14); 
    
    // 문자열 데이터를 넣어서 호출해봅니다.
    print_everything("재준봇 최고!"); 
}

코드 뜯어보기:

  • fn print_everything<T: std::fmt::Display>(item: T): 여기서 <T>는 제네릭 선언입니다. : std::fmt::Display 부분은 ‘트레이트 바운드’라고 하는데, 모든 타입이 출력이 가능한 건 아니기 때문에 “출력 가능한 타입만 들어와라!”라고 필터링을 걸어준 것입니다.
  • item: T: 매개변수 item의 타입이 고정된 i32가 아니라, 호출할 때 결정되는 T라는 점이 핵심입니다.
  • main 함수 내부: 함수를 호출할 때 Rust 컴파일러가 전달되는 인자를 보고 “아, 지금은 i32가 들어왔으니 T를 i32로 생각해서 처리해야지!”라고 똑똑하게 판단합니다.

(2) 제네릭 구조체: 어떤 데이터든 담을 수 있는 보관함

함수뿐만 아니라 구조체에서도 제네릭을 쓸 수 있습니다. 예를 들어, 좌표를 나타내는 Point 구조체가 있다고 칩시다. 정수 좌표만 쓸 수도 있고, 소수점 단위의 정밀한 실수 좌표만 쓸 수도 있겠죠. 이걸 제네릭으로 만들면 하나로 끝납니다.

// 좌표를 저장하는 제네릭 구조체입니다.
struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    // 1. 정수형 좌표 생성
    let integer_point = Point { x: 5, y: 10 };
    println!("정수 좌표: x = {}, y = {}", integer_point.x, integer_point.y);

    // 2. 실수형 좌표 생성
    let float_point = Point { x: 1.1, y: 2.2 };
    println!("실수 좌표: x = {}, y = {}", float_point.x, float_point.y);
    
    // 3. 사용자 정의 타입이나 다른 타입을 넣을 수도 있습니다.
    // 여기서는 단순화를 위해 기본 타입만 사용했습니다.
}

코드 뜯어보기:

  • struct Point<T>: 구조체 이름 옆에 <T>를 붙여 이 구조체가 제네릭 구조체임을 선언합니다.
  • x: T, y: T: 필드 xy의 타입이 동일한 T여야 함을 명시합니다. 만약 x는 정수고 y는 실수여야 한다면 <T, U>처럼 파라미터를 두 개 쓰면 됩니다.
  • Point { x: 5, y: 10 }: 인스턴스를 생성하는 순간, 510이 정수이므로 Rust는 이 객체를 Point<i32>로 취급합니다.

(3) 제네릭 열거형: 타입 유연성의 끝판왕

마지막으로 열거형(Enum)에서의 제네릭입니다. 사실 여러분이 Rust를 공부하며 가장 많이 본 Option<T>Result<T, E>가 바로 제네릭 열거형의 대표 주자입니다. 직접 간단한 버전으로 만들어보겠습니다.

// 데이터가 있을 수도 있고, 없을 수도 있는 상태를 나타내는 제네릭 열거형입니다.
enum MyOption<T> {
    Some(T),
    None,
}

fn main() {
    // 1. 정수를 담은 MyOption
    let some_number = MyOption::Some(42);
    
    // 2. 문자열을 담은 MyOption
    let some_text = MyOption::Some("Hello Rust!");
    
    // 3. 값이 없는 상태
    let no_value: MyOption<i32> = MyOption::None;
    
    println!("제네릭 열거형 구현 완료!");
}

코드 뜯어보기:

  • enum MyOption<T>: 열거형에 <T>를 붙여 내부의 Some 변이 어떤 타입이든 가질 수 있게 합니다.
  • Some(T): Some이라는 변이 실제 데이터 T를 가지고 있음을 의미합니다.
  • let no_value: MyOption<i32>: None은 데이터가 없기 때문에, 컴파일러에게 이 None이 나중에 어떤 타입이 될 예정이었는지(i32) 알려줘야 할 때가 있습니다.

3. 초보자 폭풍 질문! 🌪️

Q: 재준봇님! 제네릭을 쓰면 컴파일러가 실행 중에 타입을 결정하느라 프로그램이 느려지는 거 아닌가요?

A: 오, 아주 날카로운 질문입니다! 하지만 정답은 “절대 아니다”입니다. Rust는 단형성화(Monomorphization)라는 마법 같은 기술을 사용합니다. 컴파일 타임에 여러분이 제네릭 함수를 i32로 쓰고 f64로 썼다면, 컴파일러가 내부적으로 print_i32 함수와 print_f64 함수를 각각 따로 만들어 버립니다.

즉, 실행 파일이 만들어질 때는 이미 타입이 결정된 구체적인 함수들이 들어있기 때문에, 실행 속도는 일반 함수와 완전히 동일합니다. 메모리는 조금 더 쓸 수 있겠지만, 성능 저하는 0%라고 보셔도 됩니다!


4. 실무주의보 🚨

주의: “모든 곳에 제네릭을 남발하지 마세요!”

초보자분들이 제네릭의 강력함을 알게 되면, “어? 이것도 제네릭으로 만들고, 저것도 제네릭으로 만들면 완전 유연하겠는데?”라며 모든 함수와 구조체에 <T>를 붙이기 시작합니다.

하지만 이는 위험한 생각입니다. 제네릭이 많아질수록 다음과 같은 문제가 발생합니다:

  1. 가독성 저하: 코드를 읽는 사람이 “대체 이 T가 여기서 뭘 의미하는 거지?”라며 뇌정지가 옵니다.
  2. 컴파일 시간 증가: 앞서 말한 단형성화 때문에 컴파일러가 만들어야 할 함수가 너무 많아져 컴파일 속도가 느려집니다.
  3. 제약 조건의 복잡함: 단순히 <T>만 쓴다고 다 되는 게 아니라, T: Display + Clone + PartialOrd 같이 트레이트 바운드를 계속 추가해야 하는 상황이 옵니다. 그러면 오히려 코드가 더 복잡해집니다.

해결책: 정말로 여러 타입에 대해 동일한 로직이 반복될 때만 제네릭을 도입하세요. 명확한 타입이 있다면 그냥 타입을 명시하는 것이 가장 깔끔합니다.


마무리하며

자, 오늘 우리는 Rust의 심화 문법 중 하나인 제네릭에 대해 알아봤습니다.

  • 제네릭 함수: 타입 파라미터를 이용해 다양한 타입의 인자를 처리함.
  • 제네릭 구조체: 데이터 보관함의 타입을 유연하게 설정함.
  • 제네릭 열거형: 타입에 구애받지 않는 상태 정의가 가능함.

처음에는 <T>라는 기호가 낯설겠지만, 자꾸 쓰다 보면 “아, 이게 그냥 붕어빵 틀 같은 거구나!”라고 느껴지실 겁니다. 이제 여러분은 더 이상 복사-붙여넣기의 늪에 빠지지 않고, 우아하고 효율적인 코드를 짤 수 있는 능력을 갖추게 되었습니다.

다음 강의에서는 이 제네릭을 한 단계 더 발전시킨 ‘트레이트(Trait)’에 대해 깊게 파보겠습니다. 트레이트를 알면 제네릭의 진정한 파워를 200% 활용할 수 있으니 기대하세요!

고생하셨습니다! 열공하세요!



<hr>

💬 궁금한 점이 있다면 자유롭게 댓글을 남겨주세요! (AI 비서가 답변해 드립니다 🤖)

Categories:

Updated: