언어/C#

C# - 리플렉션 (Reflection)

tsyang 2021. 7. 4. 23:55

리플렉션


 

리플렉션은 컴파일 시에 알 수 없었던 타입이나 멤버들을 찾아내고 사용할 수 있게 해주는 메커니즘이다. 그러나 다음의 주요한 단점이 존재한다.

 

  • 리플렉션을 사용하면 컴파일 시에 타입 안정성을 해친다.
  • 리플렉션은 전반적으로 느리다. 어셈블리에서 정의하는 메타데이터를 살필 때 항상 문자열 검색이 수행되어야 한다. 
  • 리플렉션을 이용하여 멤버를 호출하면 성능에 좋지 않은 영향을 미친다. 따라서 먼저 매개변수들을 배열로 포장해야 한다. 내부적으로는 이렇게 포장된 내용을 다시 꺼내어 스레드의 스택에 옮긴다. 추가적으로 CLR이 메서드 호출 전에 각각의 매개변수들이 올바른 타입을 가지고 있는지 확인하고 호출자가 호출하려는 멤버에 접근할 보한 권한이 있는지 확인해야 한다.

 

상기의 이유로, 타입의 필드나 메서드 혹은 속성에 접근할 때에는 리플렉션을 사용하지 않는 것이 바람직하다. 만일 동적으로 타입을 찾고 타입 인스턴스를 생성해야 한다면, 컴파일 시에 구조를 알 수 있는 기본 타입(or 인터페이스)을 상속하도록 타입을 구성하는 것을 먼저 고려해보는 것이 좋다.

 

기본 타입을 사용한다면 추후에 기본 타입에 멤버들을 추가하기만 하면 되지만, 하나의 기본 타입만 상속받을 수 있기에, 개발자가 상황에 잘 부합하는 기본 타입을 선택할 여지가 없다.

인터페이스를 사용한 경우, 인터페이스에 멤버를 추가하면 인터페이스를 구현하는 모든 타입을 수정하고 다시 컴파일해야 한다. 그러나 개발자가 상황에 맞는 타입을 선택할 수 있다. 참고로 제프리 리처는 이 방법을 선호한다고 한다.

 

 


 

타입 오브젝트란?

System.Type 타입은 타입의 참조를 표현한다. System.ObjectGetType 메서드를 호출하면 객체의 타입을 확인한 후, 그 타입 자체를 참조하는 Type 객체를 반환한다. 하나의 앱도메인 안에는 특정 타입을 나타내는 Type 객체가 하나만 존재하기에 다음과 같이 두 객체를 대상으로 타입이 같은지 비교할 수 있다.

public static bool IsSameType(this object o1, object o2)
{
    return o1.GetType() == o2.GetType();
}

 

GetType 메서드보다는 타입 연산자를 사용하는게 좀 더 빠른 코드를 생성할 수 있다. C#에서는 typeof 연산자가 있다. 타입 연산자는 주로 늦은 바인딩으로 얻은 타입과 이른 바인딩으로 얻은 타입을 비교하는 용도로 사용한다.

private static void SomeMethod(object o)
{
    var type = o.GetType();
    if (type == typeof(SomeTypeA)) { }
    else if(type == typeof(SomeTypeB)) { }
}

 

위의 연산의 경우 타입이 정확히 일치하는지 여부만 확인할 뿐, 호환되는 타입인지 여부는 확인할 수 없다. 만약 호환되는 타입인지를 알고 싶다면 형 변환이나 C#의 is, as 연산자를 사용해야 한다.

 

Type객체는 타입 참조를 나타내는 아주 가벼운 객체다. 만약 타입에 대해 더 알고 싶다면 TypeInfo 객체를 얻어와야 한다. System.Reflection.IntrosepctionExtensionsGetTypeInfo 확장 메서드를 이용하면 Type 객체를 TypeInfo 객체로 변환할 수 있다. 

 

