카테고리 없음

C# 제네릭 Generic 정리

wook101118 2026. 5. 27. 07:28

C#을 공부하다 보면 <T>라는 문법을 자주 볼 수 있다.

예를 들면 이런 코드이다.

 
List<int> numbers = new List<int>();
 

또는 직접 제네릭 클래스를 만들 때도 볼 수 있다.

 
class Box<T>
{
    public T value;
}
 

여기서 <T>가 바로 제네릭과 관련된 문법이다.

제네릭은 쉽게 말해 자료형을 나중에 정할 수 있게 해주는 문법이다.


1. 제네릭이란?

제네릭은 클래스, 메서드, 인터페이스 등을 만들 때 사용할 자료형을 미리 고정하지 않고 나중에 정할 수 있게 하는 기능이다.

예를 들어 정수 값을 저장하는 상자가 있다고 해보자.

 
class IntBox
{
    public int value;
}
 

문자열을 저장하는 상자도 필요하다면 또 만들어야 한다.

 
class StringBox
{
    public string value;
}
 

이렇게 하면 자료형이 달라질 때마다 클래스를 계속 새로 만들어야 한다.

이때 제네릭을 사용하면 하나의 클래스로 여러 자료형을 처리할 수 있다.

 
class Box<T>
{
    public T value;
}
 

이제 T 자리에 원하는 자료형을 넣어서 사용할 수 있다.

 
Box<int> intBox = new Box<int>();
intBox.value = 10;

Box<string> stringBox = new Box<string>();
stringBox.value = "Hello";
 

Box<int>에서는 T가 int가 되고,
Box<string>에서는 T가 string이 된다.


2. <T>의 의미

제네릭에서 자주 보이는 <T>는 타입 매개변수라고 부른다.

 
class Box<T>
{
    public T value;
}
 

여기서 T는 실제 자료형이 아니라, 나중에 들어올 자료형을 대신하는 이름이다.

즉, T는 다음과 같은 의미로 볼 수 있다.

 
T = 나중에 정해질 자료형
 

예를 들어 다음 코드에서는 T가 int가 된다.

 
Box<int> box = new Box<int>();
 

그래서 클래스 내부의 T value는 실제로는 int value처럼 동작한다.

 
class Box<int>
{
    public int value;
}
 

물론 위 코드는 설명을 위한 표현이고, 실제로 이렇게 작성하지는 않는다.


3. C++의 template과 C#의 Type 관례

C++에서도 <T>와 비슷한 문법을 볼 수 있다.

 
template <typename T>
class Box
{
public:
    T value;
};
 

C++에서는 이 기능을 template이라고 부른다.

반면 C#에서는 같은 형태의 <T>를 사용하지만, 이것을 generic, 즉 제네릭이라고 부른다.

 
class Box<T>
{
    public T value;
}
 

여기서 T는 보통 Type의 앞 글자로 사용된다.

즉, C++에서는 template 문법에서 T를 많이 사용하고,
C#에서는 제네릭 타입 매개변수 이름으로 T를 많이 사용한다.

다만 중요한 점은, C#에서 T라는 이름은 문법적으로 반드시 정해진 것은 아니라는 것이다.
관례적으로 Type의 의미로 T를 많이 쓰는 것뿐이다.

아래 코드는 모두 가능하다.

 
class Box<T>
{
    public T value;
}
 
 
class Box<TItem>
{
    public TItem value;
}
 
 
class Box<DataType>
{
    public DataType value;
}
 

하지만 일반적으로는 짧고 익숙한 T를 많이 사용한다.


4. 제네릭을 사용하는 이유

1) 코드 중복을 줄일 수 있다

제네릭을 사용하지 않으면 자료형마다 클래스를 따로 만들어야 한다.

 
class IntBox
{
    public int value;
}

class StringBox
{
    public string value;
}
 

하지만 제네릭을 사용하면 하나의 클래스로 해결할 수 있다.

 
class Box<T>
{
    public T value;
}
 

사용할 때만 자료형을 정하면 된다.

 
Box<int> intBox = new Box<int>();
Box<string> stringBox = new Box<string>();
 

2) 타입 안정성이 높아진다

제네릭을 사용하면 잘못된 자료형이 들어가는 것을 컴파일 단계에서 막을 수 있다.

예를 들어 List<int>는 int만 저장할 수 있다.

 
List<int> numbers = new List<int>();

numbers.Add(10);
numbers.Add(20);
 

하지만 문자열은 넣을 수 없다.

 
numbers.Add("Hello"); // 오류
 

이렇게 제네릭은 자료형을 명확하게 정하기 때문에 실수를 줄여준다.


