Search
Duplicate

C# 6.0 완벽 가이드/ 응용 프로그램 도메인

응용 프로그램 도메인(appication domain)은 .NET 프로그램이 실행되는 하나의 실행 시점 격리 단위(unit of isolation)이다.
응용 프로그램 도메인은 관리되는 메모리 경계로 작용하며, 적재된 어셈블리들과 응용 프로그램 구성 설정들을 담는 컨테이너이기도 하다. 또한 분산 응용 프로그램의 경우 통신의 경계를 나타내기도 한다.
일반적으로 하나의 .NET 프로세스는 하나의 응용 프로그램 도메인을 수용한다. 프로세스 시동시 CLR이 자동으로 생성한 기본(default) 도메인이 바로 그것이다.
그러나 한 프로세스가 응용 프로그램 도메인들을 더 생성하는 것이 가능하며, 종종 유용하다.
응용 프로그램 도메인을 추가로 생성하면 개별 프로세스들을 둘 때 발생하는 통신상의 복잡한 문제를 피하면서도 코드 실행 단위들을 서로 격리할 수 있다.
이러한 접근방식은 부하 검사나 응용 프로그램 부분 갱신(patching) 같은 시나리오에 유용하며 안정적인 오류 복구 메너티즘을 구현할 때에도 유용하다.
이번 장은 Windows 스토어 앱이나 CoreCLR 앱과는 무관하다. 그런 앱에서는 오직 하나의 응용 프로그램 도메인만 사용할 수 있다.

응용 프로그램 도메인의 구조

아래 그림은 단일 도메인, 다중 도메인, 그리고 전형적인 분산 클라이언트/서버 응용 프로그램 도메인 구조를 나타낸 것이다. 대부분의 경우, 응용 프로그램 도메인을 수용하는 프로세스들은 운영체제가 암묵적으로 생성한다. (예컨대 사용자가 .NET 실행 파일을 더블클릭하거나 Windows 서비스가 시작될 때)
그러나 IIS 같은 다른 프로세스가 응용 프로그램 도메인을 가지거나 SQL Server가 CLR 통합을 통해서 응용 프로그램 도메인을 가지기도 한다.
단순한 실행 파일에서 비롯된 프로세스는 기본 응용 프로그램 도메인의 실행이 끝나면 함께 끝난다. 그러나 IIS나 SQL Server 같은 호스트에서는 프로세스가 그 수명을 제어한다.
즉 필요에 따라 .NET 응용 프로그램 도메인을 생성하고 파괴한다.

응용 프로그램 도메인의 생성과 파괴