TypeInfo 객체를 가져오려고 시도하면 CLR은 타입을 정의하는 어셈블리가 로드되었는지 확인한다. 이는 상당히 고비용의 작업이기 때문에 TypeInfo가 꼭 필요한 경우에만 TypeInfo를 얻어오는 게 좋다.

 


 

타입 인스턴스 생성

Type 객체를 사용하여 인스턴스를 생성할 수 있다. 이를 위해서 FCL은 다음과 같은 몇 가지 메커니즘을 제공한다.

 

System.Activator의 CreateInstance

이 메서드는 크게 두 가지 종류가 있는데 하나는 Type객체를 매개변수로 받는 종류이고 다른 하나는 객체의 타입을 문자열로 취하는 메서드이다.

public static object? CreateInstance(Type type);
//그외 오버로드된 메서드 다수 존재

public static ObjectHandle? CreateInstance(string assemblyName, string typeName);
//그외 오버로드된 메서드 다수 존재

ObjectHandle? CreateInstanceFrom(string assemblyFile, string typeName);
//위의 CreateInstance와 동일한 동작을 한다.

객체의 타입을 문자열로 받는 메서드는 우선 어셈블리를 구분할 수 있는 문자열을 전달해야 한다. 만약 리모팅 옵션이 올바르게 되어 있다면, 이 메서드를 이용해서 원격 객체를 생성할 수 있다. 이 메서드는 객체의 참조 대신 ObjectHandle 객체를 반환하다.

 

ObjectHandle은 특정 앱도메인에서 생성된 객체를 다른 앱 도메인에 물리적으로 생성하지 않고 전달할 수 있게 해 준다. 만약 객체를 물리적으로 생성하려면 ObjectHandleUnwrap메서드를 호출하면 된다. Unwrap을 호출하면 객체의 타입이 정의된 어셈블리를 앱 도메인에 로드해준다. 객체가 참조로 마샬링 되어있다면 프록시 타입의 객체가 생성되고, 값으로 마샬링 되어있다면 객체의 복사본을 deserialize 한다.

 

 

System.AppDomain의 메서드들

 

AppDomain 타입에서는 CreateInstance(AndUnrwap), CreateInstanceFrom(AndUnwrap) 메서드를 제공한다. 이 메서드들은 Activator의 메서드와 동일하게 동작하지만 모두 인스턴스 메서드이다. 즉, 객체가 생성될 앱 도메인을 지정할 수 있다는 차이가 있다.

 

 

System.Reflection.ConstructorInfo의 Invoke 인스턴스 메서드

 

TypeInfo 객체를 이용하면 타입의 생성자 중 하나를 나타내는 ConstructorInfo 객체를 가져올 수 있다. 이 객체의 Invoke 메서드를 호출하면 객체를 생성할 수 있다. 객체는 항상 이 메서드를 호출하는 앱 도메인에 생성되고, 생성한 객체의 참조를 반환한다.

 

위의 메커니즘들을 이용하면 배열(System.Array)과 델리게이트(System.MulticasteDelegate)를 제외한 모든 타입을 생성할 수 있다. 배열과 델리게이트의 경우 각각 Array.CreateInstance, MethodInfo.CreateDelegate 메서드를 이용해야 한다.

 

제너릭 타입의 인스턴스도 생성할 수 있는데, 우선 열려있는 타입의 참조를 얻어온다. 그다음에 MakeGenericType 메서드를 호출하여 닫힌 타입으로 만든 뒤 CreateInstance 메서드를 이용한다.

 

static void Main()
{
    Type openType = typeof(Dictionary<,>);
    Type closedType = openType.MakeGenericType(typeof(int), typeof(string));
    object obj = Activator.CreateInstance(closedType);

    var castedObj = obj as Dictionary<int, string>;
    if (castedObj != null)
    {
        castedObj.Add(1, "HELLO WORLD!");
        Console.WriteLine(castedObj[1]);
    }
    else
        Console.WriteLine("Casting Fails");

    //"HELLO WORLD!" 출력
}


