Generics

Generics

properties
Published

April 6, 2025

Abstract

비제네릭 컬렉션은 모든 타입의 데이터를 담을 수 있는 유연함을 제공했지만, 데이터를 사용할 때마다 원래 타입으로 형변환해야 하고, 컴파일 시 타입 오류를 발견하기 어려우며, 값 타입의 경우 박싱/언박싱으로 인한 성능 저하가 발생할 수 있다는 단점을 확인했습니다. 이러한 문제를 해결하고 코드의 재사용성, 타입 안정성, 성능을 모두 향상시키기 위해 C# 2.0부터 도입된 강력한 기능이 바로 제네릭(Generics), 또는 일반화 프로그래밍입니다.

일반화 프로그래밍이란? (What is Generic Programming?)

일반화 프로그래밍(Generic Programming)은 데이터 타입 자체를 형식 매개변수(Type Parameter)로 받아들이는 방식으로 코드(클래스 또는 메서드)를 작성하는 기법입니다. 즉, 특정 데이터 타입에 종속되지 않고 다양한 데이터 타입에서 동일하게 동작할 수 있는 범용적인 코드를 만드는 것을 목표로 합니다.

예를 들어, 두 변수의 값을 교환하는 간단한 메서드를 생각해 봅시다. int 타입의 두 변수를 교환하는 메서드와 string 타입의 두 변수를 교환하는 메서드는 교환 로직 자체는 동일하지만, 다루는 데이터 타입 때문에 별도의 메서드를 작성해야 합니다.

// int 타입 교환 메서드
void SwapInt(ref int a, ref int b)
{
    int temp = a;
    a = b;
    b = temp;
}

// string 타입 교환 메서드
void SwapString(ref string a, ref string b)
{
    string temp = a;
    a = b;
    b = temp;
}

// double, float, 사용자 정의 클래스... 타입마다 메서드를 만들어야 할까?

만약 object 타입을 사용하면 모든 타입을 받을 수는 있지만, 다음과 같은 문제가 발생합니다.

// object 타입을 사용한 교환 메서드 (문제점 내포)
void SwapObject(ref object a, ref object b)
{
    object temp = a;
    a = b;
    b = temp;
}

int x = 5, y = 10;
object objX = x; // 박싱 발생 (값 타입을 object로 변환)
object objY = y; // 박싱 발생
SwapObject(ref objX, ref objY);
x = (int)objX; // 언박싱 발생 (object를 값 타입으로 변환)
y = (int)objY; // 언박싱 발생

string s1 = "Hello", s2 = "World";
object os1 = s1;
object os2 = s2;
SwapObject(ref os1, ref os2);
s1 = (string)os1; // 형변환 필요
s2 = (string)os2; // 형변환 필요

// int와 string을 실수로 교환하려고 해도 컴파일 오류가 발생하지 않음!
// SwapObject(ref objX, ref os1); // 실행 시점에 문제를 일으킬 수 있음

object를 사용하면 값 타입의 경우 박싱/언박싱으로 인한 성능 저하가 발생하고, 모든 참조 타입 간의 형변환이 가능하므로 컴파일 시점에 타입 오류를 잡기 어렵습니다.

제네릭(Generics)은 이러한 문제를 해결합니다. 타입 자체를 매개변수(형식 매개변수)로 받아 코드(클래스 또는 메서드)를 정의하고, 실제 사용할 때 구체적인 타입을 지정하는 방식입니다.

제네릭의 장점

  1. 타입 안전성 (Type Safety): 컴파일 시점에 타입 오류를 발견할 수 있습니다. 잘못된 타입의 데이터를 사용하려는 시도를 컴파일러가 미리 막아줍니다.
  2. 성능 향상 (Performance): 값 타입에 대해 박싱/언박싱이 발생하지 않아 성능이 향상됩니다.
  3. 코드 재사용성 (Code Reusability): 특정 타입에 종속되지 않는 범용 코드를 작성하여 코드 중복을 줄이고 재사용성을 높입니다.

일반화 메소드 (Generic Methods)

일반화 메소드(Generic Method)는 형식 매개변수(Type Parameter)를 사용하여 정의된 메서드입니다. 메서드가 호출될 때 실제 데이터 타입이 결정되어 해당 타입에 맞게 동작합니다.

정의

일반화 메서드는 메서드 이름 뒤에 꺾쇠괄호 <>를 사용하여 형식 매개변수를 선언합니다. 형식 매개변수 이름으로는 보통 T를 많이 사용하지만, 의미를 명확히 하기 위해 다른 이름을 사용할 수도 있습니다 (TKey, TValue, TInput 등).

