[PART12.제네릭·델리게이트·람다·LINQ(2/18)] 제네릭 메서드·제네릭 클래스 정의 — 직접 만들고, 호출하고, 추론시키는 법
클래스 <T> 자리 / 메서드 <T> 자리 / 명시 호출 vs 타입 추론 / IL의 !T vs !!T
목차
왜 이 글을 읽어야 하는가
직전 글([01] 제네릭이란 무엇인가)에서는 List<T>처럼 이미 만들어진 제네릭을 쓰는 입장이었습니다. 박싱(boxing, 값 타입을 힙의 객체로 감싸는 것)이 사라지는 것까지 IL로 확인했죠.
이번 글은 그 반대편입니다. 여러분이 직접 제네릭 클래스와 제네릭 메서드를 정의하고 호출하는 쪽입니다. Unity에서 풀(pool), 싱글톤(scene 안에 단 하나만 두는 매니저 패턴), 이벤트 버스, 직렬화 헬퍼처럼 "타입을 외부에서 받아들이는" 도구를 직접 만들 때 매번 마주칠 문법입니다.
이 글이 끝나면 다음 네 가지가 명확해집니다.
class Stack<T> { ... }에서<T>가 정확히 어디에 박히고, 클래스 본문 어디에서 쓸 수 있는가T Max<T>(T a, T b)같은 제네릭 메서드를 비제네릭 클래스 안에서도 만들 수 있는 이유와 그 의미Max<int>(1, 2)와Max(1, 2)의 차이 — 컴파일러가<T>자리를 어떻게 채우는가 (타입 추론)- IL 에서 클래스 타입 매개변수(
!T)와 메서드 타입 매개변수(!!T)가 다르게 적히는 이유
1. 문제 제기 — 같은 동작, 타입만 다른 클래스 두 개를 손으로 쓰는 고통
Unity에서 카드(Card) 전용 스택과 이벤트 ID(int) 전용 스택이 동시에 필요하다고 합시다. 제네릭이 없다면 이렇게 두 벌을 쓰게 됩니다.
public class CardStack
{
private readonly Card[] _items = new Card[64];
private int _top;
public void Push(Card c) { _items[_top++] = c; }
public Card Pop() { return _items[--_top]; }
}
public class IntStack
{
private readonly int[] _items = new int[64];
private int _top;
public void Push(int v) { _items[_top++] = v; }
public int Pop() { return _items[--_top]; }
}
Card 자리만 int 로 바뀌었을 뿐 코드 골격은 동일합니다. 한 곳을 고치면 다른 한 곳도 똑같이 고쳐야 하고, 이펙트·총알·UI 노티피케이션이 추가될 때마다 같은 골격이 한 벌씩 더 늘어납니다. 직전 글에서 본 ArrayList 로 통합하는 방법은 박싱과 캐스트를 부르므로 핫패스(hot path, 매 프레임 호출되는 성능 민감 코드)에서 못 씁니다.
해결책은 타입 자리를 비워 둔 한 벌의 정의 입니다.
public class Stack<T>
{
private readonly T[] _items;
private int _top;
public Stack(int capacity) { _items = new T[capacity]; }
public void Push(T item) { _items[_top++] = item; }
public T Pop() { return _items[--_top]; }
}
이 한 벌의 정의에서 Stack<Card>, Stack<int>, Stack<Bullet> 이 모두 만들어집니다. 그런데 정확히 어떤 자리에 T 를 두어야 하고, 어디는 두지 않아야 하는지 — 그리고 Max(1, 2) 처럼 <T> 를 안 적었을 때 컴파일러가 어떻게 채워 넣는지 — 이 두 가지가 본 글의 본론입니다.
2. 개념 정의 — 제네릭 클래스 정의 문법
2.1 비유: 빈칸이 있는 양식 vs 빈칸이 채워진 양식
세무서 양식을 떠올려 봅시다. 양식 자체는 "이름: ____, 금액: ____" 처럼 빈칸으로 정의됩니다. 실제 제출할 때 한 사람은 "홍길동, 1000원" 으로, 다른 사람은 "김철수, 5000원" 으로 같은 양식을 채워 냅니다.
- 양식(서식 자체) = 제네릭 정의:
class Stack<T>— 빈칸T가 있는 설계도 - 빈칸을 채운 양식 한 장 = 제네릭 인스턴스화:
Stack<int>,Stack<Card>— 실제로 쓰이는 닫힌 타입(closed type)
C#에서는 빈칸을 클래스 이름 바로 뒤 꺾쇠괄호 <...> 안에 적습니다. 클래스 본문 어디든 — 필드, 메서드 매개변수, 메서드 반환, 지역 변수, 속성, 배열 원소 — 같은 T 가 자유롭게 등장할 수 있습니다.
2.2 구조 시각화

