[PART12.제네릭·델리게이트·람다·LINQ(17/18)] 제네릭 특성(Generic attributes) — typeof와 작별하는 C# 11의 작은 혁명
Attribute 클래스에 타입 매개변수가 들어간다 / 컴파일 타임에 타입이 메타데이터로 굳는다 / DI·직렬화·매핑 코드가 짧아진다
목차
문제 제기 — 왜 이 작은 변경이 그렇게 큰가
Unity 에디터 확장이나 게임 프레임워크를 만들다 보면 이런 코드를 자주 만나게 됩니다.
[RequireComponent(typeof(Rigidbody))]
[CustomEditor(typeof(PlayerController))]
[Inject(typeof(IInputService))]
typeof(...)가 반복됩니다. 보기에 거슬릴 뿐만 아니라, IDE의 자동 완성·리네임이 항상 의도대로 동작하지 않는 미묘한 문제도 있습니다. 더 근본적인 문제는 따로 있습니다. 특성(Attribute)은 "이 타입은 어떤 의미인가"를 선언하는 메타데이터인데, 그 의미의 핵심인 '타입 정보'가 특성 클래스의 인스턴스 필드 안에 묻혀 있다는 점입니다. 컴파일러도, 정적 분석기도, IDE도 이 필드를 들여다보고 나서야 어떤 타입이 들어왔는지 알 수 있습니다.
C# 11은 이 한 가지 제약을 풀었습니다. Attribute를 상속한 클래스에 제네릭 타입 매개변수를 둘 수 있게 된 것입니다. 한 줄짜리 변경처럼 보이지만, DI 컨테이너·직렬화 라이브러리·테스트 모킹 프레임워크의 API가 이 한 줄 덕분에 훨씬 깔끔해집니다.
이 글에서는 이 변화가 메타데이터 수준에서 어떻게 인코딩되는지, 어떤 제약이 따르는지, Unity 6에서 어떻게 활용할 수 있는지를 살펴봅니다.
개념 정의 — Attribute에 <T>가 붙는다
책 표지의 분류 라벨이 직접 타입을 말한다
도서관 책 표지에 분류 라벨이 붙어 있다고 상상해 봅시다. 과거 패턴은 라벨에 "이 책의 분류 코드는 안쪽 표지에 적혀 있습니다" 라고 쓰여 있고, 코드를 보려면 책을 펼쳐야 했습니다. C# 11의 제네릭 특성은 라벨에 분류 코드가 직접 인쇄돼 있는 것과 같습니다. 책을 펼치지 않아도(런타임 객체를 들여다보지 않아도) 라벨만 보면 어떤 분류인지 알 수 있습니다.

