[PART12.제네릭·델리게이트·람다·LINQ(3/18)] 제네릭 제약 조건(where) — 타입 매개변수가 만족해야 하는 조건
제약 없는 T는 왜 object처럼 취급되는가 / 각 제약이 풀어주는 능력 / IL 메타데이터에 어떻게 박히는가 / Unity 핫패스에서의 활용
목차
1. 문제 제기 — 제약 없는 제네릭은 왜 무력한가
Unity에서 다음과 같은 오브젝트 풀(Object Pool, 객체를 재사용해 GC 부담을 줄이는 패턴)을 만들고 싶습니다.
public class Pool<T>
{
public T Spawn(Vector3 pos)
{
T instance = new T(); // ❌ 컴파일 에러
instance.transform.position = pos; // ❌ 컴파일 에러
return instance;
}
}
두 줄 모두 컴파일되지 않습니다. 이유는 단순합니다. 컴파일러는 T가 어떤 타입이 될지 모르기 때문에 T를 System.Object로 가정하고, Object에 정의된 메서드(ToString, Equals, GetHashCode) 외에는 아무것도 호출할 수 없도록 막아 버립니다. new T()는 T에 매개변수 없는 생성자가 있다는 보장이 없어 막히고, transform은 Object의 멤버가 아니라 Component의 멤버이므로 막힙니다.
이 한계는 T 자리에 들어올 수 있는 타입의 범위가 무한이기 때문에 발생합니다. int도, string도, 포인터를 포함한 구조체도, 추상 클래스도 들어올 수 있습니다. 컴파일러는 이 무한한 가능성 중 가장 안전한(=가장 보수적인) 선택지를 강제합니다.
where T : Component, new() 같은 한 줄을 추가하면 컴파일러는 즉시 태도를 바꿉니다. "T는 Component를 상속하고 매개변수 없는 생성자를 가진다"는 약속을 받았기 때문에 transform 접근과 new T() 호출을 허용합니다. 이 "약속과 허용"의 메커니즘이 바로 where 제약 조건입니다.
2. 개념 정의 — where는 컴파일러와 맺는 계약
비유: 출입증과 출입 가능 구역
회사 건물을 떠올려 봅니다. 외부인(제약 없는 T)은 로비까지만 들어갈 수 있습니다. 임원실에 가려면 임원증이, 서버실에 가려면 인프라팀 출입증이 필요합니다. where T : Component는 "이 메서드에 들어오는 T는 모두 Component 출입증을 가진 사람"이라는 보증입니다. 그 보증이 있기에 컴파일러는 Component 구역(=Component의 멤버) 출입을 허용합니다.

코드로 보는 가장 단순한 형태
where T : 조건— 제네릭 제약 조건 (Generic constraint) 타입 매개변수T가 만족해야 하는 조건을 명시한다. 컴파일러는 이 조건을 보고T로 어떤 작업이 가능한지를 판단한다.
예시:public T Spawn<T>() where T : new() => new T();T가 매개변수 없는 생성자를 가진다고 약속했으니new T()가 허용된다.
// 제약 없음: T는 object로 가정 → 거의 아무것도 못함
public T Echo<T>(T x) => x;
// where T : new() 추가: new T() 가능
public T Create<T>() where T : new() => new T();
// where T : IComparable<T> 추가: CompareTo 호출 가능
public T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b;
IL — 제약은 메서드 시그니처 자체에 박힌다
위의 Echo와 Create를 컴파일하면 IL에서 제약이 시그니처 일부로 명시됩니다.
// 제약 없음
.method public hidebysig
instance !!T Echo<T> (!!T x) cil managed
// where T : new()
.method public hidebysig
instance !!T Create<.ctor T> () cil managed
{
IL_0000: call !!0 [System.Runtime]System.Activator::CreateInstance<!!T>()
IL_0005: ret
}
<.ctor T>—new()제약은 IL 시그니처에서.ctor플래그로 표시된다. 메타데이터 테이블에DefaultConstructorConstraint가 기록된다.new T()는 단순한newobj명령어가 아니라System.Activator::CreateInstance<T>()호출로 컴파일된다. 이 점은 [4장 실전 적용]과 [5장 함정]에서 다시 등장한다.
3. 내부 동작 — IL 메타데이터에 박히는 8가지 제약
제약은 메서드 본문이 아니라 시그니처에 들어간다
C# 컴파일러는 where 절을 메서드 본문(body) 코드로 바꾸지 않습니다. 타입 매개변수 T의 메타데이터에 플래그·속성을 부착하고, 시그니처 토큰(<T> 부분)에 약속을 인코딩합니다. JIT 컴파일러와 다른 어셈블리의 호출자(caller)는 이 플래그를 읽고 타입 인자가 제약을 만족하는지 검증합니다.

