언어/C#

C# - CLR 호스팅과 앱도메인

tsyang 2021. 6. 27. 15:19

호스팅 & 앱도메인


호스팅이란? 어떤 응용프로그램에서도 CLR을 사용할 수 있도록 해주는 기능이다. 이 기능을 이용하면 기존에 개발된 응용프로그램에 추가 기능을 관리 코드를 이용해 작성할 수 있다. 

 

그러나 이런 확장은 위험의 여지가 있는데, 제 3자의 DLL을 프로세스 공간에 로드함으로써 DLL이 응용 프로그램의 데이터나 코드를 손상시키거나 접근할 수 없는 리소스에 접근(보안 컨텍스트 취득)할 수 있기 때문이다. 

 

이를 해결하기 위해 앱도메인 기능이 있다. 앱도메인은 제 3자의 코드를 프로세스에 로드할 수 있게 해주지만 데이터나 코드 및 보안 컨텍스트가 손상되거나 탈취되지 않도록 보장해준다.

 

호스팅과 앱도메인 기능을 어셈블리 로딩과 리플렉션과 사용하면 .NET Framework의 참맛을 알 수 있다고 한다.

 

 


 

CLR 호스팅


모든 윈도우 응용프로그램은 CLR을 호스팅할 수 있다. CLR COM 서버의 인스턴스를 생성하기 위해서는 MSCorEE.dll 의 CLRCreateInstance 메서드를 이용해야 한다. 이 DLL을 심(shim)이라고 부르며 심은 CLR의 어떤 Version을 생성할지만 결정한다. 호스트 응용프로그램에서 심이 요청한 버전의 CLR을 호스트 프로세스에 로드한다.

 

호스트 응용프로그램은 다음과 같은 작업을 수행할 수 있다. 

  • 호스트 매니저를 설정한다. 호스트가 메모리의 할당, 스레드 스케줄링 및 동기화, 어셈블리 로딩 등의 작업에 참여할 것임을 CLR에 알려준다. 또한 가비지가 수집되거나 종료될 때 작업이 작업 시간을 초과할 때 호스트에게 알려줄 것을 요청한다.
  • CLR 매니저를 가저온다. CLR에게 일부 클래스나 멤버를 사용 못하게 한다. CLR이 정지되거나 예외가 발생했을 때 호스트 내의 어떤 메서드를 호출해야 하는지 알려준다.
  • CLR을 초기화하고시작한다.
  • 어셈블리를 로드하고 해당 어셈블리의 코드를 수행한다.
  • 윈도우 프로세스의 관리 코드가 수행되지 못하도록 CLR을 중단한다.

 

CLR을 호스팅하면 다음과 같은 장점이 있다

  • 응용프로그램이 CLR의 기능을 사용할 수 있고, 코드의 일부를 관리코드로 작성할 수 있다.
  • 개발자가 응용프로그램을 쉽게 확장할 수 있게 해준다.

 

참고로 일단 프로세스 내에 CLR이 로드되고 나면 프로세스를 종료하는 것 말고는 언로드할 수 있는 방법이 없다.

 

 


 

앱도메인


앱도메인이란 일련의 어셈블리를 포함하는 논리적인 컨테이너라고 볼 수 있다. CLR이 초기화되면 기본 앱도메인(Default AppDomain)이 생성된다. 기본 앱도메인 외에도 비관리 COM 인터페이스 메서드나 관리 타입 메서드를 이용하여 추가적인 앱도메인을 생성할 수 있다. 앱도메인을 만드는 목적은 격리 수단을 제공하는 것이다.

 

