EveryDay.DevUp

[PART7.제네릭(2/3)] 제네릭 제약 조건 — where T : 의 의미 본문

C# 심화

[PART7.제네릭(2/3)] 제네릭 제약 조건 — where T : 의 의미

EveryDay.DevUp 2026. 4. 5. 22:32

제네릭 제약 조건 — where T : 의 의미

제네릭 <T>는 강력하지만, 아무 타입이나 받으면 T에 대해 아는 것이 없다. where T :는 컴파일러에게 "이 T는 최소한 이런 능력을 가진 타입이다"라고 증명서를 보여주는 것이다. 이 증명 덕분에 boxing 없이 인터페이스 메서드를 호출하고, 컴파일 타임에 실수를 잡고, JIT(Just-In-Time, 실행 시점 컴파일) 컴파일러가 최적화된 코드를 생성할 수 있다.


문제 제기

Unity로 모바일 게임을 만들고 있다. 무기마다 데미지 계산 로직이 다르고, 이를 하나의 시스템으로 처리하고 싶다.

C#
// 인터페이스로 추상화했지만...
public interface IAttackable
{
    int GetDamage();
}

public struct Sword : IAttackable
{
    public int Power;
    public int GetDamage() => Power;
}

public class BattleSystem
{
    // 인터페이스 파라미터로 받는 방식
    public static int CalculateDamage(IAttackable weapon)
    {
        return weapon.GetDamage();
    }
}

겉보기에는 깔끔하다. 하지만 Sword는 구조체(struct)다. IAttackable 파라미터로 넘기는 순간 boxing이 발생한다. Sword의 4바이트 데이터가 힙(Heap)에 복사되고, GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 추적해야 할 객체가 하나 늘어난다.

Update 루프에서 매 프레임 호출된다면? 초당 수십 개의 임시 객체가 힙에 쌓인다.

"인터페이스로 추상화하면서도 boxing을 피할 방법은 없을까?" — where T : 제네릭 제약 조건이 바로 그 답이다.


개념 정의

제약 조건이란 — 타입에 붙이는 자격 요건

일상에서 비유하면, 제네릭 <T>는 "아무나 들어올 수 있는 열린 문"이다. 제약 조건 where T :는 그 문 앞에 세운 경비원이다. "이 자격증을 가진 사람만 입장 가능합니다"라고 확인하는 것이다.

컴파일러는 경비원이 확인한 자격증을 기반으로, 그 타입이 어떤 메서드와 속성을 가지고 있는지 미리 알 수 있다. 그래서 T에 대해 안전하게 멤버를 호출할 수 있다.

제약 없는 <T>

제약 조건이 없으면 TSystem.Object 수준이라 ToString(), Equals() 같은 기본 메서드만 호출할 수 있다. where T : IAttackable을 붙이면 컴파일러가 "이 TGetDamage()를 반드시 가지고 있다"는 것을 알게 되고, 안전하게 호출을 허용한다.

where — 제네릭 제약 조건 키워드 제네릭 타입 매개변수 T가 만족해야 할 조건을 선언한다. 메서드나 클래스 선언부 뒤에 위치한다.
예시: public void Attack<T>(T weapon) where T : IAttackable T는 반드시 IAttackable 인터페이스를 구현한 타입이어야 한다

기본적인 코드 형태를 보자.

C#
// 제약 조건 없음 — T에 대해 아는 것이 없다
public static void PrintInfo<T>(T item)
{
    // item.GetDamage(); → 컴파일 에러! T가 GetDamage()를 가졌는지 알 수 없음
    Console.WriteLine(item.ToString()); // Object의 메서드만 가능
}

// 인터페이스 제약 — T가 IAttackable을 구현함을 보장
public static int GetDamage<T>(T weapon) where T : IAttackable
{
    return weapon.GetDamage(); // 컴파일 OK — T는 GetDamage()를 반드시 가짐
}

제약 조건은 타입의 "자격 요건"이다. 컴파일러는 이 요건을 충족하지 않는 타입이 전달되면 즉시 컴파일 에러를 발생시킨다 — 런타임이 아니라 코드 작성 시점에 실수를 잡아준다.


내부 동작

IL에서 보는 boxing 유무 — constrained. 접두어의 비밀

