[PART12.제네릭·델리게이트·람다·LINQ(1/18)] 제네릭이란 무엇인가 — <T>의 기본 사용법
왜 <T>가 필요했는가 / 컴파일러가 T를 어떻게 채우는가 / IL과 런타임에서 실제로 일어나는 일
목차
왜 이 글을 읽어야 하는가
Unity에서 gameObject.GetComponent<Rigidbody>() 한 줄을 매일 씁니다. List<int>, Dictionary<string, GameObject>도 자연스럽게 사용합니다. 하지만 그 안의 <T>가 정확히 어떤 약속이고, 왜 이렇게 쓰면 캐스팅 오류와 GC(Garbage Collector, 사용하지 않는 객체를 자동으로 회수해 주는 런타임 구성요소) 부담이 함께 사라지는지 설명할 수 있어야 합니다.
이 글은 제네릭이 없던 시절의 코드부터 시작해서, <T>가 컴파일러와 CLR(Common Language Runtime, .NET 코드를 실행하는 가상 머신) 안쪽에서 어떻게 다뤄지는지를 IL(Intermediate Language, C# 컴파일러가 만들어 내는 중간 코드) 수준까지 따라갑니다. 다 읽고 나면 List<T>가 단순히 "타입이 정해진 리스트"가 아니라 타입별로 진짜 다른 코드가 만들어지는 구조라는 것을 보게 됩니다.
1. 문제 제기 — <T>가 없으면 무슨 일이 벌어지는가
Unity에서 적(enemy) 몬스터의 ID를 모아 두는 컬렉션이 필요하다고 합시다. C# 1.0에는 제네릭이 없었기 때문에, 모든 컬렉션은 System.Object 기반 ArrayList 같은 형태로만 존재했습니다. Object는 모든 타입의 공통 조상이니, 어떤 타입이든 받을 수 있다는 뜻입니다.
using System.Collections;
ArrayList enemyIds = new ArrayList();
enemyIds.Add(101); // int 의도
enemyIds.Add(102);
enemyIds.Add("boss-01"); // 컴파일러는 막아 주지 않음
foreach (object obj in enemyIds)
{
int id = (int)obj; // "boss-01" 차례에서 InvalidCastException
Debug.Log(id);
}
문제는 두 가지입니다.
- 타입 안전성 붕괴 — 컴파일러는 "object니까 다 받아도 된다"고 판단하므로, 잘못된 타입이 섞여도 빌드는 통과합니다. 폭발은 런타임, 그것도 게임 플레이 도중에 일어납니다.
- 박싱(Boxing) 부담 —
int같은 값 타입을object로 넣는 순간, CLR은 힙(heap)에 새 객체를 만들고 그 안에 값을 복사합니다. 이를 박싱이라 부릅니다. 꺼낼 때는 다시 캐스트하며 값을 복사하는 언박싱(Unboxing)이 발생합니다. 매 프레임 호출되는 핫패스(hot path, 매우 자주 실행돼서 성능에 큰 영향을 주는 코드 경로)에서 이 박싱이 누적되면 GC 스파이크(GC가 한꺼번에 수거하느라 프레임이 튀는 현상)로 이어집니다.
<T>는 이 두 문제를 컴파일 시점에 한꺼번에 막기 위해 C# 2.0에서 도입된 장치입니다.
2. 개념 정의 — <T>는 "타입을 나중에 채워 넣는 자리"
2.1 비유: 라벨이 붙은 보관함
체육관 보관함을 떠올려 봅시다. 보관함 자체는 운동복용·신발용으로 정해져 있지 않습니다. 사용할 때 라벨에 "운동복" 또는 "신발"이라고 적어 두면, 그 칸에는 그 종류만 들어갈 수 있습니다. 라벨이 붙은 뒤로는 다른 물건을 넣으려 해도 관리자가 입구에서 막습니다.
<T>가 바로 그 라벨입니다. 클래스나 메서드를 정의할 때는 라벨 자리만 비워 두고(T), 실제로 사용할 때 그 자리에 구체적인 타입을 적어 넣습니다(int, string, Rigidbody 등). 라벨이 정해진 뒤부터는 다른 타입은 컴파일러가 막습니다.
2.2 구조 시각화

2.3 가장 작은 제네릭 클래스: 박스 한 개
<T>— 타입 매개변수 (Type Parameter) 클래스·메서드·인터페이스·델리게이트의 정의에서 "여기에 타입이 하나 들어갈 자리"라는 뜻으로 쓰는 자리표시자다. 이름은 관례로T를 쓰지만TKey,TValue,TItem처럼 의미를 담은 이름을 써도 된다.
예시:class Box<T> { T _item; }정의 시점에는T가 무엇인지 모르고, 실제로Box<int>처럼 사용할 때 비로소T가int로 결정된다.
public class Box<T>
{
private T _item;
public void Put(T item) => _item = item;
public T Get() => _item;
}
class Program
{
static void Main()
{
Box<int> intBox = new Box<int>();
intBox.Put(123);
int n = intBox.Get(); // 캐스트 없음
Box<string> strBox = new Box<string>();
strBox.Put("hi");
string s = strBox.Get(); // 캐스트 없음
System.Console.WriteLine($"{n},{s}");
}
}
Box<int>와 Box<string>은 같은 정의 한 벌에서 만들어졌지만 서로 다른 타입입니다. intBox.Put("hi")를 시도하면 컴파일러가 막습니다. 위 [2.1 비유]에서 라벨이 다른 보관함끼리 섞을 수 없는 것과 같은 약속입니다.
이 코드의 IL을 봅시다.
.class public auto ansi beforefieldinit Box`1<T>
extends [System.Runtime]System.Object
{
.field private !T _item // 필드 타입이 "0번 타입 매개변수"
.method public hidebysig
instance void Put (!T item) cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: stfld !0 class Box`1<!T>::_item // T 그대로 저장
IL_0007: ret
}
.method public hidebysig
instance !T Get () cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld !0 class Box`1<!T>::_item // T 그대로 반환
IL_0006: ret
}
}
.method private hidebysig static void Main () cil managed
{
IL_0000: newobj instance void class Box`1<int32>::.ctor() // Box<int>로 인스턴스화
IL_0005: dup
IL_0006: ldc.i4.s 123
IL_0008: callvirt instance void class Box`1<int32>::Put(!0) // Put(int) — 박싱 없음
IL_000d: callvirt instance !0 class Box`1<int32>::Get() // Get(): int 반환
IL_0013: newobj instance void class Box`1<string>::.ctor() // Box<string>으로 인스턴스화
...
}
핵심 IL 두 가지만 봅니다.
Box1<T>** — 클래스 이름 뒤의 백틱(`)과 숫자1은 "타입 매개변수가 1개 있는 제네릭 타입"이라는 뜻입니다. 사전(Dictionary)처럼 두 개면Dictionary2<TKey,TValue>로 적힙니다. 즉 IL 수준에서도 제네릭 정보가 그대로 살아 있습니다.**Put(!0)—!0은 "이 클래스의 0번째 타입 매개변수(여기서는T)"를 가리킵니다.Box<int>로 인스턴스화하는 순간!0은int32로 굳어지고,Box<string>이면string으로 굳어집니다. 이 굳어진 결과는 IL이 아니라 다음 섹션에서 설명할 JIT(Just-In-Time, 실행 직전에 IL을 기계어로 번역하는 컴파일러) 단계에서 만들어집니다.
요약:<T>는 정의 시점에는 자리만 잡아 두는 라벨이고, 사용 시점에 비로소int·string같은 구체 타입으로 채워집니다. 이 약속 덕분에 컴파일러가 잘못된 타입을 미리 막을 수 있습니다.
3. 내부 동작 — CLR은 <T>를 어떻게 실행하는가
이제 컴파일러를 지나서 실행 시점에 어떤 일이 일어나는지를 봅니다. 결론부터 말하면, C#의 제네릭은 런타임에 진짜로 타입별 코드를 만들어 냅니다. 이를 "구체화된 제네릭(reified generics)"이라고 부릅니다.
3.1 값 타입은 인스턴스화별 코드, 참조 타입은 코드 공유

