리플렉션과 어트리뷰트
형식을 실행 중에 들여다보고, 코드에 표를 붙입니다.
형식을 실행 중에 들여다봅니다
지금까지는 무엇을 다루는지 코드를 작성할 때 이미 알고 있었습니다.
p.Name 이라고 적으려면 Person 에
Name 이 있다는 것을 알아야 합니다.
리플렉션은 그것을 실행 중에 알아내는 것입니다. 형식에 어떤 속성이 있는지 묻고, 이름으로 값을 읽고, 객체를 만들고, 메서드를 부릅니다. 컴파일할 때는 그 이름을 몰라도 됩니다.
여기에 어트리뷰트가 따라옵니다. 어트리뷰트는 코드에 붙여 두는 표시이고, 그 표시를 읽는 것이 리플렉션입니다. 둘은 거의 언제나 함께 사용합니다.
대신 컴파일러의 검사를 받지 못합니다. 이름을 문자열로 적으므로 틀려도 빌드는 통과하고 실행할 때 문제가 됩니다. 느리기도 합니다. 이 단원의 뒤쪽 절반은 그 대가에 대한 이야기입니다.
이름을 모르고 탐색합니다
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
using System.Reflection; var p = new Person { Name = "홍길동", Age = 32 }; // 속성을 하나씩 묻습니다. Name·Age 를 코드에 적지 않았습니다. foreach (var prop in typeof(Person).GetProperties()) { var label = prop.GetCustomAttribute<LabelAttribute>(); Console.WriteLine($"{label?.Text ?? prop.Name,-4} : {prop.GetValue(p)}"); } // 이름으로 찾아 담습니다. typeof(Person).GetProperty("Name")!.SetValue(p, "김철수"); Console.WriteLine($"\n담은 뒤 : {p.Name}"); // 만들고 부릅니다. var made = (Person)Activator.CreateInstance(typeof(Person))!; Console.WriteLine($"새로 만든 것 : Age={made.Age}"); var greet = typeof(Person).GetMethod("Greet")!; Console.WriteLine(greet.Invoke(p, ["안녕하세요"])); [AttributeUsage(AttributeTargets.Property)] class LabelAttribute(string text) : Attribute { public string Text { get; } = text; } class Person { [Label("이름")] public string Name { get; set; } = ""; [Label("나이")] public int Age { get; set; } public string Greet(string word) => $"{word}, {Name}"; }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
첫 줄에 이름·나이가 나온 것을 보세요. 코드 어디에도
그 한글을 적지 않았습니다. [Label] 로 붙여 둔 것을
리플렉션이 읽어 온 것입니다.
형식을 손에 넣는 세 가지
| 방법 | 언제 |
|---|---|
| typeof(Person) | 형식 이름을 코드에 적을 수 있을 때. 가장 안전합니다 |
| obj.GetType() | 담긴 실제 형식이 궁금할 때. 변수의 형식이 아니라 안에 든 것을 돌려줍니다 |
| Type.GetType("...") | 이름이 문자열로 들어올 때. 가장 위험합니다(아래 트리밍 참고) |
Animal a = new Dog(); Console.WriteLine(typeof(Animal).Name); // Animal 변수에 적힌 형식 Console.WriteLine(a.GetType().Name); // Dog 안에 든 것
멤버를 탐색하고 값을 다루기
var t = typeof(Person); t.GetProperties(); // 속성 전부 t.GetMethods(); // 메서드 전부(물려받은 것 포함) t.GetProperty("Name"); // 하나만. 없으면 null 입니다 var prop = t.GetProperty("Name")!; prop.GetValue(p); // 읽기 prop.SetValue(p, "김철수"); // 담기 Activator.CreateInstance(t); // 만들기. 매개 변수 없는 생성자가 있어야 합니다 t.GetMethod("Greet")!.Invoke(p, ["안녕하세요"]); // 부르기
없는 이름을 물으면 예외가 아니라 null 입니다.
오타를 냈을 때 그 자리에서 알려 주지 않고 조금 뒤에
NullReferenceException 으로 나타납니다.
비공개까지 열립니다
BindingFlags 를 주면 private 멤버도
읽힙니다.
var f = typeof(Person).GetField("_secret", BindingFlags.NonPublic | BindingFlags.Instance)!; Console.WriteLine(f.GetValue(p)); // 숨긴 값
된다고 해서 해도 되는 것은 아닙니다. 2.4에서 감춘 것을 여기서 열면, 감춘 쪽은 그것이 읽히는 줄 모르고 이름을 바꿉니다. 그때 빌드는 그대로 통과하고 실행할 때 무너집니다. 테스트에서 안을 확인하는 정도가 보통 허용되는 선입니다.
어트리뷰트 만들기
Attribute 를 상속하면 됩니다. 이름을
Attribute 로 끝내면 붙일 때는 그 부분을 뺄 수
있습니다.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = true)] class LabelAttribute(string text) : Attribute { public string Text { get; } = text; // 생성자로 받는 것 public bool Required { get; set; } // 이름을 적어 넘기는 것 } [Label("이름", Required = true)] // Attribute 를 뺀 이름으로 붙입니다 public string Name { get; set; } = "";
| AttributeUsage | 무엇을 |
|---|---|
| AttributeTargets | 붙일 수 있는 자리입니다. 어긋나면 컴파일 오류입니다 |
| AllowMultiple | 같은 자리에 여러 번 붙일 수 있는지 여부입니다. 기본값은 false |
| Inherited | 물려받은 형식에도 보이는지 여부입니다. 기본값은 true |
읽을 때는 하나면 GetCustomAttribute, 여럿이면
GetCustomAttributes 입니다.
var one = prop.GetCustomAttribute<LabelAttribute>(); // 없으면 null var many = prop.GetCustomAttributes<TagAttribute>(); // 없으면 빈 목록
C# 11부터는 제네릭 어트리뷰트를 만들 수 있습니다. 예전에는
Type 을 넘겨야 했던 자리입니다.
class ValidatorAttribute<T> : Attribute { public Type Target => typeof(T); } [Validator<Person>] class Person { }
이미 있는 어트리뷰트
직접 만들기 전에 컴파일러가 이미 읽는 것들이 있습니다. 이쪽은 리플렉션 없이도 동작합니다.
[Obsolete("Greet 를 사용하세요.")] public void Old() { } // 부르는 자리에서 // warning CS0618: 'Person.Old()'은(는) 사용되지 않습니다. 'Greet 를 사용하세요.'
호출한 자리를 알려 주는 것들도 있습니다. 로그를 남길 때 이름을 손으로 적지 않아도 됩니다.
static void Log(string message, [CallerMemberName] string caller = "", [CallerLineNumber] int line = 0) => Console.WriteLine($"{caller}:{line} {message}"); Log("무슨 일이 있었습니다"); // Main:12 무슨 일이 있었습니다
리플렉션 없이 되는 것은 그쪽으로
이름만 필요하다면 nameof 입니다. 컴파일할 때 문자열로
바뀌므로 비용이 없고, 이름을 바꾸면 함께 바뀝니다.
var n = typeof(Person) .GetProperty("Name")!.Name; // 속성 이름을 바꾸면 // 빌드는 통과하고 // 실행할 때 null 입니다
var n = nameof(Person.Name); // 속성 이름을 바꾸면 // 이 자리도 함께 바뀝니다 // (바뀌지 않으면 빌드 오류)
얼마나 느린가
속성 하나를 100만 번 읽어 보았습니다. 같은 값을 읽는 네 가지 방법입니다.
| 방법 | 100만 번 | 직접 읽기와 비교하면 |
|---|---|---|
| p.Name | 0.2ms | 기준 |
| 매번 GetProperty + GetValue | 40ms | 약 200배 |
| 한 번 찾아 두고 GetValue | 13.5ms | 약 68배 |
| 델리게이트로 만들어 두고 | 1.9ms | 약 10배 |
가장 비싼 것은 찾는 일입니다. 위에서 아래로 내려가는 두 걸음이 그것을 없앤 것이고, 둘 다 한 번 해 두고 담아 두는 것입니다.
// 찾은 것을 담아 둡니다 static readonly PropertyInfo NameProp = typeof(Person).GetProperty("Name")!; // 아예 부를 수 있는 것으로 바꿔 둡니다 static readonly Func<Person, string> GetName = (Func<Person, string>)NameProp.GetMethod!.CreateDelegate(typeof(Func<Person, string>));
한 번 하는 일이면 재지 말고 그냥 사용하세요. 프로그램이 시작할 때 형식을 한 번 탐색하는 것과, 요청마다 100만 번 읽는 것은 다른 이야기입니다.
트리밍이 지워 버립니다
배포할 때 사용하지 않는 코드를 덜어 내는 것을 트리밍이라고 합니다. 브라우저(WASM)와 AOT 게시에서는 기본으로 켜집니다. 문제는 트리밍이 리플렉션을 알아보지 못한다는 것입니다.
아무도 코드에서 이름을 적지 않은 형식은 사용하지 않는 것으로 보고 지웁니다. 그 뒤에 문자열로 찾으면 없습니다.
// 브라우저에서 실제로 실행한 결과입니다 typeof(Person).GetProperties() // 2개 [Name,Age] 됩니다 typeof(Person).GetProperty("Name") // 홍길동 됩니다 Activator.CreateInstance(typeof(Person)) // Person 됩니다 Type.GetType("System.Data.DataTable") // null 지워졌습니다
차이는 형식 이름을 코드에 적었는지입니다.
typeof(Person) 이라고 적어 두면 트리밍이 그것을 보고
남깁니다. 문자열은 읽지 못합니다.
형식이 매개 변수로 들어오는 자리도 마찬가지입니다. 어떤 형식이 올지 모르니 무엇을 남길지도 모릅니다. 게시할 때 알려 줍니다.
static void Dump(Type type) { foreach (var m in type.GetMethods()) Console.WriteLine(m.Name); } // warning IL2070: 'this' argument does not satisfy // 'DynamicallyAccessedMemberTypes.PublicMethods' in call to 'System.Type.GetMethods()'.
고치는 방법은 무엇을 남겨야 하는지 적어 주는 것입니다.
static void Dump( [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type) { foreach (var m in type.GetMethods()) Console.WriteLine(m.Name); } // 경고가 사라집니다. 부르는 쪽이 조건을 지키는지도 함께 검사합니다.
이름이 실행 중에 정해지는 Type.GetType 은 적어 줄 것이
없어 다른 번호가 나옵니다. 이쪽은 고칠 방법이 마땅치 않습니다.
// warning IL2057: Unrecognized value passed to the parameter 'typeName' // of method 'System.Type.GetType(String)'.
그래서 브라우저나 AOT 로 낼 코드에서는 문자열로 형식을 찾지 않는 것이 맞습니다. 최근에는 이런 일을 소스 제너레이터가 대신합니다. 빌드할 때 코드를 만들어 두면 트리밍이 그것을 그대로 봅니다. 4.7에서 다룹니다.
이 문법, 몇 버전부터 사용할 수 있나요
- 1.0Reflection · Attribute · Activator
- 5.0CallerMemberName · CallerLineNumber
- 6.0nameof
- 11.0제네릭 어트리뷰트
리플렉션은 처음부터 있었고 문법이 거의 바뀌지 않았습니다.
바뀐 것은 사용하지 않아도 되는 자리가 늘어난 것입니다.
이름을 얻는 일은 nameof 가, 호출한 자리를 아는 일은
Caller* 가 가져갔습니다. 트리밍과 AOT 가 널리
사용되면서 남은 자리도 소스 제너레이터로 옮겨 가는 중입니다.
직접 해보기
[Required] 를 만들고, 그것이 붙은
string 속성이 비어 있으면 이름을 모아 돌려주는
Validate 를 작성해보세요. 형식이 무엇이든 동작해야 합니다.
아래 메서드는 목록의 항목마다 리플렉션으로 찾습니다. 항목이 10만 개면 10만 번 찾습니다. 찾는 일을 한 번으로 줄여보세요.
static List<string> Names(List<Person> people) { var rst = new List<string>(); foreach (var p in people) rst.Add((string)typeof(Person).GetProperty("Name")!.GetValue(p)!); return rst; }
- 리플렉션은 형식을 실행 중에 들여다보는 것이고, 어트리뷰트는 그것이 읽을 표시입니다.
- 없는 이름을 물으면 예외가 아니라
null입니다. 빌드는 통과합니다. private까지 열리지만, 감춘 쪽은 그것이 읽히는 줄 모르고 이름을 바꿉니다.- 이름만 필요하면
nameof입니다. 비용이 없고 함께 바뀝니다. - 비싼 것은 찾는 일입니다. 한 번 찾아 담아 두면 3분의 1, 델리게이트로 만들어 두면 20분의 1입니다.
- 트리밍은 리플렉션을 알아보지 못합니다. 문자열로 찾은 형식은 지워져 있습니다.
- 형식이 매개 변수로 들어오면
[DynamicallyAccessedMembers]로 무엇을 남길지 적어 줍니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.