Fusion 7 : Loading Assemblies Dynamically


오늘은 동적으로 어셈블리를 로드하는 기법에 대해 알아본다.Assembly.LoadFrom(), Assembly.Load()와 같은 Reflection 메서드를 사용하는데, 이게 내부적으로 동작하는게 만만치 않게 복잡한 편이다. 현업에선 주로 LoadFrom()/Load()를 사용해 어셈블리를 동적으로 로딩한 후, 이를 활성화 하여 사용하는데, 이렇게 하는 이유는, 어플리케이션의 재컴파일없이 구성정보 등를 통해 어플리케이션의 기능을 확장(메뉴를 추가하거나 화면을 추가함)할 수 있고, 또 NTD(No-Touch Deployment)가 가능하다는 점이라 생각한다.
일반적인, 정적으로 참조가 걸린 라이브러리 형태에서, 후에 새로운 라이브러리를 생성해 기존 어플리케이션에 기능 또는 화면으로 추가하고 싶다면, 기존 어플리케이션을 개발환경에서 열고, 새 라이브러리에 대한 참조를 추가 한후 다시 컴파일 해서 다시 클라이언트에 ClickOnce 등을 통해 배포해야 한다.
그러나 동적으로 어셈블리를 로딩하는 구조라면? 구성(파일 또는 DB)을 통해 로딩할 기능(화면)을 정의해 두고, 이를 어플리케이션 시작 시에 읽어들여, 필요한 기능(화면) 에 대한 라이브러리 어셈블리를 공용의 저장소로 부터 동적으로 로딩해 사용한다면, 기존 어플리케이션을 재 컴파일할 필요가 없고, 배포할 필요도 없어진다.
그러나 당연히 이 아키텍쳐도 단점이 있으며 모든 환경에 적합한 절대 "참"의 구조라곤 할 수 없다..(아래에 조금 설명됨)

즉 자주 수정될 수 있는 어플리케이션 모듈은 서버 상의 공용 저장소에 올려두고 클라이언트 어플리케이션은 이를 LoadFrom/Load 해서 동적으로 가져다 쓰게 되면, 모듈이 수정되더라도 클라이언트 측에 새로 배포할 부담이 (거의) 없다. 그러나 클라이언트에 내려와 있는 모듈이 아니므로 이 모듈에 대한 초기 로딩 시에 동적 로딩으로 인해 발생하는 지연은 감수해야만 한다. 한 번 로딩 후에는 클라이언트 (Internet Explorer) 캐시 상의 모듈이 사용되므로 초기 지연이 다시 발생하진 않지만, 캐시 상의 모듈이 언제 다시 refresh 될지는 ..... 예측 가능한 부분, 신뢰할 수 있는 부분이 아니다. 한가지 확실한 건, 모듈이 변경되지 않는 이상, 최소한 프로그램 실행 동안에는 캐시에 존재하는 모듈을 사용한다. 종료 후 다시 시작하면?.. 사용될 수도, 그렇지 않을 수도...-_-! .. 오늘 쫌 길다..맘 단디 무라...

* 용어 : 라이브러리 - Library 형태의 닷넷 어셈블리, 즉 *.dll

라이브러리가 정적으로 연결되었다 하더라도(static linked) 지연된 로딩이 발생하게 된다. 이는 라이브러리의 풀 네임으로 참조하는 어셈블리라 하더라도, 라이브러리는 처음 사용되기 직전에 로딩된다는 의미이다.
라이브러리가 strong name을 가지고 있지 않다면, 이는 어플리케이션 폴더나 그 하위의 폴더에 존재할 수 있다. 만일 strong name을 가지고 있다면, 어플리케이션 폴더, 로컬 디스크 상의 어떤 위치, GAC 또는 다른 머신 상에 위치할 수 있다.

