본문 바로가기

프로그래밍/C#

CLR via C# 23장 어셈블리 로딩과 리플렉션

Notion에서 보기

 

이번 장에서는 컴파일 타임에 전혀 알지 못했던 타입에 대해 타입의 세부 정보를 알아내고 인스턴스를 생성하는 등의 방법(리플렉션)에 대한 것을 배운다. 이는 주로 동적으로 확장 가능한 프로그램을 만들 때 활용되는 기법이다.

 동적으로 확장 가능한 프로그램은 22장에서 살펴본 CLR 호스팅과 앱도메인의 장점을 활용할 수 있다. 호스트는 고유의 앱도메인 내에서 다른 앱도메인의 코드(애드인 등)를 수행할 수 있고 또 언로드할 수 있다.

 


어셈블리 로딩

 컴파일 타임에 JIT 컴파일러가 IL을 컴파일하다 보면 어떤 타입들이 참조되고 있는지를 알게 된다. 그리고 런타임에는 참조되고 있는 타입들이 어느 어셈블리에 정의되어 있는지를 확인하기 위해서 TypeRef, AssemblyRef 메타데이터 테이블을 이용한다.

 AssemblyRef 테이블에는 어셈블리의 이름을 구성하기 위해 필요한 모든 조건들을 가지고 있다. JIT 컴파일러는 이름, 버전, 문화권, 공용 키 토큰을 모두 모아 문자열로 결합한 후, 어셈블리 구분자를 만들고 이를 이용하여 어셈블리 앱도메인으로 읽어오려 한다.

 내부적으로 CLR은 System.Reflection.Assembly 클래스의 static Load 메서드를 이용해서 이를 로드한다. (Win32의 LoadLibrary의 CLR 버전)

 

public class Assembly 
{
	public static Assembly Load(AssemblyName assemblyRef);
	public static Assembly Load(string assemblyString);
	// ...
}

 

CLR은 Load가 호출되면 정책에 따라 해당 어셈블리를 찾아나간다.

  • 전역 어셈블리 캐시.

  • 응용프로그램의 기본 디렉토리 / 그 하위 디렉토리.

  • 코드 베이스 위치

어셈블리를 찾아냈다면, Assembly 객체를 생성하고 그 참조를 반환한다. 만약 Load 메서드가 어셈블리를 찾지 못하면, System.IO.FileNotFoundException을 발생시킨다.

 동적으로 확장 가능한 대부분의 응용프로그램이 Assembly.Load를 이용하여 특정 앱도메인으로 어셈블리를 로드한다.

만약 사용자로부터 특정 어셈블리의 경로명을 받아 로드하려면 Assembly.LoadFrom 메서드를 호출할 수 있다.

 

public class Assembly 
{ 
	public static Assembly LoadFrom(string path); 
}

 AppDomain.Load 메서드는 어셈블리의 참조를 반환한다. Assembly 클래스는 System.MarshalByRefObject를 상속하기 않았기 때문에, 호출한 앱도메인 쪽으로 객체를 반환하기 위해선 반드시 값으로 마샬링되어야 한다.

 내부적으로는 LoadFrom은 System.Reflection.AssemblyName.GetAssemblyName을 호출한다.

 해당 메서드 내부에서는 지정한 파일을 열고 AssemblyDef 메타데이터 테이블의 항목을 찾아서 어셈블리 구분자 정보를 획득한 후, AssemblyName 객체로 반환해준다.

 이제 LoadFrom은 내부적으로 위의 Assembly.Load를 호출하는데, 이 때 반환 된 AssemblyName을 매개 변수로 전달한다.

Load 메서드가 지정한 어셈블리를 찾았다면, 그냥 그 값을 반환하고 그렇지 않다면 그제서야 매개변수로 지정된 파일 경로에서 어셈블리를 로드한다.

 LoadFrom 메서드의 매개변수로 URL을 전달할 수도 있는데, 이 경우 CLR이 파일을 다운로드하여 캐시에 저장한 후 그 위치로부터 파일을 로드한다.

 이 경우 파일이 다운로드 되어있지 않거나, 오프라인 상태라면 예외가 발생된다.

 만약 리플렉션을 통하여 어셈블리의 메타데이터를 단순히 분석하고 싶고, 어셈블리 내의 실행 코드는 전혀 필요하지 않은 경우라면, 아래의 Assembly.ReflectionOnlyLoadFrom / Assembly.ReflectionOnlyLoad를 사용할 수 있다.

 