프로세스에서 응용 프로그램 도메인을 생성하고 파괴할 떄는 정적 메서드 AppDomain.CreateDomain과 AppDomain.Unload를 사용한다.
다음은 text.exe를 격리된 응용 프로그램 도메인에서 실행한 후 그 도메인을 해제하는 예이다.
static void Main() { AppDomain newDomain = AppDomain.CreateDomain("New Domain"); newDomain.ExecuteAssemly("test.exe"); AppDomain.Unload(newDomain); }
C#
복사
기본 응용 프로그램 도메인(프로그램 시동 시 CLR이 생성한 도메인)이 해제되면 응용 프로그램의 다른 모든 도메인이 자동으로 해제되며, 응용 프로그램 자체가 종료된다.
주어진 도메인이 기본 도메인인지는 AppDomain의 IsDefaultDomain 속성으로 알 수 있다.
특정 옵션들을 지정해서 새 도메인을 생성하려면 먼저 AppDomainSetup 클래스를 인스턴스화 해야 한다.
다음은 이 클래스에서 가장 유용한 속성들이다.
public string ApplicationName { get; set; } // 친근한 이름 public string ApplicationBase { get; set; } // 기준 디렉터리 public string ConfigureationFile { get; set; } public string LicenseFile { get; set; } // 자동 어셈블리 환원을 돕는 속성들 public string PrivateBinPath { get; set; } public string PrivateBinPathProbe { get; set; }
C#
복사
ApplicationBase 속성은 응용 프로그램 도메인의 기준 디렉터리에 해당한다.
도메인 기준 디렉터리는 자동 어셈블리 탐색의 루트 디렉터리로 쓰인다.
기본 응용 프로그램 도메인의 기준 디렉터리는 주 실행 파일이 있는 폴더이다. 새 도메인을 생성할 때는 임의의 디렉터리를 기준 디렉터리로 지정할 수 있다.
AppDomainSetup setup = new AppDomainSetup(); setup.ApplicationBase = @"c:\MyBaseFolder"; AppDomain newDomain = AppDomain.CreateDomain("New Domain", null, setup);
C#
복사
또한 새 도메인의 메서드를 현재 도메인에 정의되어 있는 어셈블리 환원(assembly resolution) 이벤트들에 등록(구독)할 수도 있다.
static void Main() { AppDomain new Domain = AppDomain.CreateDomain("test"); newDomain.AssemblyResolve += new ResolveEventHandler(FindAssem); ... } static Assembly FindAssem(object sender, ResolveEventArgs args) { ... }
C#
복사
단 이것이 제대로 작동하려면 이벤트 처리부가 두 도메인 모두가 접근할 수 있는 형식에 정의된 정적 메서드여야 한다. 그런 조건을 만족한다면 CLR이 해당 이벤트 처리부를 정확한 도메인에서 실행할 수 있다.
지금 예제에서 FindAssem은 기본 도메인에서 등록했지만 newDomaind 안에서 실행된다.
PrivateBinPath 속성은 기준 디렉터리 아래의 하위 디렉터리 이름들을 세미콜론으로 구분된 형태의 문자열을 담는다. 자동 어셈블리 환원 시 CLR은 그 하위 디렉터리들에서 어셈블리를 찾는다(응용 프로그램 기준 디렉터리와 마찬가지로, 이 속성은 응용 프로그램 도메인을 시작하기 전에만 설정할 수 있다.)
예컨대 어떤 프로그램의 디렉터리 구성이 다음과 같다고 하자. 기준 디렉터리에는 실행 파일이(그리고 어쩌면 구성 파일이) 있고, 세 하위 폴더에는 응용 프로그램이 참조하는 어셈블리들이 들어 있다.
c:\MyBaseFolder\ -- 주 실행 파일 \bin \bin\v1.23 -- 최신 어셈블리 DLL들 \bin\plugins -- 기타 DLL들
C#
복사
다음은 이러한 폴더 구조를 사용하도록 응용 프로그램 도메인을 설정하는 예이다.
AppDomainSetup setup = new AppDomainSetup(); setup.ApplicationBase = @"c:\MyBaseFolder"; setup.PrivateBinPath = @"bin\v.1.23;bin\plugins"; AppDomain d = AppDomain.CreateDomain("New Domain", null, setup); d.ExecuteAssembly(@"c:\MyBaseFolder\Startup.exe");
C#
복사
PrivateBinPath 속성을 구성하는 경로들은 항상 상대 경로이며, 반드시 응용 프로그램 기준 디렉터리의 하위 디렉터리여야 함을 주의하기 바란다. 절대 경로로 지정하는 것은 위법이다.
AppDomain은 또한 PrivateBinPathProbe라는 속성도 제공한다.
이 속성에 빈 문자열이 아닌 어떤 문자열을 지정하면, CLR은 어셈블리 파일 탐색 대상에서 기준 디렉터리를 제외한다(PrivateBinPathProbe가 bool 형식이 아니라 문자열인 것은 COM 호환성과 관련된 문제 때문이다)
기본 도메인이 아닌 모든 응용 프로그램 도메인은 해제 직전에 DomainUnload 이벤트가 발생한다.
도메인을 모든 DomainUnload 이벤트 처리부의 실행이 완료되기 전까지는 해제되지 않으므로, 도메인(그리고 필요하다면 응용 프로그램 전체의) 해제 전에 어떤 정리 작업이 필요하다면 이 이벤트를 활용하면 된다.
응용 프로그램 자체가 종료되기 직전에는 적재된 모든 응용 프로그램 도메인(기본 도메인 포함)에 대해 ProcessExit 이벤트가 발동된다.
DomainUnload 이벤트와는 달리 ProcessExit 이벤트의 처리에는 시간제한이 있다. 기본 CLR은 이 이벤트의 처리부들에게 도메인당 2초, 전체적으로는 3초를 허용한다. 그 시간이 지나면 해당 스레드들을 강제로 종료한다.

다중 응용 프로그램 도메인 활용