<T>— 제네릭 타입 매개변수 (Generic type parameter) 클래스·메서드 정의에 타입 자체를 매개변수처럼 받는 자리표시자. 사용 시점에 구체 타입을 지정하면 컴파일러가 그 타입으로 코드를 인스턴스화합니다.
예시:List<int> nums = new();T자리에int가 들어가List<int>가 됩니다.
가장 작은 예시
using System;
// C# 1.0~10 — 컴파일 오류 (CS0698): 특성 클래스는 제네릭일 수 없음
// public class TypeOfAttribute<T> : Attribute { }
// C# 11 — 정상 컴파일
public class TypeOfAttribute<T> : Attribute { }
// 사용
[TypeOf<string>]
public class Example { }
// 비교: 과거 패턴 (C# 11에서도 여전히 유효)
public class TypeOfOldAttribute : Attribute
{
public Type Target { get; }
public TypeOfOldAttribute(Type target) => Target = target;
}
[TypeOfOld(typeof(string))]
public class OldExample { }
겉보기 차이는 사용할 때 (typeof(string))이 <string>으로 바뀐 것뿐입니다. 하지만 컴파일러가 만들어내는 메타데이터는 완전히 다릅니다.
IL로 본 차이 — 인스턴스 필드 vs 제네릭 인스턴스
// === 과거 패턴: [TypeOfOld(typeof(string))] ===
.class public auto ansi beforefieldinit OldExample
{
// 특성 적용 — 생성자(Type) 호출 + BLOB에 어셈블리 정규화 이름 인코딩
.custom instance void TypeOfOldAttribute::.ctor(class System.Type) = (
01 00 60 53 79 73 74 65 6d 2e 53 74 72 69 6e 67 // "System.String,
2c 20 53 79 73 74 65 6d 2e 52 75 6e 74 69 6d 65 // System.Runtime,
2c 20 56 65 72 73 69 6f 6e 3d 38 2e 30 2e 30 2e // Version=8.0.0.
30 2c 20 43 75 6c 74 75 72 65 3d 6e 65 75 74 72 // 0, Culture=neutr
61 6c 2c 20 50 75 62 6c 69 63 4b 65 79 54 6f 6b // al, PublicKeyTok
65 6e 3d 62 30 33 66 35 66 37 66 31 31 64 35 30 // en=b03f5f7f11d50
61 33 61 00 00 // a3a"
)
}
// === C# 11 패턴: [TypeOf<string>] ===
.class public auto ansi beforefieldinit NewExample
{
// 특성 적용 — 생성자()는 인수 없음, 타입은 .ctor 시그니처의 제네릭 인스턴스에 박힘
.custom instance void class TypeOfNewAttribute`1<string>::.ctor() = (
01 00 00 00 // 인수 없음 — 빈 BLOB
)
}
핵심은 두 줄입니다.
- 과거 패턴:
TypeOfOldAttribute::.ctor(System.Type)생성자 호출의 인수로"System.String, System.Runtime, ..."라는 문자열 형태의 어셈블리 정규화 이름을 BLOB에 인코딩합니다. CLR은 런타임에 이 문자열을 다시 파싱해서Type객체를 만들어 인스턴스 필드에 넣어야 합니다. - C# 11 패턴:
TypeOfNewAttribute\1<string>::.ctor()— 타입 인수string이 **.ctor시그니처의 제네릭 인스턴스화 정보 자체**에 박혀 있습니다. BLOB은 비어 있고(01 00 00 00`), 별도의 문자열 파싱이 필요 없습니다.
**`1 `** — IL 메타데이터에서 제네릭 클래스의 매개변수 개수를 표시하는 표기.TypeOfNewAttribute\1은 "타입 매개변수 1개를 받는 TypeOfNewAttribute"라는 뜻입니다.
내부 동작 — 메타데이터 인코딩과 리플렉션 추출
컴파일러가 무엇을 하는가