앱도메인의 기능은 다음과 같다.

  • 앱도메인은 언로드 될 수 있다. CLR은 특정 어셈블리를 지정하여 언로드할 수 는 없지만 해당 어셈블리가 포함된 앱도메인을 언로드할 수 있다. 이렇게 되면 해당 앱도메인에 포함된 모든 어셈블리도 언로드된다.
  • 특정 앱도메인의 코드가 생성한 객체는 다른 앱도메인의 코드에서 직접 접근할 수 없다. 앱도메인의 코드가 객체를 생성하면 앱도메인의 객체를 소유한다. 유일한 접근 방법은 마샬링을 이용하는 것 뿐이다. 이러한 객체의 격리로 프로세스 내에서 특정 앱도메인을 다른 앱도메인에 영향을 끼치지 않고 안전하게 언로드할 수 있다.
  • 앱도메인은 개별적으로 보안성을 가진다. 앱도메인을 생성할 때 앱도메인 내의 어셈블리의 최대 권한 수준을 지정할 수 있다. 따라서 어셈블리가 호스트의 데이터를 읽거나 손상시키지 못하도록 방지할 수 있다.
  • 앱도메인은 개별적으로 구성된다. 각각 앱도메인 별로 설정을 지정해줄 수 있다.

 

🔺윈도우 OS처럼 각각 응용프로그램들이 개별적인 프로세스 주소 공간을 가진다는 것은 큰 장점이다. 프로세스를 격리함으로써 보안 허점이나 데이터 손상 기대하지 못한 동작등을 차단하기 때문이다. 그러나 프로세스를 생성하는 작업은 비용이 매우 많이 드는 작업이다. 

만일 응용프로그램의 안정성을 보장할 수 있고 관리코드로만 구성되어 있다면 단일 프로세스 내에서 여러 관리 응용프로그램들을 수행해도 문제 될 것이 없다.

 

 

앱도메인 격리


 

위 그림은 하나의 윈도우 프로세스에 두 개의 앱도메인이 존재하는 상황을 도식화한 것이다. 여기서 System.dll이 양쪽 앱 도메인에 중복되어 로드되어 있는데, 이는 타입 객체에 할당된 메모리가 모든 앱도메인 사이에 공유되지 않기 때문이다. 마찬가지로 IL코드도 개별적으로 생성된다. 

이런 중복은 낭비처럼 보일 수 있지만, 이렇게 함으로써 앱도메인끼리 격리를 할 수 있고 서로 영향을 미치지 않고 안전하게 언로드될 수 있다. 마찬가지로 여러 앱도메인에서 공통적으로 사용되는 타입이라 하더라도 서로 다른 정적 필드를 가진다.

 

도메인 중립 어셈블리의 경우 여러 도메인에서 공유되는 것이 바람직한 어셈블리들을 포함한다. 위 그림의 MSCorLib.dll이 그 예인데, 이 어셈블리는 System.Object, System.Int32같은 주요 타입들을 정의하고 있다. 이 어셈블리는 CLR이 초기화될 때 로드되고 모든 앱도메인이 이 어셈블리의 타입을 공유한다. 이렇게 꼭 필요한 주요 타입들의 경우 리소스의 사용량을 줄이기 위해 도메인 중립 공간이란 것을 설정해서 공유하는 것이다. 마찬가지도 힙도 모든 앱도메인에서 공유한다. 그러나 격리되지 않고 공유하기 때문에 도메인 중립 공간은 프로세스가 종료될 때만 언로드가 된다. 

 

 


 

 

앱도메인 경계 넘기 (마샬링)


다른 앱도메인에서 생성된 객체를 사용하기 위해서는 마샬링이 필요하다. 

 

마샬링 메서드 내부를 보면, 현재 스레드가 어느 앱도메인에서 수행 중인지를 알기 위해 앱도메인 객체의 참조를 얻어온다. 윈도우에서 스레드는 단일 프로세스 안에서 생성&종료 된다. 하지만 앱도메인과 스레드는 일대일 관계가 아니다. 앱도메인이라는 개념은 CLR이 제공하는 기능이므로 윈도우 OS는 앱도메인이 뭔지 모른다. 단일 프로세스 내에 여러 개의 앱도메인이 존재할 수 있으며, 하나의 스레드가 여러 앱도메인의 코드를 수행할 수 있다. System.Threading.Thread.GetDomain() 을 호출하여 자신이 수행 중인 앱도메인이 무엇인지 확인할 수 있다.

 

