공부/C#

이것이 C#이다 Chapter 16 리플렉션과 애트리뷰트

bokob 2024. 3. 17. 20:41

16.1 리플렉션

  • 런타임에 타입 정보를 다룰 수 있는 기능 -> 프로그래머에게 강력한 표편식을 제공
  • 객체의 타입(Type) 정보를 들여다 볼 수 있음
  • 프로그램 실행 중에 객체의 타입 이름부터 프로퍼티 목록, 메소드 목록, 필드, 이벤트 목록까지 모두 열어볼 수 있음
  • 타입의 이름만 있다면 동적으로 인스턴스를 만들 수 있고, 그 인스턴스의 메소드를 호출할 수도 있으며, 새로운 데이터 타입을 동적으로 만들 수도 있음

.NET 팀은 모든 타입을 들여다볼 수 있는 방법을 마련했다. 바로 모든 데이터 타입의 조상인 Object 타입에 GetType() 메소드를 넣은 것이다.

16.1.1 Object.GetType() 메소드와 Type 클래스

Object는 모든 데이터 타입의 조상이다. 즉, 모든 데이터 타입은 Object 타입이 갖고 있는 다음의 메소드를 물려받아 갖고 있다는 것이다.

  • Equals()
  • GetHashCode()
  • GetType() <- 리플렉션과 관련
  • ReferenceEquals()
  • ToString()

GetType()

  • 객체의 타입 정보를 반환하는 기능
  • 모든 데이터 타입이 GetType() 메소드를 갖고 있기 때문에, 어떤 객체에 대해서든 이 메소드를 호출해서 그 객체의 타입 정보를 얻을 수 있음
  • Type 타입의 결과를 반환한다. Type 타입은 .NET에서 사용하는 데이터 타입의 모든 정보를 담고 있다. 타입 이름, 소속된 어셈블리 이름, 프로퍼티 목록, 메소드 목록, 필드 목록, 이벤트 목록, 타입이 상속하는 인터페이스의 목록까지 갖고 있다.
int a = 0;

Type type = a.GetType();
FieldInfo[] field = type.GetFields(); // 필드 목록 조회

foreach(FieldInfo field in fields)
    Console.WriteLine("Type:{0}, Name:{1}, field.FieldType.Name, field.Name");

위 코드는 int 타입의 필드를 조회해서 출력하는 코드이다.

 

다음 표에 정리된 Type 타입의 메소드를 이용하면 다른 정보들도 뽑아낼 수 있다.

메소드 반환 타입 설명
GetConstructors() ConstructorInfo[] 해당 타입의 모든 생성자 목록을 반환한다.
GetEvents() EventInfo[] 해당 타입의 이벤트 목록을 반환한다.
GetFields() FieldInfo[] 해당 타입의 필드 목록을 반환한다.
GetGenericArguments() Type[] 해당 타입의 모든 매개변수 목록을 반환한다.
GetInterfaces() Type[] 해당 타입이 상속하는 인터페이스 목록을 반환한다.
GetMembers() MemberInfo[] 해당 타입의 멤버 목록을 반환한다.
GetMethods() MethodInfo[] 해당 타입의 메소드 목록을 반환한다.
GetNestedTypes() Type[] 해당 타입의 내장 타입 목록을 반환한다.
GetProperties() PropertyInfo[] 해당 타입의 프로퍼티 목록을 반환한다.

 

이밖에도 다른 메소드가 존재한다. MSDN에서 System.Type의 매뉴얼을 찾아보면 더 많다.

 


MSDN을 보는 이유

MSDN 라이브러리는 C#의 상세한 사양과 다양한 예제들을 수록하고 있다. 기본 개념서 없이 이것만으로 C#을 익힐 수 있지만, 초보자가 무엇을 시작할 때 사용하는 교재로는 부적합하다. 자세한 것을 알기 위해서는 MSDN이 좋다.


 

GetFields()나 GetMethods() 같은 메소드는 검색 옵션을 지정할 수 있다. public 항목만 조회할 수 있고, 비 public 항목만 조회할 수 있다. 그리고 같이 조회할 수 있다. 또한 static 항목만 조회할 수도 있고 인스턴스 항목만 조회할 수도 있다. 아니면 모든 조건을 포함하는 조건을 만들 수도 있다. 이러한 검색 옵션은 System.Reflection.BindingFlags 열거형을 이용해서 구성된다.

 