8가지 제약과 IL 표현 — 종합 표
각 제약을 하나의 메서드로 작성하고 컴파일한 결과입니다. 컴파일러는 다음과 같이 표현합니다.
| C# 제약 | IL 시그니처 표현 | 메타데이터 |
|---|---|---|
| 없음 | <T> |
(없음) |
where T : class |
<class T> |
ReferenceTypeConstraint |
where T : struct |
<valuetype .ctor ([System.Runtime]System.ValueType) T> |
NotNullableValueTypeConstraint + DefaultConstructor |
where T : new() |
<.ctor T> |
DefaultConstructorConstraint |
where T : 인터페이스 |
<(IDamageable) T> |
BaseTypeConstraint(인터페이스 토큰) |
where T : 기본클래스 |
<(MyBase) T> |
BaseTypeConstraint(클래스 토큰) |
where T : notnull |
<T> + NullableAttribute(1) |
NullableAttribute 메타데이터만 |
where T : unmanaged |
<valuetype .ctor (...modreq(UnmanagedType)) T> + IsUnmanagedAttribute |
modreq + 어트리뷰트 |
where T : allows ref struct |
<byreflike T> |
ByRefLike 플래그 (C# 13) |
조합 class, IDamageable, new() |
<class .ctor (IDamageable) T> |
위 항목들의 합 |
직접 IL을 확인한다
핵심 차이를 IL 시그니처 한 줄씩으로 비교해 봅시다. 모두 Demo 클래스에 같이 작성한 뒤 ilspycmd -il 로 디컴파일한 실제 결과입니다.
public bool IsNull<T>(T x) where T : class => x == null;
public T? AsNullable<T>(T x) where T : struct => x;
public T Create<T>() where T : new() => new T();
public void Hit<T>(T target) where T : IDamageable => target.TakeDamage(10);
public unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);
public T Spawn<T>() where T : class, IDamageable, new() => new T();
// where T : class
.method instance bool IsNull<class T> (!!T x) cil managed
// where T : struct → valuetype 키워드가 박힘
.method instance valuetype System.Nullable`1<!!T>
AsNullable<valuetype .ctor (System.ValueType) T> (!!T x) cil managed
// where T : new() → .ctor 플래그
.method instance !!T Create<.ctor T> () cil managed
{
IL_0000: call !!0 System.Activator::CreateInstance<!!T>() // ← Activator!
}
// where T : IDamageable → 괄호 안에 인터페이스 토큰
.method instance void Hit<(IDamageable) T> (!!T target) cil managed
// where T : unmanaged → modreq(UnmanagedType) 추가
.method instance int32 SizeOf<valuetype .ctor (System.ValueType
modreq(System.Runtime.InteropServices.UnmanagedType)) T> () cil managed
// 조합: class + 인터페이스 + new()
.method instance !!T Spawn<class .ctor (IDamageable) T> () cil managed
해설:
class T한 단어로 참조 타입 제약,valuetype .ctor (System.ValueType) T묶음으로 값 타입 제약을 표현한다. 즉struct제약은 IL 레벨에서 "값 타입이고, 매개변수 없는 생성자가 있고, ValueType을 상속한다"는 세 가지가 합쳐진 형태다.new()제약은.ctor플래그로 따로 명시되며, 본문 호출이Activator.CreateInstance<T>()로 변환된다. 단순newobj가 아니다 — 이유는 컴파일 시점에T의 실제 타입이 정해지지 않아newobj명령어의 타입 토큰을 결정할 수 없기 때문이다.unmanaged제약은 메타데이터를 이해하지 못하는 옛 컴파일러가 잘못 호출하는 것을 막기 위해modreq(required modifier)로 표시된다.modreq는 "이 표시를 모르는 도구는 이 메서드를 호출하면 안 된다"는 강제 표식이다.notnull은 IL 시그니처에 별도 키워드가 추가되지 않고NullableAttribute(1)메타데이터만 붙는다. 즉 NRT(Nullable Reference Types, C# 8에 도입된 null 허용 여부를 타입에 표시하는 기능)와 동일한 인프라를 재사용한다.
JIT 코드 공유 모델과 제약의 관계
CLR(Common Language Runtime, .NET의 실행 엔진)의 JIT(Just-In-Time, 실행 직전에 IL을 기계어로 변환하는 컴파일러)는 제네릭 인스턴스화마다 코드를 어떻게 만들지 다음 규칙을 따릅니다.
- 참조 타입(
Pool<Player>,Pool<string>등): 한 벌의 네이티브 코드를 모든 참조 타입이 공유한다. 어떤 참조 타입이든 메모리 상에서는 포인터 한 칸이라 동일하게 다룰 수 있기 때문이다. - 값 타입(
Pool<int>,Pool<MyStruct>등): 값 타입마다 별도 네이티브 코드를 생성한다.int(4바이트)와MyStruct(예: 12바이트)는 크기가 다르므로 같은 코드를 공유할 수 없다.
where T : struct 제약이 있으면 JIT는 "이 제네릭은 항상 값 타입 인스턴스화"임을 보장받아 박싱이 절대 발생하지 않는 코드를 만들 수 있습니다. 반대로 where T : class는 참조 타입 코드 공유 경로로 가서 박싱·언박싱 명령어 자체가 끼지 않습니다. 이 차이는 [4장]에서 IL로 직접 확인합니다.
4. 실전 적용 — 제약 하나가 IL 한 줄을 바꾼다
Before / After 1: == 비교
object.Equals— 모든 타입의 값 비교에 쓰이는 정적 메서드 두 객체가 같은지 판단한다. 인자가object타입이므로 값 타입을 넘기면 박싱(boxing, 값 타입을 힙에 복사해 참조 타입처럼 다루는 변환)이 발생한다.
Unity에서 두 컴포넌트가 같은 인스턴스인지 비교하는 헬퍼를 만든다고 합시다.
❌ Before — 제약 없음, 어쩔 수 없이 object.Equals 호출:
public bool BadEquals<T>(T a, T b)
{
// == 사용 불가 (T가 값 타입일 수 있어 == 연산자 정의 보장 없음)
return object.Equals(a, b);
}
✅ After — where T : class, == 사용 가능:
public bool GoodEquals<T>(T a, T b) where T : class
{
return a == b; // 참조 동등성 비교
}
// Before: object.Equals 호출 — 값 타입이면 boxing 두 번
.method instance bool BadEquals<T> (!!T a, !!T b) cil managed
{
IL_0000: ldarg.1
IL_0001: box !!T // ← boxing!
IL_0006: ldarg.2
IL_0007: box !!T // ← boxing!
IL_000c: call bool System.Object::Equals(object, object)
IL_0011: ret
}
// After: where T : class — 참조 동등성 비교
.method instance bool GoodEquals<class T> (!!T a, !!T b) cil managed
{
IL_0000: ldarg.1
IL_0001: box !!T
IL_0006: ldarg.2
IL_0007: box !!T
IL_000c: ceq // ← 단순 포인터 비교
IL_000e: ret
}
해설:
box !!T명령어는 값 타입을 힙에 복사해object참조를 만드는 명령어다. Before에서는 두 인자 모두에box가 끼어 호출 한 번에 두 번의 GC 할당이 일어날 수 있다.- After의
box는 참조 타입에서는 no-op이다. C# 컴파일러는 형태상box를 두지만 JIT가 참조 타입 코드 공유 경로에서 이를 제거한다. 결국 두 포인터 값을ceq(equal compare)로 비교하는 코드만 남는다. - 핵심은
class제약 한 줄이==사용을 허용하고,object.Equals호출 경로 자체를 끊었다는 점이다.
Before / After 2: 인터페이스 호출과 constrained.
❌ Before — 인터페이스 매개변수 직접 받기:
public void BadHit(IDamageable target)
{
target.TakeDamage(10);
}
// 호출 시: pool.BadHit(myEnemyStruct);
// → EnemyStruct가 IDamageable 인터페이스로 박싱되어 힙에 올라간다
✅ After — 제네릭 + 인터페이스 제약:
public void GoodHit<T>(T target) where T : IDamageable
{
target.TakeDamage(10);
}
// Before: 일반 callvirt — 호출자가 이미 boxing해서 넘김
.method instance void BadHit (class IDamageable target) cil managed
{
IL_0000: ldarg.1
IL_0001: ldc.i4.s 10
IL_0003: callvirt instance void IDamageable::TakeDamage(int32)
IL_0008: ret
}
// After: constrained. 접두로 boxing 회피
.method instance void GoodHit<(IDamageable) T> (!!T target) cil managed
{
IL_0000: ldarga.s target
IL_0002: ldc.i4.s 10
IL_0004: constrained. !!T // ← 박싱 없이 가상 호출
IL_000a: callvirt instance void IDamageable::TakeDamage(int32)
IL_000f: ret
}
해설:
constrained.접두는 .NET이 제네릭 + 인터페이스 호출을 위해 따로 만든 명령어다.T가 값 타입이면 박싱하지 않고 값의 주소를 그대로 넘겨 인터페이스 메서드를 호출한다.T가 참조 타입이면 일반callvirt처럼 동작한다.- Before는 호출 진입 시점에
EnemyStruct → IDamageable박싱이 한 번 발생한다. After는 호출 전체 경로에서 박싱이 없다. - Unity의 IL2CPP(C++로 변환해 빌드하는 백엔드) 환경에서도
constrained.패턴은 그대로 박싱을 피한다. 인터페이스를 자주 호출하는 핫패스(Update, FixedUpdate 안에서 매 프레임 도는 코드)에서 이 차이는 GC 스파이크의 원인을 줄여 준다.
패턴: ObjectPool — 가장 흔한 사용 사례
public interface IPoolable
{
void OnSpawn();
void OnDespawn();
}
public class Pool<T> where T : Component, IPoolable
{
private readonly Stack<T> _stack = new();
private readonly T _prefab;
public Pool(T prefab) => _prefab = prefab;
public T Spawn(Vector3 pos, Quaternion rot)
{
T item = _stack.Count > 0
? _stack.Pop()
: UnityEngine.Object.Instantiate(_prefab); // T : Component → 안전
item.transform.SetPositionAndRotation(pos, rot); // T : Component
item.gameObject.SetActive(true);
item.OnSpawn(); // T : IPoolable
return item;
}
public void Despawn(T item)
{
item.OnDespawn();
item.gameObject.SetActive(false);
_stack.Push(item);
}
}
해설:
where T : Component만 있어도transform,gameObject접근이 가능하다.IPoolable추가로OnSpawn/OnDespawn호출이 가능해진다.new()는 일부러 넣지 않았다. Unity의 MonoBehaviour는new T()로 만들 수 없고 반드시Instantiate로 만들어야 하기 때문이다. 제약은 정확히 필요한 능력만 요구하는 것이 좋다.
5. 함정과 주의사항
함정 1: where T : new()는 공짜가 아니다 — Activator.CreateInstance 비용
❌ 핫패스에서 new T():
public class Spawner<T> where T : new()
{
public T MakeFast() => new T(); // 보기엔 단순한 생성자 호출
}
// Update에서 매 프레임 호출하면…
void Update() {
var item = _spawner.MakeFast(); // 내부적으로 Activator.CreateInstance!
}
✅ 팩토리 델리게이트로 대체:
public class Spawner<T>
{
private readonly Func<T> _factory;
public Spawner(Func<T> factory) => _factory = factory;
public T MakeFast() => _factory();
}
// 사용처
var spawner = new Spawner<MyData>(() => new MyData());
// Before: where T : new()
.method instance !!T MakeFast<.ctor T> () cil managed
{
IL_0000: call !!0 System.Activator::CreateInstance<!!T>() // ← 리플렉션 경로
IL_0005: ret
}
// After: Func<T> 직접 호출
.method instance !!T MakeFast () cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld class System.Func`1<!0> Spawner`1::_factory
IL_0006: callvirt instance !0 System.Func`1<!!T>::Invoke()
IL_000b: ret
}
해설:
where T : new()+new T()는 IL에서System.Activator.CreateInstance<T>()호출로 컴파일된다. .NET 5 이후 JIT가 이를 인라인화하는 최적화가 도입되어 비용이 많이 줄었으나, IL2CPP나 일부 런타임 환경에서는 여전히 리플렉션 경로를 탈 수 있다.- Unity 핫패스에서 객체를 매 프레임 생성한다면 팩토리 델리게이트(
Func<T>) 주입이 더 안전한 선택이다. JIT는 직접 호출되는 람다를 더 잘 인라인화한다. new()자체가 나쁘다는 의미는 아니다. 오프라인 초기화·도구·테스트 코드처럼 호출 빈도가 낮은 곳에서는 그대로 써도 무방하다.
함정 2: where T : struct 제약과 Nullable<T>의 함정
// 컴파일 OK
public T? AsNullable<T>(T x) where T : struct => x;
// ❌ 컴파일 에러
// AsNullable<int?>(null); // T = int?는 struct 제약을 만족하지 않는다
해설:
where T : struct는 정확히 "Nullable이 아닌 값 타입"을 의미한다. IL 시그니처의valuetype플래그는NotNullableValueTypeConstraint를 의미한다.- 이 규칙은
Nullable<T>자체가where T : struct로 정의되어 있어 무한 재귀(Nullable<Nullable<int>>)를 막기 위해서다. - Unity에서
enum도 값 타입이므로where T : struct, Enum형태로 enum 전용 헬퍼를 만들 수 있다(C# 7.3+).
함정 3: where T : class는 NRT를 켜도 null 허용
#nullable enable
public T GetOrThrow<T>(T? value) where T : class
{
if (value is null) throw new InvalidOperationException();
return value;
}
// 호출: GetOrThrow<string?>(null) — T = string?로 추론 → 컴파일 통과
✅ NRT 환경에서 진짜 null 불허를 원하면 notnull 또는 class(C# 11 이후 자동으로 T는 not-null로 해석):
#nullable enable
public void RequireNotNull<T>(T value) where T : notnull
{
// value는 null이 될 수 없다 — 컴파일러가 보장
Console.WriteLine(value.GetHashCode());
}
해설:
- C# 8에서
where T : class는 nullable 경고 측면에서 "T는 참조 타입(null 허용 여부는 별개)"으로 해석된다. 명시적으로where T : class?나where T : notnull을 써야 의미가 분명해진다. - Dictionary의 키처럼 절대 null이 들어오면 안 되는 API는
where T : notnull제약이 정답이다. 실제로Dictionary<TKey, TValue>는 .NET 5부터TKey에notnull제약을 가진다.
함정 4: 상속받을 때 제약은 다시 명시해야 한다
public class Base<T> where T : Component { }
// ❌ 컴파일 에러: 제약을 다시 명시하지 않음
// public class Derived<T> : Base<T> { }
// ✅ 같은 제약을 명시해야 함
public class Derived<T> : Base<T> where T : Component { }
해설:
- 파생 클래스는 부모 제약을 그대로 또는 더 강하게 명시해야 한다. 부모 제약을 약화할 수는 없다.
- 인터페이스 구현도 마찬가지다. 인터페이스가
where T : IComparable<T>를 요구하면 구현 측도 같은 제약을 명시해야 한다.
함정 5: 불필요한 class 제약으로 값 타입 사용을 막지 않기
❌ 단지 null 비교를 위해 class 추가:
public bool IsDefault<T>(T x) where T : class
{
return x == null;
}
// → int, struct에서 못 씀
✅ EqualityComparer<T>.Default 또는 default(T) 사용:
public bool IsDefault<T>(T x)
{
return EqualityComparer<T>.Default.Equals(x, default!);
}
// → 값 타입, 참조 타입 모두 사용 가능
해설:
- 제약은 필요한 능력만 요구하는 것이 원칙이다.
class제약은 호출자에게 "값 타입은 못 씁니다"라는 강한 제한이 된다. default(T)는 값 타입에서는 모든 비트가 0인 값, 참조 타입에서는null을 반환한다.EqualityComparer<T>.Default는 값 타입에서도 박싱 없이 동등성 비교를 수행한다.
6. C# 버전별 변화
C# 2.0 (2005) — 제네릭과 함께 도입
class, struct, new(), 기본 클래스, 인터페이스 제약이 처음부터 함께 들어왔습니다. 박싱·언박싱 비용을 없애려는 것이 제네릭의 핵심 동기였고, 제약은 그 안전성을 보장하는 장치였습니다.
C# 7.3 (2018) — unmanaged, Enum, Delegate
// C# 7.3: unmanaged 제약 — 포인터 조작과 sizeof(T) 가능
public unsafe void StackAlloc<T>() where T : unmanaged
{
Span<T> buf = stackalloc T[16];
}
.method instance void StackAlloc<valuetype .ctor (System.ValueType
modreq(System.Runtime.InteropServices.UnmanagedType)) T> () cil managed
{
IL_0000: ldc.i4.s 16
IL_0002: conv.u
IL_0003: sizeof !!T // ← unmanaged 제약 덕분에 가능
IL_0009: mul.ovf.un
IL_000a: localloc // ← 스택 할당
IL_000c: ldc.i4.s 16
IL_000e: newobj instance void System.Span`1<!!T>::.ctor(void*, int32)
...
}
해설:
unmanaged는 "참조 타입 필드를 전혀 포함하지 않는 값 타입"을 의미한다.int,float, enum,Vector3, 그리고 이런 값 타입만으로 구성된 struct가 모두 해당된다.sizeof(T),T*포인터,stackalloc같은 저수준 작업이 허용된다. Unity Burst 컴파일러가 처리하는 Job System 코드에서 자주 등장한다.- IL 시그니처에
modreq(UnmanagedType)이 추가된 점에 주목하자.modreq를 모르는 옛 컴파일러가 이 메서드를 잘못 호출하지 못하도록 강제로 막는다.
C# 8.0 (2019) — notnull
#nullable enable
public void Add<TKey, TValue>(TKey key, TValue value) where TKey : notnull
{
// key는 null이 될 수 없음 — Dictionary 키 등 NRT와 결합
}
해설:
- NRT(Nullable Reference Types)와 짝을 이루는 제약이다.
class나struct와 달리 두 종류의 타입을 모두 허용하면서 null만 막는다. - IL 시그니처는 일반 제네릭과 동일하지만,
T에NullableAttribute(1)메타데이터가 부착된다.
C# 11 (2022) — 제네릭 인터페이스 제약 / Generic Math
// C# 11 + .NET 7: INumber<T> — 산술 연산을 인터페이스로 추상화
public T Sum<T>(IEnumerable<T> items) where T : INumber<T>
{
T total = T.Zero;
foreach (var x in items) total += x;
return total;
}
Sum(new[] { 1, 2, 3 }); // int
Sum(new[] { 1.5, 2.5 }); // double
해설:
- C# 11 이전에는
T + T같은 산술 연산을 제약으로 표현할 수 없었다.INumber<T>인터페이스(.NET 7)와static abstract인터페이스 멤버(C# 11) 도입으로 비로소 가능해졌다. - C# 2.0 시절 "왜 제네릭으로 산술 함수를 못 짜나"라는 오랜 불만이 해소된 시점이다.
C# 13 (2024) — allows ref struct
// C# 13: ref struct를 제네릭 인자로 허용
public void Process<T>(T span) where T : allows ref struct
{
// T = Span<int>, ReadOnlySpan<char> 등 가능
}
Process<Span<int>>(stackalloc int[16]); // 이전엔 컴파일 에러였음
해설:
Span<T>,ReadOnlySpan<T>같은ref struct(스택에만 존재해야 하는 타입)는 기존엔 제네릭 타입 인자로 사용할 수 없었다. 제네릭 코드 안에서T를 박싱하거나 힙 객체 필드에 저장할 가능성이 있어 안전하지 않기 때문이었다.allows ref struct는 일반 제약과 반대인 역제약(anti-constraint)이다. "이 제네릭 코드는T를 박싱하지도, 힙에 저장하지도 않습니다"라는 작성자의 약속에 가깝다.- 컴파일러는 이 약속이 지켜지는지(예:
T를 클래스 필드에 저장하지 않는지)를 정적으로 검사한다. - IL 메타데이터에
ByRefLike플래그가 추가된다. .NET 9 이상의 런타임에서만 동작한다.
조합 규칙 — 한 자리에 정리
순서를 지키지 않으면 컴파일 에러입니다.

추가 규칙:
class와struct는 동시 사용 불가(논리적 모순).struct는 매개변수 없는 생성자를 자동으로 가지므로new()와 동시 사용 불가.notnull은class/struct와 동시 사용 불가.allows ref struct는class,struct,notnull을 포함한 어떤 제약과도 결합 가능(역제약이라 위치가 자유롭다).
7. 정리
이것만 기억하면 됩니다.
- 제약 없는
T는 사실상object— 컴파일러는 가장 보수적으로 가정해 모든 멤버 호출을 막는다.where는 컴파일러와 맺는 "이 정도는 보장한다"는 계약이다. - 8가지 제약과 풀어주는 능력
class→ null 비교,==사용, 참조 타입 코드 공유struct→ 박싱 회피, 값 타입 코드 특수화 (Nullable 제외)new()→new T()호출 (단,Activator.CreateInstance경로 — 핫패스 주의)BaseClass/Interface→ 해당 멤버 호출,constrained.로 박싱 없는 인터페이스 호출notnull→ NRT와 결합한 null 차단 (C# 8)unmanaged→sizeof,T*,stackalloc(C# 7.3, modreq로 표현)allows ref struct→Span<T>등 ref struct를 제네릭 인자로 (C# 13, 역제약)
- IL 메타데이터에 박힌다 —
<class T>,<.ctor T>,<(IInterface) T>형태로 시그니처 자체에 인코딩된다. JIT는 이 정보로 박싱 회피·코드 공유 결정을 내린다. - 조합 순서:
class/struct/notnull/unmanaged중 하나 → 기본 클래스 → 인터페이스(여러 개) →new()마지막. 순서 위반은 컴파일 에러다. - Unity 실전:
where T : Component, IPoolable로 풀링 패턴,where T : MonoBehaviour로 싱글턴 패턴,where T : unmanaged로 Burst·Job System 호환 코드를 작성한다. 핫패스에서new()는 팩토리 델리게이트로 대체하고, 인터페이스 호출은 제네릭 + 인터페이스 제약으로 박싱을 피한다. - 제약은 적게, 정확하게 — 필요한 능력만 요구한다. 불필요한
class제약 하나가 호출자의 값 타입 사용을 막을 수 있다.