1. 참조 마샬링으로 다른 앱도메인의 객체 사용

private static void MarshallingByRef()
{
    string exeAssemblyName = Assembly.GetEntryAssembly().FullName;

    //앱 도메인을 생성한다.
    AppDomain ad = AppDomain.CreateDomain("AppDomain #2");

    //새로 생성한 앱도메인에 어셈블리를 로드한다.
    //이후 객체를 생성하여 현재의 앱도메인에 돌려준다.
    //반환된 객체는 프록시에 대한 참조를 가진다.
    var mbrt = (MarshalByRefType)ad.CreateInstanceAndUnwrap(exeAssemblyName, "MarshalByRefTye");

    //해당 객체가 프록시 오브젝트임을 확인할 수 있다.
    if (RemotingServices.IsTransparentProxy(mbrt))
        Console.WriteLine("TransparentProxy"); //출력됨

    //직접 호출하는 것이 아니라 프록시의 함수를 호출하는 것.
    //프록시가 자신이 참조하는 객체의 앱도메인 스레드로 전환 후에 해당 객체의 메서드를 호출한다.
    mbrt.SomeMethod();

    //앱도메인을 언로드. 
    AppDomain.Unload(ad);

    //이후 mbrt는 여전히 프록시 객체를 참조하지만 
    //프록시는 더이상 유효하지 않은 앱도메인을 참조한다.

    mbrt.SomeMethod(); //AppDomainUnloadedException 발생
}

public class MarshalByRefType : MarshalByRefObject
{
    public void SomeMethod() { }
}

새로운 앱도메인은 고유의 로더 힙을 가진다. CLR이 앱도메인을 생성할 때 스레드도 같이 만드는건 아니다. 사용자가 명시적으로 앱도메인 내의 코드를 호출 해야만 새로운 앱도메인의 코드가 수행된다.

 

CreateInstanceAndUnwrap을 호출하면 호출 스레드가 현재 앱도메인에서 새로운 앱도메인으로 전환되는 스레드 전환이 수행된다. 이후 스레드는 새로운 앱도메인에 어셈블리를 로드하고, 해당 어셈블리의 타입 정의 메타데이터 테이블(type definition metadata table)을 뒤져서 매개변수로 지정한 "MarshalByRefType"을 찾은 후, 매개변수가 없는 생성자를 호출한다. 중요한 점은 이 메서드가 새로운 앱도메인의 실제 객체가 아닌 프록시 타입을 반환한다는 것이다.

 

프록시 타입은 메타데이터를 이용하여 실제 타입의 인스턴스 멤버까지 완전히 동일하게 모방한다. 심지어는 GetType()을 호출하면 실제 객체의 타입을 반환한다. 이 때 System.Runtime.Remoting.RemoteServices의 IsTransparentProxy() 메서드를 이용하여 해당 메서드가 프록시 객체인지를 확인할 수 있다.

 

프록시 객체는 소유하고 있는 실제 앱도메인이 무엇이며 어떻게 실제 객체에 접근할 수 있는지에 대한 정보를 가진다. 내부적으로는 GCHandle 인스턴스를 이용하여 실제 객체를 참조한다.

 

이제 mbrt의 SomeMethod 메서드를 호출하면 프록시 객체가 자체적으로 구현한 SomeMethod를 호출한다. 여기서 프록시 객체 내부의 필드 정보를 이용하여 자신을 호출한 스레드를 새로운 앱도메인으로 전환시킨다. 이후에 스레드는 프록시 객체의 GCHandle 필드를 확인하여 앱도메인의 실제 객체를 찾고 해당 객체의 진짜 SomeMethod를 호출한다. 호출이 완료된 이후에는 다시 기존 앱도메인으로의 스레드 전환이 일어난다. 이 과정은 동기적이다.

 