값 타입은 크기와 메모리 배치가 모두 다릅니다(int는 4바이트, Vector3는 12바이트). 따라서 JIT은 List<int>와 List<float>에 대해 각각 별도의 네이티브 코드를 생성합니다. 이렇게 하면 박싱이 필요 없고, 캐시 친화적인 빠른 코드가 만들어집니다. 대신 사용하는 값 타입 종류가 늘어날수록 생성되는 코드 양도 늘어납니다(코드 비대화, code bloat).
참조 타입은 어떤 타입이든 결국 참조(포인터) 한 개로 표현됩니다. 그래서 JIT은 List<string>, List<Player>, List<Transform> 모두 같은 네이티브 코드 한 벌을 공유시킵니다. 메서드 테이블만 따로 두고, 코드 본체는 재사용합니다.
3.2 IL이 "T로 남아 있는다"는 뜻
Box<T>의 IL에서 보았듯, 제네릭 정의는 !T라는 자리표시자를 그대로 들고 있습니다. CLR은 실행 중에 Box<int>라는 구체 타입이 처음 호출될 때, !T를 int32로 치환해 네이티브 코드를 만들고 메모리에 캐시합니다. 같은 프로그램 안에서 두 번째 호출부터는 캐시된 코드를 재사용합니다.
이것이 Java의 제네릭과 결정적으로 다른 부분입니다. 자바는 컴파일이 끝나면 <T>가 모두 Object로 지워집니다(타입 소거, type erasure). 런타임에는 ArrayList<String>도 ArrayList<Integer>도 그냥 ArrayList이며, int 같은 원시 타입은 여전히 Integer로 박싱됩니다. C#은 그 반대로, 타입 정보를 끝까지 들고 가서 박싱을 없애고 typeof(List<int>) 같은 런타임 질의도 가능하게 합니다.
3.3 같은 정의에서 두 인스턴스화: ArrayList vs List<int>의 IL 차이
같은 동작(정수 두 개 넣고 첫 번째를 꺼내기)을 ArrayList와 List<int>로 각각 작성한 뒤 IL을 비교해 보면, 위에서 말한 "박싱이 사라진다"는 주장의 증거가 그대로 보입니다. 실제 비교는 [4. 실전 적용]에서 Before/After 형태로 다룹니다.
요약: C#의<T>는 컴파일러를 지나서도 IL과 런타임에 살아남고, JIT이 값 타입에는 인스턴스화별 코드를, 참조 타입에는 공유 코드를 만듭니다. 박싱 제거와typeof(List<int>)같은 기능은 이 구조 덕분에 가능합니다.
4. 실전 적용 — Unity 신입이 매일 쓰는 패턴
4.1 Before / After: ArrayList → List<T>

