drcarter의 DevLog

TNA에 대해 설명하기 전에 우선 STA와 MTA의 반응성과 대해 알아보자. STA의 경우 한 아파트먼트 안에 있는 모든 개체는 하나의 스레드를 통해서만 실행될 수 있다. 이러한 점은 STA 안에 여러 개의 객체가 존재하게 될 때 큰 문제를 지닌다. 이 STA에 속하지 않은 스레드로부터의 메서드 호출은 이 STA를 만든 스레드를 이용해 처리된다고 위에서 배웠다. 그렇다면 만약 STA에 속한 하나의 객체의 메서드를 호출하고 있다면 다른 객체에 대한 메서드 호출은 이 객체의 사용이 끝날 때 까지 기다려야 한다는 것을 쉽게 깨달을 수 있을 것이다. 이것은 STA가 보이지 않는 윈도우를 이용해 호출을 동기화 하기 때문이며 메시지 큐에 쌓인 메시지는 차례차례 처리된다. 이러한 점은 메서드가 블록킹 호출을 할 때 더욱 악화된다. 예를 들어 WaitForSingleMessage등의 메서드 호출을 한다면 사실 스레드는 아무 일도 안하는 상태임에도 불구하고 다른 객체에 대한 메서드 호출은 상당기간 동안 유보하게 된다. 이러한 점은 STA가 흔히 UI스레드가 만들게 되는 아파트먼트 하는 점에서 프로그램 자체의 반응성을 떨어뜨릴 수 도 있다. 물론 이를 해결하기 위해 CoWaitForMultipleHandles 와 같은 API가 있다. 하지만 이러한 API를 사용할 때에는 반드시 Reenterancy에 대한 내용을 숙지하여야만 한다.

반면에 MTA의 경우 이러한 반응성 저하는 없다고 볼 수 있다. 모든 스레드는 원하는 객체에 대한 메서드를 행하기 위해 Cache Thread를 통해 바로 메서드 호출을 할 수 있다. 같은 아파트먼트 안에 속해 있는 다른 객체뿐 아니라 심지어 자기자신이 사용 중이라 해도 이것은 마찬가지다. 또한 MTA에 속하지 않은 스레드에서의 호출 역시 기존의 스레드가 아닌 ORPC CACHE 스레드가 새로 생겨 처리 되므로 매서드 호출이 블록킹 되는 경우는 생기지 않는다. 이러한 점은 프로그래머에게 동기화 지원을 직접적으로 해줄 것을 요구하여 복잡함을 가중시킨다. 하지만 오히려 이러한 점은 숙련된 프로그래머로 하여금 아파트먼트 단위 또는 객체 단위가 아닌 자원단위의 또는 최소한의 잠금을 가지게 되는 단위의 동기화를 할 수 있게 함으로서 훨씬 더 낳은 반응성을 가지게 한다. 이러한 점이 STA 안에 단 하나의 객체 밖에 없다하더라도 MTA 안의 객체가 더욱 우수한 반응성을 지니게 하는 원인이 된다.

 

아파트먼트 종류와 스레딩 모델에 따른 성능 차이

 

한가지 중요한 점은 여러 개의 스레드가 돌아간다고 해서 해서 특정 작업이 빨리 수행 되는 것은 아니라는 점이다. STA에서 블록킹 메서드 호출을 하는 경우가 아닌 한 사실 STA와 MTA의 성능은 MTA에 여러 개의 스레드가 동시접근 할 수 있다는 점만으로 MTA가 우수하다고 할 수는 없다. 만약 다음의 요인을 제외 한다면 사실 STA와 MTA의 성능 차이는 없다고 볼 수 있다.