Unload를 수행하면 CLR은 지정한 앱도메인을 언로드한다. 이 과정에서 가비지 수집 작업도 강제된다. mbrt는 유효한 프록시 객체를 가지지만, 그 프록시 객체는 유효하지 않은 앱도메인의 객체를 참조한다. 따라서 Unload이후에 SomeMethod를 호출하면 예외가 발생한다.

 

참고로 참조 마샬링은 성능상의 부담이 있다. 접근하는 객체가 자신의 앱도메인에 있어도 성능 저하가 발생한다. (상황에 따라 다르겠지만, 6배정도 느리다고 한다.)

 

MarshalByRefObject를 상속한 타입은 정적 멤버를 정의하지 않는 것이 좋다. 왜냐면 정적 멤버를 사용할 때는 앱도메인 전환이 이뤄지지 않기 때문이다. 즉, 프록시 객체가 사용되지 않는다. 

 

추가적으로, 생성한 앱도메인은 어떤 루트도 존재하지 않는다. 그렇다면 프록시가 참조하는 실제 객체가 가비지 수집되어버리면 어떡하지? 라는 걱정이 들 수 있다. 이를 위해서 CLR은 리스 매니저(Lease Manager)를 이용한다. CLR은 프록시가 생성되면 해당 객체를 5분간 살아있도록 유지시킨다. 5분 동안 객체에 대한 접근이 발생하지 않으면 시렞 객체는 비활성 상태가 되고 다음 가비지 수집 때 메모리에서 삭제된다. 객체가 호출되면 리스 매니저는 객체의 리스 시간을 2분으로 재설정하여 수명을 늘려준다. 만일 리스 시간이 초과된 후에 객체에 접근하면 RemotingException 예외가 발생한다. (참고로 5분과 2분이라는 시간은 MarshalByRefObjectInitalizeLifetimeSerives를 오버라이드 하여 변경 가능함)

 

 

2. 값 마샬링으로 다른 앱도메인의 객체 사용

private static void MarshallingByVal()
{
    string exeAssemblyName = Assembly.GetEntryAssembly().FullName;

    AppDomain ad = AppDomain.CreateDomain("AppDomain #2");
    var mbrt = (MarshalByRefType)ad.CreateInstanceAndUnwrap(exeAssemblyName, "MarshalByRefTye");

    //반환 객체의 복사본을 전달한다.
    //반환 객체는 값으로 마샬링 된다.
    MarshalByValType mbvt = mbrt.GetMarshalByValType();

    //해당 객체가 프록시 오브젝트를 참조하지 않음을 알 수 있다.
    if (RemotingServices.IsTransparentProxy(mbvt))
        Console.WriteLine("TransparentProxy"); //출력 안 됨

    //앱도메인을 언로드. 
    AppDomain.Unload(ad);

    //이후 mbrt와 달리 mbvt는 유효한 객체를 참조한다.
    mbvt.SomeMethod(); //OK !!
}

public class MarshalByRefType : MarshalByRefObject
{
    public MarshalByValType GetMarshalByValType() { return new MarshalByValType(); }
}

//Serializable 한 타입은 앱도메인을 넘어 값으로 마샬링 될 수 있음.
[Serializable]
public class MarshalByValType
{
    public void SomeMethod() {}
}

MarshalByValType은 [Serializable] 어트리뷰트가 달려 있어 값으로 마샬링이 가능하다. 객체의 참조를 받아오기 위해서 CLR은 객체의 인스턴스를 바이트 배열로 serialize한다. 이후에 이 배열을 현재 앱도메인으로 복사한 뒤, 객체의 타입을 정의하는 어셈블리를 참조하여(로드가 안 되어 있다면 로드한다) deserialize 한다. 즉 이 과정은 CLR이 소스 객체와 완전히 동일한 복사본을 앱도메인에 생성하는 것이라고 볼 수 있다.