3) 형변환을 줄일 수 있다

제네릭이 없던 방식에서는 object를 많이 사용했다.

 
class Box
{
    public object value;
}
 

object는 거의 모든 자료형을 담을 수 있다.

 
Box box = new Box();
box.value = 10;
 

하지만 값을 꺼낼 때 형변환이 필요하다.

 
int number = (int)box.value;
 

만약 잘못된 자료형으로 형변환하면 오류가 발생할 수 있다.

 
string text = (string)box.value; // 오류 가능
 

제네릭을 사용하면 이런 문제를 줄일 수 있다.

 
Box<int> box = new Box<int>();
box.value = 10;

int number = box.value;
 

형변환 없이 바로 사용할 수 있다.


5. 제네릭 클래스

제네릭 클래스는 클래스 이름 뒤에 <T>를 붙여 만든다.

 
class Box<T>
{
    public T value;
}
 

사용할 때는 T 자리에 실제 자료형을 넣는다.

 
Box<int> intBox = new Box<int>();
Box<string> stringBox = new Box<string>();
Box<float> floatBox = new Box<float>();
 

각각의 객체에서 T는 다르게 해석된다.

 
Box<int>       // T는 int
Box<string>    // T는 string
Box<float>     // T는 float
 

이렇게 제네릭 클래스는 여러 자료형에 대응할 수 있다.


6. 제네릭 메서드

제네릭은 클래스뿐만 아니라 메서드에도 사용할 수 있다.

 
void Print<T>(T value)
{
    Console.WriteLine(value);
}
 

이 메서드는 어떤 자료형이든 출력할 수 있다.

 
Print<int>(10);
Print<string>("Hello");
Print<float>(3.14f);
 

보통은 자료형을 생략해도 컴파일러가 알아서 추론한다.

 
Print(10);
Print("Hello");
Print(3.14f);
 

이런 것을 타입 추론이라고 한다.

즉, 컴파일러가 전달된 값을 보고 T가 어떤 자료형인지 자동으로 판단하는 것이다.


7. 제네릭 인터페이스

인터페이스에도 제네릭을 사용할 수 있다.

 
interface IRepository<T>
{
    void Add(T item);
    T Get(int index);
}
 

이 인터페이스는 어떤 자료형을 저장할지 나중에 정할 수 있다.

 
class ItemRepository : IRepository<string>
{
    private List<string> items = new List<string>();

    public void Add(string item)
    {
        items.Add(item);
    }

    public string Get(int index)
    {
        return items[index];
    }
}
 

여기서는 T가 string이 된다.

따라서 인터페이스 내부의 T는 전부 string처럼 동작한다.


8. 제네릭 컬렉션

C#에서 제네릭을 가장 자주 볼 수 있는 곳은 컬렉션이다.

대표적으로 List<T>가 있다.

 
List<int> numbers = new List<int>();
 

여기서 T는 int이다.

 
numbers.Add(10);
numbers.Add(20);
numbers.Add(30);
 

문자열 리스트를 만들 수도 있다.

 
List<string> names = new List<string>();

names.Add("Tom");
names.Add("Jane");
 

딕셔너리도 제네릭을 사용한다.

 
Dictionary<string, int> scores = new Dictionary<string, int>();
 

여기서는 타입 매개변수가 두 개이다.

 
Dictionary<TKey, TValue>
 

TKey는 키의 자료형이고, TValue는 값의 자료형이다.

 
Dictionary<string, int>
 

이 경우 key는 string, value는 int이다.


9. 타입 매개변수 이름 짓기

제네릭 타입 매개변수는 보통 T로 시작한다.

가장 기본적인 형태는 T이다.

 
class Box<T>
{
}
 

타입이 하나이고 의미가 단순하면 T만 써도 충분하다.

하지만 타입의 역할을 더 명확히 하고 싶다면 구체적인 이름을 사용할 수 있다.

 
class Repository<TEntity>
{
}
 
 
class ObjectPool<TObject>
{
}
 
 
class Dictionary<TKey, TValue>
{
}
 

여기서 TEntity, TObject, TKey, TValue는 모두 관례적인 이름이다.

정리하면 다음과 같다.

이름의미
T 일반적인 타입
TItem 아이템 타입
TKey 키 타입
TValue 값 타입
TEntity 데이터 개체 타입
TObject 객체 타입

10. 제네릭 제약 조건

제네릭은 어떤 자료형이든 받을 수 있다.

 
class Box<T>
{
    public T value;
}
 

