AppDomain 프로그래밍에 대한 이야기

닷넷 프레임워크 기반 프로그래밍에서 회자되는 내용 중에 “어려운 범주”에 속하는 주제들이 몇 가지 있는데, 그 중 하나가 AppDomain에 관련된 것입니다. 닷넷 프레임워크는 전통적인 프로그래밍 모델인 프로세스와 스레드의 개념 위에 응용프로그램 도메인이라는 개념을 새롭게 제공합니다.


 


닷넷 프레임워크 위에서 실행되는 응용프로그램은 JRE의 경우와 마찬가지로 논리적으로 구획이 나뉘어진 하나의 Virtual Machine 위에서 실행되고, 모든 메모리 관리가 이루어지게 됩니다. 생산성 향상을 위하여 이러한 부분을 내부적으로 모두 감추고, 마치 컴파일러나 IL Assembler가 직접 실행 가능한 EXE 파일을 생산해내는것처럼 묘사되어있지만, 우리가 일상적으로 만들어내는 닷넷 컴파일러 기반의 EXE 파일은 미리 프로그래밍된 스텁 코드입니다.


 


대부분의 경우, 응용프로그램 도메인이 여러 개일 필요 없이, 단순히 프로세스 내에 여러 개의 스레드만을 사용하여 프로그래밍하기 때문에, 스텁 코드의 내용에 대해 고민할 필요가 없습니다. 하지만, 닷넷 프레임워크 실행 환경 안에서 다른 어셈블리를 불러온다는 것은, 기존에 LoadLibrary 같은 Win32 API를 이용하여 DLL을 후기 바인딩하는 것과는 개념적으로 차이가 있습니다.


 


LoadLibrary의 경우는 Win32 API이며, 메모리를 비롯한 모든 자원 관리가 운영 체제 내의 커널에 의하여 처리됩니다. 하지만 닷넷 프레임워크 환경에서 다른 어셈블리를 로드하고 해제하는 일은 닷넷 프레임워크 환경 내에 위치한 개별 VM에서 처리되는 것이고, 로드하고 해제하는 위치 또한 어셈블리를 로드하도록 요구한 코드가 속해있는 응용프로그램 도메인에 고정시키게 됩니다. 그리고, 프로그램의 안정성을 위하여, 한번 로드한 어셈블리를 개별적으로 언로드하는 동작은 Win32 API 때와는 달리 지원되지 않습니다. 이러한 특성 때문에 응용프로그램 도메인을 별도로 분리하여 어셈블리를 필요한만큼 한꺼번에 로드하고, 일괄적으로 해지하는 방식이 나타나게 된 것입니다.


 


이러한 규칙 아래에서 프로그래밍을 해야 하기 때문에 만약 여러 응용프로그램 도메인을 생성하고 해지하는 일이 빈번한 프로그램을 작성한다면, 다음과 같은 사항들을 고려할 필요가 있습니다.


 



  • 컴파일러가 제공하는 표준 Stub 프로그램 대신, 직접 Visual C++ 컴파일러로 작성하여 mscoree.dll과 fusion.dll에 링크하는 커스텀 Stub 프로그램을 작성하여 프레임워크 응용프로그램을 시작하기

  • 스텁 프로그램을 직접 작성하지 않을 경우, 진입점 메서드에 [LoaderOptimization] 어트리뷰트를 사용하여 다중 응용프로그램 도메인을 관리함을 명시하는 방법

 


그리고, 응용프로그램 도메인을 이용하여 프로그래밍할 때에는, Global Assembly Cache에 등록할 어셈블리가 아니라 할지라도, 강력한 이름을 어셈블리 내에 부여하여, 어셈블리에 고유한 Identity를 설정하는 것이 좋습니다.


 


흔히 강력한 이름 (Strong Name)은 어셈블리의 이름, 버전, 언어 및 지역 코드 만으로는 완전하게 식별할 수 없다는 것을 보완하기 위하여, 그리고 다수의 DLL 간의 버전 충돌이 발생하였을 때 DLL을 식별할 여지가 없는 DLL 지옥 (DLL hell)에 빠지는 상황을 예방하기 위하여 도입된 개념입니다만, GAC에 등록을 하던 하지 않던 간에 강력한 이름으로 서명한 어셈블리는 버전 간 충돌에 대한 걱정 없이 “고유한 성격”을 유지할 수 있습니다. 그런데, 이 부분이 왜 중요할까요?


 


간혹 전사적 정책에 따라, Active Directory 등에 연결되어있는 모든 컴퓨터로부터 관리자 권한을 박탈하고, 최소한의 권한만으로 컴퓨터를 액세스할 수 있도록 관리하는 경우가 있는데, 이런 환경에서 GAC는 사용하기 불편한 상태가 됩니다. (GAC의 기본 디렉터리가 %WINDIR%Assembly에 있으므로 GAC를 변경하는 작업은 “당연하게도” 관리자 권한이 요구됩니다.) 지금 설명할 응용프로그램 도메인이 어셈블리를 찾는 방법은, GAC를 사용할 수 없을 경우, GAC를 대신하여 이용할 수 있는 방법이므로, 강력한 이름을 기초로 식별하는 방법이 유일하기 때문입니다.


 


닷넷 프레임워크에서 어셈블리를 로드하는 기본 방식은, 응용프로그램 도메인이 시작될 때 설정된 BaseDirectory를 기준으로 아랫쪽에 있는 디렉터리나 파일들을 검색하는 방식입니다. 그러나 이 방법은 편리하지만 매우 제약이 심합니다. 그래서 부수적으로 제공하는 API가 하나 더 있는데, 바로 System.Reflection 네임스페이스의 Assembly 클래스가 노출하는 일부 정적 메서드들 중 From 으로 끝이나는 정적 메서드 시리즈들입니다. 그런데 여기서 고민이 하나 더 생깁니다.


 