하지만 실질적으로 COM의 함수 호출이 느려지게 만드는 요인은 따로 있다. 바로 Cross-Apartment Call의 비용이다. STA에서 다른 STA로 또는 STA에서 MTA로 또는 MTA에서 STA로의 함수 호출은 모두 Cross-Apartment Call이라고 볼 수 있다. 물론 다른 프로세스나 네트웍상의 다른 컴퓨터에 대한 호출 역시 Cross-Apartment Call이지만 이 경우 사실 다른 요인이 더욱 성능을 감소시키게 되므로 일단 논의에서 제외하자. 그렇다면 무엇이 문제인가?

이 글의 맨 처음에 Thread Context Switching은 매우 비싼 명령이라고 설명했다. 문제는 Cross-Apartment Call이 이 Thread Context Switching을 일으키게 된다는 점이다. 예를 들어 다른 아파트먼트에 들어간 스레드A로부터 어떤 STA에 있는 객체로 메서드 호출을 생각해 보자. 이 스레드A는 STA에 들어간 스레드B에게 메시지를 보낸 후 대기 상태가 된다. 이후 그러면 스레드B가 그때부터 활성화된다.(일반적으로 GetMessage에서 깨어난다.) 여기서 한번의 Thread Context Switching이 일어난다. 그리고 스레드B는 객체에 대해 실제로 메서드 호출한 후 이 결과 값을 다시 스레드A에게 넘겨준다. 여기서 또 한 번의 Thread Context Switching이 일어난다. 한 메서드 호출당 두번의 Thread Context 호출은 절대 쉽게 넘어갈 수 있는 비용이 아니다. 만약 이 메서드가 매우 자주 불린다면 이것은 엄청난 성능 저하로 이어 질 것이다. MTA의 경우 MTA에 호환되도록 만들어진 객체를 호출할 경우 같은 MTA에 속하는 객체이기만 하면 같은 아파트먼트에 속하므로 Thread Context Switching이 필요 없다는 점에서 STA보다 훨씬 낳은 상황을 만든다. 하지만 MTA에 들어가있는 스레드가 STA에 있는 객체에 접근 할려면 Cross-Apartment Call이 이루어져야만 하며 여전히 Context Switching이 필요하다.

 따라서 특정 객체의 스레딩 모델을 정하거나 스레드가 어떤 아파트먼트안에 들어갈 것인가를 결정할 때에는 매우 주의를 기울여야 한다. 만약 특정 스레드가 Single Threaded 모델을 지니고 있는 객체를 생성해서 주로 사용한다면 그 스레드는 반드시 STA 안으로 들어가야 한다. 단순히 MTA가 더 낳은 성능을 제공하겠지 하고 기대하는 것은 엄청난 실수다. 성능을 향상하는 것은 최대한 Thread Context Switching을 줄이는 것이며 이것은 곧 Cross-Apartment Call의 횟수를 줄이는 것과 직결된다. 물론 MTA와 호환되는 객체를 사용한다면 MTA로 들어가는 것이 성능향상에 도움이 된다.