2.3 가장 작은 제네릭 클래스: Stack<T>
<T>— 타입 매개변수 (Type Parameter) 클래스나 메서드 정의에서 "여기에 타입 한 개가 들어갈 자리" 라는 뜻으로 쓰는 자리표시자다. 클래스 이름 바로 뒤(class Stack<T>)나 메서드 이름 바로 뒤(T Max<T>(...))에 둘 수 있다.
예시:class Stack<T> { T[] _items; }정의 시점에는T가 무엇인지 모르고,Stack<int>처럼 사용할 때 비로소T가int로 결정된다.
public class Stack<T>
{
private readonly T[] _items;
private int _top;
public Stack(int capacity)
{
_items = new T[capacity];
_top = 0;
}
public void Push(T item)
{
_items[_top++] = item;
}
public T Pop()
{
return _items[--_top];
}
public T Peek() => _items[_top - 1];
}
이 정의에서 주의할 점은 생성자 이름 입니다. 클래스 헤더에는 Stack<T> 라고 적지만, 생성자는 public Stack(int capacity) 입니다. public Stack<T>(int capacity) 라고 쓰면 컴파일 에러가 납니다. 클래스 헤더에서 한 번 선언된 T 는 클래스 본문 전체에서 그대로 보이므로, 생성자가 다시 선언할 필요가 없기 때문입니다.
이 코드의 IL 핵심 부분만 봅시다 (실제 ilspycmd 디컴파일 결과).
.class public auto ansi beforefieldinit Stack`1<T>
extends [System.Runtime]System.Object
{
.field private initonly !T[] _items // 필드 타입: T 배열
.field private int32 _top
.method public hidebysig specialname rtspecialname
instance void .ctor (int32 capacity) cil managed // 생성자: 이름은 ".ctor", T를 다시 적지 않음
{
IL_0007: ldarg.1
IL_0008: newarr !T // new T[capacity] — IL은 !T로 적는다
IL_000d: stfld !0[] class Stack`1<!T>::_items
...
}
.method public hidebysig
instance void Push (!T item) cil managed // 매개변수 타입: !T
{
IL_0017: ldarg.1
IL_0018: stelem !T // 배열에 T를 그대로 저장 (박싱 없음)
IL_001d: ret
}
.method public hidebysig
instance !T Pop () cil managed // 반환 타입: !T
{
...
IL_0017: ldelem !T // 배열에서 T를 그대로 꺼냄
IL_001c: ret
}
}
여기서 IL을 처음 본다면 두 가지만 짚으면 됩니다.
- **
Stack1<T>** — 클래스 이름 뒤의 백틱(```)과 숫자1은 "타입 매개변수가 1개 있는 제네릭 타입" 이라는 표식입니다.Dictionary2<TKey,TValue>라면 매개변수가 2개라는 뜻입니다. !T/!0— "이 클래스의 0번째 타입 매개변수"를 뜻합니다.!T와!0은 같은 것을 가리킵니다 — 사람이 읽기 쉽게T라는 이름을 보존한 표기와, 위치 기반 표기의 차이일 뿐입니다.
stelem !T / ldelem !T 가 박싱 없이 T 자체를 다루는 명령어라는 점이 핵심입니다 — Stack<int> 인스턴스화에서는 stelem.i4 와 동일한 효과로 굳어집니다.
2.4 여러 타입 매개변수: <TKey, TValue> 와 명명 관례
타입 매개변수는 한 개일 필요가 없습니다. 쉼표로 여러 개를 둘 수 있습니다.
public class Pair<TFirst, TSecond>
{
public TFirst First { get; }
public TSecond Second { get; }
public Pair(TFirst first, TSecond second)
{
First = first;
Second = second;
}
}
// 사용
var nameAge = new Pair<string, int>("길동", 30);
명명 관례는 다음과 같습니다 (.NET 표준 명명 가이드).
| 상황 | 권장 이름 |
|---|---|
| 타입 매개변수가 한 개이고 의미가 자명할 때 | T |
| 여러 개거나 의미를 분명히 해야 할 때 | TKey, TValue, TItem, TInput, TOutput 등 — T 접두사 + 의미 |
T 접두사는 일반 타입(Key, Value)과 시각적으로 구분하기 위한 약속입니다. 코드 리뷰 단계에서 "이 타입은 매개변수인가, 일반 타입인가" 를 따지지 않게 해 줍니다.
요약: 제네릭 클래스는class 이름<T>로 정의하며, 본문의 필드·매개변수·반환·지역 변수 어디에든T가 등장할 수 있습니다. 생성자 이름에는<T>를 다시 적지 않습니다. 매개변수가 여러 개면<TKey, TValue>처럼 쉼표로 구분하고, 이름은T접두사를 붙이는 것이 관례입니다.
3. 내부 동작 — 제네릭 메서드와 메서드 타입 매개변수
이제 시선을 메서드로 옮깁니다. 제네릭 메서드는 클래스와 별개로 메서드 자체에 빈칸을 두는 방식입니다.
3.1 비제네릭 클래스 안의 제네릭 메서드
가장 많이 헷갈리는 지점이 여기입니다. 클래스가 제네릭이 아니어도, 그 안의 메서드는 제네릭일 수 있습니다.
public static class Util // 클래스는 비제네릭
{
public static T Max<T>(T a, T b) // 메서드만 제네릭
where T : System.IComparable<T>
{
return a.CompareTo(b) > 0 ? a : b;
}
}
Util 자체에는 <T> 가 없습니다. Max 만 제네릭입니다. 호출 측에서 Util.Max(3, 7) 을 부를 때마다 T 가 새로 결정됩니다 — 같은 Util 클래스를 여러 호출이 공유하면서 T 만 호출별로 달라지는 구조입니다.
where T : ...— 제네릭 제약 조건 (Generic Constraint) 타입 매개변수T가 만족해야 할 조건을 컴파일러에 알려 주는 절이다. 위 예시의where T : IComparable<T>는 "T 는 자신과 비교 가능해야 한다" 는 뜻으로, 본문에서a.CompareTo(b)를 쓰려면 컴파일러가 그 메서드가 존재함을 보장받아야 한다.
예시:where T : new()—new T()를 호출할 수 있도록 기본 생성자를 가진 타입만 허용 제약 조건의 종류와 동작 메커니즘은 다음 글([03] 제네릭 제약 조건)에서 본격적으로 다룬다.
3.2 클래스 매개변수 vs 메서드 매개변수 — IL 표기 차이
메서드가 제네릭 클래스 안에 있다고 해서 클래스의 T 를 쓰는 것은 제네릭 메서드가 아닙니다. 반대로 메서드가 자기 이름 뒤에 새로운 <U> 를 도입해야만 제네릭 메서드입니다. 이 차이는 IL 에 그대로 박힙니다.

이제 같은 프로그램의 IL 을 봅니다 (위 [2.3]의 Stack<T> 와 [3.1]의 Util.Max<T> 를 함께 사용한 결과).
// 제네릭 클래스: 매개변수에 !T (느낌표 1개)
.method public hidebysig
instance void Push (!T item) cil managed { ... }
// 제네릭 메서드: 매개변수에 !!T (느낌표 2개)
.method public hidebysig static
!!T Max<(class [System.Runtime]System.IComparable`1<!!T>) T> (!!T a, !!T b)
cil managed
{
IL_0000: ldarga.s a
IL_0002: ldarg.1
IL_0003: constrained. !!T // 값 타입이라도 박싱 없이 인터페이스 호출
IL_0009: callvirt instance int32 class [System.Runtime]System.IComparable`1<!!T>::CompareTo(!0)
IL_000e: ldc.i4.0
IL_000f: bgt.s IL_0013
IL_0011: ldarg.1
IL_0012: ret
IL_0013: ldarg.0
IL_0014: ret
}
세 가지가 핵심입니다.
!!T— 메서드 타입 매개변수. 클래스 측!T와 구분됩니다. IL 명령어call !!0 Util::Max<int32>(!!0, !!0)같은 것을 보게 되면 "메서드의 0번 매개변수" 라고 읽으면 됩니다.- **
Max<(... IComparable1<!!T>) T>** — 메서드 시그니처에 제약 조건이 함께 박힙니다.where T : IComparable<T>` 는 IL 메타데이터로 그대로 살아남습니다. constrained. !!T— 제약 호출 접두 명령어. 값 타입이 인터페이스 메서드를 호출할 때 박싱 없이 직접 호출하기 위한 명령어로, 제네릭 + 인터페이스 제약의 조합에서 자주 등장합니다.
3.3 호출부 IL — 인스턴스화 정보가 호출 명령어에 박힌다
호출 측 코드는 어떻게 보일까요? 다음 C# 을 봅시다.
int top = Util.Max(3, 7); // 추론: T = int
string later = Util.Max<string>("a", "b"); // 명시: T = string
이 두 호출의 IL 입니다.
// Max(3, 7) — 추론으로 T=int 결정
IL_002c: ldc.i4.3
IL_002d: ldc.i4.7
IL_002e: call !!0 Util::Max<int32>(!!0, !!0) // 호출 명령어에 <int32> 가 박힘
// Max<string>("a", "b") — 명시
IL_0034: ldstr "a"
IL_0039: ldstr "b"
IL_003e: call !!0 Util::Max<string>(!!0, !!0) // 호출 명령어에 <string> 이 박힘
핵심: 타입 추론이든 명시든, IL 호출 명령어 자체에는 항상 인스턴스화 정보(<int32>, <string>)가 박혀 있습니다. 컴파일러가 호출 시점에 무엇으로 채워야 할지를 결정해서 IL 에 적어 둘 뿐, 런타임이 추가로 추론하는 것이 아닙니다. C# 의 타입 추론은 컴파일 타임 기능이지 런타임 기능이 아닙니다.
3.4 같은 클래스, 다른 인스턴스화: Stack<int> 와 Stack<string>
같은 Stack<T> 정의를 int 와 string 으로 동시에 사용하면 호출부 IL 이 어떻게 보일까요?
Stack<int> intStack = new Stack<int>(8);
intStack.Push(10);
intStack.Push(20);
int popped = intStack.Pop();
Stack<string> strStack = new Stack<string>(4);
strStack.Push("hello");
IL_0000: ldc.i4.8
IL_0001: newobj instance void class Stack`1<int32>::.ctor(int32) // Stack<int>로 인스턴스화
IL_0006: dup
IL_0007: ldc.i4.s 10
IL_0009: callvirt instance void class Stack`1<int32>::Push(!0) // Push(int) — !0 == int32
IL_000e: dup
IL_000f: ldc.i4.s 20
IL_0011: callvirt instance void class Stack`1<int32>::Push(!0)
IL_0016: callvirt instance !0 class Stack`1<int32>::Pop() // Pop(): int32 반환
IL_001c: ldc.i4.4
IL_001d: newobj instance void class Stack`1<string>::.ctor(int32) // Stack<string>으로 인스턴스화
IL_0022: ldstr "hello"
IL_0027: callvirt instance void class Stack`1<string>::Push(!0) // !0 == string
Stack1<int32> 와 Stack1<string> 은 IL 수준에서 서로 다른 닫힌 타입으로 취급됩니다. JIT(Just-In-Time, IL 을 실행 직전에 기계어로 번역하는 컴파일러) 단계에서는 직전 글 [3.1]에서 다룬 대로 값 타입(int)에는 전용 네이티브 코드를, 참조 타입(string)에는 공유 네이티브 코드를 생성합니다.
요약: 제네릭 메서드는 메서드 이름 뒤에 자체<T>를 두는 메서드이며, 비제네릭 클래스 안에서도 자유롭게 정의할 수 있습니다. IL 에서 클래스 매개변수는!T(느낌표 1개), 메서드 매개변수는!!T(느낌표 2개)로 구분됩니다. 호출 명령어에는<int32>,<string>같은 인스턴스화 정보가 컴파일러에 의해 직접 박힙니다.
4. 실전 적용 — 호출 측 타입 인수: 명시 vs 추론
이제 호출 측의 가장 중요한 결정으로 넘어갑니다. <T> 를 명시적으로 적을 것인가, 컴파일러에게 추론을 맡길 것인가.
4.1 추론 가능한 호출 — Before/After

Before — 불필요한 명시:
int bigger = Util.Max<int>(3, 7); // <int> 불필요
string later = Util.Max<string>("a", "b"); // <string> 불필요 (예시는 명시 호출 분기 비교용)
After — 추론으로 대체:
int bigger = Util.Max(3, 7); // 컴파일러: 인수가 int → T = int
// string later = Util.Max("a", "b"); // 컴파일러: 인수가 string → T = string
이 둘은 컴파일된 IL이 동일합니다. 직접 확인해 보면 둘 다 call !!0 Util::Max<int32>(!!0, !!0) 으로 같은 명령어가 나옵니다. 즉 추론은 호출 코드의 가독성을 위한 컴파일 타임 편의 기능이지 성능 차이는 없습니다.
4.2 추론이 작동하는 원리 — "인수에서 거꾸로 풀기"
컴파일러의 타입 추론은 다음 두 단계로 단순화할 수 있습니다.
- 수집(gather) — 메서드 시그니처에서
T가 등장하는 모든 매개변수 자리를 찾습니다. 호출 인수의 정적 타입에서 그 자리에 채울 후보 타입을 모읍니다.Max<T>(T a, T b)에Max(3, 7)을 호출하면T = int,T = int두 후보가 모입니다. - 고정(fix) — 모든 후보가 일관된 단일 타입이면 그 타입으로
T를 고정합니다. 후보가 모순(예:int와double)이면 컴파일 오류입니다.
핵심 원칙: 컴파일러는 "메서드 인수"에서 정보를 얻습니다. 반환 타입이나 변수 선언 타입은 추론에 직접 사용되지 않습니다. 이것이 다음 [4.3]의 실패 케이스를 만듭니다.
4.3 추론이 실패하는 대표 케이스
(1) 반환 타입에만 T 가 사용될 때
public static class Factory
{
public static T Create<T>() where T : new() => new T();
}
// 호출
List<int> a = Factory.Create(); // ❌ CS0411: 타입 추론 실패
List<int> b = Factory.Create<List<int>>(); // ✅ 명시 필수
매개변수가 없으니 컴파일러가 인수에서 모을 정보가 없습니다. 호출자는 좌변 변수의 타입을 보면 T 를 알 수 있다고 생각하지만, C# 의 추론 알고리즘은 좌변을 보지 않습니다. 좌변까지 보려면 다른 언어의 *return type inference* 가 필요한데, C# 의 메서드 호출은 그렇게 동작하지 않습니다.
이 코드의 IL 호출부:
IL_0000: call !!0 Factory::Create<class [System.Collections]System.Collections.Generic.List`1<int32>>()
호출 명령어에 <List1<int32>>` 가 박혀 있는 것이 보입니다. 컴파일러가 호출 시점에 명시 정보를 받아 IL 에 적어 두었기 때문입니다.
(2) 인수가 모순될 때
double d = Util.Max(1, 2.5); // ❌ CS0411: int와 double로 충돌
double d2 = Util.Max(1.0, 2.5); // ✅ 둘 다 double — T = double
double d3 = Util.Max<double>(1, 2.5); // ✅ 명시: int → double 암시 변환 적용
첫 번째 호출은 T = int 와 T = double 두 후보가 충돌해서 실패합니다. 명시적으로 <double> 을 적어 주면 두 인수 모두 double 로 변환된 뒤 호출됩니다.
(3) 람다 인수만 있을 때
public static T First<T>(System.Func<T> factory) => factory();
var n = First(() => 42); // ✅ 람다 본문이 int를 반환 → T = int
// var x = First(() => default); // ❌ default 만으로는 T 결정 불가
람다의 매개변수·반환에서 추론할 정보가 충분하면 성공하지만, default 처럼 컨텍스트가 필요한 표현식은 추론을 막을 수 있습니다.
4.4 명시가 필요한 경우의 가이드라인
- 반환 타입에만
T가 등장 → 명시 필수 - 인수가 서로 다른 타입(예:
int와long)이라 모순 → 명시 또는 인수 형 변환 - 가독성을 위해 의도를 명확히 보여 주고 싶을 때 — 예: 코드 리뷰에서 "이건 정확히
Max<long>입니다" 를 강조 → 명시 가능 - 그 외 경우는 추론에 맡기는 것이 관례입니다.
List<int> list = new List<int>();같은 코드에서 우변의<int>는 추론 대상이 아닌 별개 문법이지만, 메서드 호출에서는 추론이 가능하면 적지 않는 것이 일반적입니다.
4.5 Unity 실전: GetComponent<T>(), Instantiate<T>()
Unity API 의 핵심 진입점 두 가지입니다.
public class PlayerController : MonoBehaviour
{
private Rigidbody _rb;
private AudioSource _sfx;
[SerializeField] private Bullet _bulletPrefab;
private void Awake()
{
_rb = GetComponent<Rigidbody>(); // T = Rigidbody (반환 타입)
_sfx = GetComponent<AudioSource>(); // T = AudioSource
}
private void Fire()
{
Bullet b = Instantiate(_bulletPrefab); // T = Bullet 추론 (인수 타입에서)
b.Launch(transform.forward);
}
}
GetComponent<T>() 는 [4.3 (1)] 의 패턴 — 매개변수가 없고 반환에만 T 가 있는 케이스 — 라서 호출자가 <Rigidbody> 를 명시해야 합니다. 반대로 Instantiate(_bulletPrefab) 는 Bullet 인수에서 T 가 추론되므로 <Bullet> 을 적지 않아도 됩니다. 한 줄 안에서 두 패턴이 공존하는 것이 Unity 코드의 흔한 풍경입니다.
4.6 직접 정의해 보는 제네릭 풀(SimplePool)
직전 글 [4.4]에서 본 SimplePool<T> 를 본 글의 시점에서 다시 봅니다 — 정의 문법에 집중해서.
using System.Collections.Generic;
using UnityEngine;
public class SimplePool<T> where T : Component // 클래스 매개변수 T
{
private readonly T _prefab; // 필드에 T
private readonly Stack<T> _stock = new Stack<T>();// 다른 제네릭에 T를 다시 전달
public SimplePool(T prefab) => _prefab = prefab; // 생성자 매개변수에 T
public T Rent() // 반환 타입 T
{
if (_stock.Count > 0) return _stock.Pop();
return Object.Instantiate(_prefab); // Instantiate<T>의 T 추론
}
public void Return(T item) // 매개변수 T
{
item.gameObject.SetActive(false);
_stock.Push(item);
}
}
public class BulletSpawner : MonoBehaviour
{
[SerializeField] private Bullet bulletPrefab;
private SimplePool<Bullet> _pool; // 인스턴스화: T = Bullet
private void Awake() => _pool = new SimplePool<Bullet>(bulletPrefab);
private void Fire()
{
Bullet b = _pool.Rent(); // 반환 타입이 정확히 Bullet
b.transform.position = transform.position;
b.gameObject.SetActive(true);
}
}
이 한 클래스 안에서 T 가 여섯 자리에 등장합니다 — 헤더, 필드 두 개, 생성자, 두 메서드, 그리고 다른 제네릭 Stack<T> 에 전달되는 자리까지. 모두 같은 T 를 가리키고, SimplePool<Bullet> 으로 인스턴스화되는 순간 모든 자리가 Bullet 으로 통일됩니다.
요약: 호출 측에서는 인수에서 추론 가능하면<T>를 적지 않는 것이 관례이며, IL 결과는 동일합니다. 추론은 인수 타입에서만 정보를 얻으므로, 반환 타입에만T가 있거나 인수가 모순되면 명시가 필요합니다. Unity의GetComponent<T>()가 명시 호출을 강제하는 것도 같은 이유입니다.
5. 함정과 주의사항
5.1 ❌ 제네릭 클래스의 static 필드는 인스턴스화별로 별개
가장 자주 마주치는 함정입니다. 제네릭 클래스 안의 static 필드는 "모든 인스턴스화가 공유하는 단일 변수" 가 아니라, 인스턴스화 한 벌마다 별도의 변수입니다.
// ❌ 의도: 전체 카운터. 실제: T별로 별도의 카운터
public class Counter<T>
{
public static int Count = 0;
public Counter() { Count++; }
}
var c1 = new Counter<int>(); // Counter<int>.Count = 1
var c2 = new Counter<int>(); // Counter<int>.Count = 2
var c3 = new Counter<string>(); // Counter<string>.Count = 1 (별도 변수)
System.Console.WriteLine(Counter<int>.Count); // 출력: 2
System.Console.WriteLine(Counter<string>.Count); // 출력: 1
IL 에서 그 증거를 직접 봅니다:
.class public auto ansi beforefieldinit Counter`1<T>
{
.field public static int32 Count // 필드는 한 줄로 정의되지만…
.method instance void .ctor() cil managed
{
IL_0006: ldsfld int32 class Counter`1<!T>::Count // !T가 어떤 타입이냐에 따라
IL_000b: ldc.i4.1
IL_000c: add
IL_000d: stsfld int32 class Counter`1<!T>::Count // 다른 정적 슬롯이 잡힌다
IL_0012: ret
}
}
// Main에서:
IL_0029: ldsfld int32 class Counter`1<int32>::Count // int32 인스턴스화의 정적 슬롯
IL_0041: ldsfld int32 class Counter`1<string>::Count // string 인스턴스화의 정적 슬롯
ldsfld 명령어가 Counter1<int32>::Count 와 Counter1<string>::Count 라는 서로 다른 정규 이름을 참조하는 것이 결정적 증거입니다. 런타임은 이 둘을 완전히 다른 정적 슬롯으로 관리합니다.
// ✅ "모든 T 공유 카운터" 가 의도라면, 비제네릭 클래스에 두기
public static class GlobalCounter
{
public static int Count;
}
public class Counter<T>
{
public Counter() { GlobalCounter.Count++; }
}
Unity 환경에서 이 함정은 싱글톤 풀 코드에서 자주 터집니다 — Pool<Bullet>.AllInstances 에 풀을 모아 두려 했는데, Pool<Enemy>.AllInstances 와 분리되어 의도와 다른 결과가 나옵니다.
5.2 ❌ 매개변수에 안 쓰고 반환에만 T 를 두기
[4.3 (1)] 에서 본 케이스를 함정 시점에서 다시 봅니다.
// ❌ 호출자가 매번 명시해야 함 → API 사용성 저하
public static T MakeDefault<T>() where T : new() => new T();
// 호출
var list = MakeDefault<List<int>>(); // 매번 <List<int>> 입력
이런 시그니처는 호출자가 매번 <...> 를 적게 만듭니다. 진짜 다형 동작이 필요한 경우(다양한 타입의 인스턴스를 한 메서드로 만들고 싶을 때)에는 어쩔 수 없지만, 종종 제네릭 자체가 과한 설계일 때도 있습니다.
// ✅ 차라리 호출자가 직접 new를 부르거나, 인터페이스 다형성 사용
List<int> list = new List<int>(); // 그냥 직접 생성
IShape shape = ShapeFactory.Make("circle"); // 인터페이스 반환으로 다형성
가이드라인: T 가 메서드 시그니처(인수 + 반환)에서 단 한 번만 등장한다면, 제네릭이 정말 필요한지 다시 생각해 봅니다. 두 번 이상 등장해서 "같은 T" 라는 약속이 의미를 가질 때 제네릭이 빛납니다.
5.3 ❌ 클래스 매개변수와 메서드 매개변수에 같은 이름 쓰기
// ❌ 컴파일러 경고 CS0693: 클래스의 T를 메서드 T가 가린다(shadowing)
public class Container<T>
{
public void Add<T>(T item) { } // 이 T는 클래스의 T가 아닌 새 타입
}
var c = new Container<int>();
c.Add("hi"); // 호출 가능 — 메서드의 T가 string 으로 추론됨
클래스의 T 와 메서드의 T 는 같은 이름이지만 서로 다른 매개변수입니다. 이 코드는 컴파일은 되지만 거의 항상 의도와 다릅니다. 메서드가 클래스의 타입을 사용해야 한다면 메서드에서 <T> 를 다시 선언하지 않고 그대로 사용하면 됩니다.
// ✅ 클래스의 T를 그대로 사용 — 메서드에 <T>를 두지 않는다
public class Container<T>
{
public void Add(T item) { } // 이 T는 클래스의 T (메서드 매개변수가 아니라 타입 매개변수 사용)
}
5.4 Unity 함정 — IL2CPP 환경의 MakeGenericMethod/MakeGenericType
직전 글 [5.3] 에서 다룬 IL2CPP(IL을 C++로 변환해 미리 컴파일하는 Unity의 AOT(Ahead-Of-Time, 실행 전에 미리 기계어로 번역) 백엔드) 의 코드 비대화는 본 글의 "정의" 측면에서도 영향을 줍니다. 정의된 제네릭 메서드를 런타임 리플렉션으로 새로운 인스턴스화 하려는 시도는 모바일 빌드에서 실패합니다.
// ❌ IL2CPP에서 ExecutionEngineException — AOT 컴파일러가 모르는 인스턴스화
var method = typeof(Util).GetMethod("Max").MakeGenericMethod(typeof(MyStruct));
method.Invoke(null, new object[] { a, b });
// ✅ 컴파일 시점에 명시 — IL2CPP가 미리 코드를 생성
var bigger = Util.Max(a, b);
직접 정의한 제네릭 메서드를 게임 도메인에서 다양한 타입에 적용해야 할 때는 호출 시점에 인스턴스화를 컴파일러가 알 수 있도록 코드를 작성하는 것이 원칙입니다. 리플렉션 기반 동적 디스패치는 PC/서버 빌드에는 가능하지만 모바일·콘솔에서는 불안정합니다.
요약: 제네릭 클래스의 정적 필드는 인스턴스화별로 별개라는 것이 가장 자주 터지는 함정입니다. 반환에만 T 를 두면 호출자에게 부담이 가고, 클래스/메서드의 타입 매개변수 이름이 겹치면 가려집니다(shadowing). IL2CPP에서는 런타임 동적 인스턴스화가 실패하므로, 컴파일 시점에 인스턴스화가 보이도록 코드를 작성합니다.
6. C# 버전별 변화
| 버전 | 변화 | 의미 |
|---|---|---|
| C# 1.0 (2002) | 제네릭 정의 자체가 불가 | "타입 자리"를 비워 두는 클래스/메서드를 사용자 코드에서 만들 방법이 없음 |
| C# 2.0 (2005) | 제네릭 클래스·제네릭 메서드 정의 + 타입 추론 도입 | 본 글의 모든 문법이 이 시점에 한꺼번에 추가 |
| C# 3.0 (2007) | 메서드 그룹의 타입 추론 강화 + 람다 인수에서의 추론 | Where(x => x.Age > 18) 같은 LINQ 호출에서 <T> 가 자연스럽게 생략 가능 |
| C# 7.3 (2018) | 제네릭 메서드의 타입 추론에 Method group 변환 개선 |
list.Select(int.Parse) 같은 메서드 그룹 전달이 더 잘 추론됨 |
| C# 11 (2022) | 제네릭 특성(generic attributes), 정적 가상 멤버 | 정의 문법 자체가 확장 — 인터페이스 static abstract 멤버를 통해 "타입에 대한 연산"을 제네릭화 가능 |
이 글의 범위는 C# 2.0 의 정의 문법과 호출 패턴까지입니다. C# 11의 제네릭 특성은 [17] 글에서, 정적 가상 멤버는 별도 글에서 다룹니다.
C# 1.0 → 2.0 영향: 본 글의 모든 문법(class Stack<T>,T Max<T>(...), 호출 시<int>명시 또는 추론)은 C# 2.0 에서 한꺼번에 도입되었습니다. C# 3.0 이후의 LINQ·람다 친화성은 사실상 이 정의·호출 문법을 사용성 측면에서 다듬은 결과입니다.
7. 정리 — 이것만 기억하세요
- 클래스 타입 매개변수 vs 메서드 타입 매개변수 — 클래스 측은 IL 에서
!T(느낌표 1개), 메서드 측은!!T(느낌표 2개). 비제네릭 클래스 안에도 제네릭 메서드를 둘 수 있다. T가 등장할 수 있는 자리 — 필드, 메서드 매개변수·반환, 생성자 매개변수, 속성, 지역 변수, 다른 제네릭에 전달되는 자리. 단, 생성자 이름에는<T>를 다시 적지 않는다.- 호출 측
<T>명시 vs 추론 — 인수에서 추론 가능하면 적지 않는 것이 관례이며, IL 은 동일하다. 반환에만T가 있거나 인수가 모순되면 명시 필수다. - 추론 알고리즘은 매개변수에서만 정보를 얻는다 — 좌변 변수 타입은 보지 않는다. C# 의 추론은 컴파일 타임 기능이지 런타임 기능이 아니다.
static필드는 인스턴스화별로 별개 —Counter<int>.Count와Counter<string>.Count는 IL 에서 서로 다른 정적 슬롯(ldsfld)으로 잡힌다. 전역 카운터를 의도했다면 비제네릭 클래스로 옮긴다.- 명명 관례 — 한 개면
T, 여럿이면TKey,TValue처럼T접두사 + 의미. 코드 리뷰에서 일반 타입과의 시각적 구분을 도와준다. - IL2CPP 환경 — 런타임
MakeGenericMethod/MakeGenericType으로 새 인스턴스화를 만들지 말고, 컴파일 시점에 인스턴스화가 보이도록 작성한다.
다음 글([03] 제네릭 제약 조건)에서는 본 글에서 살짝 등장한 where T : IComparable<T> 같은 제약 조건의 종류와 동작을 본격적으로 다룹니다.
