반응형

[PART12.제네릭·델리게이트·람다·LINQ(3/18)] 제네릭 제약 조건(where) — 타입 매개변수가 만족해야 하는 조건

제약 없는 T는 왜 object처럼 취급되는가 / 각 제약이 풀어주는 능력 / IL 메타데이터에 어떻게 박히는가 / Unity 핫패스에서의 활용


1. 문제 제기 — 제약 없는 제네릭은 왜 무력한가

Unity에서 다음과 같은 오브젝트 풀(Object Pool, 객체를 재사용해 GC 부담을 줄이는 패턴)을 만들고 싶습니다.

C#
public class Pool<T>
{
    public T Spawn(Vector3 pos)
    {
        T instance = new T();              // ❌ 컴파일 에러
        instance.transform.position = pos; // ❌ 컴파일 에러
        return instance;
    }
}

두 줄 모두 컴파일되지 않습니다. 이유는 단순합니다. 컴파일러는 T가 어떤 타입이 될지 모르기 때문에 TSystem.Object로 가정하고, Object에 정의된 메서드(ToString, Equals, GetHashCode) 외에는 아무것도 호출할 수 없도록 막아 버립니다. new T()T에 매개변수 없는 생성자가 있다는 보장이 없어 막히고, transformObject의 멤버가 아니라 Component의 멤버이므로 막힙니다.

이 한계는 T 자리에 들어올 수 있는 타입의 범위가 무한이기 때문에 발생합니다. int도, string도, 포인터를 포함한 구조체도, 추상 클래스도 들어올 수 있습니다. 컴파일러는 이 무한한 가능성 중 가장 안전한(=가장 보수적인) 선택지를 강제합니다.

where T : Component, new() 같은 한 줄을 추가하면 컴파일러는 즉시 태도를 바꿉니다. "TComponent를 상속하고 매개변수 없는 생성자를 가진다"는 약속을 받았기 때문에 transform 접근과 new T() 호출을 허용합니다. 이 "약속과 허용"의 메커니즘이 바로 where 제약 조건입니다.


2. 개념 정의 — where는 컴파일러와 맺는 계약

비유: 출입증과 출입 가능 구역

회사 건물을 떠올려 봅니다. 외부인(제약 없는 T)은 로비까지만 들어갈 수 있습니다. 임원실에 가려면 임원증이, 서버실에 가려면 인프라팀 출입증이 필요합니다. where T : Component는 "이 메서드에 들어오는 T는 모두 Component 출입증을 가진 사람"이라는 보증입니다. 그 보증이 있기에 컴파일러는 Component 구역(=Component의 멤버) 출입을 허용합니다.

제약 없는 T vs 제약 있는 T

코드로 보는 가장 단순한 형태

where T : 조건 — 제네릭 제약 조건 (Generic constraint) 타입 매개변수 T가 만족해야 하는 조건을 명시한다. 컴파일러는 이 조건을 보고 T로 어떤 작업이 가능한지를 판단한다.
예시: public T Spawn<T>() where T : new() => new T(); T가 매개변수 없는 생성자를 가진다고 약속했으니 new T()가 허용된다.
C#
// 제약 없음: 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 — 제약은 메서드 시그니처 자체에 박힌다

위의 EchoCreate를 컴파일하면 IL에서 제약이 시그니처 일부로 명시됩니다.

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)는 이 플래그를 읽고 타입 인자가 제약을 만족하는지 검증합니다.

where 제약이 IL에 박히는 위치

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 로 디컴파일한 실제 결과입니다.

C#
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();
IL
// 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 호출:

C#
public bool BadEquals<T>(T a, T b)
{
    // == 사용 불가 (T가 값 타입일 수 있어 == 연산자 정의 보장 없음)
    return object.Equals(a, b);
}

✅ After — where T : class, == 사용 가능:

C#
public bool GoodEquals<T>(T a, T b) where T : class
{
    return a == b; // 참조 동등성 비교
}
IL
// 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 — 인터페이스 매개변수 직접 받기:

C#
public void BadHit(IDamageable target)
{
    target.TakeDamage(10);
}
// 호출 시: pool.BadHit(myEnemyStruct);
// → EnemyStruct가 IDamageable 인터페이스로 박싱되어 힙에 올라간다

✅ After — 제네릭 + 인터페이스 제약:

C#
public void GoodHit<T>(T target) where T : IDamageable
{
    target.TakeDamage(10);
}
IL
// 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 — 가장 흔한 사용 사례

C#
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():

C#
public class Spawner<T> where T : new()
{
    public T MakeFast() => new T(); // 보기엔 단순한 생성자 호출
}

// Update에서 매 프레임 호출하면…
void Update() {
    var item = _spawner.MakeFast(); // 내부적으로 Activator.CreateInstance!
}

✅ 팩토리 델리게이트로 대체:

C#
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());
IL
// 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>의 함정

C#
// 컴파일 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 허용

C#
#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로 해석):

C#
#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부터 TKeynotnull 제약을 가진다.

함정 4: 상속받을 때 제약은 다시 명시해야 한다

C#
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 추가:

C#
public bool IsDefault<T>(T x) where T : class
{
    return x == null;
}
// → int, struct에서 못 씀

EqualityComparer<T>.Default 또는 default(T) 사용:

C#
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#
// C# 7.3: unmanaged 제약 — 포인터 조작과 sizeof(T) 가능
public unsafe void StackAlloc<T>() where T : unmanaged
{
    Span<T> buf = stackalloc T[16];
}
IL
.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

C#
#nullable enable
public void Add<TKey, TValue>(TKey key, TValue value) where TKey : notnull
{
    // key는 null이 될 수 없음 — Dictionary 키 등 NRT와 결합
}

해설:

  • NRT(Nullable Reference Types)와 짝을 이루는 제약이다. classstruct와 달리 두 종류의 타입을 모두 허용하면서 null만 막는다.
  • IL 시그니처는 일반 제네릭과 동일하지만, TNullableAttribute(1) 메타데이터가 부착된다.

C# 11 (2022) — 제네릭 인터페이스 제약 / Generic Math

C#
// 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#
// 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 이상의 런타임에서만 동작한다.

조합 규칙 — 한 자리에 정리

순서를 지키지 않으면 컴파일 에러입니다.

where 절 제약 조합 순서 (좌→우)

추가 규칙:

  • classstruct는 동시 사용 불가(논리적 모순).
  • struct는 매개변수 없는 생성자를 자동으로 가지므로 new()와 동시 사용 불가.
  • notnullclass/struct와 동시 사용 불가.
  • allows ref structclass, struct, notnull을 포함한 어떤 제약과도 결합 가능(역제약이라 위치가 자유롭다).

7. 정리

이것만 기억하면 됩니다.

  • 제약 없는 T는 사실상 object — 컴파일러는 가장 보수적으로 가정해 모든 멤버 호출을 막는다. where는 컴파일러와 맺는 "이 정도는 보장한다"는 계약이다.
  • 8가지 제약과 풀어주는 능력
    • class → null 비교, == 사용, 참조 타입 코드 공유
    • struct → 박싱 회피, 값 타입 코드 특수화 (Nullable 제외)
    • new()new T() 호출 (단, Activator.CreateInstance 경로 — 핫패스 주의)
    • BaseClass / Interface → 해당 멤버 호출, constrained. 로 박싱 없는 인터페이스 호출
    • notnull → NRT와 결합한 null 차단 (C# 8)
    • unmanagedsizeof, T*, stackalloc (C# 7.3, modreq로 표현)
    • allows ref structSpan<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 제약 하나가 호출자의 값 타입 사용을 막을 수 있다.
반응형

+ Recent posts