.NET Framework은 코드 상에서 어셈블리를 로드할 수 있는 방법을 제공한다. 하지만 이는 컴파일 타임에 해당 라이브러리의 메타데이터를 알 수 없으므로 자주 사용되어 지는 방법은 아닐 것이다. 이 경우 타입과 멤버의 메터데이터 정보가 요청하는 어셈블리내에 저장되지 않는게 되는 것이다.그리고 new 키워드를 사용해 객체를 생성할 수도 없다. 또는 타입의 멤버를 직접 호출할 수도 없다. 대신, 객체를 생성하기 위해 Framework의 활성화(Activation) 클래스를 사용해야 하며, 객체의 멤버에 접근하기 위해 Reflection을 사용해야 한다. 이는 Visual Basic의 "late 바인딩"과 같은 개념이면서 동일한 문제를 내포하고 있다.
Late 바인딩은 타입 검사가 런타임 시에 코드의 사용자, 즉 고객에 의해 수행된다. 가급적 late binding은 지양해야 할 방법이라 할 수 있다.
라이브러리는 어플리케이션 도메인 상에 로드되는데 라이브러리 로드를 위한 메커니즘은 존재하지만 언로드를 위한 메커니즘은 존재하지 않는다. 하지만 라이브러리들이 적재되어 있는 전체 어플리케이션 도메인을 언로드 하는 메커니즘은 존재한다. 라이브러리를 동적으로 로드할 때 발생할 수 있는 몇가지 흥미로운 이슈들이 존재한다.


■ Reflection을 이용해서 어셈블리 로드하고, 메서드 호출하기

아래의 strong name을 가지고 버전이 1.0.0.0인 lib.cs를 사용한다.
만일 프로세스 어셈블리(lib을 참조하고 있는 어셈블리) 코드가 아래와 같다면,


using System;
using System.Reflection;
class App
{
static void Main()
{
try
{
object obj = FromAssembly("lib");
InvokeObject(obj);
}
catch(Exception e1)
{
Console.WriteLine(e1.Message);
}
}
static object FromAssembly(string shortName)
{
Console.WriteLine("Use Assembly.Load");
AssemblyName name = new AssemblyName();
name.Name = shortName;
Assembly a = Assembly.Load(name);
Type type = a.GetType("LibraryCode");
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
return ctor.Invoke(null);
}
static void InvokeObject(object obj)
{
Type type = obj.GetType();
MethodInfo mi = type.GetMethod("GetVersion");
string version = (string)mi.Invoke(obj, null);
Console.WriteLine(version);
}
}

위 코드에서 FromAssembly 메서드는 객체를 로드하고, InvokeOjbect 메서드는 객체의 GetVersion 메서드를 호출한다.
이 코드 내에서 FromAssembly는 어셈블리 명을 생성한 후 Assembly.Load() 를 이용해서 어셈블리를 로드한다.
컴파일 하고 실행; 라이브러리가 로컬 폴더에서 로드되는 것을 볼 수 있다. Private 어셈블리를 위해 Fusion은 오직 Short Name만을 확인한다.

라이브러리의 public key 토큰을 추출한다.

C:\TestFolder>sn -T lib.dll
Microsoft (R) .NET Framework Strong Name Utility Version 1.1.4322.573
Copyright (C) Microsoft Corporation 1998-2002. All rights reserved.
Public key token is 3bf941bb1f722efe


이제 라이브러리를 GAC에 추가하고 로컬 폴더에 있는 라이브러리 어셈블리(lib.dll)를 제거한다. 그리고 실행한다.
FileNotFoundException이 발생한다. 이유는 Assembly.Load()을 이용해서 GAC으로부터 어셈블리를 로드할 때, Fusion은 어셈블리의 Full Name을 요구하기 때문이다.

그러므로 코드를 변경하여 Full Name을 명시하도록 한다.

static object FromAssembly(string shortName)
{
Console.WriteLine("Use Assembly.Load");
AssemblyName name = new AssemblyName();
name.Name = shortName;
name.Version = new Version("1.0.0.0");
name.CultureInfo = new CultureInfo("");
byte[] pkt
= new byte[] {0x3b,0xf9,0x41,0xbb,0x1f,0x72,0x2e,0xfe};
name.SetPublicKeyToken(pkt);
Assembly a = Assembly.Load(name);
Type type = a.GetType("LibraryCode");
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
return ctor.Invoke(null);
}