다음은 BindingFlags 열거형을 이용해서 GetFields() 멧소드에 검색 옵션을 입력하는 코드다.

Type type = a.GetType();

// public 인스턴스 필드 조회
var fields1 = type.GetFields(BindingFlags.Public | BindingFlags.Instance);

// 비 public 인스턴스 필드 조회
var fields2 = type.GetFields(BindingFlags.NonPublic | BindingFlags.Instance);

// public 정적 필드 조회
var fields3 = type.GetFields(BindingFlags.Public | BindingFlags.Static);

// 비 public 정적 필드 조회
var fields4 = type.GetFields(BindingFlags.NonPublic | BindingFlags.Static);

 

GetFields()나 GetMethods() 등의 메소드는 BindingFlags 매개변수를 받지 않는 버전으로도 오버로딩되어 있다. 이 경우 이들 메소드는 public 멤버만 반환한다.

 


Object.GetType() 메소드를 사용하지 않고 타입 정보를 얻는 방법

Object.GetType() 메소드는 반드시 인스턴스가 있어야 호출 가능하다. 예를 들어, 순수하게 int 타입의 정보를 열어보는 코드를 작성하려고 해도 int의 인스턴스를 만들고 초기화해야 한다. C#에서는 Object.GetType() 외에도 타입 정보를 얻을 수 있는 typeof 연산자와 Type.GetType() 메소드를 제공한다.

typeof 연산자와 Type.GetType() 메소드는 똑같이 Type 타입을 반환하지만, typeof 연산자는 타입의 식별자 자체를 인수로 받고 Type.GetType() 메소드는 타입의 전체 이름, 즉 네임스페이스를 포함한 타입 이름을 인수로 받는다는 점이 다르다.

Type a = typeof(int); // typeof 연산자의 인수는 int
Console.WriteLine(a.FullName);

// Type.GetType() 메소드의 인수는 타입의 네임스페이스를 포함하는 전체 이름
Type b = Type.GetType("System.Int32");
Console.WriteLine(b.FullName);

16.1.2 리플렉션을 이용해서 객체 생성하기 

리플렉션을 이용해 특정 타입의 인스턴스를 만들고 데이터를 할당하며 메소드를 호출하는 방법이 있다.

이렇게 코드 안에서 런타임에 특정 타입의 인스턴스를 만들 수 있게 되면 프로그램이 조금 더 동적으로 동작할 수 있도록 구성할 수 있다.

리플렉션을 이용해서 동적으로 인스턴스를 만들기 위해서는 System.Activator 클래스의 도움이 필요하다.

인스턴스를 만들려는 타입의 Type 객체를 매개변수에 넘기면, Activator.CreateInstance() 메소드는 입력받은 타입의 인스턴스를 생성하여 반환한다.

object a = Activator.CreateInstance(typeof(int));

 

일반화를 지원하는 버전의 CreateInstance() 메소드도 있다.

List<int> list = Activator.CreateInstannce<List<int>>();

 

인스턴스 생성만 동적으로 할 수 있는 것이 아니다. 객체의 프로퍼티에 값을 할당하는 것도 동적으로 할 수 있다.

Type.GetProperties()의 반환 타입인 PropertyInfo 클래스에는 SetValue()와 GetValue() 라는 메소드가 있는데, GetValue() 를 호출하면 프로퍼티로부터 값을 읽을 수 있고, SetValue()를 호출하면 프로퍼티에 값을 할당할 수 있다.

class Profile
{
    public string Name{get; set;}
    public string Phone{get; set;}
}

static void Main()
{
    Type type = typeof(Profile);
    Object profile = Activator.CreateInstance(type);
    
    /* 
    Type.GetProperties() 메소드는 그 타입의 모든 프로퍼티를 PropertyInfo 타입 배열로 반환하지만,
    Type.GetProperty() 메소드는 특정 이름의 프로퍼티를 찾아 
    그 프로퍼티의 정보를 담은 하나의 PropertyInfo 객체만 반환한다.
    */
    PropertyInfo name = type.GetProperty("Name");
    PropertyInfo phone = type.GetProperty("Phone");
    
    name.SetValue(profile, "박찬호", null);
    name.SetValue(profile, "997-5511", null);
    
    Console.WriteLine("{0}, {1}",
                name.GetValue(profile, null),
                phone.GetValue(profile, null));
}