하지만 가끔은 특정 조건을 만족하는 자료형만 받고 싶을 때가 있다.
이럴 때 사용하는 것이 제네릭 제약 조건이다.

제약 조건은 where 키워드를 사용한다.

 
class Box<T> where T : class
{
}
 

이 코드는 T가 참조형이어야 한다는 뜻이다.


1) class 제약 조건

 
class Repository<T> where T : class
{
}
 

T는 클래스 같은 참조형만 사용할 수 있다.

 
Repository<string> repo1 = new Repository<string>();
 

string은 참조형이므로 가능하다.

하지만 int는 값형이므로 사용할 수 없다.

 
Repository<int> repo2 = new Repository<int>(); // 오류
 

2) struct 제약 조건

 
class ValueBox<T> where T : struct
{
}
 

T는 값형만 사용할 수 있다.

 
ValueBox<int> box1 = new ValueBox<int>();
ValueBox<float> box2 = new ValueBox<float>();
 

하지만 클래스는 사용할 수 없다.

 
ValueBox<string> box3 = new ValueBox<string>(); // 오류
 

3) new() 제약 조건

 
class Factory<T> where T : new()
{
    public T Create()
    {
        return new T();
    }
}
 

new() 제약 조건은 T가 매개변수 없는 생성자를 가져야 한다는 뜻이다.

그래야 new T()를 사용할 수 있다.


4) 상속 제약 조건

특정 클래스를 상속받은 타입만 받을 수도 있다.

 
class Character
{
}

class Player : Character
{
}

class Enemy : Character
{
}
 
 
class Spawner<T> where T : Character
{
}
 

이 경우 T는 Character이거나 Character를 상속받은 클래스여야 한다.

 
Spawner<Player> playerSpawner = new Spawner<Player>();
Spawner<Enemy> enemySpawner = new Spawner<Enemy>();
 

5) 인터페이스 제약 조건

특정 인터페이스를 구현한 타입만 받을 수도 있다.

 
interface IDamageable
{
    void TakeDamage(int damage);
}
 
 
class DamageSystem<T> where T : IDamageable
{
    public void Damage(T target)
    {
        target.TakeDamage(10);
    }
}
 

이렇게 하면 T가 반드시 TakeDamage()를 가지고 있다는 것을 보장할 수 있다.

그래서 클래스 내부에서 안전하게 메서드를 호출할 수 있다.


11. 제네릭 제약 조건이 필요한 이유

제약 조건이 없으면 T가 어떤 타입인지 알 수 없다.

 
class DamageSystem<T>
{
    public void Damage(T target)
    {
        target.TakeDamage(10); // 오류
    }
}
 

컴파일러 입장에서는 T가 TakeDamage()를 가지고 있는지 알 수 없다.

그래서 오류가 난다.

하지만 인터페이스 제약 조건을 걸면 가능해진다.

 
interface IDamageable
{
    void TakeDamage(int damage);
}
 
 
class DamageSystem<T> where T : IDamageable
{
    public void Damage(T target)
    {
        target.TakeDamage(10);
    }
}
 

이제 컴파일러는 T가 반드시 IDamageable을 구현한다는 것을 안다.
그래서 TakeDamage()를 호출할 수 있다.


12. 타입 매개변수가 여러 개인 제네릭

제네릭은 타입 매개변수를 여러 개 가질 수 있다.

 
class Pair<TFirst, TSecond>
{
    public TFirst first;
    public TSecond second;
}
 

사용할 때는 각각의 타입을 지정한다.

 
Pair<string, int> pair = new Pair<string, int>();

pair.first = "Score";
pair.second = 100;
 

여기서 TFirst는 string, TSecond는 int가 된다.

딕셔너리도 같은 방식이다.

 
Dictionary<string, int> scores = new Dictionary<string, int>();
 

string은 key 타입, int는 value 타입이다.


13. 제네릭과 object의 차이

제네릭을 잘 이해하려면 object와 비교하면 좋다.

object는 모든 자료형의 부모이기 때문에 여러 자료형을 담을 수 있다.

 
object value;

value = 10;
value = "Hello";
value = 3.14f;
 

하지만 꺼내서 사용할 때 형변환이 필요하다.

 
int number = (int)value;
 

그리고 잘못 변환하면 오류가 발생할 수 있다.

제네릭은 처음부터 타입을 정해놓고 사용한다.

 
Box<int> box = new Box<int>();
box.value = 10;
 

그래서 값을 꺼낼 때 형변환이 필요 없다.

 
int number = box.value;
 

또한 잘못된 자료형을 넣으려고 하면 컴파일 단계에서 막아준다.

 
box.value = "Hello"; // 오류
 