제약 조건의 진짜 가치는 IL(Intermediate Language, C# 코드가 컴파일되어 생성되는 중간 코드) 수준에서 드러난다. 같은 인터페이스 메서드를 호출하더라도, 제약 조건 유무에 따라 IL 코드가 완전히 달라진다.

인터페이스 파라미터 (제약 없음)

Before — 인터페이스 파라미터 (boxing 발생)

C#
public interface IAttackable
{
    int GetDamage();
}

public struct Sword : IAttackable
{
    public int Power;
    public int GetDamage() => Power;
}

public class BattleSystem
{
    // 인터페이스 파라미터로 직접 받음
    public static int CalculateDamage(IAttackable weapon)
    {
        return weapon.GetDamage();
    }
}

// 호출부
var sword = new Sword { Power = 50 };
int damage = BattleSystem.CalculateDamage(sword); // Sword → IAttackable 변환
IL
// === CalculateDamage(IAttackable) — 인터페이스 파라미터 버전 ===
.method public hidebysig static 
    int32 CalculateDamage (
        class IAttackable weapon
    ) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: callvirt instance int32 IAttackable::GetDamage()  // 가상 호출 — 이미 boxing된 상태
    IL_000b: ret
}

// === Main — 호출부 ===
IL_0014: ldloc.0                    // 스택에 있는 Sword 로드
IL_0015: box Sword                  // ★ boxing — Sword를 힙에 복사
IL_001a: call int32 BattleSystem::CalculateDamage(class IAttackable)

box Sword 명령어가 핵심이다. 구조체 Sword를 인터페이스 IAttackable로 넘기기 위해 힙에 복사본을 만든다. 이 복사본은 GC가 나중에 회수해야 할 쓰레기가 된다.

After — 제네릭 제약 조건 (boxing 제거)

C#
public class BattleSystem
{
    // 제네릭 제약 조건으로 받음
    public static int CalculateDamageGeneric<T>(T weapon) where T : IAttackable
    {
        return weapon.GetDamage();
    }
}

// 호출부
var sword = new Sword { Power = 50 };
int damage = BattleSystem.CalculateDamageGeneric(sword); // boxing 없음
IL
// === CalculateDamageGeneric<T> — 제네릭 제약 조건 버전 ===
.method public hidebysig static 
    int32 CalculateDamageGeneric<(IAttackable) T> (
        !!T weapon
    ) cil managed 
{
    IL_0001: ldarga.s weapon             // T의 주소를 로드 (복사 아님)
    IL_0003: constrained. !!T            // ★ constrained 접두어 — boxing 회피
    IL_0009: callvirt instance int32 IAttackable::GetDamage()
    IL_0012: ret
}

// === Main — 호출부 ===
IL_0020: ldloc.0                         // 스택에 있는 Sword 로드
IL_0021: call int32 BattleSystem::CalculateDamageGeneric<valuetype Sword>(!!0) // boxing 없이 직접 호출

constrained. !!T 접두어가 핵심이다. 이 접두어는 JIT 컴파일러에게 "T가 값 타입이면 boxing 없이 직접 메서드를 호출하고, 참조 타입이면 일반 가상 호출을 수행하라"고 지시한다.

호출부에서도 box 명령어가 완전히 사라졌다. Sword가 스택에서 바로 전달되고, 힙 할당은 제로다.

컴파일러가 이 최적화를 수행할 수 있는 이유: where T : IAttackable 제약 덕분에 컴파일러는 T가 반드시 GetDamage()를 구현한다는 것을 알고 있다. 이 보장이 있으니 constrained. 접두어를 안전하게 생성할 수 있고, JIT는 값 타입에 대해 boxing 없는 직접 호출로 최적화한다.

JIT 컴파일러의 코드 생성 전략

제약 조건이 적용된 제네릭 코드는 런타임에 JIT 컴파일러가 실제 타입을 보고 네이티브 코드를 생성한다.

  • 참조 타입: CalculateDamageGeneric<Enemy>(), CalculateDamageGeneric<Boss>() 등 모든 참조 타입은 네이티브 코드를 단 한 번만 생성하고 공유한다. 모든 참조는 동일한 크기(포인터)이기 때문이다.
  • 값 타입: CalculateDamageGeneric<Sword>(), CalculateDamageGeneric<Shield>() 등 각 값 타입마다 별도의 네이티브 코드를 생성한다. 타입마다 크기와 필드 구조가 다르기 때문이다. 이 과정에서 인라이닝(함수 본문을 호출 지점에 직접 삽입)까지 적용되어 가상 호출 비용도 사라질 수 있다.