modifiers returnType MethodName<T>(/* 매개변수 목록 */)
{
    // 메서드 본문 (T 타입을 사용)
}
  • T: 형식 매개변수. 실제 타입으로 대체될 자리 표시자입니다.

Swap 메서드

앞서 예시로 든 Swap 메서드를 일반화 메서드로 작성하면 다음과 같습니다.

using System;

public class GenericMethodExample
{
    // 두 변수의 값을 교환하는 일반화 메서드
    public static void Swap<T>(ref T a, ref T b) // 형식 매개변수 T 선언
    {
        Console.WriteLine($"Swap<{typeof(T)}>() 호출됨"); // 어떤 타입으로 호출되었는지 확인
        T temp = a; // T 타입의 임시 변수
        a = b;
        b = temp;
    }

    public static void Main(string[] args)
    {
        int x = 5, y = 10;
        Console.WriteLine($"Before Swap: x={x}, y={y}");
        Swap<int>(ref x, ref y); // 명시적으로 타입 인수 <int> 지정
        // Swap(ref x, ref y); // 컴파일러가 타입을 추론할 수 있다면 <int> 생략 가능
        Console.WriteLine($"After Swap: x={x}, y={y}");

        Console.WriteLine();

        string s1 = "Hello", s2 = "World";
        Console.WriteLine($"Before Swap: s1={s1}, s2={s2}");
        Swap(ref s1, ref s2); // 타입 인수 <string> 생략 (타입 추론)
        Console.WriteLine($"After Swap: s1={s1}, s2={s2}");

        // Swap(ref x, ref s1); // 컴파일 오류! T가 int와 string으로 동시에 추론될 수 없음
    }
}

타입 추론 (Type Inference)

대부분의 경우, 컴파일러는 메서드에 전달되는 인수를 보고 형식 매개변수 T가 어떤 타입이어야 하는지 추론할 수 있습니다. 따라서 Swap<int>(ref x, ref y) 대신 Swap(ref x, ref y)와 같이 타입 인수를 생략하고 호출할 수 있습니다. 컴파일러가 타입을 추론할 수 없는 경우에는 명시적으로 타입 인수를 지정해야 합니다.

일반화 클래스 (Generic Classes)

일반화 클래스(Generic Class)는 클래스 정의 자체에 형식 매개변수를 사용하여, 해당 클래스의 멤버(필드, 속성, 메서드)들이 형식 매개변수 타입에 따라 동작하도록 만든 클래스입니다.

정의

클래스 이름 뒤에 꺾쇠괄호 <>를 사용하여 형식 매개변수를 선언합니다.

modifiers class ClassName<T>
{
    // 멤버 정의 (필드, 속성, 메서드 등에서 T 타입 사용)
    private T data;

    public T GetData()
    {
        return data;
    }

    public void SetData(T value)
    {
        data = value;
    }
}

인스턴스 생성

일반화 클래스의 인스턴스를 생성할 때는 형식 매개변수 T에 사용할 구체적인 타입을 꺾쇠괄호 안에 명시해야 합니다.

// T를 int로 지정하여 인스턴스 생성
ClassName<int> intObject = new ClassName<int>();
intObject.SetData(100);
int value = intObject.GetData();

// T를 string으로 지정하여 인스턴스 생성
ClassName<string> stringObject = new ClassName<string>();
stringObject.SetData("Hello Generic");
string text = stringObject.GetData();

// ClassName<int>와 ClassName<string>은 서로 다른 타입으로 취급됩니다.

내부적으로 List<T>를 사용하여 간단한 스택 기능을 제공하는 일반화 클래스를 만들어 보겠습니다.

using System;
using System.Collections.Generic;

public class MyGenericStack<T> // 형식 매개변수 T를 받는 일반화 클래스
{
    private List<T> items = new List<T>(); // 내부적으로 List<T> 사용

    public void Push(T item)
    {
        items.Add(item); // T 타입의 아이템 추가
        Console.WriteLine($"Push: {item} (Type: {typeof(T)})");
    }

    public T Pop()
    {
        if (items.Count == 0)
        {
            throw new InvalidOperationException("스택이 비어 있습니다.");
        }
        int lastIndex = items.Count - 1;
        T item = items[lastIndex]; // T 타입의 아이템 가져오기
        items.RemoveAt(lastIndex);
        Console.WriteLine($"Pop: {item} (Type: {typeof(T)})");
        return item;
    }