당연히 mbvt는 프록시도 아니고 mbvt의 메서드를 호출하면 스레드 전환도 일어나지 않는다. Unload된 이후에도 사용이 가능하다.

 

System.String은 참고로 Serializable 이므로 값으로 마샬링을 수행할 수 있다. CLR은 string 객체에 대해서 특별한 최적화를 진행하는데, string은 불변(immutable) 객체이기 때문에 앱도메인의 경계를 넘을 때 그냥 참조값으로 넘겨버린다. 

 

3. 마샬링할 수 없는 타입을 앱도메인 경계를 넘어 사용

private static void TryNonMarshalable()
{
    string exeAssemblyName = Assembly.GetEntryAssembly().FullName;

    AppDomain ad = AppDomain.CreateDomain("AppDomain #2");
    var mbrt = (MarshalByRefType)ad.CreateInstanceAndUnwrap(exeAssemblyName, "MarshalByRefTye");

    //프록시가 마샬링 할 수 없는 객체를 반환하려 함.
    NonMarshalableType nmt = mbrt.ReturnNonmarshalable();   //예외 발생!!
}

public class MarshalByRefType : MarshalByRefObject
{
    public NonMarshalableType ReturnNonmarshalable() { return new NonMarshalableType(); }
}

//MarshalByRefObject를 상속하지도,
//Serializable하지도 않기 때문에 마샬링 될 수 없음
public class NonMarshalableType { }

우선 mbrt 변수는 실제 객체의 프록시이다. mbrt를 통해 반환되는 객체가 MarshalByRefObject를 상속했다면 CLR이 프록시를 생성한뒤 참조로 마샬링을 수행할 것이다. 혹은 객체가 [Serializable]로 지정되어 있다면 CLR은 객체를 serialize 를 이용하여 복사본을 만들었을 것이다. 그러나 위 경우에는 둘 다 아니기 때문에 SerializationException을 발생시킨다.

 

 


 

앱도메인 언로딩


앱도메인의 장점 중 하나는 앱도메인이 언로드 될 수 있다는 것이다. CLR은 앱도메인을 올바르게 언로드 하기 위해서 다음의 상당한 작업들을 수행한다.

 

  1. 관리 코드를 수행한 적이 있었던 모든 스레드를 일시 중지시킨다.

  2. 위의 스레드의 스레드 스택을 뒤져서 언로드할 코드 쪽으로 반환될 스레드를 찾아낸다. 이후 CLR은 자신의 스택에 언로드될 앱도메인의 코드를 가지고 있는 스레드들에게 ThreadAbortException을 발생시킨다(스레드가 수행을 재개할 때). 스레드는 스택 언와인드를 수행하고 그 후 finally 블록을 수행한다. ThreadAbortException은 조금 특이한데, CLR은 이 예외를 삼켜버려 밖으로 내보내지 않는다. 따라서 프로세스는 종료되지 않고 계속 수행된다. 

    (반면에 비관리 코드를 수행하는 스레드는 어떻게 할까? 비관리 코드뿐 아니라 finally, catch블록이나 생성자, CER 및 CLR이 완료 여부를 알 수 없는 코드를 수행하는 스레드를 즉시 중단하면 예상치 못한 동작이나 잠재적인 보안 취약점이 발생할 가능성이 있다. 따라서 이러한 코드를 수행하는 스레드의 경우 코드 블록을 모두 수행할 때 까지 기다렸다가 ThreadAbortException을 발생한다.)

  3. 위 단계를 통해 스레드들이 앱도메인에서 나오면 CLR은 힙을 뒤져서 언로드될 앱도메인 내에 생성된 객체를 참조하는 프록시 객체에 특정 플래그를 설정한다. 그러면 프록시 객체들은 참조하던 객체가 사라졌음을 알게 된다. 이제 이 프록시 객체의 메서드를 호출하면 AppDomainUnloadedException이 발생한다.

  4. CLR은 강제로 가비지 수집을 한다. 언로드될 앱도메인 내에 생성된 객체들의 Finalize 메서드는 모두 호출된다.

  5. CLR은 남아있는 모든 스레드를 재개한다. 동기적인 작업을 수행하는 메서드인 AppDomain.Unload를 호출한 스레드도 수행을 이어간다. 

 