다중 응용 프로그램 도메인의 핵심적인 용도는 다음 두 가지이다.
최소한의 추가부담으로 프로세스와 비슷한 격리성을 제공한다.
프로세스를 재시작하지 않고도 어셈블리 파일을 해제한다.
한 프로세스에서 기본 도메인 이외의 응용 프로그램 도메인을 추가로 생성하면 CLR은 개별 프로세스들에서 돌릴 떄와 비슷한 수준의 격리성을 각 도메인에 부여한다.
좀 더 구체적으로 말하면, 각 도메인은 개별적인 메모리를 가지며, 한 도메인의 객체가 다른 도메인의 객체에 간섭하지 못한다. 더 나아가서 같은 클래스의 정적 멤버라도 도메인마다 독립적인 값을 가질 수 있다.
ASP.NET에서 수많은 사이트가 서로에게 영향을 주지 않고 하나의 공유 프로세스 안에서 실행되는 것은 바로 이러한 접근방식 덕분이다.
ASP.NET에서 응용 프로그램 도메인은 프로그래머의 개입 없이 ASP.NET 기반 시스템이 직접 생성한다. 그러나 한 프로세스 안에서 독자가 여러 개의 도메인을 명시적으로 생성하는 것이 바람직한 경우도 종종 있다.
예컨대 어떤 커스텀 인증 시스템을 개발하는 과정에서 스트레스 검사(stress testing)를 위해 20개의 클라이언트가 동시에 로그인하는 상황을 시뮬레이션한다고 하자. 20개의 동시적 로그인을 흉내내는 방법은 다음 세 가지이다.
Process.Start를 20회 호출해서 20개의 개별 프로세스를 시작한다.
한 프로세스와 도메인 안에서 스레드를 20개 실행한다.
한 프로세스 안에서 스레드 20개를 각각 개별적인 응용 프로그램 도메인에서 실행한다.
첫 옵션은 지저분하고 자원을 많이 소비한다. 또한 프로세스간 통신도 쉽지 않다(프로세스들에 좀 더 구체적인 명령을 내려야 하는 경우)
둘째 옵션은 클라이언트 쪽 코드가 스레드에 안전해야 유효한데, 그럴 가능성은 작다. 특히 만일 현재 인증 상태를 정적 변수들에 담는다면 더욱 작다. 그리고 클라이언트 쪽 코드 주변에 자물쇠를 걸면 서버의 스트레스 검사에 필요한 병렬 실행 능력이 제한된다.
이상적인 해결책은 셋째 옵션이다. 이 방법에서는 각 스레드가 격리되어서 독립적인 상태를 가지며, 그러면서도 호스팅 프로그램이 스레드들에 쉽게 접근할 수 있다.
명시적인 응용 프로그램 도메인 생성은 프로세스를 끝내지 않고 특정 어셈블리를 해제하고자 할 때 유용하다.
어셈블리는 그것이 적재된 응용 프로그램 도메인을 닫지 않은 한 해제되지 않으므로, 프로세스 종료 없이 어셈블리만 해제하려면 추가 도메인 생성이 필수이다.
특히 기본 도메인에 적재한 어셈블리는 응용 프로그램 자체를 닫지 않는 한 해제되지 않는다.
어셈블리가 적재된 상태에서는 해당 어셈블리 이진 파일이 잠겨 있다. 따라서 그 파일을 부분 갱신하거나 다른 파일로 대체할 수 없다.
어셈블리들을 필요에 따라 종료/재생성 할 수 있는 개별 응용 프로그램 도메인에 적재하면 그럼 문제가 사라진다.
이러한 접근 방식은 가끔 큰 어셈블리들을 적재해야 하는 응용 프로그램의 메모리 사용량을 줄이는데에도 도움이 된다.

[LoaderOptimization] 특성