만약 이정도로만 끝난다면 얼마나 좋을까? 하지만 아직도 몇 가지 고려사항이 남았다. 바로 사용할 특정 객체가 다른 객체들을 사용할 경우다. 이점은 바로 Both 스레딩 모델과 Free Threaded 모델이 따로 존재하는 이유이기도 하다. 지금까지의 논의만을 생각한다면 당연히 동기화 기능을 제공하는 모든 객체는 Both 스레딩 모델일 경우 더욱 우수한 성능을 보일 것으로 기대된다. 하지만 그렇게 간단하지는 않다. 예를 들어 객체(객체 A라고 하자)를 사용하는 스레드(스레드A라고 하자)가 STA에 들어가야만 하고 만약 객체A에 대한 메서드 호출을 할 때 객체A가 다른 객체(객체 B라고 하자)로의 메서드 호출을 한다면? 특히 객체B가 MTA에 존재 한다면? 이것은 매우 심각한 고려 사항이 된다. 객체A가 스레드A와 같은 아파트먼트 안에 존재해야 할 것인가? 아니면 객체B와 같은 아파트먼트 안에 존재해야 할 것인가? 이것은 얼마나 자주 객체A가 객체B의 메서드를 호출하는가에 달려 있다. 만약 객체A가 스레드A와 같은 스레드에 존재한다면 스레드A에서 객체A로의 메서드 호출은 직접적인 메서드 호출이다. 다만 문제는 이 메서드 내에서 객체B를 호출할 경우다. 이 경우 이 메서드를 실행중인 스레드A는 객체B와 다른 아파트먼트 안에 있으므로 Cross-Apartment Call을 하게 된다. 만약 이 메서드 내에서 객체B에 대한 호출이 여러 번 있다면 그 횟수만큼 Cross-Apartment Call을 하게 된다. 하지만 만약 객체A가 객체B와 같은 아파트먼트 안에 존재한다고 하자. 스레드A는 객체 A에 대한 매서드 호출을 할 때 Cross-Apartment Call을 해야만 한다. 하지만 그 이후 객체 A에서 이루어지는 객체 B에 대한 매서드 호출은 직접적인 메서드 호출이다. 이런 점을 종합해보면 만약 객체 A에서 객체 B로의 메서드 호출이 잦다면  객체A는 Both 스레딩 모델을 사용해 스레드A와 같은 아파트먼트 안에 들어가는 것보다는 Free 스레딩 모델을 사용해 무조건 MTA에 들어가는 것이 더 효율적이다.

 

Standard Marshaling과 Custom Marshaling

위에서 채널을 통해 데이터를 직렬화해서 보낸다고 했다. 이렇게 데이터를 직렬화 하는 것을 COM의 용어로는 마샬링이라고 한다. 사실 지금까지의 논의는 객체의 인터페이스를 Standard 마샬링 하는 경우를 가정한 것이다. 그렇다면 마샬링이란 무엇인가? 마샬링이라는 것은 특정 아파트먼트 안에서의 데이터나 인터페이스를 다른 아파트먼트에 전달될 수 있는 스트림 형태로 바꾸는 과정을 의미한다. 우선 데이터의 마샬링에 관한 내용은 이곳의 내용과 관련이 적으므로 다음으로 미루도록 하고 인터페이스의 마샬링에 대한 내용을 살펴보자. 지금까지 다른 아파트먼트에 속한 객체를 호출 할 때 프록시 객체가 만들어진다고 했다. 그렇다면 과연 프록시 객체는 누가 어떻게 만드는 것인가? 이것 즉 프록시를 구현하는 일이 바로 인터페이스 마샬링이 하는 일이다. (좀 더 기술적으로 말하면 두 가지는 동일하지는 않다. 프록시를 구현한다기 보다는 프록시를 만들 수 있는 스트림을 만들어내는 것이 마샬링이며 이것을 특정 아파트먼트에 넘기고 언마샬링하고 COM Library 또는 타입라이브러리 기반의 프록시/스텁 코다가 이 스트림을 해석해 원래의 인터페이스에 대한 호출을 한다. 마샬링에 관한 자세한 논의는 다음에 하도록 하자.) 하지만 앞에서 보았다면 프록시가 하는 일이 그렇게 간단하지만은 않다는 것을 보았을 것이다. 이것을 매번 프로그래머가 해줘야 한다면? 더욱이 지금까지 알아본 프로세스 내에서의 Cross-Apartment Call은 네트워크 상의 다른 컴퓨터의 객체를 부르는 것보다 훨씬 더 간단하다는 것을 상기한다면 네트워크 까지 생각해야하는 마샬링 코드를 매번 프로그래머가 작성해야 한다면 아마 전세계에 COM을 하는 프로그래머는 손가락을 꼽을 정도가 될 것이다. 하지만 다행히도 그렇지는 않다. 비록 C++의 데이터형이 조금 모호함을 가지고 있어 (이 부분에 대한 얘기도 다음에 하자) IDL이라는 언어로 interface를 만들어줘야 하긴 하지만 어쨌든 인터페이스에 대한 정의만 제대로 내려주면 인터페이스의 마샬링 자동으로 이루어지게 된다. 해줘야 할 일은 오직 CoMarshalInterface와 CoUnmarshalInterface를 호출하는 일이다.