public class Assembly
{
	public static Assembly ReflectionOnlyLoadFrom(string assemblyFile);
	public static Assembly ReflectionOnlyLoad(string assemblyString);
}

 

 위의 메서드를 사용하여 어셈블리를 로드하면 어셈블리 내의 코드가 수행되는 것을 허용하지 않는다. 수행시키는 경우 InvalidOperationException이 발생한다.

 위의 메서드를 이용하면 지연-서명 되었거나 보통의 경우 보안 자격이 없어서 로드할 수 없거나, CPU 아키텍쳐가 달라 로드할 수 없는 어셈블리들을 로드할 수 있다.

 위의 메서드 중 하나의 방법으로 로드된 어셈블리를 분석하려는 경우, 참조하는 어셈블리를 수동으로 로드하기 위해, AppDomain.ReflectionOnlyAssemblyResolve 이벤트에 콜백 메서드를 등록해야한다.

 CLR이 자동으로 이 작업을 수행해주지 않는데, 콜백 메서드가 수행되면 참조하는 어셈블리를 명시적으로 로드하고 이 어셈블리에 대한 참조를 반환받기 위해 위의 메서드들을 호출해줘야 한다.

 

internal void Run()
{
    AppDomain curDomain = AppDomain.CurrentDomain;

    curDomain.ReflectionOnlyPreBindAssemblyResolve += new ResolveEventHandler(MyReflectionOnlyResolveEventHandler);

    Assembly asm = Assembly.ReflectionOnlyLoadFrom(m_rootAssembly);

    // force loading all the dependencies
    Type[] types = asm.GetTypes();

    // show reflection only assemblies in current appdomain
    Console.WriteLine("------------- Inspection Context --------------");

    foreach (Assembly a in curDomain.ReflectionOnlyGetAssemblies())
    {
        Console.WriteLine("Assembly Location: {0}", a.Location);
        Console.WriteLine("Assembly Name: {0}", a.FullName);
        Console.WriteLine();
    }
}



private Assembly MyReflectionOnlyResolveEventHandler(object sender, ResolveEventArgs args)
{
    AssemblyName name = new AssemblyName(args.Name);

    String asmToCheck = Path.GetDirectoryName(m_rootAssembly) + "\\" + name.Name + ".dll";

    if (File.Exists(asmToCheck)) {

        return Assembly.ReflectionOnlyLoadFrom(asmToCheck);
    }
    return Assembly.ReflectionOnlyLoad(args.Name);
}

동적으로 확장 가능한 응용프로그램을 만들기 위해서 리플렉션을 사용하기

 메타데이터는 여러 개의 테이블에 저장되어 있다.

 어셈블리나 모듈을 생성하면 사용 중인 컴파일러가 타입 정의, 필드 정의, 모듈 정의 테이블등의 여러 테이블을 생성하게 된다. System.Reflection 안의 여러 타입들을 사용하면, 이러한 메타데이터 테이블의 내용을 리플렉션하는 코드를 사용할 수 있다.

 리플렉션은 어떤 작업을 완료하기 위해 특정 어셈블리 내의 타입을 런타임시에 로드해야 할 경우 사용되곤 한다. 예를 들어 사용자로부터 어셈블리와 타입의 이름을 입력 받는 프로그램의 경우, 입력한 정보를 바탕으로 어셈블리를 로드하고 인스턴스 생성, 특정 메서드를 호출해야 할 수 있다.

 이는 개념적으로 Win32의 LoadLibrary와 GetProcAddress 함수와 유사하다. 이렇게 타입과 호출할 메서드를 런타임 시에 바인딩하는 것을 Late Binding이라고 한다.