실전 적용

제약 조건의 종류와 Unity 활용

C#이 제공하는 주요 제약 조건과, 각각이 Unity에서 어떤 상황에 쓰이는지 정리한다.

struct 제약 — GC 압력 차단

where T : structT를 값 타입으로 한정한다. 참조 타입이 들어오는 것을 막아 힙 할당을 원천 차단한다.

C#
// 이벤트 데이터를 값 타입으로 강제 — GC 압력 제거
public struct DamageEvent
{
    public int TargetId;
    public float Amount;
}

public class EventBus
{
    public static void Fire<T>(T eventData) where T : struct
    {
        // T는 반드시 값 타입 — 힙 할당 없이 스택에서 처리
        ProcessEvent(eventData);
    }

    private static void ProcessEvent<T>(T data) where T : struct
    {
        Console.WriteLine($"Event fired: {typeof(T).Name}");
    }
}

// 사용
EventBus.Fire(new DamageEvent { TargetId = 1, Amount = 25.5f });
// EventBus.Fire("string"); → 컴파일 에러! string은 참조 타입

Unity의 Update 루프에서 매 프레임 이벤트를 발생시키는 경우, struct 제약이 없으면 실수로 참조 타입 이벤트를 넘겨 GC 스파이크를 유발할 수 있다.

class 제약 — null 비교 허용

where T : classT를 참조 타입으로 한정한다. null 비교와 as 연산자 사용이 가능해진다.

C#
public class NullChecker
{
    public static bool IsNull<T>(T value) where T : class
    {
        return value == null;  // class 제약 덕분에 null 비교 가능
    }
}
IL
// === IsNull<T> — class 제약 ===
.method public hidebysig static 
    bool IsNull<class T> (
        !!T 'value'
    ) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: box !!T                // 참조 타입이므로 실제로는 no-op (이미 참조)
    IL_0007: ldnull
    IL_0008: ceq                    // null과 비교
    IL_000e: ret
}

IL에서 box !!T가 보이지만, T가 참조 타입으로 제약되어 있으므로 JIT 컴파일러는 이 box 명령어를 아무 동작도 하지 않는 no-op으로 최적화한다. 실제 힙 할당은 발생하지 않는다.

new() 제약 — 팩토리 패턴

where T : new()T가 매개변수 없는 public 생성자를 가진다는 것을 보장한다. new T() 표현식으로 인스턴스를 생성할 수 있다.

C#
public class Factory
{
    public static T CreateDefault<T>() where T : new()
    {
        return new T();  // new() 제약 덕분에 인스턴스 생성 가능
    }
}

var sword = Factory.CreateDefault<Sword>(); // Sword의 기본 생성자 호출
IL
// === CreateDefault<T> — new() 제약 ===
.method public hidebysig static 
    !!T CreateDefault<.ctor T> () cil managed  // .ctor 제약이 메타데이터에 기록됨
{
    IL_0001: call !!0 [System.Runtime]System.Activator::CreateInstance<!!T>()  // ★ Activator.CreateInstance 호출
    IL_000a: ret
}

new T()가 IL에서는 Activator.CreateInstance<T>()로 변환된다. 이 메서드는 내부적으로 리플렉션을 사용한다. 다만 JIT 컴파일러는 값 타입에 대해 initobj 명령어(스택에서 0으로 초기화)로 최적화한다.

주의: Unity의 IL2CPP(Intermediate Language To C++, Unity가 IL을 C++ 코드로 변환하는 AOT 컴파일러) 환경에서는 Activator.CreateInstance가 AOT(Ahead-Of-Time) 제약으로 인해 문제를 일으킬 수 있다. 값 타입에는 안전하지만, 참조 타입에 대해서는 빌드 시 확인이 필요하다.

struct + default — 안전한 기본값 생성