하지만 특정한 객체의 경우 이러한 방법을 통한 마샬링이 비효율적인 경우가 있을 수 있다. 비록 IDL이 다양한 기능으로 여러가지 경우에 매우 효율적인 프록시/스텁 코드를 만들어 주기는 하지만 아무래도 객체에 대해 더 잘 알고 있는 프로그래머가 조금 더 효율적인 코드를 만들 수 있는 것은 당연한 것 아닌가? 그러다면 커스텀 마샬링은 어떻게 구현 하면 되는가? 바로 커스텀 마샬링을 하고 싶은 개체한테 IMarshal 이라는 인터페이스를 구현하면 된다.

interface IMarshal : IUnknown
   {
   HRESULT GetUnmarshalClass(REFIID iid, void *pvInterface
       , DWORD dwDestContext, void *pvDestContext, DWORD mshlflags
       , CLSID *pclsid);
   HRESULT GetMarshalSizeMax(REFIID iid, void *pvInterface
       , DWORD dwDestContext, void *pvDestContext, DWORD mshlflags
       , DWORD *pcb);
   HRESULT MarshalInterface(IStream *pstm, REFIID iid, void *pvInterface
       , DWORD dwDestContext, void *pvDestContext, DWORD mshlflags);
   HRESULT UnmarshalInterface(IStream *pstm, REFIID iid, void **ppv);
   HRESULT DisconnectObject(DWORD dwReserved);
   HRESULT ReleaseMarshalData(IStream *pstm);
   };
사실 IMarshal 인터페이스를 구현하는 경우는 매우 드물다. 하지만 이 인터페이스를 이해하고 있는 것은 큰 도움이 된다. COM Library는 객체를 마샬링할 필요가 있을 때 객체에게 QueryInterface를 통해 IMarshal 인터페이스를 구현하고 있는 지 알아본다. 그리고 만약 구현하고 있지 않다면 스탠다드 마샬링을 사용한다. 하지만 만약 구현하고 있다면 여기의 매서드를 통해 인터페이스를 마샬링한다. 가장 중요한 매서드는 MarshalInterface와 UnmarshalInterface 이다. 인자는 거의 명확하다. 직렬화 시킬 저장 공간과 인터페이스의 IID와 포인터 그리고 위치에 대한 정보다. 여기서 위치에 대한 정보는 이것이 같은 프로세스 안에서 사용 될 것인지 아니면 다른 프로세스 또는 다른 컴퓨터에서 사용될 것인지를 결정하는 내용이다. UnmarshalInterface의 경우는 반대로 이 스트림에서 프록시 객체를 만들어 내는 메서드이다.

 

Free Threaded Marshaler

여기서 이전의 논의를 기억해내자. 같은 프로세스 내에서 STA에서 MTA에 있는 객체의 메서드를 부르는 것도 아파트먼트의 경계를 지나게 되므로 프록시를 통해 매서드 호출을 하게 된다고 했다. 하지만 사실 이 객체는 MTA와 호환되는 객체이므로 분명 여러 스레드가 접근하는 것에 대해 안전한 객체일 것이다. 즉 STA에 있는 스레드가 직접 접근해도 문제가 생기지 않는 다는 뜻이다. 하지만 스탠다드 마샬링을 쓸 경우 프록시 객체를 쓰게 되며 이는 스레드 컨텍스트 스위칭을 발생시키고 상당한 성능 저하를 나타내게 된다. 하지만 우리에겐 Custom Marshaling이 있다. MarshalInterface와 UnmarshalInterface를 통해 마샬링 할 때 위치정보가 주어진다는 사실을 상기하며 만약 이 위치정보가 같은 프로세스 내를 가르키고 있을 때에는 프록시 객체가 아닌 실제 객체에 대한 포인터를 돌려 준다면? 그렇다면 만약 다른 아파트먼트에 있는 스레드가 접근 하더라하더라도 스레드 컨텍스트 스위칭을 일으키지 않고 메서드를 호출할 수 있게 된다. 사실 이러한 마샬링 방법은 흔히 사용되는 방법이기도 하다. 그래서 이러한 구현을 이미 해놓고 통합(Aggregation)을 통해 재사용 할 수 있는 API가 제공된다. 이것이 바로 CoCreateFreeThreadedMarshaler 라는 API이다.