Before — ArrayList:
using System.Collections;
ArrayList list = new ArrayList();
list.Add(10);
list.Add(20);
int first = (int)list[0];
System.Console.WriteLine(first);
IL_0000: newobj instance void [System.Runtime]System.Collections.ArrayList::.ctor()
IL_0006: ldc.i4.s 10
IL_0008: box [System.Runtime]System.Int32 // 10을 힙에 박싱
IL_000d: callvirt instance int32 ArrayList::Add(object)
IL_0014: ldc.i4.s 20
IL_0016: box [System.Runtime]System.Int32 // 20을 힙에 박싱
IL_001b: callvirt instance int32 ArrayList::Add(object)
IL_0021: ldc.i4.0
IL_0022: callvirt instance object ArrayList::get_Item(int32)
IL_0027: unbox.any [System.Runtime]System.Int32 // 꺼낼 때 다시 언박싱
IL_002c: call void [System.Console]System.Console::WriteLine(int32)
box 명령어가 보일 때마다 힙에 객체 한 개가 새로 만들어집니다. 이 코드를 Update() 안에서 매 프레임 돌리면, 1초에 60번 × 두 번 = 120번의 짧은 수명 객체가 생기고, 곧 GC가 와서 정리합니다. Unity의 GC는 Boehm GC(보수적 마크 앤 스윕 방식 GC, 한 번 돌 때 메인 스레드를 잠시 멈춘다)라 이 멈춤이 그대로 프레임 드랍으로 보입니다.
After — List<int>:
using System.Collections.Generic;
List<int> list = new List<int>();
list.Add(10);
list.Add(20);
int first = list[0];
System.Console.WriteLine(first);
IL_0000: newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
IL_0006: ldc.i4.s 10
IL_0008: callvirt instance void class List`1<int32>::Add(!0) // !0 == int32, box 없음
IL_000e: ldc.i4.s 20
IL_0010: callvirt instance void class List`1<int32>::Add(!0)
IL_0015: ldc.i4.0
IL_0016: callvirt instance !0 class List`1<int32>::get_Item(int32) // int32 반환
IL_001b: call void [System.Console]System.Console::WriteLine(int32)
box/unbox.any가 모두 사라졌습니다. Add 시그니처도 Add(object)가 아니라 Add(!0) — 즉 Add(int32)입니다. int가 그대로 흘러가므로 힙 할당이 0개입니다. 이 한 글자 차이(<int>)가 핫패스에서 GC 부담을 0으로 만듭니다.
4.2 제네릭 메서드와 타입 추론
<T>는 클래스뿐 아니라 메서드에도 붙일 수 있습니다. 가장 단순한 예가 두 변수 값을 바꾸는 Swap<T>입니다.
ref— 참조로 전달 (pass by reference) 메서드 호출 시 변수 자체(주소)를 전달해서, 메서드 안에서의 변경이 호출자 쪽에도 반영되게 한다. 값 복사가 아니라 같은 칸을 가리키게 만드는 방식이다.
예시:void Swap<T>(ref T a, ref T b)—a와b에 대입한 결과가 호출자의 변수에 그대로 보인다.
public static void Swap<T>(ref T a, ref T b)
{
T temp = a;
a = b;
b = temp;
}
int x = 1, y = 2;
Swap(ref x, ref y); // 컴파일러: T = int 로 추론
string s1 = "a", s2 = "b";
Swap(ref s1, ref s2); // 컴파일러: T = string 으로 추론
Swap<int>(...)라고 명시하지 않아도 됩니다. 컴파일러는 인수의 타입을 보고 <T> 자리에 무엇이 들어가야 하는지 자동으로 결정합니다. 이를 타입 추론(type inference) 이라 부릅니다. IL을 보면 두 호출이 어떻게 다른 인스턴스화로 굳어졌는지 확인할 수 있습니다.
.method public hidebysig static
void Swap<T> (!!T& a, !!T& b) cil managed // !!T = "메서드의 0번 타입 매개변수"
{
...
}
// Main에서:
IL_0008: call void Util::Swap<int32>(!!0&, !!0&) // T=int32 인스턴스화
IL_004e: call void Util::Swap<string>(!!0&, !!0&) // T=string 인스턴스화
여기서 IL의 !!T는 클래스 타입 매개변수(!T, !0)와 구분되는 메서드 타입 매개변수입니다. 느낌표 두 개가 메서드, 한 개가 클래스 — 이 차이만 기억해 두면 IL을 읽을 때 헷갈리지 않습니다. 그리고 호출 측에서는 Swap<int32>, Swap<string>이라는 두 개의 서로 다른 인스턴스화가 명시적으로 적혀 있다는 점도 확인할 수 있습니다.
4.3 Unity의 핵심 API: GetComponent<T>()
GetComponent<T>()는 사실상 모든 Unity 코드의 진입점입니다. 제네릭 덕분에 캐스트 없이, 박싱 없이, IDE 자동완성까지 살아 있는 형태로 컴포넌트를 가져올 수 있습니다.
// 과거(또는 문자열 기반): 캐스트가 필요하고, 오타는 런타임에서야 발견됨
Rigidbody rbOld = (Rigidbody)gameObject.GetComponent("Rigidbody");
// 권장: 컴파일러가 타입을 알고, 캐스트도 박싱도 없음
Rigidbody rb = gameObject.GetComponent<Rigidbody>();
내부적으로 GetComponent<T>는 참조 타입 Component 계열의 하위 타입만 받도록 제약된 제네릭 메서드입니다(다음 글에서 다룰 where T : Component). 참조 타입이므로 [3.1]에서 본 대로 JIT은 단 하나의 공유 코드만 만들고, Rigidbody / Collider / 직접 만든 EnemyController가 모두 그 한 벌의 코드를 사용합니다.
4.4 직접 정의해 보는 제네릭 풀(Pool)
Unity에서는 적·총알·이펙트 등 같은 종류의 객체를 "풀(pool)"에 미리 만들어 두고 재사용하는 패턴을 자주 씁니다. 풀 한 개에는 한 종류만 들어가니, 제네릭과 잘 맞습니다.
using System.Collections.Generic;
using UnityEngine;
public class SimplePool<T> where T : Component
{
private readonly T _prefab;
private readonly Stack<T> _stock = new Stack<T>();
public SimplePool(T prefab) => _prefab = prefab;
public T Rent()
{
if (_stock.Count > 0) return _stock.Pop();
return Object.Instantiate(_prefab);
}
public void Return(T item)
{
item.gameObject.SetActive(false);
_stock.Push(item);
}
}
// 사용
public class BulletSpawner : MonoBehaviour
{
[SerializeField] private Bullet bulletPrefab;
private SimplePool<Bullet> _pool;
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);
}
}
SimplePool<Bullet>, SimplePool<Enemy>, SimplePool<Effect>를 각각 만들 수 있고, 어느 풀에서 꺼내든 Rent()의 반환 타입이 정확히 그 타입으로 잡힙니다. Bullet 풀에 실수로 Enemy를 넣으려 하면 컴파일러가 막아 줍니다.
요약: 실전에서 제네릭은 두 곳에서 즉시 효과가 납니다. 첫째, 컬렉션·풀·캐시 같은 "한 종류 그릇"에서 캐스트와 박싱을 한꺼번에 제거합니다. 둘째, GetComponent<T> 같은 API에서 컴파일 시점 타입 보장과 IDE 지원을 받게 해 줍니다.
5. 함정과 주의사항
5.1 ❌ object 컬렉션을 그대로 두기
// ❌ Update 안에서 박싱이 매 프레임 발생
void Update()
{
ArrayList damages = new ArrayList();
foreach (var enemy in _enemies)
damages.Add(enemy.CalcDamage()); // int → object, 박싱
int total = 0;
foreach (object d in damages)
total += (int)d; // unbox
}
매 프레임 박싱 객체가 적 수만큼 만들어집니다. 이 코드는 100마리 기준 1초에 6,000번의 박싱을 일으킵니다. Unity Profiler의 GC.Alloc 항목에 그대로 잡히고, 곧 GC 스파이크가 시작됩니다.
// ✅ 제네릭 컬렉션으로 박싱 제거
void Update()
{
List<int> damages = new List<int>(); // 더 좋은 방법은 풀 / 재사용
foreach (var enemy in _enemies)
damages.Add(enemy.CalcDamage()); // int 그대로, 박싱 없음
int total = 0;
foreach (int d in damages)
total += d;
}
IL 차이는 [4.1]에서 본 그대로입니다. box 한 줄이 사라지면 힙 할당 한 번이 사라집니다.
5.2 ❌ 제네릭으로 했다고 안심하기 — 반환을 object로 받으면 도로아미타불
// ❌ List<int>로 만들었지만 object에 다시 담음 → 박싱 부활
List<int> ids = new List<int> { 1, 2, 3 };
object boxed = ids[0]; // int → object 박싱
// ✅ 타입 그대로 받기
List<int> ids = new List<int> { 1, 2, 3 };
int first = ids[0]; // 박싱 없음
List<int>라는 타입은 안에서 박싱을 막아 주지만, 꺼낸 값을 object로 다시 담는 순간 박싱이 다시 일어납니다. 박싱은 "값 타입을 참조 타입 자리에 넣을 때" 발생하는 일이지, List가 막아 주는 것이 아닙니다.
5.3 Unity 함정 — IL2CPP 빌드의 코드 비대화
[3.1]에서 본 대로 값 타입 인스턴스화는 인스턴스화별로 별도 네이티브 코드가 만들어집니다. iOS·콘솔용으로 빌드할 때 쓰이는 IL2CPP(IL을 C++로 변환해 미리 컴파일하는 Unity의 AOT(Ahead-Of-Time, 실행 전에 미리 기계어로 번역) 빌드 백엔드)는 이 인스턴스화를 모두 C++ 코드로 펼칩니다. 제네릭에 들어가는 값 타입 종류가 많을수록 빌드 결과 바이너리가 커지고 빌드 시간도 늘어납니다.
또 IL2CPP는 미리 만들어 둔 인스턴스화만 실행할 수 있어서, 런타임에 리플렉션으로 MakeGenericType 같은 식으로 새 인스턴스화를 만들려 하면 ExecutionEngineException 류의 오류를 일으킵니다. 권장 패턴은 다음과 같습니다.
// ❌ IL2CPP 빌드에서 런타임에 새 인스턴스화 시도 — AOT 컴파일러가 모르는 코드
Type listType = typeof(List<>).MakeGenericType(someValueType);
object list = Activator.CreateInstance(listType);
// ✅ 컴파일 시점에 어떤 인스턴스화를 쓸지 명시 — IL2CPP가 미리 코드를 만들어 둠
List<int> list = new List<int>();
값 타입 종류는 핵심 몇 가지(예: int, Vector3, 게임 도메인 구조체 두세 개)로 절제하고, 리플렉션 기반 동적 인스턴스화는 모바일·콘솔 빌드에서 쓰지 않습니다.
요약: 박싱은 컬렉션이 아니라 "값 타입 → 참조 타입" 변환에서 일어나므로 항상 흐름 전체를 봐야 합니다. IL2CPP 환경에서는 값 타입 인스턴스화 종류를 절제하고, 리플렉션으로 새 제네릭을 만드는 패턴을 피합니다.
6. C# 버전별 변화
| 버전 | 변화 | 의미 |
|---|---|---|
| C# 1.0 (2002) | 제네릭 없음 | ArrayList, Hashtable 등 object 기반 컬렉션만 존재. 박싱·캐스트가 일상. |
| C# 2.0 (2005) | <T> 도입 |
List<T>, Dictionary<TKey,TValue> 등 제네릭 컬렉션과 제네릭 메서드. |
| C# 4.0 (2010) | 공변성 / 반공변성 (out T / in T) |
IEnumerable<string> → IEnumerable<object> 같은 안전한 대입이 가능해짐. |
| C# 7.3 (2018) | 제네릭 제약 확장 (unmanaged, Enum, Delegate) |
시스템 프로그래밍·게임에 유용한 제약이 추가. |
| C# 11 (2022) | 정적 가상 멤버 / 제네릭 수학(INumber<T>) |
인터페이스에 static 멤버 정의 가능, 숫자 연산을 제네릭화 가능. |
이 글의 범위는 C# 2.0의 기본 사용법까지입니다. 공변성·반공변성, 제약 조건(where), 제네릭 수학 같은 주제는 별도 글에서 깊이 다룹니다.
C# 1.0 → 2.0 영향: 같은 ArrayList 코드가List<int>로 바뀌면서 (1) 캐스트가 사라지고, (2)box/unboxIL이 사라지고, (3) 컴파일러가 잘못된 타입을 미리 막아 줍니다. 이 세 가지 효과는 [4.1]의 IL 비교가 그대로 보여 줍니다.
7. 정리 — 이것만 기억하세요
<T>는 정의 시점에 타입을 비워 두고, 사용 시점에 채우는 자리표시자다.Box<T>는 정의,Box<int>는 인스턴스화.- C# 컴파일러는 메서드 호출 시 인수 타입을 보고
<T>를 자동 추론한다.Swap<int>(...)라고 안 적어도 된다. - IL에서 제네릭 정보는 사라지지 않는다. 클래스는 ``
1<T> ``, 타입 매개변수는!T(클래스)·!!T`(메서드)로 적힌다. - CLR과 JIT은 값 타입은 인스턴스화별 네이티브 코드, 참조 타입은 공유 코드를 만든다. 이것이 박싱 제거와
typeof(List<int>)를 가능하게 한다. - Java의 타입 소거와 다르다 — C#의 제네릭은 런타임까지 살아남는 구체화된 제네릭이다.
- Unity 핫패스에서
ArrayList대신List<T>를 쓰면 IL의box/unbox명령어가 사라지면서 GC 부담이 직접 줄어든다. - IL2CPP 빌드에서는 값 타입 인스턴스화 종류를 절제하고, 런타임 리플렉션으로 새 제네릭을 만드는 패턴을 피한다.
다음 글(02. 제네릭 메서드·제네릭 클래스 정의)에서는 제약 조건(where T : ...)과 여러 타입 매개변수(<TKey, TValue>)를 다룹니다.