PropertyInfo 클래스는 프로퍼티뿐 아니라 인덱서의 정보도 담을 수 있는데, SetValue()와 GetValue()의 마지막 인수는 인덱서의 인덱스를 위해 사용된다. 프로퍼티는 인덱서가 필요없으므로 null로 할당한 것이다.

 

리플렉션을 이용해서 메소드를 호출하는 방법도 있다. 메소드의 정보를 담는 MethodInfo 클래스의 Invoke() 메소드를 사용하는 것이다. 이 메소드를 이용하면 동적으로 메소드를 호출할 수 있게 된다.

class Profile
{
    public string Name{get; set;}
    public string Phone{get; set;}
    
    public void Print()
    {
        Console.WriteLine("{0}, {1}", Name, Phone);
    }
}

static void Main()
{
    Type type = typeof(Profile);
    Profile profile = (Profile)Activator.CreateInstance(type);
    profile.Name = "류현진";
    profile.Phone = "010-1234-5678";
    
    // 메소드 찾는다.
    MethodInfo method = type.GetMethod("Print");
    
    /*
    null 인수가 오는 자리에는 Invoke() 메소드가 호출할 메소드의 인수가 와야한다.
    Profile.Print() 메소드의 인수가 없으므로 null을 넘긴다.
    */
    method.Invoke(profile, null);
}

 

16.1.3 타입 내보내기

리플렉션을 이용하면 프로그램 실행 중에 원하는 타입의 정보를 읽어낼 수 있을 뿐 아니라, 그 타입의 인스턴스도 만들 수 있으며 프로퍼티나 필드에 값을 할당하고 메소드를 호출할 수도 있다. 

여기서 더 나아가 프로그램 실행 중에 새로운 타입을 만들어낼 수 있는 기능도 있다.

 

동적으로 새로운 타입을 만드는 작업은 System.Refelection.Emit 네임스페이스에 있는 클래스들을 통해 이루어진다.

Emit("이밋"이라고 읽는다)은 영어로 레이저 빔 등을 '내뿜다' 또는 지폐 등을 '발행하다'라는 뜻을 갖고 있다.

리플렉션에서 Emit은 프로그램이 실행 중에 만들어낸 새 타입을 CLR의 메모리에 '내보낸다'는 의미로 생각하면 된다.

 

다음 표는 Emit 네임스페이스에서 제공하는 클래스의 목록이다. 이 클래스들은 코드 요소를 만든다는 의미에서 ~Builder 골의 이름을 갖고 있다.

클래스 설명
AssemblyBuilder 동적 어셈블리를 정의하고 나타낸다.
ConstructorBuilder 동적으로 만든 클래스의 생성자를 정의하고 나타낸다.
CustomAttributeBuilder 사용자 정의 애트리뷰트를 만든다.
EnumBuilder 열거 타입을 정의하고 나타낸다.
EventBuilder 클래스의 이벤트를 정의하고 나타낸다.
FieldBuilder 필드를 정의하고 나타낸다.
GenericTypeParameterBuilder 동적으로 정의된 타입(클래스)과 메소드를 위한 일반화 타입 매개변수를 정의하고 생성한다.
ILGenerator MSIL(Microsoft Intermediate Language) 명령어를 생성한다.
LocalBuilder 메소드나 생성자 내의 지역 변수를 나타낸다.
MethodBuilder 동적으로 만든 클래스의 메소드(또는 생성자)를 정의하고 나타낸다.
ModuleBuilder 동적 어셈블리 내의 모듈을 정의하고 나타낸다.
OpCodes ILGenerator 클래스의 멤버를 이용한 내보내기 작업에 사용할 MSIL 명령어의 필드 표현을 제공한다.
ParameterBuilder 매개변수 정보를 생성하거나 결합한다.
PropertyBuilder 타입(클래스)의 프로퍼티를 정의한다.
TypeBuilder 실행 중에 클래스를 정의하고 생성한다.

 