HRESULT CoCreateFreeThreadedMarshaler(
LPUNKNOWN punkOuter,
LPUNKNOWN * ppunkMarshaler
);
첫번째 인자로 FTM을 구현하고자 하는 객체를 넣으면 두 번째 인자로 만들어진 객체가 나온다. 이렇게 만들어진 객체는 같은 프로세스내에서는 Cross-Apartment Call로 인한 어떠한 성능저하도 겪지 않는다.

주의 : 커스텀 마샬링을 구현하는 것은 COM+에서 Configured Component로 사용될 수 없음을 의미한다. FTM의 경우도 마찬가지 이므로 주의 하자. 사실 COM+는 마샬링 과정을 (COM+용어로 말하면 Interception이다) 자신이 제공하는 동기화, 보안, Queued Component등의 서비스를 제공하는데 매우 유용하게 이용하고 있다. 만약 Custom Marshaling을 구현한다면 이를 이용할 수 없게 되는 것이다. 만약 이 점이 COM+의 유용성에 큰 단점이 된다고 생각하면 COM+와 함께 새로 소개된 TNA라는 새로운 아파트먼트에 대해서 알아보자.

 

Thread Neutral Apartment

윈도우 2000, COM+는 TNA라고 하는 새로운 아파트먼트 모델을 발표하였다. 이 아파트먼트 역시 MTA 처럼 모든 프로세스에서 하나씩만 존재하는 아파트먼트이다. 하지만 중요한 점은 이 아파트먼트 안에 들어가기 위해 CoInitializeEx를 호출 할 필요가 없다는 점이다. 이 아파트먼트는 CoInitializeEx를 통해 들어가는 것이 아니다. 이 아파트먼트에는 어떤 스레드던 자신이 원한다면 들어갈 수 있다. 즉 STA에 들어가있는 스레드던 MTA에 들어가 있는 스레드건 TNA에 직접 들어가 메서드를 실행시킬 수 있는 것이다.

그러다면 TNA에 있는 객체와 FTM을 구현하는 객체와는 어떻게 다른가? FTM의 경우도 어떠한 아파트먼트에서의 호출이던 객체에 직접 접근하지 않는가? 바로 그것은 TNA에 있는 객체의 경우 다른 아파트먼트에서 이에 객체에 대한 인터페이스를 얻을려면 이것은 역시 직접적인 포인터가 아닌 프록시 객체라는 점이다. 다만 이 프록시 객체가 다른 아파트먼트의 경우처럼 스레드 컨텍스트 스위칭을 일으키지는 않는다. 다만 COM+가 제공하는 서비스에 대한 검사만을 행하고 메서드 호출을 한 스레드가 직접 TNA에 들어가서 메서드를 실행 시킨다.

그렇다면 무엇이 FTM에 비해 낳은가? 아무리 검사만을 행한다고 하지만 분명 FTM처럼 직접적인 포인터를 통한 접근은 아니다. 이는 조금은 속도 저하를 가져올 수도 있는 부분이다. 하지만 그 차이는 스레드 컨텍스트 스위칭과 비교한다면 정말 아무것도 아닌 비용이다. 하지만 이에 비해 COM+의 서비스가 제공하는 각종 서비스를 받을 수 있다는 점을 생각한다면 TNA는 성능과 유연성을 겸비한 매우 가치있는 아파트먼트 모델이 된다.