어셈블리를 특정 도메인 위에서 로드하려면, AppDomain 클래스의 메서드를 이용해야 하지만, 앞서 이야기한 규칙에 따르면 자유롭게 로드하기 위해서는 Assembly 클래스의 메서드들을 이용해야 하는 충돌 상황이 발생합니다. 어떻게 이 상황을 풀면 좋을까요? 이 부분에 대해서 많은 Workaround를 찾아다닌 끝에 제가 찾은 방법은 아래와 같습니다.


 


AppDomain.CreateDomain 메서드를 사용하여 새 응용프로그램 도메인을 만듭니다. 다만, 기존 응용프로그램 도메인과 동일한 Evidence, 유사한 Setting을 상속받을 수 있게 하기 위하여 아래와 같이 매개 변수를 좀 더 구체적으로 써 넣습니다.


 

            AppDomainSetup domainSetup = new AppDomainSetup();
domainSetup.ApplicationBase = Environment.CurrentDirectory;
domainSetup.ConfigurationFile = configPath;
domainSetup.PrivateBinPath = privateBinPath;

        AppDomain domain = AppDomain.CreateDomain(
            String.Concat("Delegate Wrapper #", func.GetHashCode()),
            null, domainSetup);


 


 


만들어진 domain 객체의 SetData 메서드를 사용하여, 응용프로그램 도메인 내에서 참조할 수 있는 데이터를 전달합니다. (함수 수준에서 응용프로그램 도메인 경계를 넘어 직접 매개 변수를 전달할 수 없다는 점에 따른 부분입니다.)


 

             domain.SetData(“_ApplicationBasePath”, Path.Combine(Environment.GetEnvironmentVariable(“USERPROFILE”), “MyData”));

 


반환 형식이 없고, 매개 변수가 없는 일반 함수를 만들고, 본문 안에서 AppDomain.CurrentDomain 속성을 활용하여 새 도메인 기준으로 코드를 작성해 나갑니다. 이 때, AppDomain.CurrentDomain.AssemblyResolve 이벤트를 구독하도록 프로그래밍합니다.


 

            AppDomain.CurrentDomain.AssemblyResolve += new ResolveEventHandler(CurrentDomain_AssemblyResolve);

 


AssemblyResolve 이벤트는 보통의 이벤트 핸들러와는 다르게 이벤트 핸들러가 직접 결과를 반환하는 형태로 구성되어있습니다. 이 지점에서, 응용프로그램 도메인에 전달한 기준 디렉터리 경로를 활용하여 닷넷 프레임워크가 기본 정책에 따라 찾지 못한 어셈블리를 Assembly.LoadFrom 메서드로 따로 로드한 뒤 객체를 되돌려줌으로서 경로 문제를 보완하게 됩니다. 이 때, 로드할 대상 어셈블리에 대해 강력한 이름 서명이 되어있어야 DLL 간 충돌 문제를 완벽히 예방할 수 있습니다.


 

        private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
string basePath = AppDomain.CurrentDomain.GetData(“_ApplicationBasePath”) as string;

        if (basePath == null || !Directory.Exists(basePath))
            return null;

        AssemblyName parsedName = new AssemblyName(args.Name);
        switch (parsedName.Name)
        {
            case "My.Common":
            case "My.Core":
            case "My.Productivity":
                return Assembly.LoadFrom(Path.Combine(Path.Combine(basePath, "Library"), String.Concat(parsedName.Name, ".dll")));

            default:
                return null;
        }
    }


 


그리고 새로 만든 함수를 domain.DoCallBack 메서드에 콜백 객체로 전달합니다. 만약, 실행 결과를 되돌려 받기 원한다면, domain.GetData 메서드로 콜백 함수 내에서 설정한 데이터를 넘겨받을 수 있습니다. DoCallBack 메서드는 동기 방식 메서드이므로 콜백 메서드의 실행이 끝나기 전까지는 제어권이 되돌아오지 않습니다.


 


참고로, 콜백 실행 도중 처리되지 않은 예외는 새로 만든 domain의 UnhandledException 이벤트 핸들러를 호출하게 되며, 이 보다 더 직접적으로 예외 처리를 할 필요가 있다면 아래와 같이 TargetInvocationException 형식에 대한 예외를 잡아내어 InnerException을 조사하도록 하면 더 빠르게 예외 처리를 할 수 있습니다.


 

                try
{
domain.DoCallBack(appDelegate);
}
catch (TargetInvocationException remoteException)
{
if (remoteException != null &&
remoteException.InnerException != null)
throw remoteException.InnerException;
else
throw remoteException;
}

 


도메인의 사용이 모두 끝나면, domain.Unload 메서드를 사용하여 안전하게 도메인에 로드된 모든 어셈블리를 메모리에서 해지합니다.


 


긴 강좌를 읽어 주셔서 감사합니다. 적절한 상황이나 조건이 그 때 마다 다르겠지만, 프로그램의 규모가 커지고 복잡해 질 수록 .NET Framework의 숨겨진 이면을 충분히 분석하여 활용하는 것이 중요할 것입니다. 응용프로그램 도메인에 관하여 복잡하게 고민 중이신 분들께 도움이 되었으면 합니다.


 

AppDomain 프로그래밍에 대한 이야기”에 대한 3개의 생각

    • 사실 닷넷의 기본 내용에 충실하게 접근하는 것이 가장 이상적인 프로그래밍 방식이겠지만, 유감스럽게도 닷넷 플랫폼을 있는 그대로 사용할 수 있는 경우는 흔하지 않은것 같습니다. 도움 되시면 좋겠습니다. ㅎㅎ

댓글 남기기