클래스를 사용하는 요령은 다음과 같다.

  1. AssemblyBuilder를 이용해서 어셈블리를 만든다.
  2. ModuleBuilder를 이용해서 1.에서 생성한 어셈블릴 안에 모듈을 만들어 넣는다.
  3. 2.에서 생성한 모듈 안에 TypeBuilder로 클래스(타입)을 만들어 넣는다.
  4. 3.에서 생성한 클래스 안에 메소드(MethodBuilder 이용)나 프로퍼티(PropertyBuilder 이용)를 만들어 넣는다.
  5. 4.에서 생성한 것이 메소드라면, ILGenerator를 이용해서 메소드 안에 CPU가 실행할 IL 명령들을 넣는다.

[어셈블리] -> [모듈] -> [클래스] -> [메소드] 또는 [프로퍼티]로 이어지는 .NET 프로그램의 계층 구조다.

 

~Builder와 ILGenerator를 이용해서 타입을 만들어보자. 먼저 어셈블리를 만들어야 한다.

AssemblyBuilder 클래스를 이용해야겠지만, AsemblyBuilder는 스스로를 생성하는 생성자가 없다.

그래서 다른 팩토리 클래스(Factory Class, 객체의 생성을 담당하는 클래스를 일컫는다)의 도움을 받아야 한다.

DefineDynamicAssembly() 메소드를 호출하면 AssemblyBuilder의 인스턴스를 만들 수 있다.

AssemblyBuilder newAssembly =
    AssemblyBuilder.DefineDynamicAssembly(
        new AssemblyName("CalculatorAssembly"), AssemblyBuilderAccess.Run);

 

다음은 모듈을 만들 차례다. 모듈은 어셈블리 내부에 생성된다. AssemblyBuilder는 동적 모듈을 생성하는 DefineDynamicModule() 메소드를 갖고 있으므로 이 메소드를 호출해서 모듈을 만들면 된다.

ModuleBuilder newModule = newAssembly.DefineDynamicModule("Calculator");

 

모듈을 만들었으면 클래스를 만들어야 한다. ModuleBuilder의 DefineType() 메소드를 이용해서 클래스를 생성한다.

TypeBuilder newType = newModule.DefineType("Sum1To100");

 

메소드를 만들 차례다. TypeBuilder 클래스의 Define.Method() 메소드를 호출해서 메소드를 만든다.

MethodBuilder newMethod = newType.DefineMethod(
    "Calculate", // 메소드 이름
    MethodAttributes.Public, // public
    typeof(int); // 반환 타입
    new Type[0]); // 매개변수

 

메소드 안에 실행할 코드(IL 명령어)를 채워 넣어야 한다. 이 작업은 ILGenerator 객체를 통해 이루어진다.

ILGenerator 객체는 MethodBuilder 클래스의 GetILGenerator() 메소드를 통해 얻을 수 있다.

ILGenerator generator = newMethod.GetILGenerator();

generator.Emit(OpCodes.Ldc_I4, 1); // 32비트 정수(1)를 계산 스택에 넣는다.

for(int i=2; i<=100; i++)
{
    generator.Emit(OpCodes.Ldc_I4, i); // 32비트 정수(i)를 계산 스택에 넣는다.
    
    // 계산 후 계산 스택에 담겨 있는 두 개의 값을 꺼내서 더한 후, 그 결과를 다시 계산 스택에 넣는다.
    generator.Emit(OpCodes.Add);
}

generator.Emit(OpCodes.Ret)' // 계산 스택에 담겨 있는 값을 반환한다.

 

메소드 안에 IL 명령어를 채웠으니 CLR에 만든 클래스를 제출하면 된다. 만들었던 newType 객체에 CreateType() 메소드를 호출한다.

newType.CreateType();

 

마지막으로 사용하면 된다.

object sum1To100 = Activator.CreateInstance(newType);
MethodInfo Calculate = sum1To100.GetType().GetMethod("Calculate");
Console.WriteLine(Calculate.Invoke(sum1To100, null));

 

16.2. 애트리뷰트