한가지 더 얘기 하자면 만약 Essential COM등의 책을 보신 분이나 기존에 COM+에 대한 개발방향등을 접하신 분이라면 RTA라는 모델에 대해서 들어보신 분이 있을 것이다. 이 모델은 TNA와 매우 비슷하지만 TNA의 경우 여러 개의 스레드가 동시에 접근 가능하고 RTA의 경우 한 스레드만이 접근할 수 있는 모델이다. (RTA란 모델은 실제 하지 않는다.) 하지만 마이크로소프트는 TNA를 RTA처럼 제한 되게 만들지 않고 기본적으로 여러 스레드의 접근을 허용한 후 COM+의 동기화등을 통해 RTA처럼 사용될 수 있게 만들었다.

 

결론

 

아파트먼트와 스레드에 관한 내용은 사실 보안과 함께 COM의 가장 어려운 부분이다. 사실 이 부분은 COM뿐 아니라 모든 어플리케이션에게 있어 가장 힘든 부분이기도 하다. 게다가 소스코드 거의 한 줄 없는 이 글은 매우 어려울지도 모른다. 하지만 이것은 분명 스레딩 모델의 차이 때문에 기존의 객체를 사용하지 못하는 것 보다는 낫다. 특히 Apartment Threaded 모델의 경우는 스레딩에 관해 생각하지 않고 짜도 멀티스레드 프로그램에서도 쉽게 재사용할 수 있다. 하지만 조금 더 우수한 컴포넌트를 만들기 위해 다른 스레딩 모델에 대해서도 알아보도록 하자. 최근들어 많은 컴포넌트의 개발이 VB를 통해 이루어지면서 Apartment Threaded 밖에 지원하지 않는 VB의 특성상 다른 아파트먼트 모델에 대해 관심을 가지는 경우는 매우 드문 경우 였던거 같다. 아니 VC++를 이용해 COM Component를 만드는 것 자체를 낭비라고 생각하는 것 같다. 이러한 점은 기존의 MTS가 Apartment Threaded모델 만을 지원했던 점과 맞물려 심지어 COM+ Component는 VB만으로 만들 수 있다, 내지는 COM+가 STA만 지원한다는 설까지 만들어 낸 거 같다. 물론 개인적으로 VB를 매우 유용한 언어라고 생각하며 프로젝트시 VC++보다 많이 쓰일 가능성이 높다는 점을 인정한다. 또한 개발기간이 프로젝트에 매우 중요한 요소임도 안다. 하지만  VC++로 좀 더 우수한 성능을 낼 수 있는 모델로 개발할 수 있다는 가능성마저 잊지는 말자. 어쨌든 최소한 개발기간 다음으로 중요한 요인은 성능과 안정성이니까.

또 하나 언급하고 싶은 점은 COM+의 경우 아파트먼트 보다 조금더 세분화 된 Context라는 단위로 객체가 존재하는 공간을 나눈다. 그리고 Context 간의 메서드 호출을 interceptor를 이용해 처리해 각종 서비스를 제공한다. 위의 논의의 상당부분이 이를 통해 논의되는 것이 정확함에도 불구하고 객체를 아파트먼트 단위로만 논의 했다. 이는 스레드에 관한 내용에 집중하기 위해서이다. 또한 마샬링에 관한 많은 내용이 자세히 설명되지 못했는데 이에 관한 논의는 다음에 하도록 하자.

 

참고서적

Essential IDL(Addison Wesley)
Essential COM (Addison Wesley)
COM+ Programming – A Practical Guide Using Visual C++ and ATL (Prentice Hall)
Application Programming for Microsoft Windows 4th(Microsoft Press)
MSJ – House of COM – Don Box