리플렉션으로 타입의 멤버 찾기


 

이 기능의 경우 개발 도구 자체를 개발하거나 분석도구를 만들거나, 유니티의 경우 에디터를 만드는데 자주 쓰인다.

 

타입 내의 멤버 검색

타입의 멤버로서 정의할 수 있는 것은 필드, 생성자, 메서드, 속성, 이벤트, 중첩 타입이 있다.

 

리플렉션 타입의 계층도

 

 

private class SomeType
{
    private int someField;
    private string SomeProperty { get; set; }

    SomeType() { }

    void SomeMethod() { }
}

static void Main()
{
    foreach (MemberInfo mi in typeof(SomeType).GetTypeInfo().DeclaredMembers)
        Console.WriteLine($"{mi.MemberType} : {mi.Name}");

    //출력
    //Method: get_SomeProperty
    //Method : set_SomeProperty
    //Method : SomeMethod
    //Constructor : .ctor
    //Property : SomeProperty
    //Field : someField
    //Field : < SomeProperty > k__BackingField
}

위와 같은 방법으로 타입의 멤버를 조회할 수 있다.

 

리플렉션 객체 모델들을 순회하는 과정은 다음과 같다. 

  1. 앱 도메인으로부터 로드된 어셈블리를 찾아낸다.
  2. 어셈블리로부터 모듈들을 찾아낸다.
  3. 개별 어셈블리와 모듈 내에 정의된 타입들을 찾아낸다.
  4. 이 타입으로부터 멤버들을 찾아낸다.

 

네임스페이스의 경우 단순히 타입을 모아둔 것이므로 계층 구조에 포함되지 않는다. 만약 네임스페이스를 가져오고 싶다면 타입 내의 Namespace 속성을 확인하면 된다.

 


 

타입 내의 멤버 수행

 

  • FieldInfo : 필드 값을 가져오거나 할당하기 위해 GetValue, SetValue 호출
  • ConstructorInfo : Invoke를 호출하여 타입의 인스턴스 생성
  • MethodInfo : Invoke를 호출하면 메서드가 호출됨
  • PropertyInfo : GetValue, SetValue를 통해 get, set 접근자 메서드 호출
  • EventInfo : AddEventHandleradd 접근자 메서드를, RemoveEventHandlerremove 접근자 메서드를 호출

 

 

리플렉션 활용

public  class SomeType
{
    private int _someField;
    public string SomeProperty { get; set; }

    public event EventHandler SomeEvent;

    public SomeType(ref string str) { str = "Enter Constructor of SomeType"; }

    public string SomePublicMethod() { return "SomeType's Public Method"; }

    private string SomePrivateMethod() { return "SomeType's Private Method"; }
}

위와 같은 SomeType이 있다.

 

1. 멤버를 바인딩하고, 호출하는 법