where T : structdefault(T)를 조합하면 boxing 없이 안전하게 기본값을 생성한다.

C#
public class Factory
{
    public static T CreateStruct<T>() where T : struct
    {
        return default(T);  // 모든 필드가 0으로 초기화된 T 반환
    }
}
IL
// === CreateStruct<T> — struct 제약 ===
.method public hidebysig static 
    !!T CreateStruct<valuetype .ctor ([System.Runtime]System.ValueType) T> () cil managed 
{
    IL_0001: ldloca.s 0
    IL_0003: initobj !!T   // ★ 스택에서 0으로 초기화 — 힙 할당 없음
    IL_0009: ldloc.0
    IL_000e: ret
}

initobj !!T는 스택 위의 메모리를 0으로 채운다. Activator.CreateInstance와 달리 리플렉션이 전혀 개입하지 않으며, 힙 할당도 없다. Unity 핫패스에서 안전하게 사용할 수 있다.

복수 제약 조건 — Unity 오브젝트 풀

하나의 타입 매개변수에 여러 제약을 동시에 걸 수 있다. 순서 규칙이 있다.

where T : class, IPoolable, new()
         ↑        ↑          ↑
      구조 제약    인터페이스   생성자 (항상 마지막)
C#
public interface IPoolable
{
    void OnSpawn();
    void OnDespawn();
}

// T는 참조 타입 + IPoolable 구현 + 기본 생성자 보유
public class ObjectPool<T> where T : class, IPoolable, new()
{
    private Queue<T> pool = new Queue<T>();

    public T Spawn()
    {
        T obj = pool.Count > 0 ? pool.Dequeue() : new T();
        obj.OnSpawn();
        return obj;
    }

    public void Despawn(T obj)
    {
        obj.OnDespawn();
        pool.Enqueue(obj);
    }
}
IL
// === ObjectPool<T>.Spawn — 복수 제약 조건 ===
.method public hidebysig 
    instance !T Spawn () cil managed 
{
    IL_0001: call !!0 [System.Runtime]System.Activator::CreateInstance<!T>()  // new T()
    IL_0006: stloc.0
    IL_0007: ldloc.0
    IL_0008: box !T                                  // IPoolable 인터페이스 호출을 위한 box
    IL_000d: callvirt instance void IPoolable::OnSpawn()  // 인터페이스 메서드 호출
    IL_0018: ret
}

IL에서 box !T가 보인다. 이 경우 Tclass 제약으로 참조 타입임이 보장되므로, JIT는 이 box를 no-op으로 최적화한다. 만약 class 제약 없이 값 타입이 들어올 수 있었다면 실제 boxing이 발생했을 것이다.

복수 제약 조건 순서 규칙:

위치 허용되는 제약 예시
첫 번째 class / struct / unmanaged / notnull 중 하나 where T : struct
중간 기본 클래스 (최대 1개) + 인터페이스 (여러 개) where T : MonoBehaviour, IPoolable
마지막 new() (항상 가장 마지막) where T : class, IPoolable, new()

structnew()는 동시에 쓸 수 없다 — 구조체는 항상 매개변수 없는 생성자를 가지므로 암시적으로 new() 조건을 만족한다.

Unity 싱글톤 — 기본 클래스 제약

Unity에서 가장 자주 만나는 패턴이다. 여러 매니저 클래스(GameManager, SoundManager)에 대해 싱글톤 보일러플레이트를 반복 작성하는 대신, 제네릭 제약 조건으로 하나의 베이스 클래스에 담는다.

C#
// T는 반드시 MonoBehaviour를 상속한 타입이어야 한다
public abstract class Singleton<T> : MonoBehaviour where T : MonoBehaviour
{
    private static T instance;

    public static T Instance
    {
        get
        {
            if (instance == null)
            {
                instance = FindObjectOfType<T>();  // where T : MonoBehaviour 덕분에 가능
            }
            return instance;
        }
    }
}

// 사용 — 한 줄로 싱글톤 완성
public class GameManager : Singleton<GameManager>
{
    public int Score { get; set; }
}

// 다른 곳에서
GameManager.Instance.Score += 100;