    public int Count => items.Count; // 속성
}

public class GenericClassExample
{
    public static void Main(string[] args)
    {
        // int 타입을 사용하는 스택 생성
        MyGenericStack<int> intStack = new MyGenericStack<int>();
        intStack.Push(1);
        intStack.Push(2);
        int poppedInt = intStack.Pop(); // 2
        Console.WriteLine($"Popped int: {poppedInt}, Count: {intStack.Count}");

        Console.WriteLine();

        // string 타입을 사용하는 스택 생성
        MyGenericStack<string> stringStack = new MyGenericStack<string>();
        stringStack.Push("Apple");
        stringStack.Push("Banana");
        string poppedString = stringStack.Pop(); // Banana
        Console.WriteLine($"Popped string: {poppedString}, Count: {stringStack.Count}");

        // MyGenericStack<int>와 MyGenericStack<string>은 다른 타입입니다.
        // intStack = stringStack; // 컴파일 오류!
    }
}

일반화 클래스를 사용하면 MyGenericStack<int>, MyGenericStack<string>, MyGenericStack<MyClass> 등 다양한 타입에 대해 동일한 스택 로직을 타입 안전하고 효율적으로 재사용할 수 있습니다.

형식 매개변수 제약시키기 (Constraining Type Parameters)

때로는 일반화 코드(메서드 또는 클래스) 내부에서 형식 매개변수 T에 대해 특정 작업을 수행해야 할 때가 있습니다. 예를 들어, T 타입의 객체들을 비교하거나(CompareTo 메서드 호출), T 타입의 새로운 인스턴스를 생성하거나(new T()), T가 특정 클래스를 상속하거나 인터페이스를 구현하는지 확인해야 할 수 있습니다.

이런 경우, 형식 매개변수 T가 특정 조건을 만족해야 함을 컴파일러에게 알려주어야 하는데, 이를 형식 매개변수 제약(Type Parameter Constraint) 이라고 합니다. 제약 조건은 where 키워드를 사용하여 지정합니다.

where 절은 클래스나 메서드의 형식 매개변수 목록 뒤에 위치합니다.

// 일반화 클래스 제약
class MyClass<T> where T : constraint1, constraint2 { /* ... */ }

// 일반화 메서드 제약
void MyMethod<T>(T param) where T : constraint1 { /* ... */ }
  • 하나의 형식 매개변수에 여러 제약 조건을 적용할 때는 콤마(,)로 구분합니다.
  • 여러 형식 매개변수에 대한 제약 조건은 각각 where 절을 사용합니다 (예: where T : class where U : struct).

주요 제약 조건

  • where T : struct: Tnull을 허용하지 않는 값 타입이어야 합니다 (예: int, double, bool, 사용자 정의 struct).
  • where T : class: T는 참조 타입이어야 합니다 (예: string, 사용자 정의 class, interface, delegate). object도 가능합니다.
  • where T : new(): T는 반드시 public이고 매개변수가 없는 생성자(기본 생성자)를 가져야 합니다. 이 제약 조건이 있으면 코드 내에서 new T()를 사용하여 T 타입의 인스턴스를 생성할 수 있습니다. new() 제약 조건은 다른 제약 조건들 뒤에 와야 합니다.
  • where T : <base class name>: T는 지정된 베이스 클래스를 상속받거나 베이스 클래스 자체여야 합니다.
  • where T : <interface name>: T는 지정된 인터페이스를 구현해야 합니다. 여러 인터페이스 제약을 지정할 수 있습니다.
  • where T : U: (Naked type constraint) 형식 매개변수 T는 다른 형식 매개변수 U로부터 상속받거나 U 자체여야 합니다.

IComparable<T> 제약: 두 객체를 비교하는 메서드

```csharp
using System;

public class ComparisonHelper
{
    // T가 IComparable<T> 인터페이스를 구현해야 함을 제약
    public static T Max<T>(T a, T b) where T : IComparable<T>
    {
        // CompareTo 메서드를 안전하게 호출 가능
        return a.CompareTo(b) > 0 ? a : b;
    }

    public static void Test()
    {
        Console.WriteLine($"Max(5, 10): {Max(5, 10)}");     // int는 IComparable<int> 구현
        Console.WriteLine($"Max(\"Apple\", \"Banana\"): {Max("Apple", "Banana")}"); // string은 IComparable<string> 구현

        // Max(new object(), new object()); // 컴파일 오류! object는 IComparable<object>를 구현하지 않음
    }
}
```