기본적으로 직접 생성한 응용 프로그램 도메인에 어셈블리를 적재하면 JIT 컴파일러가 그 어셈블리를 다시 처리한다. 특히 다음과 같은 어셈블리들에도 JIT 컴파일이 다시 적용된다.
호출자의 도메인에서 이미 JIT 컴파일을 거친 어셈블리
네이티브 이미지를 ngen.exe 도구로 생성한 어셈블리
mscorlib를 제외한 .NET Framework의 모든 어셈블리
이러한 재컴파일 때문에 성능이 크게 떨어질 수 있다. 특히 큰 .NET Framework 어셈블리를 참조하는 응용 프로그램 도메인을 거듭 생성하고 해제하는 경우에는 더욱 그렇다. 이에 대한 한 가지 우회책은 응용 프로그램의 주 진입점 메서드에 다음 특성을 부여하는 것이다.
[LoaderOptimization (LoaderOptimization.MultiDomainHost)]
C#
복사
이렇게 하면 CLR은 GCA 어셈블리들을 도메인에 중립적인(domain-neutral) 방식으로 적재한다. 즉, 네이티브 이미지들이 존중되며, 여러 응용 프로그램 도메인들이 JIT 이미지들을 공유한다.
GAC에 모든 .NET Framework 어셈블리가(또한 독자의 응용 프로그램에서 변하지 않는 일부 어셈블리도) 포함된다는 점에서 일반적으로 이것이 이상적인 방식이다.
더 나아가서 LoaderOptimization.MultiDomain을 지정할 수도 있다. 그러면 모든 어셈블리가 도메인 중립적으로 적재된다(단, 보통의 어셈블리 환원 메커니즘 바깥에서 적재되는 어셈블리들은 제외) 그러나 어셈블리가 도메인과 함께 해제되길 원한다면 이 방식은 바람직하지 않다. 도메인 중립적 어셈블리는 모든 도메인이 공유하므로, 부모 프로세스가 종료되기 전까지는 해제되지 않는다.

DoCallBack 활용

가장 기본적인 다중 도메인 시나리오를 다시 생각해 보자.
static void Main() { AppDomain newDomain = AppDomain.CreateDomain("New Domain"); newDomain.ExecuteAssemly("test.exe"); AppDomain.Unload(newDomain); }
C#
복사
개별 도메인에 대해 ExecuteAssembly를 호출해서 어셈블리를 싱행하는 것이 편리하긴 하지만, 그 도메인과의 상호작용 여지가 거의 없어서 유연성이 떨어진다.
또한 이 방법은 실행 파일 형태의 어셈블리만 지원하며, 하나의 진입점만 실행할 수 있다. 유연성을 조금이나마 확보하는 유일한 방법은 실행 파일에 명령줄 인수들을 문자열 형태로 제공하는 것 뿐이다.
좀 더 강력한 접근방식은 AppDomain의 DoCallBack 메서드를 사용하는 것이다. 이를 이용하면 주어진 형식의 한 메서드를 다른 응용 프로그램 도메인에서 실행할 수 있다.
CLR은 그 형식의 어셈블리를 해당 도메인에 자동으로 적재한다(그 어셈블리의 이진 파일이 현재 도메인이 참조할 수 있는 곳에 있다면 CLR도 그 파일ㅇ르 찾을 수 있다)
다음 예제는 현재 실행 중인 메서드가 속한 클래스의 다른 한 메서드를 새 도메인에서 실행한다.
class Program { static void Main() { AppDomain newDomain = AppDomain.CreateDomain("New Domain"); newDomain.DoCallBack(new CrossAppDomainDelegate(SayHello)); AppDomain.Unload(newDomain); } static void SayHello() { Console.WriteLine("Hi From " + AppDomain.CurrentDomain.FriendlyName); } }
C#
복사
이 예제가 작동하는 이유는 대리자가 정적 메서드를 참조하기 때문이다. 즉, 대리자는 인스턴스가 아니라 형식을 가리키는데, 이 덕분에 대리자가 ‘도메인과 무관한(domain-agnostic)’ 또는 ‘민첩한(agile)’ 대리자가 된다.
그러한 대리자는 원래의 도메인의 그 어떤 것과도 연관되어 있지 않으므로 그 어떤 도메인에서도 동일하게 실행된다.
인스턴스 메서드를 참조하는 대리자에 DoCallBack을 적용하는 것도 가능하긴 하지만, 그러면 CLR은 Remoting 의미론을 적용한다.
지금 예제에서 Remoting 의미론은 우리가 원하는 것의 정반대에 해당한다.

응용 프로그램 도메인 감시