static void Main()
{
    /* ------------------ 인스턴스 생성 ------------------ */
    Type ctorArg = Type.GetType("System.String&"); //typeof(string).MakeByRefType(); 와 같다.
    ConstructorInfo ctor = typeof(SomeType).GetTypeInfo().DeclaredConstructors
        .First(ctor => ctor.GetParameters()[0].ParameterType == ctorArg);
    object[] args = new object[] { "Before Enter Constructor of SomeType" };

    Console.WriteLine(args[0]); 
    // 출력 : "Before Enter Constructor of SomeType"

    object obj = ctor.Invoke(args);

    Console.WriteLine(args[0]);
    // 출력 : "Enter Constructor of SomeType" => args의 값 변경


    /* ------------------ 필드 읽고 쓰기------------------ */
    FieldInfo fi = obj.GetType().GetTypeInfo().GetDeclaredField("_someField");

    //private field임에도 읽고 쓰기 가능
    fi.SetValue(obj, 20);
    Console.WriteLine(fi.GetValue(obj)); // 출력 : 20


    /* ------------------ 메서드 호출 ------------------ */
    MethodInfo mi = obj.GetType().GetTypeInfo().GetDeclaredMethod("SomePrivateMethod");

    //private 메서드임에도 호출 가능
    string s = (String)mi.Invoke(obj, null); //두번째는 parameter        
 
    Console.WriteLine(s);
    // 출력 : SomeType's Private Method        


    /* ------------------ 속성 ------------------ */
    PropertyInfo pi = obj.GetType().GetTypeInfo().GetDeclaredProperty("SomeProperty");
    pi.SetValue(obj, "PropertyInfo.SetValue");

    Console.WriteLine(pi.GetValue(obj));
    // 출력 : PropertyInfo.SetValue


    /* ------------------ 이벤트 ------------------ */
    EventInfo ei = obj.GetType().GetTypeInfo().GetDeclaredEvent("SomeEvent");
    EventHandler eh = new EventHandler((object sender, EventArgs e) => { });
    ei.AddEventHandler(obj, eh);
    ei.RemoveEventHandler(obj, eh);
}

특이한 점은 private 필드를 읽고 쓰거나 private 메서드를 호출할 수 있음

 

 

2. 객체나 멤버를 참조하는 델리게이트 생성

static void Main()
{
    /* ------------------ 인스턴스 생성 ------------------ */
    object[] args = new object[] { "Before Enter Constructor of SomeType" };
    Console.WriteLine(args[0]);
    // 출력 : "Before Enter Constructor of SomeType"

    object obj = Activator.CreateInstance(typeof(SomeType), args); //인스턴스 생성

    Console.WriteLine(args[0]);
    // 출력 : "Enter Constructor of SomeType" => args의 값 변경


    /* ------------------ delegate로 메서드 호출 ------------------ */
    MethodInfo mi = obj.GetType().GetTypeInfo().GetDeclaredMethod("SomePrivateMethod");
    var del = mi.CreateDelegate<Func<string>>(obj);

    string s = del();
 
    Console.WriteLine(s);
    // 출력 : SomeType's Private Method        


    /* ------------------ 속성 ------------------ */
    PropertyInfo pi = obj.GetType().GetTypeInfo().GetDeclaredProperty("SomeProperty");

    var setSomeProp = pi.SetMethod.CreateDelegate<Action<string>>(obj); //pi.SetMethod는 MethodInfo
    var getSomeProp = pi.GetMethod.CreateDelegate<Func<string>>(obj);

    setSomeProp("SetSomeProp - Delegate");
    Console.WriteLine(getSomeProp());
    // 출력 : SetSomeProp - Delegate


    /* ------------------ 이벤트 ------------------ */
    EventInfo ei = obj.GetType().GetTypeInfo().GetDeclaredEvent("SomeEvent");

    var addSomeEvent = ei.AddMethod.CreateDelegate<Action<EventHandler>>(obj);
    var removeSomeEvent = ei.RemoveMethod.CreateDelegate<Action<EventHandler>>(obj);

    addSomeEvent(EventCallback); 
    removeSomeEvent(EventCallback);
}

private static void EventCallback(object sender, EventArgs e) { }

 

델리게이트를 이용한 호출 방식은 매우 빠르다. 동일 객체의 동일 멤버를 여러 번 호출해야 하는 경우 매우 빠른 성능을 보여준다.

 

 

 

3. dynamic 기본 타입을 활용