애트리뷰트(Attribute)는 코드에 대한 부가 정보를 기록하고 읽을 수 있는 기능이다.

애트리뷰트가 주석과 다른 점은 주석이 사람이 읽고 쓰는 정보라면, 애트리뷰트는 사람이 작성하고 컴퓨터가 읽는다는 것이다.

애트리뷰트를 이용해서 클래스나 구조체, 메소드, 프로퍼티 등에 데이터를 기록해두면 C# 컴파일러나 C#으로 작성된 프로그램이 이 정보를 읽고 사용할 수 있다.

 

C#으로 배포한 라이브러리가 있다고 가정하자. A() 메소드에 문제가 있음을 알게 되어 B() 메소드를 사용하라고 컴파일러에서 경고하게 만들기로 할 때 사용할 수도 있다.

 

16.2.1 애트리뷰트 사용하기

설명하려는 코드 요소 앞에 대괄호 [ 와 ]를 붙이고 그 안에 애트리뷰트의 이름을 넣으면 된다.

[애트리뷰트_이름(애트리뷰트_매개변수)]
public void MyMethod()
{
    // ...
}

 

컴파일러에서 경고 메시지가 나오게 하는 법은 .NET에서 기본적으로 제공하는 Obsolete 애트리뷰트를 이용하면 된다.

class MyClass
{
    [Obsolete("OldMethod는 폐기되었다. NewMethod()를 이용하라.")]
    public void OldMethod()
    {
         Console.WriteLine("I'm old");
    }
    
    public void NewMethod()
    {
        Console.WriteLine("I'm new");
    }
}

 

16.2.2 호출자 정보 애트리뷰트

C/C++에서 제공하는 __FILENAME__, __LINE__, __FUNCTION__ 매크로 (이 매크로들은 각각 현재 코드의 소스 파일 이름, 행(Line) 번호, 함수 이름으로 컴파일러에 의해 치홚되어 실행 코드에 들어간다)에 해당하는 기능을 C#이 제공하지 않았었다.

C# 5.0부터 호출자 정보(Caller Information) 애트리뷰트가 도입되었다. 호출자 정보는 메소드의 매개변수에 사용되며 메소드의 호출자 이름, 호출자 메소드가 정의된 소스 파일 경로, 소스 파일 내 행 번호까지 알 수 있다.

이를 이용해서 응용 프로그램의 이벤트 로그 파일이나 화면에 출력하면 그 이벤트가 어떤 코드에서 일어났는지 알 수 있다.

애트리뷰트 설명
CallerMemberNameAttribute 현재 메소드를 호출한 메소드 또는 프로퍼티의 이름을 나타낸다.
CallerFilePathAttribute 현재 메소드가 호출된 소스 파일 경로를 나타낸다. 이때 경로는 소스 코드를 컴파일할 때의 전체 경로를 나타낸다.
CallerLineNumberAttribute 현재 메소드가 호출된 소스 파일 내의 행 번호를 나타낸다.

 

public static class Trace
{
    public static void WriteLine(string message,
        [CallerFilePath] string file ="",
        [CallerLineNumber] int line = 0,
        [CallerMemberName] string member = "")
    {
        Console.WriteLine("{0}(Line:{1}) {2}: {3}", file, line, member, message);
    }
}

void SomeMethod()
{
    Trace.WriteLine("Hello World!");
}

 

 

16.2.3 내가 만드는 애트리뷰트

.NET이 제공하는 애트리뷰트는 Obsolete 말고도 그 종류가 많다. C나 C++로 작성된 네이티브 DLL(Dynamic Link Library)에 있는 함수를 호출할 때 사용하는 [DLLImport], 조건부 메소드 실행을 지정할 때 사용하는 [Conditional] 등이 그 예이다.

이 애트리뷰트들은 애트리뷰트 자체보다 용도를 중심으로 공부하는 편이 좋다. 애트리뷰트는 부가 정보이지 핵심 내용이 아닌 데다가 사용 방법도 그 수만큼 다양하기 때문이다.

 

애트리뷰트도 하나의 클래스일 뿐이다. 모든 애트리뷰트는 예를 들어 다음과 같이 System.Attribute 클래스로부터 상속 받아 만들어진다.