배열 pkt는 public key 토큰의 바이트 배열이다. 이 경우 어셈블리가 culture 정보를 가지지 않는다. 하지만 neutral culture로 간주되어야 하므로 CultureInfo 객체가 공백문자열로서 초기화 되어 할당되어 진다.(CultureInfo 를 사용하기 위해서 System.Globalization 네임스페이스를 사용해야 함)
다시 컴파일 하고 실행 이제 GAC으로 부터 라이브러리가 로드되는 것을 알 수 있다.

Assembly.Load()에는 스트링을 인자로 받는 overload된 메서드를 제공한다.

어셈블리의 short name을 제공하여 호출하는 경우, 라이브러리가 private assembly 인 경우에만 로드가 된다. Short name을 제공했지만 private assembly가 아닌 경우 로드는 실패하게 된다.

□ 호출 : Assembly a = Assembly.Load("lib");

lib.dll : 어플리케이션 폴더에 존재하고 GAC에도 등록
결과 : GAC으로 부터 로드됨

l ib.dll:GAC에만 존재
결과 : 예외 발생 : Could not load file or assembly "lib" or one of its dependencies.

어셈블리에 대한 full name을 알고 있다면 아래 처럼 full name을 인자로 전달함으로써 GAC에서 로드할 수 있다.

호출 : Assembly a = Assembly.Load("lib, Version=1.0.0.0, Culture=neutral, PublicKeyToken=3bf941bb1f722efe");

lib.dll : 어플리케이션 폴더에 존재하고 GAC에도 등록
결과 : GAC으로 부터 로드됨

l ib.dll:GAC에만 존재
결과 : GAC으로 부터 로드됨

큰 그림 보려면 클릭하세요!(돋보기도 같이 활용)

결론:

Assembly.Load를 호출 시,

short name 을 사용해서 호출할 경우 반드시 어플리케이션 폴더에 참조하는 라이브러리 어셈블리가 존재해야 함.
이때 동일한 short name의 어셈블리가 GAC에도 등록되어 있다면, GAC에서 로드함. (그러나 어플리케이션 폴더에 참조하는 어셈블리가 없다면 예외 발생함.)
이때, 어플리케이션 폴더에 있는 라이브러리 어셈블리의 버전과 동일한 버전이 GAC에 존재하면 GAC에서 로드하지만, 존재하지 않는 경우(다른 버전만 GAC에 존재하는 경우) 어플리케이션 폴더 내의 라이브러리를 로드한다.

어플리케이션 폴더에는 short name과 일치하는 어셈블리가 존재하다면, 어떤 버전이든 상관없이 로드하게 된다.
full name을 사용해서 호출할 경우 반드시 일치하는(버전, public key 토큰) 라이브러리가 GAC또는 어플리케이션 폴더에 존재해야 함.
Full name을 사용해서 호출할 경우 GAC에 full name에 일치하는(버전, public key 토큰)의 라이브러리가 존재하면, GAC에서 로드함. 그러나 GAC에 존재하지 않으면 어플리케이션 폴더에서 full name에 일치하는(버전, public key 토큰)의 라이브러리를 로드함.어떤 경우든 full name과 버전, public key 토큰이 일치하는 게 없다면 예외 발생함.

어셈블리를 로드하는 다른 방법도 존재한다.
Assembly a1 = Assembly.LoadFile(@"c:\TestFolder\lib.dll");
Assembly a2 = Assembly.LoadFrom("lib.dll");

LoadFile은 Fusion을 사용해 파일을 찾지 않는다. 대신 이 메서드에는 인자로서 어셈블리에 대한 전체 경로를 전달해야 한다. 이는 어떤 위치에 있는 어셈블리도 로드할 수 있다는 의미가 된다. 예를 들면, 파일의 win32 폴더(GAC의 물리적 위치)로 파일에 대한 경로를 지정한다면 GAC으로 부터의 로드도 가능하다는 의미이다.
LoadFrom 역시 전체 경로를 인자로 받지만, 상대 경로를 허용한다.
이들 메서드의 문제는 어셈블리가 반드시 private assembly 여야 한다는 것이다. GAC으로 부터 로드할 수 있는 충분한 정보를 제공할 수 없기 때문이다.
추가로, Load 메서드는 또한 byte[] 배열 인자를 취하는 overload 도 제공한다. 이 배열은 어셈블리의 실제 바이트들을 포함해야 한다.