where T : MonoBehaviour 제약이 없으면 FindObjectOfType<T>()를 호출할 수 없다 — 이 Unity API 자체가 Component를 상속하는 타입만 받기 때문이다. 제약 조건이 Unity API와 제네릭을 안전하게 연결해주는 다리 역할을 한다.


함정과 주의사항

함정 1: new() 제약과 IL2CPP AOT 문제

C#
// ❌ 위험 — IL2CPP에서 런타임 에러 가능
public class Factory
{
    public static T Create<T>() where T : new()
    {
        return new T(); // Activator.CreateInstance 사용 — 리플렉션 기반
    }
}

new T()는 IL에서 Activator.CreateInstance<T>()로 변환된다. 이 메서드는 리플렉션을 사용하는데, Unity의 IL2CPP AOT 환경에서는 리플렉션으로 생성할 타입의 코드가 빌드 시점에 포함되지 않으면 ExecutionEngineException이 발생할 수 있다.

C#
// ✅ 안전 — 팩토리 델리게이트 사용
public class SafeFactory<T>
{
    private readonly Func<T> creator;

    public SafeFactory(Func<T> creator)
    {
        this.creator = creator;
    }

    public T Create() => creator();
}

// 사용
var factory = new SafeFactory<Enemy>(() => new Enemy());
var enemy = factory.Create(); // Activator 없이 직접 생성

팩토리 델리게이트를 주입하면 Activator.CreateInstance를 우회할 수 있다. IL2CPP 환경에서도 안전하다.

함정 2: struct 제약에서 null 비교 시도

C#
// ❌ 컴파일 에러 — struct는 null이 될 수 없다
public static bool IsEmpty<T>(T value) where T : struct
{
    return value == null; // CS0019: 'T'와 '<null>'에 '==' 연산자를 적용할 수 없습니다
}

where T : struct로 제약된 T는 값 타입이므로 null이 될 수 없다. null 비교 자체가 컴파일 에러다.

C#
// ✅ 올바른 방식 — default와 비교
public static bool IsDefault<T>(T value) where T : struct
{
    return EqualityComparer<T>.Default.Equals(value, default(T));
}

값 타입의 "비어있음"을 확인하려면 default(T)와 비교한다. EqualityComparer<T>.Default는 boxing 없이 비교를 수행한다.

함정 3: 제약 조건은 상속되지 않는다

C#
public class Base
{
    public virtual void Process<T>(T item) where T : IComparable<T>
    {
        // ...
    }
}

public class Derived : Base
{
    // ❌ 컴파일 에러 — override 메서드에는 제약 조건을 다시 명시할 수 없다
    // public override void Process<T>(T item) where T : IComparable<T> { }

    // ✅ 제약 조건은 자동으로 상속됨 — 다시 쓰지 않는다
    public override void Process<T>(T item)
    {
        // T는 여전히 IComparable<T>를 구현한다 — 상위 클래스의 제약이 그대로 적용
        int result = item.CompareTo(default(T));
    }
}

override할 때 where 절을 다시 쓰면 컴파일 에러가 발생한다. 제약 조건은 베이스 클래스의 선언에서 자동으로 상속된다.


C# 버전별 변화

제네릭 제약 조건은 C# 2.0에서 처음 도입된 이후, 여러 버전에 걸쳐 새로운 제약이 추가되었다.

C# 2.0 — 기본 제약 조건 도입 (2005)

struct, class, new(), 기본 클래스, 인터페이스, U(naked type) 제약이 모두 이 버전에서 도입되었다. 이전에는 제네릭 자체가 없었으므로 ArrayListobject 캐스팅에 의존해야 했다.

C#
// Before (C# 1.x) — 제네릭 없음, object 캐스팅 필수
ArrayList weapons = new ArrayList();
weapons.Add(new Sword { Power = 50 }); // boxing 발생
int damage = ((IAttackable)weapons[0]).GetDamage(); // unboxing + 캐스팅

// After (C# 2.0) — 제네릭 제약 조건으로 타입 안전 + boxing 제거
public static int GetDamage<T>(T weapon) where T : IAttackable
{
    return weapon.GetDamage(); // boxing 없음, 타입 안전
}

C# 7.3 — unmanaged, Enum, Delegate 제약 (2018)