.NET Framework 4.0부터는 특정 응용 프로그램 도메인의 메모리와 CPU 소비량을 감시(monitoring) 할 수 있다. 이를 위해서는 다음과 같은 설정을 통해서 응용 프로그램 도메인 감시 기능을 활성화 해야 한다.
AppDomain.MonitoringIsEnabled = true;
C#
복사
이렇게 하면 현재 프로세스의 모든 도메인에 대한 감시가 활성화된다. 일단 감시를 활성화하면 다시 비활성화 할 수 없다. 이 속성에 false를 배정하면 예외가 발생한다.
응용 프로그램 구성 파일을 통해서도 도메인 감시를 활성화할 수도 있다. 구성 파일에 다음과 같이 요소 하나를 추가하면 된다.
<configuration> <runtime> </appDomainResourceMonitoring enabled="true"/> </runtime> </configuration>
XML
복사
도메인 감시를 활성화 한 후에는 AppDomain의 다음 세 속성을 이용해서 CPU와 메모리 사용량을 조회할 수 있다.
MonitoringTotalProcessorTime MonitoringTotalAllocatedMemorySize MonitoringSurvivedMemorySize
C#
복사
처음 두 속성은 해당 도메인이 시작된 후 누적된 총 CPU 소비량과 관리되는 메모리 할당량을 돌려준다(이 수치들은 증가할 뿐 감소하지 않는다). 셋째 속성은 마지막 쓰레기 수거 당시 도메인의 관리되는 메모리의 실제 소비량을 돌려준다.
현재 도메인뿐만 아니라 다른 도메인에 대해서도 이 속성들을 조회할 수 있다.

도메인과 스레드

