콘텐츠로 이동

C# Generics & Constraints 심층 분석

제네릭(Generics) 이전에는 object 기반 컬렉션을 사용했습니다. 이는 박싱/언박싱 비용과 런타임 타입 캐스트 오류를 유발했습니다.

// 제네릭 이전: object 기반 (비효율, 타입 안전 없음)
ArrayList list = new ArrayList();
list.Add(42); // 박싱 발생
int val = (int)list[0]; // 언박싱 + 캐스트
// 제네릭: 타입 안전, 박싱 없음
List<int> typed = new List<int>();
typed.Add(42);
int val2 = typed[0];

// 제네릭 클래스
public class Repository<T> where T : class
{
private readonly List<T> _items = new();
public void Add(T item) => _items.Add(item);
public T? Find(Func<T, bool> predicate) => _items.FirstOrDefault(predicate);
public IReadOnlyList<T> GetAll() => _items.AsReadOnly();
}
// 제네릭 메서드
public static T Max<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}
Console.WriteLine(Max(3, 7)); // 7
Console.WriteLine(Max("apple", "orange")); // orange

where 키워드로 타입 매개변수에 제약을 걸어 컴파일 타임 안전성을 높입니다.

// where T : struct — 값 타입만 허용
public struct NullableValue<T> where T : struct
{
private T _value;
public bool HasValue { get; private set; }
public T Value => HasValue ? _value : throw new InvalidOperationException();
}
// where T : class — 참조 타입만 허용
public class Cache<T> where T : class
{
// T는 null 가능
}
// where T : new() — 매개변수 없는 생성자 필수
public static T CreateInstance<T>() where T : new()
{
return new T();
}
// where T : SomeBaseClass — 특정 기반 클래스 요구
public void Process<T>(T entity) where T : EntityBase, IValidatable
{
entity.Validate(); // IValidatable 멤버 호출 가능
entity.Id; // EntityBase 멤버 접근 가능
}
// 다중 제약 조건
public static T DeepCopy<T>(T source)
where T : class, ICloneable, new()
{
return (T)source.Clone();
}
제약의미
where T : struct값 타입 (int, struct 등)
where T : class참조 타입 (class, interface, delegate)
where T : notnullnull 불가 (C# 8+)
where T : unmanaged비관리 타입 (포인터 연산 가능)
where T : new()기본 생성자 보유
where T : BaseClass특정 기반 클래스 상속
where T : IInterface인터페이스 구현
where T : UT는 U이거나 U의 파생 타입

out 키워드는 타입 매개변수를 반환 위치에만 사용하도록 제한하며, 더 파생된 타입을 더 기반 타입으로 암묵적 변환을 허용합니다.

// IEnumerable<T>는 out T로 선언됨: IEnumerable<out T>
IEnumerable<string> strings = new List<string> { "hello" };
// string은 object의 파생 타입이므로 공변성으로 허용
IEnumerable<object> objects = strings; // OK
// 직접 선언
interface IProducer<out T>
{
T Produce(); // 반환 위치: OK
// void Consume(T item); // 매개변수 위치: 컴파일 오류
}
class StringProducer : IProducer<string>
{
public string Produce() => "hello";
}
IProducer<string> strProd = new StringProducer();
IProducer<object> objProd = strProd; // 공변성: OK

in 키워드는 타입 매개변수를 입력 위치에만 사용하도록 제한하며, 더 기반 타입을 더 파생 타입으로 암묵적 변환을 허용합니다.

// Action<T>는 in T로 선언됨: Action<in T>
Action<object> objAction = obj => Console.WriteLine(obj);
// object를 받는 액션은 string도 처리 가능 → 반변성
Action<string> strAction = objAction; // OK
// 직접 선언
interface IConsumer<in T>
{
void Consume(T item); // 입력 위치: OK
// T Produce(); // 반환 위치: 컴파일 오류
}
class ObjectConsumer : IConsumer<object>
{
public void Consume(object item) => Console.WriteLine(item);
}
IConsumer<object> objCons = new ObjectConsumer();
IConsumer<string> strCons = objCons; // 반변성: OK
strCons.Consume("hello");

CLR은 값 타입(int, double, struct) 제네릭에 대해 별도의 JIT 컴파일 코드를 생성합니다. 박싱 없이 실행됩니다.

// List<int>와 List<double>은 별도의 JIT 코드 생성
// 박싱 없음 → 고성능
var ints = new List<int>();
var doubles = new List<double>();

참조 타입 제네릭은 하나의 JIT 코드를 공유합니다. List<string>List<object>는 같은 JIT 코드를 사용합니다.


제네릭 타입 검사로 타입별 최적화를 구현할 수 있습니다.

public static class Serializer
{
public static string Serialize<T>(T value)
{
// 타입별 특화 처리
if (value is int i)
return i.ToString();
if (value is string s)
return $"\"{s}\"";
if (value is IEnumerable<object> enumerable)
return $"[{string.Join(",", enumerable.Select(Serialize))}]";
return value?.ToString() ?? "null";
}
}

static abstract 인터페이스 멤버 (C# 11+)

섹션 제목: “static abstract 인터페이스 멤버 (C# 11+)”
// 수치 연산을 제네릭으로 표현
public interface IAddable<T> where T : IAddable<T>
{
static abstract T operator +(T left, T right);
static abstract T Zero { get; }
}
public static T Sum<T>(IEnumerable<T> source) where T : IAddable<T>
{
var result = T.Zero;
foreach (var item in source)
result = result + item;
return result;
}

타입을 키로 사용하는 타입 안전 컨테이너를 구현합니다.

public class TypedContainer
{
private readonly Dictionary<Type, object> _store = new();
public void Set<T>(T value) => _store[typeof(T)] = value!;
public T Get<T>()
{
if (_store.TryGetValue(typeof(T), out var value))
return (T)value;
throw new KeyNotFoundException($"{typeof(T).Name} not found");
}
public bool TryGet<T>(out T? value)
{
if (_store.TryGetValue(typeof(T), out var obj))
{
value = (T)obj;
return true;
}
value = default;
return false;
}
}
// 사용
var container = new TypedContainer();
container.Set<string>("hello");
container.Set<int>(42);
Console.WriteLine(container.Get<string>()); // hello
Console.WriteLine(container.Get<int>()); // 42

컴파일러는 인수 타입에서 타입 매개변수를 자동으로 추론합니다.

public static Pair<T1, T2> MakePair<T1, T2>(T1 first, T2 second)
=> new Pair<T1, T2>(first, second);
// 타입 명시 불필요
var pair = MakePair("hello", 42); // Pair<string, int>

개념핵심
제약 조건where로 컴파일 타임 타입 안전성 보장
공변성(out)파생 타입 → 기반 타입 암묵적 변환
반변성(in)기반 타입 → 파생 타입 암묵적 변환
값 타입 특화CLR이 타입별 JIT 코드 생성, 박싱 없음
참조 타입 공유모든 참조 타입이 동일한 JIT 코드 공유

제네릭의 제약 조건과 분산 규칙을 이해하면 재사용 가능하고 타입 안전한 라이브러리를 설계할 수 있습니다.