정리하면 다음과 같다.

구분objectgeneric
여러 자료형 처리 가능 가능
타입 안정성 낮음 높음
형변환 필요함 거의 필요 없음
오류 발견 시점 실행 중 발생 가능 컴파일 중 발견 가능
코드 가독성 낮아질 수 있음 좋음

14. 제네릭과 박싱, 언박싱

값형을 object에 넣으면 박싱이 일어난다.

 
object value = 10;
 

여기서 10은 int 값형인데, object에 들어가면서 박싱된다.

다시 int로 꺼낼 때는 언박싱이 일어난다.

 
int number = (int)value;
 

박싱과 언박싱은 불필요한 비용을 만들 수 있다.

하지만 제네릭 컬렉션을 사용하면 이런 문제를 줄일 수 있다.

 
List<int> numbers = new List<int>();
numbers.Add(10);
 

List<int>는 int를 그대로 다루기 때문에 object처럼 매번 박싱, 언박싱할 필요가 없다.

그래서 제네릭은 성능 면에서도 유리할 수 있다.


15. Unity에서 제네릭 사용 예시

Unity에서도 제네릭은 많이 사용된다.

대표적으로 GetComponent<T>()가 있다.

 
Rigidbody rb = GetComponent<Rigidbody>();
 

여기서 T는 Rigidbody이다.

즉, 다음과 같은 의미로 볼 수 있다.

 
GetComponent<Rigidbody>()
 

Rigidbody 타입의 컴포넌트를 가져오겠다는 뜻이다.

다른 컴포넌트도 가져올 수 있다.

 
Collider col = GetComponent<Collider>();
Animator animator = GetComponent<Animator>();
 

Unity의 GetComponent<T>()는 제네릭 메서드의 대표적인 예시이다.


16. 제네릭 메서드 직접 만들기

두 값을 바꾸는 Swap 메서드를 만들어보자.

먼저 int 전용으로 만들면 다음과 같다.

 
void SwapInt(ref int a, ref int b)
{
    int temp = a;
    a = b;
    b = temp;
}
 

문자열도 바꾸고 싶다면 또 만들어야 한다.

 
void SwapString(ref string a, ref string b)
{
    string temp = a;
    a = b;
    b = temp;
}
 

제네릭을 사용하면 하나의 메서드로 만들 수 있다.

 
void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}
 

사용 예시는 다음과 같다.

 
int x = 10;
int y = 20;

Swap(ref x, ref y);
 
 
string a = "Hello";
string b = "World";

Swap(ref a, ref b);
 

자료형이 달라도 같은 메서드를 사용할 수 있다.


17. 제네릭 클래스 직접 만들기

간단한 저장소 클래스를 만들어보자.

 
class Storage<T>
{
    private T _data;

    public void Save(T data)
    {
        _data = data;
    }

    public T Load()
    {
        return _data;
    }
}
 

사용 예시는 다음과 같다.

 
Storage<int> intStorage = new Storage<int>();

intStorage.Save(100);
int value = intStorage.Load();
 

문자열도 저장할 수 있다.

 
Storage<string> stringStorage = new Storage<string>();

stringStorage.Save("Hello");
string text = stringStorage.Load();
 

하나의 클래스로 여러 자료형을 처리할 수 있다.


18. 제네릭과 var의 차이

제네릭과 var를 헷갈릴 수 있다.

var는 변수를 선언할 때 컴파일러가 자료형을 추론하게 하는 문법이다.

 
var number = 10;
 

이 코드는 컴파일러가 number를 int로 판단한다.

 
int number = 10;
 

하지만 제네릭은 클래스나 메서드에서 사용할 타입을 나중에 정하는 문법이다.

 
class Box<T>
{
    public T value;
}
 

즉, var는 변수 선언을 편하게 해주는 문법이고,
제네릭은 여러 타입을 처리할 수 있게 해주는 문법이다.


19. 제네릭과 배열, 리스트

배열은 특정 자료형만 담을 수 있다.

 
int[] numbers = new int[3];
 

numbers에는 int만 들어간다.

 
numbers[0] = 10;
 

리스트도 제네릭을 사용해서 특정 자료형만 담게 만든다.

 
List<int> numbers = new List<int>();
 

문자열 리스트는 다음과 같이 만든다.

 
List<string> names = new List<string>();
 

배열과 리스트 모두 특정 자료형을 담는다는 점은 비슷하다.
하지만 리스트는 크기를 동적으로 늘릴 수 있고, 제네릭을 통해 타입을 안전하게 관리한다.


20. 제네릭을 사용할 때 주의할 점