어떤 메서드를 다른 응용 프로그램 도메인에서 호출하면 그 메서드의 실행이 끝날 때까지 호출이 차단된다. 이는 메서드를 현재 도메인에서 호출할 떄와 마찬가지이다.
일반적으로는 이러한 행동 방식이 바람직하지만, 종종 메서드를 다른 도메인에서 현재 도메인과 동시에 실행하고 싶을 때도 있다. 그러려면 다중 스레드 기법을 사용해야 한다.
인증 시스템의 스트레스 검사를 위해 다중 응용 프로그램 도메인을 이용해서 20개의 동시 클라이언트 로그인을 흉내내는 시나리오를 앞에서 언급했었다. 각 클라이언트가 개별 응용 프로그램 도메인에서 로그인을 시도하게 되면 클라이언트들이 격리되며 따라서 정적 클래스 멤버를 통해서 서로에게 영향을 미치는 일도 없어진다.
아래는 스레드 20개를 생성하고 각각 개별적인 응용 프로그램 도메인에서 로그인 메서드를 실행하는 예이다.
class Program { static void Main() { // 도메인 20개와 스레드 20개를 생성한다. AppDomain[] domains = new AppDomain[20]; Thread[] threads = new Thread[20]; for (int i = 0; i < 20; i++) { domains[i] = AppDomain.CreateDomain("Client Login " + i); threads[i] = new Thread(LoginOtherDomain); } // 모든 스레드를 시작하되, 각자 개별적인 앱 도메인을 지정한다. for (int i = 0; i < 20; i++) threads[i].Start(domains[i]); // 모든 스레드의 완료를 기다린다. for (int i = 0; i < 20; i++) threads[i].Join(); // 앱 도메인들을 해제한다. for (int i = 0; i < 20; i++) AppDomain.Unload(domains[i]); Console.ReadLine(); } // 매개변수화된 스레드 시작 메서드. 스레드가 실행될 도메인을 받는다. static void LoginOtherDomain(object domain) { (AppDomain)domain).DoCallBack(Login); } static void Login() { Client.Login("Joe", ""); Console.WriteLine("로그인된 이름: " + Client.CurrentUser + ", 도메인: " + AppDomain.CurrentDomain.FriendlyName); } } class Client { // 만일 모든 클라이언트를 같은 앱 도메인에서 실행했다면, 이 정적 필드를 통해서 클라이언트들 사이에 간섭이 생겼을 것이다. public static string CurrentUser = ""; public static void Login (string name, string password) { if (CurrentUser.Length == 0) // 아직 로그인하지 않았으면 { // 인증 과정을 흉내 내기 위해 잠시 지연한다. Thread.Sleep(500); CurrentUser = name; // 인증된 것으로 기록한다. } } } /* 출력 로그인된 이름 Joe, 도메인: Client Login 0 로그인된 이름 Joe, 도메인: Client Login 1 로그인된 이름 Joe, 도메인: Client Login 4 로그인된 이름 Joe, 도메인: Client Login 2 로그인된 이름 Joe, 도메인: Client Login 3 로그인된 이름 Joe, 도메인: Client Login 5 로그인된 이름 Joe, 도메인: Client Login 6 ... */
C#
복사

도메인 간 자료 공유

슬롯을 이용한 자료 공유

둘 이상의 응용 프로그램 도메인이 명명된 슬롯(named slot)을 이용해서 자료를 공유할 수 있다. 다음이 그러한 예이다.
class Program { static void Main() { AppDomain newDomain = AppDomain.CreateDomain("New Domain"); // "Message"라는 이름의 슬롯에 자료를 기록한다. 슬롯 이름(키)은 그 어떤 문자열로도 가능하다. newDomain.SetData("Message", "저기 말이야..."); newDomain.DoCallBack(SayMessage); newDomain.Unload(newDomain); } static void SayMessage() { // "Message" 슬롯에서 자료를 읽는다. Console.WriteLine(AppDomain.CurrentDomain.GetData("Message")); // 저기 말이야... } }
C#
복사
슬롯은 처음 쓰일 때 자동으로 생성된다. 슬롯을 통해 주고받는 자료는 반드시 직렬화 가능 형식이거나 MarshalByRefObejct 파생 형식이어야 한다.
직렬화 가능 자료는 다른 응용 프로그램 도메인에 복사된다. MarshalByRefObejct 파생 형식의 자료에는 Remoting 의미론이 적용된다.

프로세스 내부 Remoting 적용

다른 응용 프로그램 도메인과 통신하는 가장 유연한 방법은 원하는 객체를 프록시를 통해서 다른 도메인 안에 생성하는 것이다. .NET의 Remoting 프레임워크가 바로 그러한 원격 생성 기능을 제공한다.
“원격 생성되는” 클래스는 반드시 MarshalByRefObject를 상속하는 형식이어야 한다. 클라이언트는 원격 도메인의 AppDomain 클래스에 대해 CreateInstanceXXX 메서드를 호출해서 원격으로 객체를 인스턴스화 한다.
다음 예제는 Foo 형식의 객체를 다른 응용 프로그램 도메인 안에서 생성한 후 SayHello 메서드를 호출한다.
class Program { static void Main() { AppDomain newDomain = AppDomain.CreateDomain("New Domain"); Foo foo = (Foo) newDomain.CreateInstanceAndUnwrap(typeof(Foo).Assembly.FullName, typeof(Foo).FullName); Console.WriteLine(foo.SayHello()); AppDomain.Unload(newDomain); Console.ReadLine(); } } public class Foo : MarshalByRefObject { public string SayHello() => "Hello from " + AppDomain.CurrentDomain.FriendlyName; // 다음은 클라이언트가 원하는 만큼 객체가 오래 유지되게 하는 효과를 낸다. public override object InitializeLifetimeService() => null; }
C#
복사
현재 도메인의 foo가 다른 응용 프로그램 도메인(이를 원격(remote) 도메인이라고 부른다)에 생성된 Foo 객체를 직접 참조하지는 않는다. 이는 응용 프로그램 도메인들이 격리되어 있기 때문이다. 현재 도메인의 foo는 사실 하나의 투명한 프록시이다.
‘투명한’이라는 말은 foo가 마치 원격 객체를 직접 참조하는 것처럼 보이기 때문에 붙은 말이다.
foo에 대해 SayHello 메서드를 호출하면 Remoting 프레임워크는 내부적으로 하나의 메시지를 생성해서 ‘원격’ 응용 프로그램 도메인에 전달한다. 그러면 그 도메인에서 진짜 Foo 객체에 대해 해당 메서드가 호출된다.
이는 마치 전화기에 대고 ‘안녕하세요’ 라고 말하는 것과 비슷하다. 이때 통화자는 정말로 상대방에게 말하는 것이 아니라, 상대방의 투명한 프록시(대리인)로 행동하는 플라스틱 조각에 대고 말하는 것 뿐이다.
메서드가 어떤 값을 돌려주면 비슷한 과정을 통해서 그 값이 호출자에게 전달된다.
.NET Framework 3.0에서 WCF(Windows Communication Foundation)가 도입되기 전에는 Remoting이 주된 분산 응용 프로그램 작성 기술 두 가지 중 하나였다(다른 하나는 Web Services)
분산 Remoting 응용 프로그램에서는 프로세스와 네트워크 경계를 넘는 통신을 수행하려면 프로그래머가 양쪽에 HTTP 또는 TCP/IP 통신 채널을 명시적으로 설정해야 한다.
분산 응용 프로그램 작성에는 WCF가 Remoting보다 우월하지만, 프로세스 내부의 도메인간 통신에는 Remoting이 적합한 부분이 여전히 남아 있다. 지금 예제에서 Remoting의 장점은 따로 설정할 것이 없다는 점이다. 통신 채널이 자동으로 생성되며, 형식을 미리 등록할 필요도 없다. 그냥 바로 사용하면 된다.
Foo의 메서드들이 또 다른 MarshalByRefObject 인스턴스를 돌려줄 수도 있다. 즉, 그런 메서드들을 호출하면 또 다른 투명 프록시가 생긴다. 또한 Foo의 메서드들이 MarshalByRefObject 인스턴스를 인수로 받을 수도 있다. 이 경우에는 호출자가 ‘원격’ 객체를 가지고 호출 대상이 프록시를 가진다.
객체를 참조 전달 방식으로 인도하는 것 외에, 응용 프로그램 도메인들이 스칼라값이나 임의의 직렬화 가능 객체를 실제로 주곱다을 수도 있다.
여기서 직렬화 가능 객체는 [Serializable] 특성이 부여되었거나 ISerializable을 구현하는 형식의 객체를 말한다.
그런 객체를 응용 프로그램 도메인 경계를 넘어 전달할 때에는 프록시가 아니라 객체의 완전한 복사본이 전달된다. 다른 말로 하면, 객체가 참조가 아니라 값으로 인도된다.
같은 프로세스 안에서 Remoting 적용은 클라이언트가 활성화한다. 다른 말로 하면 CLR 같은 또는 다른 클라이언트들에서 원격으로 생성한 객체들은 공유하거나 재사용하려 들지 않는다.
예컨대 클라이언트가 Remoting 프레임워크를 이용해서 Foo 객체를 두 개 생성하면 두 객체는 원격 도메인에서 생성되고, 클라이언트 쪽에서는 프록시 두 개가 생성된다. 클라이언트 쪽에서 객체를 평소처럼 자연스럽게 사용하게 하려면 이런 방식이 제일 낫다.
한가지 단점은, 원격 도메인이 클라이언트의 쓰레기 수거기에 의존하게 된다는 것이다. 즉, 원격 도메인의 실제 Foo 객체는 클라이언트의 쓰레기 수거기가 foo(프록시)를 더 이상 유효하지 않다고 판단한 후에야 비로소 메모리에서 제거된다. 만일 클라이언트의 응용 프로그램 도메인이 충돌하면(crash) 원격 객체는 영영 해제되지 못할 수 있다.
이런 시나리오를 방지하기 위해 CLR은 원격으로 생성된 객체의 수명을 관리하는 임대(lease) 기반 메커니즘을 제공한다. 이 메커니즘의 기본 행동 방식은 5분 이상 쓰이지 않는 원격 생성 객체는 자동으로 파괴한다는 것이다.
이 예제에서 클라이언트는 기본 응용 프로그램 도메인에서 실행되므로, 클라이언트만 따로 충돌할 수는 없다. 클라이언트가 죽으면 그냥 프로세스 전체가 끝난다. 따라서 이 예제에서는 5분 수명 임대 기능을 끄는 것이 바람직하다.
이를 위해 이 예제는 InitailizeLifetimeService가 null을 돌려주도록 재정의했다. 이 메서드가 null을 돌려주면 원격 생성 객체는 클라이언트의 쓰레기 수거에 의해서만 파괴된다.

형식과 어셈블리의 격리

다음은 앞의 예제에서 Foo 형식의 객체를 원격 생성하는데 사용한 코드이다.
Foo foo = (Foo) newDomain.CreateInstanceAndUnwrap(typeof(Foo).Assembly.FullName, typeof(Foo).FullName);
C#
복사
여기에 쓰인 CreateInstanceAndUnwrap 메서드의 서명은 다음과 같다.
public object CreateInstanceAndUnwrap(string assemblyName, string typeName)
C#
복사
이 메서드는 어셈블리와 형식의 이름을 받는다. Type 객체를 받는 것이 아님을 주목하기 바란다. 이 덕분에 현재 도메인에 해당 형식을 적재하지 않고도 원격 도메인에서 객체를 생성할 수 있다.
이는 형식의 어셈블리를 호출자의 응용 프로그램 도메인에 적재하고 싶지 않을 때 유용한 기능이다.
AppDomain은 또한 CreateInstanceFromAndUnwrap이라는 메서드도 제공한다. 두 메서드의 차이점은 다음과 같다.
CreateInstanceAndUnwrap은 완전 한정 어셈블리 이름을 받지만
CreateInstanceFromAndUnwrap은 어셈블리의 경로 또는 파일 이름을 받는다.
이해를 돕기 위한 예로 사용자가 서드파티 플러그인들을 적재하고 해제할 수 있는 텍스트 편집기를 작성한다고 하자. 21장의 ‘다른 어셈블리에 모래상자 적용’에서는 이런 플러그인의 보안 문제에 초점을 두었기 때문에 플러그인을 실제로 실행하는 문제는 그냥 ExecuteAssemby 호출로 간단하게 처리했다.
이번 예제에서는 Remoting을 이용해서 호스트 프로그램이 플러그인과 좀 더 다채로운 방식으로 상호작용한다.
웃너 할 일은 호스트와 플러그인이 참조할 공통 라이브러리를 작성하는 것이다. 그러한 라이브러리는 플러그인이 할 수 있는 일을 서술하는 하나의 인터페이스를 정의하는 역할을 한다. 다음은 그러한 라이브러리의 간단한 예이다.
namespace Plugin.Common { public interface ITextPlugin { string TransformText(string input); } }
C#
복사
다음으로 간단한 플러그인을 작성한다. 다음 어셈블리를 AllCapitals.dll 이라는 파일로 컴파일한다고 가정한다.
namespace Plugin.Extensions { public class AllCapitals : MarshalByRefObject, Plugin.Common.ITextPlugin { public string TransformText (string input) => input.ToUpper(); } }
C#
복사
이제 이 플러그인을 사용하는 호스트 쪽 코드를 보자. 다음의 호스트는 AllCapitals.dll을 기본 응용 프로그램 도메인과는 개별적인 응용 프로그램 도메인에 적재하고, Remoting을 이용해서 플러그인의 TransformText 메서드를 호출하고, 해당 응용 프로그램 도메인을 해제한다.
using System; using System.Reflection; using Plugin.Common; class Program { static void Main() { AppDomain domain = AppDomain.CreateDomain("Plugin Domain"); ITextPlugin plugin = (ITextPlugin)domain.CreateInstanceFromAndUnwrap("AllCapitals.dll", "Plugin.Extensions.AllCapitals"); // Remoting을 이용해서 TransformText 메서드를 호출한다. Console.WriteLine(plugin.TransformText("hello")); // HELLO AppDomain.Unload(domain); // 이제 AllCapitals.dll 파일이 완전히 해제되었으며 따라서 이동하거나 삭제할 수 있다. } }
C#
복사
이 프로그램은 오직 공통의 인터페이스인 ITextPlugin을 통해서만 플러그인과 상호작용하므로, AllCapitals의 형식들은 호출자의 응용 프로그램 도메인에 절대로 적재되지 않는다.
이 덕분에 호출자의 도메인의 무결성이 유지되며, 플러그인 도메인을 해제하면 플러그인 어셈블리 파일에 대해 그 어떤 잠금도 남아있지 않게 된다.

형식 발견

실제 응용 프로그램에서 플러그인을 지원하려 한다면, Plugin.Extensions.AllCapitals 같은 플러그인 형식의 이름을 코드 자체에 박아 둘 수는 없다. 어떤 방법으로든 플러그인 형식의 이름을 알아낼 수 있어야 한다. 즉, ‘형식 발견(type discovery)’ 능력이 필요하다.
한 가지 방법은 공통 라이브러리에 반영(reflection) 기능을 이용한 형식 발견 클래스를 추가하는 것이다. 다음이 그러한 클래스의 예이다.
public class Discover : MarshalByRefObject { public string[] GetPluginTypeNames(string assemblyPath) { List typeNames = new List(); Assembly a = Assembly.LoadFrom(assemblyPath); foreach(Type t in a.GetTypes()) { if (t.IsPublic && t.IsMarshalByRef && typeof(ITextPlugin).IsAssignableFrom(t)) typeNames.Add(t.FullName); } return typeNames.ToArray(); } }
C#
복사
그런데 이 클래스에서 한 가지 주의할 점은, GetPluginTypeNames 메서드의 Assembly.LoadFrom 호출이 어셈블리를 현재 응용 프로그램 도메인에 적재한다는 것이다. 따라서 GetPluginTypeNames 메서드를 반드시 플러그인 도메인에서 호출해야 한다.
class Program { static void Main() { AppDomain domain = AppDomain.CreateDomain("Plugin Domain"); Discoverer d = (Discoverer)domain.CreateInstanceAndUnwrap(typeof(Discoverer).Assembly.FullName, typeof(Discoverer).FullName); string[] plugInTypeNames = d.GetPluginTypeNames("AllCapitals.dll"); foreach (string s in plugInTypeNames) Console.WriteLine(s); // Plugin.Extensions.AllCapitals ...
C#
복사