new() 제약: 객체를 생성하는 팩토리 메서드

```csharp
using System;

public class Factory
{
    // T가 매개변수 없는 public 생성자를 가져야 함을 제약
    public static T CreateInstance<T>() where T : new()
    {
        Console.WriteLine($"Creating instance of {typeof(T)}");
        return new T(); // new T() 호출 가능
    }

    public static void Test()
    {
        MyClass instance1 = CreateInstance<MyClass>();
        MyStruct instance2 = CreateInstance<MyStruct>();
        // string instance3 = CreateInstance<string>(); // 컴파일 오류! string은 public 기본 생성자가 없음
    }
}

public class MyClass { public MyClass() {} } // public 기본 생성자 필요
public struct MyStruct {} // struct는 기본적으로 new() 제약 만족
```

제약 조건을 사용하면 일반화 코드가 특정 타입의 기능을 안전하게 사용하도록 보장하면서도 제네릭의 유연성을 유지할 수 있습니다.

일반화 컬렉션 (Generic Collections)

제네릭의 가장 큰 수혜자 중 하나는 바로 컬렉션입니다. System.Collections.Generic 네임스페이스에는 앞서 살펴본 비제네릭 컬렉션들의 타입 안전하고 성능이 개선된 일반화 컬렉션(Generic Collection) 버전들이 포함되어 있습니다.

이제 C# 프로그래밍에서는 비제네릭 컬렉션 대신 일반화 컬렉션을 사용하는 것이 표준입니다.

주요 일반화 컬렉션:

List<T> - ArrayList의 일반화 버전입니다. T 타입의 요소만 저장할 수 있는 동적 배열입니다. - 주요 멤버: Add(T item), Remove(T item), RemoveAt(int index), Count (속성), 인덱서 this[int index]. - 타입 안전성: 지정된 T 타입 외의 데이터를 추가하려고 하면 컴파일 오류가 발생합니다. - 성능: 값 타입(struct, int 등)을 저장해도 박싱/언박싱이 발생하지 않습니다.

```csharp
using System.Collections.Generic;

List<int> numbers = new List<int> { 1, 2, 3 };
numbers.Add(4);
// numbers.Add("Hello"); // 컴파일 오류! int 타입만 가능

Console.WriteLine($"List<int> Count: {numbers.Count}"); // 출력: 4
Console.WriteLine($"Element at index 1: {numbers[1]}"); // 출력: 2
```
  • Queue<T>
    • Queue의 일반화 버전입니다. T 타입의 요소만 저장하는 FIFO(First-In, First-Out) 큐입니다.
    • 주요 멤버: Enqueue(T item), Dequeue(), Peek(), Count.
    using System.Collections.Generic;
    
    Queue<string> tasks = new Queue<string>();
    tasks.Enqueue("Task A");
    tasks.Enqueue("Task B");
    // tasks.Enqueue(123); // 컴파일 오류! string 타입만 가능
    
    string nextTask = tasks.Dequeue(); // "Task A" 반환 (형변환 필요 없음)
    Console.WriteLine($"Dequeued task: {nextTask}");
  • Stack<T>
    • Stack의 일반화 버전입니다. T 타입의 요소만 저장하는 LIFO(Last-In, First-Out) 스택입니다.
    • 주요 멤버: Push(T item), Pop(), Peek(), Count.
    using System.Collections.Generic;
    
    Stack<double> values = new Stack<double>();
    values.Push(3.14);
    values.Push(2.71);
    // values.Push("pi"); // 컴파일 오류! double 타입만 가능
    
    double topValue = values.Pop(); // 2.71 반환 (형변환 필요 없음)
    Console.WriteLine($"Popped value: {topValue}");
  • Dictionary<TKey, TValue>
    • Hashtable의 일반화 버전입니다. TKey 타입의 키와 TValue 타입의 값을 쌍으로 저장합니다.
    • 주요 멤버: Add(TKey key, TValue value), Remove(TKey key), ContainsKey(TKey key), TryGetValue(TKey key, out TValue value), 인덱서 this[TKey key], Count.
    • 키와 값 모두 타입 안전성이 보장됩니다.
    using System.Collections.Generic;
    
    Dictionary<string, int> studentScores = new Dictionary<string, int>();
    studentScores.Add("Alice", 95);
    studentScores["Bob"] = 88; // 인덱서를 사용한 추가/수정
    // studentScores.Add("Charlie", "Good"); // 컴파일 오류! 값은 int 타입이어야 함
    // studentScores.Add(100, 90); // 컴파일 오류! 키는 string 타입이어야 함
    
    if (studentScores.TryGetValue("Alice", out int aliceScore))
    {
        Console.WriteLine($"Alice's score: {aliceScore}"); // 출력: 95 (형변환 필요 없음)
    }