포인터 연산이 필요한 고성능 코드와 열거형/델리게이트를 타입 안전하게 다룰 수 있게 되었다.

C#
// Before (C# 7.2 이하) — Enum을 제약할 방법이 없음
public static string GetName<T>(T value) // T가 Enum인지 런타임에야 알 수 있음
{
    if (typeof(T).IsEnum)
        return Enum.GetName(typeof(T), value);
    throw new ArgumentException("Enum only");
}

// After (C# 7.3) — 컴파일 타임에 Enum만 허용
public static string GetName<T>(T value) where T : struct, Enum
{
    return Enum.GetName(typeof(T), value); // T가 Enum임이 보장됨
}

where T : unmanaged는 포인터를 포함하지 않는 순수 값 타입(int, float, 참조 필드가 없는 struct)만 허용한다. Unity의 ECS(Entity Component System) / DOTS(Data-Oriented Technology Stack)에서 NativeArray<T>에 저장할 수 있는 타입을 컴파일 타임에 보장할 때 사용된다.

C#
// C# 7.3 — unmanaged 제약
public struct EnemyData // 참조 필드 없는 순수 값 타입
{
    public int Id;
    public float Hp;
}

public static void ProcessBuffer<T>(T[] data) where T : unmanaged
{
    // sizeof(T) 사용 가능, Span<T>과 조합하여 zero-allocation 처리 가능
    int size = System.Runtime.InteropServices.Marshal.SizeOf<T>();
}

C# 8.0 — notnull 제약 (2019)

NRT(Nullable Reference Types, null 허용 참조 타입)와 함께 도입되었다. null이 될 수 없는 타입만 허용한다.

C#
// C# 8.0 — null을 허용하지 않는 타입만 받음
public class Registry<TKey, TValue>
    where TKey : notnull
{
    private Dictionary<TKey, TValue> map = new();

    public void Register(TKey key, TValue value)
    {
        map[key] = value; // TKey가 null이 될 수 없으므로 Dictionary 키로 안전
    }
}

C# 11 — 정적 가상 인터페이스 멤버 (2022)

인터페이스에 정적 연산자를 정의하고, 이를 제약 조건으로 사용할 수 있게 되었다. 이전에는 T + T 같은 수학 연산을 제네릭으로 작성하는 것이 불가능했다.

C#
// Before (C# 10 이하) — 제네릭 덧셈 불가능
// public static T Add<T>(T a, T b) => a + b; // 컴파일 에러

// After (C# 11) — IAdditionOperators 제약으로 가능
using System.Numerics;

public static T Add<T>(T a, T b) where T : IAdditionOperators<T, T, T>
{
    return a + b; // T가 + 연산자를 지원함이 보장됨
}

int sum = Add(5, 10);         // 15
float fSum = Add(3.14f, 2.0f); // 5.14

이 기능은 Unity에서 아직 .NET 버전 제약으로 바로 사용하기 어렵지만, Unity 6 이후 .NET 8+ 지원이 확대되면 수학 유틸리티 작성에 큰 변화가 예상된다.


정리

항목 핵심 내용
제약 조건이란 타입 매개변수 T에 자격 요건을 부여하여, 컴파일 타임에 타입 안전성을 보장하는 메커니즘
boxing 제거 where T : IInterface로 인터페이스 제약을 걸면 constrained. IL 접두어 덕분에 값 타입도 boxing 없이 인터페이스 메서드 호출 가능
struct 제약 where T : struct는 값 타입만 허용 — GC 압력 차단, Unity 핫패스에서 필수
class 제약 where T : class는 참조 타입만 허용 — null 비교, as 연산자 사용 가능
new() 제약 new T() 가능하지만 IL에서 Activator.CreateInstance 사용 — IL2CPP AOT 주의
복수 제약 구조 제약 → 기본 클래스 → 인터페이스 → new() 순서, struct와 new() 동시 불가
Unity 패턴 싱글톤(where T : MonoBehaviour), 오브젝트 풀(where T : class, IPoolable, new()), 이벤트 버스(where T : struct)
IL2CPP 함정 new T()Activator.CreateInstance가 AOT에서 문제 가능 — 팩토리 델리게이트로 우회
제약 상속 override 메서드에서 where 절 재선언 불가 — 자동 상속됨