리플렉션의 성능

 리플렉션은 컴파일 시에는 알 수 없던 타입이나 멤버들을 찾아내고 사용할 수 있다는 측면에서 대단히 강력하지만, 두 가지 주요한 단점이 있다.

  1. 컴파일 시의 타입 안정성

    리플렉션은 문자열을 광범위하게 사용하기 때문에 컴파일 타임의 타입 안정성을 잃게 된다. "int" 타입을 찾기 위해 Type.GetType("int"); 라고 코드를 친다면 CLR은 해당 타입을 "System.Int32"로 알고 있기 때문에 null을 반환한다.

  2. 전반적으로 느리다.

    결국 리플렉션을 사용하게 되면 런타임에 어셈블리에서 정의하고 있는 메타데이터를 살표보기 위해 System.Reflection에 있는 타입을 이용해야하고 타입 검색을 수행해야 한다.

 리플렉션을 이용하여 멤버를 호출하면 성능에 좋지 않은 영향을 미치게 된다.

 우선 리플렉션을 이용하여 메서드를 호출하려면 먼저 매개변수들을 배열로 포장해야하고, 이를 다시 꺼내어 스레드의 스택에 옮겨야 한다. 추가적으로 CLR은 메서드 호출 전에 개별 매개변수가 올바른 타입을 가지고 있는지 확인해야하고, 호출자가 호출하려는 멤버에 접근할 수 있는 충분한 보안 권한이 있는지 확인해야 한다.

 이런 이유로 타입의 필드나 메서드 혹은 속성 등에 접근할 때에는 리플렉션을 사용하지 않는 것이 바람직하다. 만약 동적으로 타입을 찾고 타입 인스턴스를 생성해야 한다면 다음과 같은 접근 방법을 고려해보기 바란다.

  • 컴파일 시에 타입의 구조를 알 수 있는 기본 타입을 상속하도록 타입을 구성.

  • 컴파일 시에 타입의 구조를 알 수 있는 인터페이스를 상속하도록 타입을 구성.

 추후 수정 가능성을 고려하면 기본 타입에 멤버들을 추가하기만 하면 되는 기본 타입을 이용하는 방법이 좀 더 유리한 측면이 있지만, 기본 타입은 하나의 타입만을 상속 받을 수 있기 때문에 인터페이스를 이용하는 방법을 선호하는 편이다.

이러한 방법 중 하나를 사용하기로 하였다면, 위의 인터페이스나 기본 타입은 반드시 자신의 어셈블리 내에 포함시켜야 한다.

어셈블리 내에 정의된 타입 찾기.

 리플렉션은 종종 어셈블리가 어떤 타입들을 정의하고 있는지 알아보기 위해 사용되고는 하는데, 가장 많이 사용되는 API는 단연코 Assembly의 ExportedTypes 속성일 것이다.

 

using System;
using System.Reflection;

public static class Program 
{
    public static void Main() 
    {
        string dataAssembly = 
            "System.Data, version=4.0.0.0, culture=neutral, PublicKeyToken ...";
        LoadAssemAndShowPublicTypes(dataAssembly);
    }

    public static void LoadAssemAndShowPublicTypes(string assemId)
    {
        // 현재 앱 도메인에 명시적으로 어셈블리를 로드한다.
        Assembly a = Assembly.Load(assemId);

        // 로드된 어셈블리로부터 public으로 노출된 각각의 타입별로 루프가 수행된다.
        foreach (Type t in a.ExportedTypes)
        {
            Console.WriteLine(t.FullName);
        }
    }
}

 

타입 오브젝트란?

 위에서 System.Type 객체를 순차적으로 순회한다. System.Type 타입은 타입과 객체를 다루는 데 있어서 시작 지점이 되는 타입으로 타입의 참조를 표현한다. (타입의 정의와 반대되는)

 System.Object의 GetType() 메서드를 사용하면 Object의 타입을 참조하는 Type 객체를 반환해준다. 하나의 앱도메인 안에는 특정 타입을 나타내는 Type 객체가 하나씩만 존재하기 때문에 아래와 같이 동일성 확인 연산을 수행할 수 있다.

private static bool AreObjectTheSameType(object o1, object o2) 
{
	return o1.GetType() == o2.GetType();
}

이 외에도 타입을 얻는 다른 메서드들이 많이 있다.

  • System.Type.GetType(string typename)

  • System.Type.ReflectionOnlyGetType()

  • System.TypeInfo.DeclaredNestedTypes, GetDeclaredNestedType

  • System.Reflection.Assembly.GetType, DefinedTypes, ExportedTypes

 여러 프로그램 언어에서 컴파일 시에 이미 알려져 있는 타입 이름을 이용하여 Type 객체를 얻어 올 수 있는 연산자를 제공한다. 가능하면 앞의 메서드들 보다는 C#에서 사용되는 typeof 연산자를 사용하는 것이 좋다. 앞의 메서드보다 더 빠른 코드를 생성할 수 있다.

typeof는 Late Binding으로 획득한 타입 정보와 Early Binding으로 획득한 타입 정보를 비교하는 용도로 사용한다.

 