제네릭은 편리하지만 모든 상황에서 필요한 것은 아니다.

단순히 int만 사용할 클래스라면 굳이 제네릭으로 만들 필요가 없다.

 
class Score
{
    public int value;
}
 

하지만 여러 타입을 처리해야 한다면 제네릭이 유용하다.

 
class Storage<T>
{
    public T value;
}
 

또한 제네릭 타입 T는 아무 타입이나 들어올 수 있기 때문에, 특정 기능을 사용하고 싶다면 제약 조건을 걸어야 한다.

 
class DamageSystem<T> where T : IDamageable
{
}
 

제약 조건 없이 T에 특정 메서드가 있다고 가정하면 오류가 날 수 있다.


21. 제네릭 사용 기준

제네릭을 사용할지 고민될 때는 다음처럼 생각하면 된다.

여러 자료형에서 같은 구조가 반복되는가?

반복된다면 제네릭을 사용할 수 있다.

 
Box<int>
Box<string>
Box<float>
 

이런 구조라면 다음처럼 만들 수 있다.

 
Box<T>
 

자료형만 다르고 로직은 같은가?

자료형만 다르고 동작 방식이 같다면 제네릭이 적합하다.

 
void PrintInt(int value)
void PrintString(string value)
 

이런 코드는 하나로 줄일 수 있다.

 
void Print<T>(T value)
 

특정 기능이 필요한가?

T가 특정 메서드나 속성을 가져야 한다면 제약 조건을 사용해야 한다.

 
where T : IDamageable
 

22. 제네릭의 장점

제네릭의 장점은 다음과 같다.

  1. 코드 중복을 줄일 수 있다.
  2. 여러 자료형을 하나의 코드로 처리할 수 있다.
  3. 타입 안정성이 높아진다.
  4. 불필요한 형변환을 줄일 수 있다.
  5. 박싱과 언박싱을 줄일 수 있다.
  6. 컬렉션을 안전하게 사용할 수 있다.
  7. 재사용성이 높은 코드를 만들 수 있다.

23. 제네릭의 단점

제네릭도 단점이 있다.

첫 번째로, 처음 보면 문법이 어렵게 느껴질 수 있다.

 
Dictionary<string, List<int>>
 

이런 코드는 초반에는 복잡해 보일 수 있다.

두 번째로, 너무 과하게 사용하면 코드가 오히려 어려워질 수 있다.

 
class Manager<TData, TKey, TValue, TResult>
{
}
 

타입 매개변수가 너무 많아지면 읽기 힘들어진다.

세 번째로, T가 어떤 타입인지 알 수 없기 때문에 아무 메서드나 호출할 수 없다.

 
target.TakeDamage(10); // 제약 조건 없으면 오류
 

이럴 때는 where 제약 조건이 필요하다.


24. 제네릭 예제 전체 코드

아래는 제네릭 저장소 예제이다.

 
using System;

class Storage<T>
{
    private T _data;

    public void Save(T data)
    {
        _data = data;
    }

    public T Load()
    {
        return _data;
    }
}

class Program
{
    static void Main()
    {
        Storage<int> intStorage = new Storage<int>();
        intStorage.Save(100);

        int number = intStorage.Load();
        Console.WriteLine(number);

        Storage<string> stringStorage = new Storage<string>();
        stringStorage.Save("Hello");

        string text = stringStorage.Load();
        Console.WriteLine(text);
    }
}
 

출력 결과는 다음과 같다.

100
Hello
 

하나의 Storage<T> 클래스로 int와 string을 모두 처리할 수 있다.


25. 정리

제네릭은 자료형을 미리 고정하지 않고, 사용할 때 정할 수 있게 해주는 문법이다.

 
class Box<T>
{
    public T value;
}
 

여기서 T는 타입 매개변수이다.
보통 Type의 앞 글자로 T를 사용하지만, 이것은 문법적으로 강제된 것은 아니고 관례에 가깝다.

C++에서는 비슷한 개념을 template이라고 부른다.

 
template <typename T>
 

C#에서는 이를 제네릭이라고 부른다.

 
class Box<T>
 

제네릭을 사용하면 하나의 코드로 여러 자료형을 처리할 수 있고, 타입 안정성도 높일 수 있다.

대표적인 예시는 다음과 같다.

 
List<int>
List<string>
Dictionary<string, int>
GetComponent<Rigidbody>()
 

제네릭은 처음에는 어렵게 느껴질 수 있지만, 핵심은 간단하다.

제네릭은 자료형을 나중에 정해서 코드의 재사용성을 높이는 문법이다.