class History : System.Attribute
{
    // ...
}

 

System.Attribute를 상속하는 것만으로도 애트리뷰트 하나를 만든 셈이 된다. 이렇게 선언한 애트리뷰트는 Obsolete 애트리뷰트처럼 대괄호 [와 ] 안에 애트리뷰트 이름을 넣어 사용하면 된다.

[History]
class MyHistory
{
    // ...
}

 

위의 History는 애트리뷰트이긴 하지만 아무것도 설명하는 것이 없다. History 애트리뷰트가 구체적으로 설명할 내용과 이것을 어떻게 설명하게 할 것인지를 나타내게 하는 코드를 추가하자.

class History : System.Attribute
{
    private string programmer;
    
    public double Version { get; set; }
    public string Changes { get; set; }
    
    public History(string programmer) // 생성자
    {
        this.programmer = programmer;
        Version = 1.0;
        Changes = "First release";
    }
    
    public string Programmer
    {
        get {return programmer;}
    }
}

 

History 클래스는 System.Attribute로부터 상속받았을 뿐이지 여느 클래스와 다를게 없다.

[History("Sean",
    Version = 0.1, Changes = "2017-11-01 Created class stub")]
class MyClass
{
    public void Func()
    {
        Console.WriteLine("Func()");
    }
}

이와 같이 MyClass를 History 애트리뷰트로 설명해놓으면 리플렉션을 이용해서 손쉽게 Release 노트를 만들 수 있다.

한 가지 문제가 있다. 다른 프로그래머가 MyClass를 수정한 뒤 History 애트리뷰트를 추가하고 싶어도 더 추가할 수 없다. 지금의 History 애트리뷰트는 단 한 번 밖에 사용할 수 없기 때문이다. 다음과 같이 사용하려 하면 에러 메시지가 나올 것이다.

[History("Sean",
    Version = 0.1, Changes = "2017-11-01 Created class stub")]
[History("Bob",
    Version = 0.2, Changes = "2017-12-03 Added Func() Method")] // 이렇게 겹쳐 사용 X
class MyClass
{
    public void Func()
    {
        Console.WriteLine("Func()");
    }
}

이 문제를 해결하려면 System.AttributeUsage라는 애트리뷰트의 도움을 받아야 한다.

System.AttributeUsage는 애트리뷰트의 애트리뷰트이다. 애트리뷰트가 어떤 대상을 설명할지, 이 애트리뷰트를 중복해서 사용할 수 있는지 등을 설명한다. System.AttributeUsage는 다음 예제와 같이 애트리뷰트 선언부에 사용하면 된다.

[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple=true)]
class History : System.Attribute
{
    // ...
}

 

System.AttributeUsage의 첫 번째 매개변수는 지금 선언하고 있는 애트리뷰트의 설명 대상이 무엇인지 나타낸다.

이것을 Attribute Target이라고 하는데, 애트리뷰트의 설명 대상이 될 수 있는 코드 요소는 다음과 같다.

Attribute Target 설명
All 이 표의 나머지 모든 요소
Assembly 어셈블리
Module 모듈
Interface 인터페이스
Class 클래스
Struct 구조체
ClassMembers 클래스 안에 선언된 클래스나 구조체를 포함한 클래스의 모든 멤버
Constructor 생성자
Delegate 대리자
Enum 열거형
Event 이벤트
Field 필드
Property 프로퍼티
Method 메소드
Parameter 메소드의 매개변수
ReturnValue 메소드의 반환값

 

이 Attribute Target은 논리합 연산자를 이용해서 결합할 수도 있다.

예를 들어 AttributeTargets.Calss | AttributeTargets.Method 를 System.AttributeUsage의 애트리뷰트 매개변수에 넘기면 된다.

[System.AttributeUsage(
    System.AttributeTargets.Class | System.AttributeTargets.Method,
    AllowMultiple=true)]
class History : System.Attribute
{
    // ...
}

 

System.AttributeUsage의 두 번째 매개변수인 AllowMultiple은 애트리뷰트를 여러 번 사용하기 위해 찾던 것이다.

이 매개변수에 true를 대입하면 여러 번 사용할 수 있게 된다.