private static void Method(object o) 
{
    if (o.GetType() == typeof(FileInfo) { ... } 
    if (o.GetType() == typeof(DirectoryInfo) { ...} 
}

 

 위의 코드는 o.GetType (Late Binding 정보)와 typeof(FileInfo) (Early Binding 정보)를 비교하고 있다. 다만 위의 코드는 정확히 타입이 일치하는지 여부를 확인하는 것이고 호환되는 타입인지는 체크할 수 없다. C#의 is, as 연산자를 사용하면 호환 여부까지 포함하여 비교할 수 있다.

 Type은 객체를 참조하는 아주 가벼운 객체이고, 타입에 대해 좀 더 알고 싶다면 타입의 정의를 나타내는 TypeInfo 객체를 얻어와야 한다.

System.Reflection.IntrospectionExtensions의 GetTypeInfo Extension 메서드를 사용하면 Type객체를 TypeInfo 객체로 변환할 수 있다.

 

Type typeReference = ...; 
TypeInfo typeDefinition = typeReference.GetTypeInfo();

 

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

 그러나 가져 온 뒤에는 타입에 대해서 아주 많은 부분들을 살펴볼 수 있다. 대부분의 속성의 타입과 관련된 플래그 정보들을 확인할 수 있도록 해준다.

 


타입 인스턴스 생성

Type 객체를 가지고 있다면, 이 타입으로 인스턴스를 생성하고 싶을 수 있다. 이를 위한 몇 가지 방법이 있다.

  • System.Activator의 CreateInstance 메서드

    이 메서드를 호출할 때 Type 객체를 전달하거나 타입 구분자를 전달하면 객체를 생성할 수 있다. Type 객체를 전달하는 것이 더 간편하고, 문자열을 전달하는 메서드는 조금 더 복잡하다.

  • System.Activator의 CreateInstanceFrom 메서드

    CreateInstance 메서드와 동일하게 동작하지만, 매개변수로 타입을 정의하고 있는 어셈블리를 항상 같이 지정해주어야 한다.

  • System.AppDomain의 메서드

    Activator의 메서드들과 동일하게 동작하지만 모두 인스턴스 메서드기 때문에 객체가 생성될 앱도메인을 지정할 수 있다.

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

    TypeInfo 객체를 이용하면 타입의 생성자 중 하나를 나타내는 ConstructorInfo를 가져올 수 있고, 이 객체의 Invoke 메서드를 호출하면 객체를 생성할 수 있다.


애드인을 지원하는 응용프로그램 설계

확장 가능한 프로그램을 개발하는데에는 인터페이스를 설계하는 것이 중요하다.

  • 호스트 프로그램과 애드인 컴포넌트 사이에 통신 메커니즘에 사용될 메서드를 인터페이스로 정의하고 있는 호스트 SDK 어셈블리를 작성하라. 인터페이스의 정의를 확정지었다면 어셈블리에 강력한 이름을 부여한 후, 패키지로 묶어 전달하면 된다. 어셈블리를 조금이라도 수정했다면 배포자 정책 파일 (publisher policy file)과 함께 어셈블리를 배포하는 것이 좋다.

  • 애드인 개발자는 자신이 개발하는 애드인 타입의 어셈블리에 호스트 SDK를 참조하도록 만들것이다.

  • 호스트 SDK 어셈블리를 호스트 프로그램을 구현하고 있는 어셈블리와 분리하자. 애드인 어셈블리는 SDK 어셈블리를 참조할 것이고 구현하고 있는 어셈블리를 아무리 바꾸더라도 애드인 개발자에게 영향을 미치지 않을 것이다.


타입 내의 멤버를 찾기 위해 리플렉션 사용하기

 좋은 성능과 타입 안정성을 유지하려면 가능한 리플렉션을 적게 사용하는 것이 좋다. 동적으로 확장 가능한 시나리오에서도 컴파일 타임에 이미 알려진 인터페이스 타입이나 베이스 타입으로 형 변환을 수행해야 한다.

 지금까지와는 다르게 리플렉션을 사용하여 타입의 멤버를 찾고 수행하는 방법에 대해서 알아보자. 이런 기능은 IIDasm.exe, FxCopCmd.exe 등의 도구와 유틸리티를 만들때 많이 사용된다.

 


타입 내의 멤버 검색

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

FCL은 System.Reflection.MemberInfo라는 추상 기본 클래스를 통해서 타입 멤버들의 공통 속성을 캡슐화하고, 이 MemberInfo를 상속하여 개별 타입 멤버의 고유 특성을 캡슐화하고 있다.

 

 

 아래 코드와 같이 타입의 멤버를 가져오고 정보를 출력할 수 있다. 아래의 코드는 도메인 내의 모든 어셈블리들을 대상으로 public으로 정의되어 있는 모든 타입을 처리한다.

using System;
using System.Reflection;

public static class Program
{
    public static void Main()
    {
        // 이 도메인에 로드되어 있는 모든 어셈블리에 대하여 루프를 돈다.
        Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();

        foreach (var a in assemblies)
        {
            Show(0, "Assembly : {0}", a);

            foreach (Type t in a.ExportedTypes)
            {
                Show(1, "Type : {0}", t);

                // 타입 내의 정의된 멤버들.
                foreach (MemberInfo mi in t.GetTypeInfo().DeclaredMembers)
                {
                    string typeName = string.Empty;
                    if (mi is Type) typeName = "(Nested) Type";
                    if (mi is FieldInfo) typeName = "FieldInfo";
                    if (mi is MethodInfo) typeName = "MethodInfo";
                    if (mi is ConstructorInfo) typeName = "ConstructorInfo";
                    if (mi is PropertyInfo) typeName = "PropertyInfo";
                    if (mi is EventInfo) typeName = "EventInfo";
                    Show(2, "{0} : {1}", typeName, mi);
                }
            }
        }
    }

    public static void Show(int indent, string format, params object[] args)
    {
        Console.WriteLine(new string(' ', 3 * indent) + format, args);
    }
}

 

 MemberInfo는 아래와 같은 readonly 속성들을 제공하여 사용자에게 정보를 제공한다.

 

MemberInfo 계통의 타입들이 공통적으로 사용할 수 있는 속성

멤버 이름 멤버 타입 설명
Name string 멤버의 이름을 반환
DeclaringType Type 멤버를 선언하고 있는 Type을 반환
Module Module 멤버를 선언하고 있는 Module을 반환
CustomAttributes IEnumerable<CustomAttributeData>를 반환하는 속성 이 멤버에 적용된 사용자 정의 특성을 나타내는 인스턴스들의 컬렉션 반환

 

 이 외에도 문자열을 이용하여 특정 타입의 멤버만을 가져오는 다양한 메서드들도 있다. (TypeInfo.GetDeclaredNestedType...)

타입 내의 멤버 수행

 이제 타입 내에 정의되어 있는 멤버들을 어떻게 찾아내는지는 알았으므로, 이 멤버들을 어떻게 수행할 수 있는지에 대해서 알아보자.

 

멤버 수행 방식

멤버의 타입 메서드
FieldInfo GetValue / SetValue
ConstructorInfo Constructor
MethodInfo Invoke
PropertyInfo GetValue / SetValue
EventInfo AddEventHandler / RemoveEventHandler

 

 프로세스의 메모리 소비량을 줄이기 위해서 핸들 바인딩 기법 사용하기.

 많은 프로그램에서 타입과 타입 멤버들을 바인딩한 후, 이들을 컬렉션에 저장해두는 기법을 사용하곤 한다. 이런 방식은 사용 빈도 수가 매우 낮은 타입까지 저장하게 될 경우 메모리 소비량이 증가할 수 밖에 없다.

 CLR은 내부적으로 이러한 정보를 나타내는 좀 더 간결한 방법을 가지고 있어서 개발자가 이를 활용할 수 있다. 만일 상당히 많은 Type과 MemberInfo 계통의 객체들을 처리해야 할 필요가 있다면 런타임 핸들(runtime handle)을 이용하여 프로세스의 부하를 줄일 수 있다.

 FCL의 System안에는 RuntimeTypeHandle, RuntimeFieldHandle, RuntimeMethodHandle의 세 가지 런타임 핸들 타입을 정의하고 있다. 이 세가지 타입은 모두 값타입이고 IntPtr 타입의 필드 하나만을 가지고 있기 때문에 메모리 소비 측면에서 매우 가볍다.

 해당 타입의 IntPtr 필드는 앱도메인의 로더 힙에 존재하는 타입, 필드, 메서드를 참조하도록 구성되어 있다. 이제 무거운 Type/MemberInfo 객체를 이들로 변경하고 그 반대로 변경하는 방법을 알아보자.

 

목적 사용 메서드
Type -> RuntimeTypeHandle Type.GetTypeHandle(Type t)
RuntimeTypeHandle -> Type Type.GetTypeFromHandle(RuntimeTypeHandle th)
FieldInfo -> RuntimeFieldHandle FieldInfo.FieldHandle property
RuntimeFieldHandle -> FieldInfo FieldInfo.GetFieldFromHandle(RuntimeFieldHandle fh)
MethodInfo -> RuntimeMethodHandle MethodInfo.MethodHandle property
RuntimeMethodHandle -> MethodInfo MethodInfo.GetMethodFromHandle(RuntimeMethodHandle mh)

 

 해당 기법을 사용하면 아래와 같이 메모리 효율을 볼 수 있다.