스레드가 AppDomain.Unload 호출했을 때, CLR은 스레드가 해당 앱도메인을 벗어날 때 까지 10초간 기다린다. 10초가 지나면 스레드는 정상 반환되지 못한걸로 간주, CannotUnloadAppDomainException을 발생시킨다. 예외가 발생하더라도 앱도메인은 성공적으로 언로드 되었을수도 있다.

 

 


 

 

앱도메인 모니터링


호스트 응용프로그램은 앱도메인이 사용하는 리소스를 모니터링 할 수 있다. 모니터링은 비용이 발생하는 일이기 때문에 명시적으로 AppDomain의 정적 속성인 MonitoringEnabled를 true로 설정해야 한다. 모니터링은 일단 시작되면 종료될 수 없다.

 

AppDomain은 모니터링 관련하여 다음의 네 가지 읽기 전용 속성을 제공한다.

  1. MonitoringSurvivedProcessMemorySize : 모든 앱도메인들이 현재 사용 중인 메모리 크기
  2. MonitoringTotalAllcoatedMemorySize : 지정한 앱도메인이 할당한 적 있는 메모리의 전체 크기
  3. MonitoringSurvivedMemorySize : 지정한 앱도메인이 현재 사용 중인 메모리 크기
  4. MonitoringTotalProcessorTime : 지정한 앱도메인의 CPU 사용 시간

참고로 1,2,3번은 가비지 수집 이후에 정밀해진다. 책에 있는 예제를 보면 (667p), IDisposable을 구현한 타입을 이용하여 Dispose 메서드 내에 GC.Collect를 수행한 이후에(가비지 수집 이후에 정밀해지므로) 모니터링 속성들을 로깅한다. 

 

 

 


 

 

앱도메인 예외통지


앱도메인 내에서 예외가 발생하면 CLR은 앱도메인에 등록된 FirstChanceException 콜백 메서드를 호출한다. (해당 콜백 메서드는 사용자가 등록하여 사용할 수 있다.) 이후 CLR은 동일 앱도메인에서 Catch를 찾는다. 없으면 예외가 발생한 앱도메인을 호출한 앱도메인까지 거슬러 올라가 같은 예외를 다시 발생시킨다. (직렬화한뒤 다시 역직렬화 하는 식으로 경계 넘음) 이제 호출한 앱도메인에서 다시 FirstChanceException 메서드를 호출하고 Catch블록을 찾아 예외 처리를 시도한다. 이 과정이 반복되어도 예외가 처리되지 못한다면 CLR은 프로세스를 종료시킨다.

 

 


 

 

참고로 .Net 5+ 부터는 위에 나온 앱도메인이나 Remote 관련 기능 중 일부가 동작하지 않는다......

https://docs.microsoft.com/ko-kr/dotnet/core/porting/net-framework-tech-unavailable

 

 

참고 : 제프리 리처, CLR via C# 4판 , 비제이퍼블릭, 22장. CLR 호스팅과 앱도메인 [645~677p]

 

 

🤮

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

런타임 Serialization - 1  (1) 2022.04.03
C# - 리플렉션 (Reflection)  (0) 2021.07.04
C# - 관리 힙과 GC (2)  (1) 2021.04.03
C# - 관리 힙과 GC (1)  (2) 2021.03.28
C# Nullable, Null 결합 연산자  (0) 2021.03.06