static object FromFile(string fileName)
{
System.IO.FileStream fs = System.IO.File.OpenRead(fileName);
byte[] data = new byte[fs.Length];
fs.Read(data, 0, data.Length);
fs.Close();
Assembly a = Assembly.Load(data);
Type type = a.GetType("LibraryCode");
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
return ctor.Invoke(null);
}


위의 Load(byte() 사용해 라이브러리를 로드하면 해당 경로에 반드시 어셈블리 파일이 존재해야 한다.
그러나, GAC에 동일한 어셈블리가 등록된 상태라면? 위의 Load(shortname) 처럼 GAC에서 로드할까? 그러하다(?)..
큰 그림 보려면 클릭하세요!(돋보기도 같이 활용)
.NET Framework 3.0에서는 두 개의 새로운 메서드 ReflectionOnlyLoad(), ReflectionOnlyLoadFrom()을 제공한다. 이들은 코드 검사만을 위한 어셈블리를 리턴하므로 코드를 실행할 수는 없다.

■ 객체 활성화(Activating Objects)

Assembly 함수들을 사용하는 데 있어서의 다른 주요한 문제는 reflection을 통해 객체를 활성화 해야 하다는 점이다.
객체를 활성화 하는 두 개의 다른 방법이 존재한다. : 아래 함수들은 내부적으로 Assembly.Load()에 의해 수행되는 어셈블리 로딩 작업을 포함하므로 Assembly.Load() 대신 사용해도 된다.

1. Activator 사용

static object UseActivator(string name)
{
ObjectHandle oh
= Activator.CreateInstance(name, "LibraryCode");
return oh.Unwrap();
}

Activator는 리모팅에 의해 사용되며 ObjectHandle은 객체 참조를 어플레케이션 도메인 간에 전달하는 메커니즘을 제공한다.
위에서 보듯이 객체를 사용하기 전에 반드시 ObjectHandler 을 unwap 해야 하는 것을 볼 수 있다. AppDomain은 객체를 활성화 하고 핸들을 unwrap 하는 메서드를 제공한다.
다른 하나의 코드이다.

2.AppDomain 사용
static object UseAppDomain(string name)
{
AppDomain ad = AppDomain.CurrentDomain;
return ad.CreateInstanceAndUnwrap(name, "LibraryCode");
}

1 번 방법보다 간단하게 호출할 수 있다.


■ 로컬 어셈블리와 GAC 어셈블리

메인 메서드를 아래와 같이 변경해서 실행한다고 가정한다.

static void Main()
{
object obj = null;
try
{
obj = UseAppDomain("lib, Version=1.0.0.0, "
+ "Culture=neutral, PublicKeyToken=3bf941bb1f722efe");
InvokeObject(obj);
}
catch(Exception e1)
{
Console.WriteLine(e1.GetType().ToString());
}
try
{
obj = UseAppDomain("lib");
InvokeObject(obj);
}
catch(Exception e2)
{
Console.WriteLine(e2.GetType().ToString());
}

static object UseAppDomain(string name)
{
AppDomain ad = AppDomain.CurrentDomain;
return ad.CreateInstanceAndUnwrap(name, "LibraryCode");
}
static void InvokeObject(object obj)
{
Type type = obj.GetType();
MethodInfo mi = type.GetMethod("GetVersion");
string version = (string)mi.Invoke(obj, null);
Console.WriteLine(version);
}
}


GAC에는 lib 어셈블리 등록된게 없다.(gacutil -u lib)
위 코드 상의 두 호출 모두 lib.dll을 어플리케이션 폴더에서 로드할 것이다.
이제 GAC에 lib.dll을 등록하고 로컬 파일 명을 lib.old.dll로 변경한다.
그러면, full name을 이용한 호출은 성공할 것이지만, short name 호출은 실패(FileNotFoundException)할 것이다. 이유는 위에서 설명되었다.
이제 다시 lib.old.dll을 lib.dll로 원상복구 한다.
이렇게 한 후 호출을 하면, 예상컨데, 위의 full name호출은 GAC으로 부터, 아래의 short name 호출은 어플리케이션 폴더(로컬)로 부터 로드될 것이다. 그러나. 결과는 두 호출 모두 GAC으로 부터 로드된다.
이는 Fusion은 주어진 short name으로 로컬 버전을 찾고, 존재하면 로컬 버전의 정보를 이용해 full name을 얻는다 그리고 이를 가지고 GAC으로부터 어셈블리를 로드하려고 하기 때문이다.

결론적으로 Load() 의 작동메카니즘은,

먼저 (검색할) 어플리케이션 폴더를 확인한다. 이를 위해 구성 파일의 codeBase 항목을 확인하여 존재하는 경우 그 하위 폴더도 포함된다.
어셈블리에 Culture 정보가 명시된 경우, culture 명으로 된 하위 폴더를 검색함. Culture 정보가 명시되지 않은 경우 어플리케이션 폴더 내에서 검색됨.

1. 기본 어플리케이션 폴더 검색

i. Short name을 가진 DLL을 어플리케이션 폴더에서 검색. --> /appFolder/lib.dll
ii. 존재하지 않는다면,short name 명을 가진 하위 폴더를 검색함. --> /appFolder/lib/lib.dll
iii. 존재하지 않는다면, EXE 가 검색됨.(어플리케이션 폴더, short name 명을 가진 하위 폴더)

--> /appFolder/lib.exe
--> /appFolder/lib/lib.exe

2. Private Path 검색
그런 후 Fusion은 <probing> 요소 내에 privatePath가 존재하는지 확인함. 존재하는 경우 명시된 그 폴더와 short name 명을 가지 그 하위폴더들에 대해 DLL/EXE 검색을 수행함.

i. /appFolder/private/lib.dll
ii. /appFolder/private/lib/lib.dll
iii. /appFolder/private/lib.exe
iv. /appFolder/private/lib/lib.exe

현재 테스트 중인 라이브러리는 strong name을 가진다. 그리고 GAC과 어플케이션 폴더 모두에 존재한다.
위의 어셈블리 찾기 과정은 private assembly을 로드 할 것이다. 하지만 Load는 찾은 라이브러리가 strong name을 가지는지 확인 후, 가지는 경우, 그 정보를 이용해 다시 어셈블리를 검색하게 된다.

3. Binding policy 확인(Version Redirection 확인)
어플리케이션 구성, publisher policy, machine 구성을 확인하여 버전 redirect가 있는지 확인한다.

4. 그리고 어셈블리가 이미 로드되었는지를 확인한다.
5. 이미 로드되지 않았다면, GAC을 확인한다.
6. 만일 GAC에서 어셈블리가 발견되면 그것을 리턴하게 된다. --> GAC 버전
7. 그렇지 않으면 어플리케이션 폴더의 로컬 버전이 리턴된다. --> 어플리케이션 폴더 버전

■ Partial Names

Assembly 클래스는 LoadWithPartialName 메서드를 제공한다. 이 방법은 권장되지 않는다.
이 메서드를 사용할 때 완전한 full name을 전달하지 않으면 Fusion은 가장 먼저 찾은(가장 높은 버전의) 어셈블리을 리턴하게 된다.

static object UsePartialName(string name)
{
Assembly a = Assembly.LoadWithPartialName(name);
Type type = a.GetType("LibraryCode");
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
return ctor.Invoke(null);
}

■ <qualifyAssembly>

Partial name을 사용하는 다른 방법으로서 구성 파일 설정을 포함하는 방법을 제공한다.
<assemblyBinding> 내의 <qualifyAssembly> 요소를 추가할 수 있는데 이는 partial name을 해당하는 full name으로 매치시켜 준다.
이를 위해 직접 구성파일을 편집해야 한다.
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<qualifyAssembly
partialName="lib"
fullName="lib,version=1.0.0.0,culture=neutral, ¶
publicKeyToken=3bf941bb1f722efe"
/>
</assemblyBinding>
</runtime>
</configuration>

호출을 아래와 같이 partial name(short name) 으로 하더라도 매치되는 full name으로 어셈블리가 로드된다.
object obj = UsePartialName("lib");

■ AssemblyResolve Event

어셈블리를 로드 시 Fusion은 특정 어셈블리를 찾지 못하면 AssemblyResolve 이벤트를 현재 AppDomain 상에 발생시킨다. 이 이벤트는 다른 .NET 이벤트와 달리 값을 리턴한다. 그러므로 오직 하나의 델리게이트 만이 이벤트를 처리해야 한다.
하나 이상의 이벤트 핸들러를 가진다면 가장 마지막 이벤트 핸들러로 부터의 리턴 값이 사용된다. 다른 이벤트 핸들러는 무의미 해진다.
AssemblyResolve 이벤트는ResolveEventHandler 델리게이트 이다.:
public delegate Assembly ResolveEventHandler( object sender, ResolveEventArgs args);

델리게이트는 하나의 ResolveEventArgs 인자를 취하며, 이 ResolveEventArgs 는 Name이라는 string 속성을 가진다. 이 속성은 요청된 어셈블리의 full name 값을 가진다. 이벤트 핸들러는 이 속성(Name)을 이용해 요청된 어셈블리를 찾고 로드하게 된다.
이 시점에서 Fusion이 어셈블리를 찾을 수 없는 경우에 임의로 어셈블리를 로드하기 위해, Fusion을 내부적으로 이용하는 특정 메서드를 사용해서는 안된다.
예를 들어, 아래와 같이 샘플의 main 메서드를 변경하고,

static void Main()
{
AppDomain.CurrentDomain.AssemblyResolve
+= new ResolveEventHandler(ResolveAssembly);
try
{
object obj = FromAssembly("lib");
InvokeObject(obj);
}
catch(Exception e)
{
Console.WriteLine(e.GetType().ToString());
}
}
static object FromAssembly(string shortName)
{
Console.WriteLine("Use Assembly.Load");
AssemblyName name = new AssemblyName();
name.Name = shortName;
name.Version = new Version("1.0.0.0");
name.CultureInfo = new CultureInfo("");
byte[] pkt
= new byte[] {0x3b,0xf9,0x41,0xbb,0x1f,0x72,0x2e,0xfe};
name.SetPublicKeyToken(pkt);
Assembly a = Assembly.Load(name);
Type type = a.GetType("LibraryCode");
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
return ctor.Invoke(null);
}


이벤트 핸들러가 어셈블리를 로드하도록 시도하게 할 것이다. 일단 아무 일도 하지 않도록 하고 테스트 한다.
static Assembly ResolveAssembly(object sender, ResolveEventArgs args)
{
return null;
}


(GAC 에 등록된 lib 이 없는 상태에서)
컴파일 후 lib.dll을 lib.old로 rename 하고 실행을 하면, FileNotFoundException 예외가 발생할 것이다. Short name을 가진 어셈블리가 어플리케이션 폴더에 존재하지 않고, full name을 가진 어셈블리가 GAC에 존재하지 않기 때문이다. 하지만 우리는 요청하는 어셈블리가 로컬 폴더에 존재한다는 것을 알고 있다. 단지 이름만 변경되었다는 것을 알고 있다. 그래서 우리는 이벤트 핸들러를 이용해서 실제의 이름으로 로드 해보기로 한다.

static Assembly ResolveAssembly(
object sender, ResolveEventArgs args)
{
string[] name = args.Name.Split();
string assem = name[0].Substring(0, name[0].Length-1);
return Assembly.LoadFrom(assem + ".old");
}


Name 속성이 full name을 전달할 것이므로 이를 파싱하여 파일명을 얻는 과정이 필요하다.
실행을 하면, 이름 변경된 lib.old가 로드된다.

큰 그림 보려면 클릭하세요!(돋보기도 같이 활용)

끝.

+ Recent posts