static void Main()
{
    /* ------------------ 인스턴스 생성 ------------------ */
    object[] args = new object[] { "Before Enter Constructor of SomeType" };
    Console.WriteLine(args[0]);
    // 출력 : "Before Enter Constructor of SomeType"

    dynamic obj = Activator.CreateInstance(typeof(SomeType), args); //dynamic 사용!!

    //obj의 타입은 뭘까? 참고로 컴파일타임에 유추 불가능
    Console.WriteLine(obj.GetType()); 
    //출력 : SomeType

    Console.WriteLine(args[0]);
    // 출력 : "Enter Constructor of SomeType" => args의 값 변경

    /* ------------------ 필드 읽고 쓰기------------------ */
    try
    {
        obj._someField = 99;
        int value = (int)obj._someField;
        Console.WriteLine(value); //출력되지 않는다.
    }
    catch (Exception e) //private 필드이므로 에러 발생할 것
    {
        Console.WriteLine(e.GetType());
    }
    //출력 : Microsoft.CSharp.RuntimeBinder.RuntimeBinderException
        


    /* ------------------ 메서드 호출 ------------------ */
    try
    {
        string s1 = (string)obj.SomePrivateMethod();
        Console.WriteLine(s1);
    }
    catch (Exception e) //private method이므로 exception 발생
    {
        Console.WriteLine(e.GetType());
    }
    //출력 : Microsoft.CSharp.RuntimeBinder.RuntimeBinderException

    try
    {
        string s2 = (string)obj.SomePublicMethod();
        Console.WriteLine(s2);
    }
    catch (Exception e)
    {
        Console.WriteLine(e.GetType());
    }
    //출력 : SomeType's Public Method


    /* ------------------ 속성 ------------------ */
    obj.SomeProperty = "SomePropByDynamic";
    Console.WriteLine((string)obj.SomeProperty);
    //출력 : SomePropByDynamic



    /* ------------------ 이벤트 ------------------ */
    obj.SomeEvent += new EventHandler(EventCallback);
    obj.SomeEvent -= new EventHandler(EventCallback);
}

private static void EventCallback(object sender, EventArgs e) { }

 

동일 타입의 동일 멤버를 호출할 때 설사 다른 객체라 하더라도 나름 괜찮은 성능을 보여준다. dynamic을 활용하면 타입별로 바인딩이 이뤄지고, 바인딩된 내용이 캐시 되기 때문에 여러 번 호출할 때 빠르게 동작한다. 이 기법으로 서로 다른 타입의 멤버를 호출할 때도 사용할 수 있다.

 

 


핸들 바인딩 기법

프로세스의 메모리 소비량을 줄이기 위해 핸들 바인딩 기법을 사용할 수 있다. 많은 응용프로그램에서 타입과 타입 멤버들을 바인딩한 후 이 객체들을 컬렉션에 저장하여 사용하곤 한다. 이런 방식은 좋은 기법이나, Type과 MemberInfo 계통의 객체들로 인해 자칫 메모리를 많이 소비할 가능성이 있다. 사용 빈도수가 매우 낮은 타입들을 나타내는 객체까지 컬렉션에 저장하게 되면 메모리 소비량이 증가할 수밖에 없다.

 

이때, 런타임 핸들(runtime handle)을 이용하여 프로세스의 워킹 셋을 감소시킬 수 있다. FCL의 System 네임스페이스에는 RuntimeTypeHandle, RuntimeFieldHandle, RuntimeMethodHandle 세 가지 형태의 런타임 핸들 타입이 정의되어 있다. 이 세 타입은 모두 값 타입이며, 단 하나의 IntPtr타입 필드만을 가지고 있기 때문에 상당히 가벼운 타입이다. 이 IntPtr필드는 앱 도메인의 로더 힙에 존재하는 타입, 필드, 메서드를 참조한다. 

 

변환 방법은 구글링 하면 나온다.

 

🤢

 

참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 23장. 어셈블리 로딩과 리플렉션 [679~711p]

'언어 > C#' 카테고리의 다른 글

CLR 스레딩1 기본  (0) 2022.04.10
런타임 Serialization - 1  (1) 2022.04.03
C# - CLR 호스팅과 앱도메인  (1) 2021.06.27
C# - 관리 힙과 GC (2)  (1) 2021.04.03
C# - 관리 힙과 GC (1)  (2) 2021.03.28