이 외에도 HashSet<T>(중복 없는 집합), LinkedList<T>(연결 리스트), SortedList<TKey, TValue>(키로 정렬된 리스트) 등 다양한 일반화 컬렉션이 제공됩니다. 데이터를 다룰 때는 필요에 맞는 일반화 컬렉션을 선택하여 사용하는 것이 좋습니다.

foreach를 사용할 수 있는 일반화 클래스

IEnumerable 또는 IEnumerable<T> 인터페이스를 구현하면 사용자 정의 클래스 객체를 foreach 문으로 순회할 수 있음을 배웠습니다. 이 개념은 일반화 클래스에도 동일하게 적용됩니다.

일반화 클래스가 IEnumerable<T> 인터페이스를 구현하면, 해당 클래스의 인스턴스를 foreach로 순회할 때 형식 매개변수 T 타입의 요소를 타입 안전하게 얻을 수 있습니다. 구현 방법은 비제네릭 클래스와 동일하며, yield return 키워드를 사용하는 것이 가장 간편하고 권장되는 방식입니다.

MyGenericStack<T> 클래스를 foreach로 순회 가능하도록 수정해 보겠습니다. 스택의 특성상 LIFO 순서(가장 나중에 추가된 것부터)로 순회하도록 구현해 봅시다.

using System;
using System.Collections; // IEnumerable 사용을 위해 필요
using System.Collections.Generic;

// MyGenericStack<T> 클래스 (IEnumerable<T> 구현 추가)
public class MyGenericStack<T> : IEnumerable<T> // IEnumerable<T> 인터페이스 구현
{
    private List<T> items = new List<T>();

    public void Push(T item)
    {
        items.Add(item);
        // Console.WriteLine($"Push: {item} (Type: {typeof(T)})"); // 이전 코드 생략
    }

    public T Pop()
    {
        if (items.Count == 0) throw new InvalidOperationException("스택이 비어 있습니다.");
        int lastIndex = items.Count - 1;
        T item = items[lastIndex];
        items.RemoveAt(lastIndex);
        // Console.WriteLine($"Pop: {item} (Type: {typeof(T)})"); // 이전 코드 생략
        return item;
    }

    public int Count => items.Count;

    // IEnumerable<T>의 GetEnumerator 구현 (LIFO 순서로 반환)
    public IEnumerator<T> GetEnumerator()
    {
        // 스택의 맨 위(리스트의 끝)부터 아래(리스트의 시작)로 순회
        for (int i = items.Count - 1; i >= 0; i--)
        {
            yield return items[i]; // yield return 사용
        }
    }

    // IEnumerable의 GetEnumerator 구현 (비제네릭)
    IEnumerator IEnumerable.GetEnumerator()
    {
        return this.GetEnumerator(); // 제네릭 GetEnumerator 호출
    }
}

// 사용 예시
public class GenericForeachExample
{
    public static void Main(string[] args)
    {
        MyGenericStack<string> bookStack = new MyGenericStack<string>();
        bookStack.Push("C# Programming");
        bookStack.Push("Data Structures");
        bookStack.Push("Algorithms");

        Console.WriteLine($"책 스택 (Count: {bookStack.Count}) 내용 (foreach):");
        // MyGenericStack<string> 객체를 foreach로 순회
        // item 변수는 string 타입으로 자동 추론됨 (타입 안전)
        foreach (string item in bookStack)
        {
            Console.WriteLine($"- {item}");
        }
        /* 출력:
        책 스택 (Count: 3) 내용 (foreach):
        - Algorithms       (LIFO 순서)
        - Data Structures
        - C# Programming
        */
    }
}

이제 MyGenericStack<T> 클래스는 IEnumerable<T>를 구현했기 때문에 foreach 문으로 쉽게 순회할 수 있으며, 순회하는 동안 각 요소(item)는 형식 매개변수 T에 지정된 타입(string 예시에서는)으로 안전하게 처리됩니다. yield return 덕분에 복잡한 반복자 클래스를 직접 만들 필요 없이 간결하게 구현할 수 있습니다.