CLR은 어셈블리를 로드할 때 메타데이터 테이블을 인덱싱합니다. 제네릭 특성은 TypeSpec 토큰(제네릭 인스턴스화 시그니처)을 통해 인코딩되므로, string 같은 타입 정보가 어셈블리 인덱스 안에서 타입 토큰으로 직접 식별됩니다. 과거 패턴에서는 "System.String, System.Runtime, Version=8.0.0.0, ..." 같은 정규화 이름 문자열을 런타임에 파싱(Type.GetType(string) 호출)해야 했는데, 그 단계가 사라집니다.
리플렉션으로 타입 꺼내기 — IL 비교
using System;
using System.Reflection;
public class TypeOfOldAttribute : Attribute
{
public Type Target { get; }
public TypeOfOldAttribute(Type target) => Target = target;
}
public class TypeOfNewAttribute<T> : Attribute { }
[TypeOfOld(typeof(string))] public class OldExample { }
[TypeOfNew<string>] public class NewExample { }
public class Program
{
public static void Main()
{
// 과거: 인스턴스 필드에서 직접 꺼냄
var oldAttr = typeof(OldExample).GetCustomAttribute<TypeOfOldAttribute>();
Console.WriteLine($"OLD: {oldAttr.Target}");
// 출력: OLD: System.String
// C# 11: 객체의 제네릭 인자를 꺼냄
var newAttr = typeof(NewExample).GetCustomAttributes(false)[0];
Type t = newAttr.GetType().GetGenericArguments()[0];
Console.WriteLine($"NEW: {t}");
// 출력: NEW: System.String
}
}
// === 과거 패턴: oldAttr.Target 추출 ===
IL_0001: ldtoken OldExample
IL_0006: call class System.Type System.Type::GetTypeFromHandle(...)
IL_000b: call !!0 CustomAttributeExtensions::GetCustomAttribute<TypeOfOldAttribute>(MemberInfo)
IL_002a: callvirt instance class System.Type TypeOfOldAttribute::get_Target()
// ↑ 인스턴스의 Target 프로퍼티 호출 — 한 번 인스턴스화 + 한 번 가상 호출
// === C# 11 패턴: GetGenericArguments() ===
IL_0042: ldtoken NewExample
IL_004d: callvirt instance object[] MemberInfo::GetCustomAttributes(bool) // 인스턴스 1개 생성
IL_0056: callvirt instance class System.Type System.Object::GetType() // 객체의 런타임 타입
IL_005b: callvirt instance class System.Type[] System.Type::GetGenericArguments() // 제네릭 인자
IL_0061: ldelem.ref // 첫 번째 인자
// ↑ Type 시스템이 메타데이터에서 직접 토큰으로 읽음
두 코드 모두 인스턴스를 한 번 만든 뒤 Type을 꺼냅니다(차이는 어디에서 꺼내느냐에 있습니다). 과거 패턴은 특성 클래스가 Target이라는 인스턴스 필드/프로퍼티를 직접 노출해야 하므로 라이브러리마다 명명 규칙이 제각각이 됩니다(어떤 곳은 Target, 어떤 곳은 Type, 어떤 곳은 For). C# 11 패턴은 Type.GetGenericArguments()라는 한 가지 표준 API로 통일됩니다 — 라이브러리 사용자가 외워야 할 이름이 줄어듭니다.
IL 레벨에서 무엇이 사라졌나
- 사라진 것: 인스턴스 필드/프로퍼티 정의(
Target백킹 필드 + getter), 생성자 매개변수, BLOB의 어셈블리 정규화 이름 문자열, 런타임Type.GetType(string)파싱 - 추가된 것: 메타데이터의 TypeSpec 토큰 1개
런타임 비용은 사실상 동일하거나 C# 11이 약간 더 빠릅니다 — 어셈블리 정규화 이름 문자열을 매번 새 인스턴스에서 파싱할 필요가 없기 때문입니다. 하지만 핫패스에 적용할 만한 수준의 차이는 아닙니다. 이 기능의 진짜 장점은 API 표면과 컴파일 타임 안전성입니다.
실전 적용 — 라이브러리 API가 짧아진다
Before/After: DI 컨테이너 속성 주입
Unity 프레임워크나 자체 DI 컨테이너에서 필드/프로퍼티 주입을 선언적으로 지정할 때를 생각해 봅시다. 다음은 IInputService를 자동으로 주입받는 시나리오입니다.
// === Before (C# 1.0~10) ===
public class InjectAttribute : Attribute
{
public Type ServiceType { get; }
public InjectAttribute(Type serviceType) => ServiceType = serviceType;
}
public class PlayerController : MonoBehaviour
{
[Inject(typeof(IInputService))]
private IInputService _input;
[Inject(typeof(IAudioService))]
private IAudioService _audio;
}
// DI 컨테이너 측 — 인스턴스 필드를 들여다봐야 함
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
{
var attr = field.GetCustomAttribute<InjectAttribute>();
if (attr != null)
{
object service = container.Resolve(attr.ServiceType); // 인스턴스 필드 사용
field.SetValue(instance, service);
}
}
// === After (C# 11) ===
public class InjectAttribute<T> : Attribute { }
public class PlayerController : MonoBehaviour
{
[Inject<IInputService>]
private IInputService _input;
[Inject<IAudioService>]
private IAudioService _audio;
}
// DI 컨테이너 측 — 표준 GetGenericArguments() 사용
foreach (var field in type.GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
{
var attrs = field.GetCustomAttributes(false);
foreach (var a in attrs)
{
var attrType = a.GetType();
if (attrType.IsGenericType && attrType.GetGenericTypeDefinition() == typeof(InjectAttribute<>))
{
Type serviceType = attrType.GetGenericArguments()[0];
object service = container.Resolve(serviceType);
field.SetValue(instance, service);
}
}
}
// === Before: Inject 특성의 IL ===
.custom instance void InjectAttribute::.ctor(class System.Type) = (
01 00 ... "MyGame.IInputService, MyGame, ..." // 어셈블리 정규화 이름 문자열
)
// === After: Inject<IInputService> 특성의 IL ===
.custom instance void class InjectAttribute`1<class MyGame.IInputService>::.ctor() = (
01 00 00 00 // 빈 인수 — 타입은 시그니처에 박힘
)
After 쪽은 선언이 짧아졌고, IInputService 타입을 잘못 입력하면 컴파일 오류가 납니다(과거 패턴은 typeof(SomeWrongType)을 넣어도 컴파일은 통과). 추가로 제약 조건을 걸 수 있습니다.
// 인터페이스만 주입 가능하도록 컴파일 타임에 강제
public class InjectAttribute<T> : Attribute where T : class { }
Unity 6 핫패스 고려사항
DI 주입·특성 스캔은 거의 항상 초기화 시점(Awake/Start/씬 로드)에 일어나므로, 제네릭 특성이든 과거 패턴이든 프레임마다 호출되는 코드가 아닙니다. 다만 Unity 모바일에서는 초기화 시점의 GC 스파이크가 첫 화면 로딩 체감 속도에 직결되므로, 어셈블리 정규화 이름 문자열을 매번 파싱하지 않아도 되는 제네릭 특성 쪽이 약간 유리합니다(체감 차이는 미미하지만 합산 GC 압력은 줄어듭니다).
GC 스파이크 (Garbage Collection spike) Unity의 Boehm GC가 누적된 garbage를 한 번에 회수할 때 발생하는 프레임 드롭. Unity 모바일에서는 16ms 프레임 예산을 넘기면 즉각적으로 화면 끊김으로 나타납니다.
Unity 6는 C# 11/12를 지원하는가
검증된 사실: Unity 6 (6000.x)는 C# 9 기본, C# Latest 옵션을 통해 C# 11·12 일부 기능을 사용할 수 있습니다. 단, Unity가 공식적으로 보장하는 범위는 빌드 타깃(Mono / IL2CPP) 별로 다르며, 제네릭 특성처럼 컴파일러 단계에서만 처리되는 기능은 안전한 편입니다(런타임 동작은 C# 1.0 시절과 동일한 메타데이터 메커니즘을 사용하므로).
권장 패턴: <LangVersion> 을 명시적으로 설정한 어셈블리(예: Editor 도구)에서 먼저 도입하고, 핵심 게임 코드는 Unity가 공식 지원 범위에 들어올 때까지 기존 패턴을 유지하는 식으로 점진적 적용이 안전합니다.
함정과 주의사항
❌ 함정 1 — 제네릭 클래스 안에서 타입 매개변수를 그대로 넘기기
가장 자주 마주치는 컴파일 오류입니다.
// ❌ 컴파일 오류 CS8968
public class FooAttribute<T> : Attribute { }
public class Container<T>
{
[Foo<T>] // ← T는 컨테이너의 타입 매개변수 — 닫힌 타입이 아님
public void Method() { }
}
$ dotnet build
error CS8968: 'T': 특성 유형 인수는 형식 매개 변수를 사용할 수 없습니다.
왜 안 되는가: 특성은 메타데이터에 인코딩되어야 합니다. 메타데이터는 컴파일 시점에 확정되어야 하는데, T는 Container<T>가 어떤 타입으로 인스턴스화되는지에 따라 달라지는 오픈 타입(open type) 입니다. 컴파일러는 어떤 타입 토큰을 박아 넣어야 할지 결정할 수 없습니다.
// ✅ 해결 — 닫힌 타입을 사용
public class Container<T>
{
[Foo<string>] // 또는 [Foo<int>] 등 컴파일 타임에 확정 가능한 타입
public void Method() { }
}
특성 안에서 컨테이너의 T에 따라 동적으로 동작하고 싶다면, 특성은 표식(marker)으로만 쓰고 실제 타입 분기는 메서드 본문에서 합니다.
❌ 함정 2 — dynamic·포인터·함수 포인터는 사용 불가
public class FooAttribute<T> : Attribute { }
[Foo<dynamic>] // ❌ CS8970
public class A1 { }
public unsafe class A3
{
[Foo<int*>] // ❌ CS0306
public void M() { }
}
$ dotnet build
error CS8970: 유형 'dynamic'은(는) 메타데이터로 표현할 수 없기 때문에 이 컨텍스트에서 사용할 수 없습니다.
error CS0306: 'int*' 형식은 형식 인수로 사용할 수 없습니다.
왜 안 되는가:
dynamic은 사실 컴파일러가 만들어내는 환영(illusion)이고, 실제 IL에는[Dynamic]특성이 붙은object로 나갑니다. 특성의 타입 인수 자리에서는 이런 보조 특성을 다시 적용할 수 없으므로 메타데이터로 표현할 방법이 없습니다.- 포인터와 함수 포인터는 CLR의 제네릭 시스템 자체가 허용하지 않는 타입입니다(이건 제네릭 특성의 제약이 아니라 모든 제네릭의 제약).
// ✅ 우회 — object로 받고 특성 본문에서 처리
public class FooAttribute<T> : Attribute { }
[Foo<object>] // dynamic 대신
public class A1 { }
✅ 함정 같지만 동작하는 것 — 튜플 표기법
다음은 직관적으로 안 될 것 같지만 컴파일됩니다.
public class FooAttribute<T> : Attribute { }
[Foo<(int, string)>] // ✅ 정상 컴파일
public class A2 { }
C# 컴파일러는 (int, string)을 자동으로 System.ValueTuple<int, string>으로 변환합니다. 단, 튜플 요소 이름((int Id, string Name) 같은)은 메타데이터 측면에서 보조 특성에 의존하므로 일부 시나리오에서 잃어버릴 수 있습니다. 이름 없는 튜플로 쓰는 편이 안전합니다.
❌ 함정 3 — GetCustomAttribute<T>()로 제네릭 인자를 꺼낼 수 없다
// ❌ 잘못된 시도
var attr = typeof(NewExample).GetCustomAttribute<TypeOfNewAttribute<string>>();
// 컴파일은 되지만 attr.???로 string 타입을 꺼낼 명시적 멤버가 없다.
// 이 호출은 "정확히 string으로 인스턴스화된 특성이 있는지"만 확인할 뿐
// "어떤 T로 인스턴스화됐는지"를 동적으로 알아내지 못한다.
// ✅ 올바른 패턴 — 객체로 받아 GetType()으로 분석
var attrs = typeof(NewExample).GetCustomAttributes(false);
foreach (var a in attrs)
{
Type attrType = a.GetType();
if (attrType.IsGenericType &&
attrType.GetGenericTypeDefinition() == typeof(TypeOfNewAttribute<>))
{
Type t = attrType.GetGenericArguments()[0];
Console.WriteLine($"T = {t}"); // T = System.String
}
}
DI 컨테이너나 직렬화 라이브러리는 보통 "임의의 T"를 받아 처리해야 하므로, 위 패턴(GetGenericTypeDefinition() + GetGenericArguments())이 표준입니다.
❌ 함정 4 — 같은 특성을 여러 T로 중복 적용
public class FooAttribute<T> : Attribute { }
[Foo<int>]
[Foo<string>] // ❌ 기본 설정으로는 중복 적용 불가
public class Example { }
error CS0579: 'FooAttribute<>' 특성이 중복되었습니다.
C#은 같은 특성 정의의 인스턴스를 여러 개 적용하려면 [AttributeUsage(AllowMultiple = true)]가 필요합니다. 제네릭 특성에서도 동일한 규칙이 적용됩니다 — Foo<int>와 Foo<string>은 컴파일러 입장에서 같은 특성 정의(FooAttribute<>)의 두 인스턴스이므로 충돌합니다.
// ✅ 해결
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class FooAttribute<T> : Attribute { }
[Foo<int>]
[Foo<string>] // 이제 OK
public class Example { }
C# 버전별 변화
C# 1.0~10 — 제네릭 특성 금지 (CS0698)
// 컴파일 오류
public class FooAttribute<T> : Attribute { }
// error CS0698: 제네릭 클래스는 'System.Attribute'에서 직접 또는 간접적으로 파생될 수 없습니다.
// 우회 — Type 매개변수 + typeof
.class public auto ansi beforefieldinit FooAttribute extends System.Attribute
{
.field private initonly class System.Type '<T>k__BackingField'
.method public hidebysig specialname rtspecialname instance void .ctor(class System.Type t) cil managed
}
// 사용 측 IL — BLOB에 어셈블리 정규화 이름 문자열
.custom instance void FooAttribute::.ctor(class System.Type) = (
01 00 60 53 79 73 74 65 6d 2e 53 74 72 69 6e 67 ... // "System.String, ..."
)
CLR/IL은 이미 제네릭 메타데이터를 표현할 수 있었지만, C# 언어 명세가 금지하고 있었습니다. 이는 표현 능력의 부족이 아니라 언어 설계상의 보수적 결정이었습니다.
C# 11 (2022) — 금지 해제
public class FooAttribute<T> : Attribute { } // ✅ 정상 컴파일
[Foo<string>] public class Example { }
// 사용 측 IL — 타입 인수가 시그니처에 박힘
.custom instance void class FooAttribute`1<string>::.ctor() = (
01 00 00 00 // 빈 BLOB
)
C# 11은 단순히 컴파일러 검사 한 줄(if (typeof(Attribute).IsAssignableFrom(genericClass)) emitError(...);)을 제거한 셈입니다. CLR 변경은 필요 없었습니다(메타데이터 형식은 이미 제네릭 인스턴스화를 지원했음). 그래서 .NET 6 런타임에서도 C# 11 컴파일러로 빌드한 어셈블리는 그대로 동작합니다.
C# 12 (2023) 이후 — 변화 없음
C# 12·13·14는 제네릭 특성 자체에 대한 추가 변경이 없습니다. 단, 관련 영역으로 C# 14의 언바운드 제네릭 nameof (nameof(List<>)) 기능이 있어 메타프로그래밍 친화도가 함께 올라가는 흐름에 있습니다(이 주제는 다음 글에서 다룹니다).
정리 — 이것만 기억하세요
- C# 11의 제네릭 특성은
Attribute를 상속한 클래스에<T>를 둘 수 있게 한 변경.[TypeOf(typeof(string))]→[TypeOf<string>]. - 메타데이터 인코딩 차이: 과거 패턴은 어셈블리 정규화 이름 문자열을 BLOB에 인코딩, C# 11은 타입 인수를
.ctor시그니처의 제네릭 인스턴스에 직접 박음. - 리플렉션은
attr.GetType().GetGenericArguments()[0]이 표준.GetCustomAttribute<T>()는 정확한 T 매칭만 가능하므로 동적 추출에는 적합하지 않음. - 제약: 오픈 타입 불가(CS8968),
dynamic불가(CS8970), 포인터·함수 포인터 불가(CS0306). 튜플 표기(int, string)은 자동으로ValueTuple<,>로 변환되어 사용 가능. - 활용: DI(
[Inject<T>]), 직렬화([JsonConverter<TConverter>]), 매핑([MapsTo<TDto>]), 모킹([Mock<T>]) 등 라이브러리 API가 짧고 안전해짐. - Unity 6:
<LangVersion>Latest</LangVersion>옵션으로 사용 가능. 컴파일러 단계에서만 처리되는 기능이라 IL2CPP·Mono 양쪽에서 안전. 핫패스가 아닌 초기화 시점에 동작하므로 성능 부담은 없음. - AllowMultiple: 같은 제네릭 특성 정의를 여러 T로 중복 적용하려면
[AttributeUsage(AllowMultiple = true)]필요.