COM, ATL

COM인터페이스 디자인 가이드

디버그정 2008. 8. 18. 20:46

COM인터페이스 디자인 가이드


이재규 (영산정보통신)

Microsoft는 표준 인터페이스를 제공함과 더불어 커스텀 인터페이스를 쉽게 정의할 수 있게 해 준다. 커스텀 인터페이스는 표준 인터페이스로는 표현할 수 없는 서비스를 구현하기 위해서 사용된다.

인터페이스는 재사용되는 경우가 많고, 원칙적으로 변경되어서는 안되기 때문에 인터페이스를 새로 만드는 일은 매우 신중해야 한다. 이 글에서는 커스텀 인터페이스를 만들때 고려해야할 디자인 요소에 대해서 살펴본다.

MS의 컴포넌트 기술

Microsoft의 컴포넌트 기술은 COM / OLE / ActiveX 등으로 혼돈스런 명칭이 붙어 있다.

엄밀한 의미에서 구분을 해 본다면, COM(Component Object Model)은 인터페이스, COM 클래스, COM객체등을 정의하는 스펙과 표준적인 인터페이스, 그리고 운영체제에 포함된 COM 런타임 모듈을 말한다.

OLE(Object Linking & Embedding)는 주로 OLE 문서 규약, 예를 들면 Word에 Excel문서를 포함시키는 기술에 관한 프로토콜이다. OLE는 이전에 DDE기반으로 구현되었으나, 현재는 COM기반으로 구현되어 있다.

ActiveX는 주로 인터넷을 활성화(Activation)시키는 기술에 관한 것이다. ActiveX 컨트롤, Active 도큐먼트 등의 기술은 인터넷을 보다 더 액티브하게 만드는 기술들이다.

서로 구분되어 있는 듯한 세가지 영역은 기술적으로는 서로 혼합되어 있는 형태이다. 예를 들어 COM에서 데이타는 IDataObject라는 인터페이스로 표현된다. 이 IDataObject는 OLE에서도 ActiveX컨트롤에서도 사용된다.

IDataObject와 같은 인터페이스는 그것이 잘 설계되었기 때문에 서로 다른 서비스에서도 사용된다. 이런 공통부분은 프로그래머들에게 쉽게 다른 서비스를 사용할 수 있게 해주는 잇점을 가지고 있다.

이러한 이유로 인터페이스의 디자인은 매우 중요하다.

인터페이스란 무엇인가?

인터페이스는 의미적으로 보아서 “기능”의 표현이다. 예를 들어 IDataObject는 데이타의 전송기능을 의미하고, IViewObject는 데이타의 표현(rendering)기능을 의미한다. 이 두 인터페이스를 모두 구현한 COM객체는 데이타 전송과 표현기능을 가지고 있는 객체가 된다.

인터페이스는 “표면”이라는 뜻이다. COM클래스가 실제로 인터페이스를 구현하지만 클라이언트는 COM클래스의 밖을 싸고 있는 인터페이스를 이용해서 COM객체를 사용할 수 있다.

<그림 1> COM 클래스와 클라이언트

인터페이스의 실체는 규약(contract) 를 정의하는 연관된 함수들의 집합이다. 인터페이스의 함수들은 의미적(semantic)으로만 정의된다. 예를 들어 IDataObject의 GetData함수는 인자로 주어진 FORMATETC 형식의 데이타를 STGMEDIUM에 담아서 가져온다는 의미만 정의한다.

인터페이스는 IID(Interface ID)라는 GUID(Globally Unique Identifier)로 구별된다. IDataObject와 같은 이름은 단지 편의상 붙이는 이름일 뿐이고, 내부적인 메카니즘에서는 IID가 사용된다. 예를 들어 QueryInterface에서 객체의 특정 인터페이스 포인터를 요구할 때 IID가 인자로 들어간다.

모든 인터페이스는 IUnknown을 계승한다. IUnknown은 객체가 특정 인터페이스를 구현했는지 알아내는 QueryInterface함수와 객체의 생존을 스스로 결정하는 AddRef와 Release라는 함수를 가지고 있다. QueryInterface를 통해 객체가 구현한 인터페이스를 알아내는 것을 인터페이스 협상(interface negotiation)이라고 한다.

서비스 - 인터페이스의 집합

COM은 여러가지 서비스들을 제공한다. 데이타 전송 서비스, OLE문서 서비스, ActiveX 컨트롤 서비스 등이 그것들이다. 그런데 이 서비스들은 서비스에 사용되는 인터페이스의 집합과 그것을 구현한 표준 COM객체, 서버와 클라이언트의 통신규약들로 이루어진다.

예를 들어 ActiveX컨트롤은 IDispatch, IViewObjectEx, IPropertyPage 등과 같은 인터페이스들과 IOleControl과 IOleControlSite 인터페이스간의 통신규약등을 정의하고 있는 서비스이다.

COM 클래스 / COM 객체 / COM 컴포넌트

COM클래스는 하나 이상의 인터페이스가 구현된 실체이다. COM클래스는 일종의 붕어빵기계라고 생각하면 된다. 붕어빵 기계로 찍어낸 것(인스턴스)이 바로 COM객체이며, 이 COM객체가 실제로 클라이언트에 의해 사용된다.

COM클래스는 클래스 팩토리(class factory)에 의해서 인스턴스를 생성할 수 있다. C++로 치면 new연산자에 해당하는 클래스 팩토리는 COM이 다양한 환경에 적용되더라도 동일한 객체 생성 메카니즘을 구현하기 위해서 만들어 진 것이다.

COM 컴포넌트는 서비스를 하는 COM객체와, COM객체를 생성하는 클래스 팩토리, 자기 등록 코드등을 포함하는 패키지이다. COM객체는 이 완전한 패키지의 형태로 배포되어야 한다.

COM클래스는 CLSID에 의해서 구분된다. CLSID는 인터페이스를 구현한 COM클래스를 유일하게 구분할 수 있게 해준다. 그러나 CLSID는 GUID이기 때문에 프로그램에서 다루기 힘들다.

이를 위해서 인간이 읽을 수 있는 ProgID라는 것을 사용하기도 한다. 예를 들어 정보시대에서 만든 MyControl이라는 ActiveX 컨트롤의 CLSID는 “Infoage.MyControl.1”과 같은 이름을 가질 수 있다. ProgID와 CLSID의 맵핑은 시스템 레지스트리에 저장되어 있다.

인터페이스 디자인의 중요성

프로그래밍에 있어 디자인이 차지하는 비중은 실로 크다. 정확히 말하자면 디자인에 투자하는 노력이 많을 수록 결과적으로 훌륭한 프로그램을 만들 수 있다. COM 인터페이스를 정의한다는 것은 순전히 디자인에 관한 작업이다. 그만큼 어렵지만 매우 중요하기도 하다.

인터페이스는 불변(immutable)의 성질을 가지고 있다. 즉 특정 인터페이스가 배포되어 다른 곳에서 사용되기 시작하면 절대로 인터페이스를 변경해서는 안된다는 얘기다.

클라이언트는 COM객체를 다음과 같은 방법으로 사용한다.

  1. 사용하고자 하는 COM객체의 CLSID를 인자로 주고 CoCreateInstance함수를 호출한다. 이때 클라이언트가 원하는 인터페이스의 포인터가 리턴된다.
  2. 클라이언트는 주어진 인터페이스 포인터의 QueryInterface를 이용하여 다른 인터페이스 포인터를 구한다. 그리고 인터페이스를 사용한다.

위와 같이 클라이언트는 단지 인터페이스만을 사용한다. 따라서 인터페이스를 변경하는 것은 클라이언트의 코딩을 변경해야함을 의미한다. 그래서 인터페이스가 어떤 식으로든 발표되게 되면 인터페이스 함수의 순서를 바꾼다든지, 인터페이스 함수의 의미를 바꾼다든지, 인터페이스 함수의 인자의 타입이나 갯수를 바꾸어서는 안된다.

위와 같은 이유로 커스텀 인터페이스의 디자인은 매우 신중해야 하며, 많은 노력을 기울여야 한다.

인터페이스 디자인 가이드

Microsoft는 많은 수의 인터페이스를 정의해두고 이를 표준 인터페이스라고 이름붙였다. 예를 들어 IDataObject, IOleObject 등이 그것들이다. 그러나 경우에 따라서는 구현하고자 하는 서비스를 표준 인터페이스가 수용하지 못할 수도 있다.

이때는 새로운 인터페이스를 만들어야 하며, 이를 커스텀 인터페이스라고 한다. 커스텀 인터페이스를 만드는 것 자체는 그리 어렵지 않으나, 커스텀 인터페이스의 디자인은 매우 신중해야 한다.

기존의 인터페이스를 재활용할 수 있는가?

먼저 커스텀 인터페이스를 만들기 전에, 반드시 새로운 인터페이스를 만들어야 하는가라는 고민을 해야 한다.

Microsoft는 대부분의 서비스에 대해서 인터페이스를 만들어 두었다. 그러므로 부분적으로 표준 인터페이스를 재사용할 수도 있다. 예를 들어 ActiveX컨트롤은 IOleControl이라는 새로운 인터페이스를 정의하지만, 기존의 IDataObject, IViewObject와 같은 인터페이스를 재사용하기도 한다.

이런 고찰이 끝난 뒤에도 여전히 커스텀 인터페이스가 필요하다면 이어지는 사항들을 고려해야 한다.

서비스를 분해하여 인터페이스를 단순하게 만든다.

새로운 서비스에 대해 새로운 인터페이스가 필요하다면 먼저 인터페이스 함수들을 열거해 본다. 그리고 그것들을 분해(factoring)해서 단순하게 만들어야 한다. 인터페이스의 분해는 인터페이스의 재사용성을 높이고 E_NOTIMPL사용을 적게 한다.

예를 들어 IDataObject와 같은 인터페이스는 완전히 분해되어 아주 좁은 의미를 가지는 인터페이스이다. IDataObject는 오직 데이타의 표현과 전송에만 관계한다. 비슷하게 IViewObject인터페이스는 데이타의 렌더링에만 관계한다.

만일 IDataObject와 IViewObject가 하나의 인터페이스로 구성되어 있었다면, 렌더링을 필요로 하지 않는 구현에서는 IViewObject에 해당하는 인터페이스 함수가 모두 E_NOTIMPL을 리턴해야 할 것이다.

또한 IDataObject와 같이 단순화된 인터페이스는 다른 서비스에 쉽게 적용할 수 있으며, 클라이언트에게도 쉽게 다가간다. 즉 데이타가 있는 서비스는 IDataObject가 구현되어 있다는 암시적 규칙을 사용할 수 있다는 뜻이다.

COM객체가 어떤 기능을 구현하는지를 기능 플랙을 사용하여 표현할 수도 있다. 그러나 이것은 권장되지 않는다. COM은 객체의 기능을 질의하기 위한 QueryInterface를 제공하기 때문이다. QueryInterface를 통해서 분해된 기능들을 지원하는지 살펴볼 수 있다.

인터페이스 함수는 의미적으로 명확히 정의되어야 한다.

예를 들어 IDataObject의 GetData라는 멤버함수는 의미적으로 FORMATETC 형식의 데이타를 STGMEDIUM에 담아서 돌려달라는 명확한 정의가 있다. 함수의 의미외에도 다음과 같은 사항들을 명확히 정의해야 한다.

  1. 함수 인자에 대한 명확한 정의가 있어야 한다. 인자의 의미와 인자의 조건이 명시되어야 한다. 특히 포인터인 경우 메모리를 호출측에서 할당하는지, 피호출측에서 할당하는지, 피호출측에서 할당하는 경우 메모리의 해제는 누가 책임을 져야 하는지 등의 명확한 정의가 있어야 한다.
  2. 리턴값에 대한 명확한 정의가 있어야 한다. 대부분의 인터페이스 함수의 경우 HRESULT를 리턴한다. 특히 에러를 리턴할 경우 어떤 경우에 어떤 에러를 리턴하는지 명확하게 정의해야 한다.
  3. COM객체 내부적으로 상태를 가지고 있다면, 함수의 호출결과로 COM객체가 어떤 상태로 놓여지는지를 명확히 정의해야 한다.
  4. 재진입(reentrancy)에 관한 허용여부를 명시해야 한다. 재진입이란 멀티-쓰레딩환경에서 함수의 호출이 완료되기 전에 다른 쓰레드에서 그 함수의 실행에 들어가는 경우를 의미한다. 일반적으로 로컬 리소스를 사용하는 경우는 재진입 가능하지만, 글로벌 리소스를 사용하는 경우 재진입을 허용하지 않는다.
  5. 함수가 반드시 구현되어야 하는 가도 명시한다. 그렇지 않다면 구현하는 측은 E_NOTIMPL을 리턴하게 할 수 있다.

<표 1>은 실제로 인터페이스 멤버함수를 어떻게 정의하는지를 보여준다.

HRESULT WakeUp(HROBOT hRobot, WCHAR *pwcsMsg)
  설명 WakeUp함수는 로봇을 수면상태에서 준비상태로 바꾼다. 그리고 인자로 주어진 메시지를 표시한다.
인자 hRobot 깨어날 로봇의 핸들. NULL이어서는 안된다.
pwcsMsg 로봇이 깨어날 때 표시하는 NULL로 끝나는 유니코드 스트링. NULL일 경우에는 디폴트 스트링을 표시한다. 디폴트 스트링은 SetWakeUpString함수에서 정의한다.
리턴값 S_OK
        함수의 수행결과가 성공적임
ERR_ALREADY_READY
        로봇이 이미 대기상태임
ERR_HW_FAILURE
        대기상태로 전환하는데 에러가 발생했음
E_INVALIDARG
        인자가 적절하지 않음

<표 1> 인터페이스 함수 정의의 예 (<참고자료 1> 에서 인용)

이름을 잘 정해야 한다.

COM의 입장에서는 인터페이스 멤버함수의 이름은 중요하지 않다. 왜냐하면 COM은 인터페이스 포인터가 가리키는 곳의 몇번째 함수인가만 따지는 바이너리 표준이기 때문이다.

따라서 인터페이스의 멤버함수에 이름을 정하는 것은 순전히 클라이언트 프로그래머를 위한 것이다. 함수의 정의에 맞는 간략한 이름을 대소문자를 섞어서 정의해 둔다면 클라이언트 코딩에 많은 도움을 준다.

인터페이스의 이름을 정할 때는 되도록이면 사용되는 서비스를 접두어로 붙이는 것이 좋다. 예를 들어 IOleObject는 OLE문서 서비스에서 사용되는 인터페이스임을 알 수 있게 하며, IMAPIPropertySet은 MAPI 서비스에 사용되는 인터페이스임을 알 수 있다.

인터페이스가 열거(enumeration) 를 위한 것이라면 IEnum...으로 시작하는 이름을 붙이는 것이 관례이다.

만일 기존의 인터페이스를 개선한 새로운 버전의 인터페이스를 만든다면 버전 번호를 뒤에 붙이는 것이 좋다. 예를 들어 ISomeService를 개선한 인터페이스라면 ISomeService2라고 이름을 붙여라. ISomeServiceEx와 같은 이름은 곤란하다. 왜냐하면 ISomeServiceEx를 개선한 인터페이스를 만들 때 이름을 ISomeServiceExEx와 같이 주어야 하니까.

메모리 관리 규칙을 따르라.

COM은 표준 메모리관리 객체를 제공한다. 이 객체는 IMalloc 인터페이스를 구현한 것이며 CoGetMalloc 함수를 이용하여 메모리 관리객체를 얻을 수 있다. 또한 사용의 편의를 위하여 CoTaskMemAlloc, CoTaskMemFree, CoTaskMemRealloc등의 함수도 지원한다.

COM이 표준 메모리 관리자를 제공하는 이유는, COM이 다양한 환경에서 적용되기 때문이다. 예를 들어 C++의 경우 new로 할당한 메모리와 malloc으로 할당한 메모리는 서로 호환될 수 없듯이, COM에서도 이런 상황이 발생해서는 안되기 때문이다.

사실 클라이언트 내부에서 메모리를 할당하고 사용하거나, 서버 내부에서 메모리를 할당하고 사용하는 것은 어떤 메모리 관리자를 써도 상관은 없다. 그러나 클라이언트와 서버가 통신을 할 때 (주로 함수의 인자로 메모리가 전달될 때)는 반드시 표준 COM메모리 관리자를 사용해야 한다.

함수의 인자로 메모리블럭이 넘어갈 경우 몇가지 규칙을 준수해야 한다.

  • 입력인자의 경우 클라이언트가 메모리를 할당하고 해제해야 한다.
  • 출력인자의 경우 서버에 의해서 메모리가 할당되고, 클라이언트에 의해서 해제되어야 한다.
  • 입출력 인자의 경우 클라이언트에 의해 메모리가 할당되고, 서버는 이 메모리를 해제할 수도 있고, 재할당할 수도 있다. 어쨌든 결과적으로 리턴된 메모리는 클라이언트가 최종적으로 해제해야 한다.

스트링은 유니코드를 사용하라.

COM은 내부적으로도 대외적으로도 유니코드를 사용한다. 유니코드는 MBCS(Multi-Byte Code System)와 달리 아시아계 문자 뿐 아니라, 영어권 문자도 2바이트를 사용한다. 이로 인해 아시아계 문자와 영어권 문자의 표현 크기가 틀려서 생기는 코딩의 어려움을 해결할 수 있다.

유니코드는 프로그래머의 편의보다도, 전세계 문자를 하나의 코드체계에 포함시킨 것에 더 큰 의미를 가지고 있다. COM 인터페이스는 잘 정의된다면 전세계적으로 통용되는 것이다. 따라서 스트링의 표현을 유니코드로 하는 것은 자연스러운 일이다.

Visual C++에서 유니코드 문자 데이타는 WCHAR 혹은 OLECHAR 타입으로 정의되어 있다. 또한 리터럴(literal)을 유니코드로 지정할 경우 OLESTR 매크로를 사용한다. 그 외에도 유니코드에서 MBCS로, 혹은 그 반대로 변환하는 함수로서 wcstombs, mbstowcs와 같은 함수들이 있다.

스트링을 리턴할 경우 로케일을 고려하라.

로케일(locale)은 사용언어마다 다른 설정을 의미한다. 만일 커스텀 인터페이스가 스트링을 리턴해야 한다면, 과연 어떤 언어로 리턴할 것인가? 일반적으로 영어를 사용할 수도 있지만, 상품성을 위해서 한국어나 일본어도 지원할 수 있으면 좋을 것이다.

그러나 이를 위해서 한국어 버전의 COM클래스, 영어 버전의 COM클래스를 따로 만들 필요는 없다. WIN32에서 로케일에 관한 ID를 LCID라는 타입으로 정리해두었기 때문에 이를 인자로 받아서 LCID에 해당하는 스트링을 리턴하면 된다.


<그림 2> LCID의 구성

LCID는 <그림 2>와 같이 구성되어 있다. 정렬 ID는 정렬(sorting)을 어떻게 할 것인지를 지정하고, 주 언어코드와 부 언어코드는 실제로 사용할 언어코드를 지정한다. 주언어코드와 부언어코드를 합친 16비트를 LANGID라고도 한다.

WIN32는 LCID를 다루기 위한 마크로들이 제공한다. 이것들은 LCID를 만드는 MAKELCID, LCID로부터 LANGID를 뽑아내는 LANGIDFROMCLSID, LANGID로부터 주언어코드와 부언어코드를 얻어내는 PRIMARYLANGID와 SUBLANGID마크로 들이다. 그외에도 디폴트 LANGID를 얻기 위한 API로 GetSystemDefaultLangID와 GetUserDefaultLangID를 제공한다.

정렬코드와 주언어/부언어 코드는 Visual C++의 <olenls.h>에 정의되어 있으므로 참고하기 바란다.

에러코드를 정의하라.

인터페이스 함수의 리턴값은 함수의 실행결과를 알려주는 HRESULT를 적용하는 것이 좋다. HRESULT는 비트별로 그 의미가 구분되어 있다. <표 2>과 <그림 3>에 필드에 대한 설명이 있다.

<그림 3> HRESULT 의 구조

필드 비트수 의미
S 1 0은 성공을 1은 에러를 의미한다.
R 2 Reserved. 0이어야 함
Facility 13 Code가 속하는 부류를 의미한다. 부류는 유일해야 하기 때문에 Microsoft에서만 정의한다.
Code 16 실제 상태를 나타낸다.

<표 2> HRESULT 필드의 의미

COM은 이미 E_NOTIMPL과 같은 표준 에러코드들을 정의해 두었지만, 커스텀 인터페이스에서는 새로운 에러코드가 필요하게 된다. 그러나 되도록이면 표준 에러코드들을 사용하는 것이 좋다. 다시 한번 더 생각해서 새로운 에러코드가 필요하다면 FACILITY_ITF 부류를 사용해야 한다.

FACILITY_ITF는 인터페이스를 정의한 측에서 정하는 에러코드란 의미이다. 주의할 점은 COM은 이미 FACILITY_ITF의 0x0000 ~ 0x01FF까지의 영역을 사용하고 있으므로, 안전하게 사용할 수 있는 코드의 영역은 0x0200 ~ 0xFFFF까지라는 점이다.

에러코드는 규약에 따라 정의할 수 있지만 성공코드는 새로 정의하지 말아야 한다. 왜냐하면 어떤 COM클라이언트 개발언어는 오직 표준적인 성공코드만 인식하기 때문이다. 따라서 새로운 성공코드를 사용할 경우 이런 환경에서는 성공했더라도 실패로 인식하기 때문에 문제가 생길 수 있다.

인터페이스 멤버함수가 HRESULT를 리턴하는 것은 권고사항이지만 반드시 지켜지는 것이 좋다. 이것은 원격 COM객체를 사용할 때 특히 그러한데, 네트웍 문제로 원격 COM객체의 호출이 실패했을 경우 리턴값으로만 에러코드를 넘길 수 있기 때문이다.

클라이언트의 언어를 고려하라.

만일 커스텀 인터페이스의 클라이언트가 Visual Basic이나, VBScript, JavaScript와 같은 언어를 사용한다면 인터페이스 디자인에 몇가지 제한이 따른다.

Visual Basic과 같은 언어는 IDispatch만 사용할 수 있다. 그래서 VB를 고려한다면 객체는 자동화객체로 구현되어야 한다. 그러나 IDispatch의 디스패치 로직은 불필요한 부하가 많이 걸리므로 C++과 같은 언어를 위해서 이중인터페이스(dual interface)로 구현하는 것이 좋다.

이중 인터페이스는 IDispatch를 계승하는 인터페이스로서 C/C++과 같은 언어에서는 직접 인터페이스 함수를 사용할 수 있게 하고, Visual Basic과 같은 언어에서는 IDispatch 메카니즘을 사용할 수 있게 한다.

Visual Basic류의 언어는 COM객체가 IDispatch만을 구현하고 있다고 가정한다. 따라서 Visual Basic을 위해 구현하는 IDispatch는 그것만으로도 모든 기능을 할 수 있도록 배려해야 한다. 예를 들어 IWebBrowser2 (역시 이중 인터페이스이다)의 멤버함수 ExecWB와 QueryStatusWB는 웹브라우저 객체가 구현한 IOleCommandTarget인터페이스를 사용하게 하기 위한 엔트리 포인트이다.

또한 Visual Basic과 같은 언어는 C++의 타입을 인식하지 못한다. 따라서 Visual Basic의 타입을 사용해야 한다. 이것들이 바로 BSTR, VARIANT, SAFEARRAY와 같은 것들이다. C++에서 이들 타입을 사용하는 것은 무척이나 성가신 일이지만 COM객체가 널리 쓰이길 바란다면 이정도는 고생해주어야 한다.

Visual Basic은 C++식의 구조체를 지원하지 않는다. 따라서 함수의 인자나 결과로 구조체와 같은 형식을 사용해야 한다면, 이 구조체의 멤버를 프로퍼티로 대체한 자동화객체를 따로 정의해야 한다. 이 자동화객체의 IDispatch를 통해서 Visual Basic은 멤버들에게 접근할 수 있다.

콜백의 설계

콜백(callback)은 서비스를 이용하고 있는 클라이언트가 서버에 의해 호출되는 메카니즘을 의미한다. COM에서는 서버의 입장에서 outgoing 인터페이스라고 한다.

초기의 COM에서는 콜백 메카니즘에 대한 표준이 없었기 때문에 IDataObject의 DAdvise와 같은 식으로 인터페이스마다 필요한 콜백 메카니즘을 구현했었다. 그러나 이후 COM은 연결되는 객체 (Connectable Object)라는 메카니즘을 소개했다.

연결되는 객체 메카니즘은 서버측의 IConnectionPoint와 이를 관리하는 IConnectionPointContainer 그리고 클라이언트 측의 싱크(sink) 인터페이스 구현으로 이루어진다.

IConnectionPoint는 클라이언트 측에 구현되는 싱크 인터페이스와 맵핑된다. 그래서 서버가 뭔가를 클라이언트에 알려주어야 할 때 (이벤트나 통보) IConnectionPoint에 저장된 싱크 인터페이스 포인터의 함수를 호출한다. 즉 콜백을 호출하는 것이다.

Visual Basic에서 사용하는 이벤트는 바로 이 연결되는 객체 메카니즘을 IDispatch로 구현한 것이다. 따라서 Visual Basic에서 사용되어야 하는 인터페이스라면 연결되는 객체 메카니즘을 사용하여 콜백을 설계해야 한다.

In-Proc서버냐 Local서버냐

COM은 위치투명성(location transparency)을 제공한다. 위치투명성이란 클라이언트와 서버가 같은 프로세스 내에 있든(인프록 서버), 한 컴퓨터내의 다른 프로세스에 있든(로컬 서버), 다른 컴퓨터에 있든(리모트 서버) 관계없이 동일한 방법으로 COM객체를 사용할 수 있다는 것을 의미한다.

그러나 커스텀 인터페이스를 구현하는 객체가 인프록 서버로 구현될 것인가, 로컬 서버로도 구현될 것인가에 따라 차이점이 약간 있다.

우선 인프록 서버인 경우 클라이언트와 같은 메모리 공간에 코드가 위치하기 때문에 로컬서버에 비해 단순하고 실행속도도 빠르다.

만일 인터페이스의 멤버함수가 HDC나 HWND와 같은 프로세스 내부의 리소스를 인자로 가진다면 이 인터페이스를 구현한 COM객체는 반드시 인프록 서버여야만 한다. 만약 클라이언트 프로세스 내부의 리소스를 사용해야 하지만, COM객체는 로컬서버로 구현해야 한다면 인프록 핸들러를 따로 구현해서 처리해 주어야 한다.

또한 로컬 서버로 구현될 수도 있는 커스텀 인터페이스의 경우 인자의 마샬링(marshalling)을 고려해야 한다. 마샬링 코드는 IDL파일로부터 만들어지기 때문에 인터페이스 정의는 IDL로 작성하는 것이 여러모로 편리하다.

미묘한 사항으로 인터페이스 함수의 인자가 참조에 의한 호출(call by reference)로 넘어갈 경우에 인프록 서버와 로컬서버의 차이가 있다. 인프록 서버인경우 참조로 넘어간 부분을 함수에서 변경할 경우, 그것은 즉시 클라이언트 측에 업데이트된다. 예를 들면 인자로 메모리의 어떤 부분에 대한 포인터가 넘어갔고, 인터페이스 함수가 포인터가 가리키는 주소의 내용을 바꾸었다면, 클라이언트 측에서 볼 때 이 변경은 즉시 반영된다.

그러나 로컬 서버를 사용하는 클라이언트의 경우 인자로 넘겨진 포인터의 메모리 블럭이 복사되어 서버로 넘겨지기 때문에 서버의 멤버함수가 메모리 블럭의 내용을 바꾸더라도 즉시 업데이트 되지 않는다. 업데이트 되는 시점은 함수의 실행이 끝나고 클라이언트 측의 프록시(proxy)가 호출될 때이다.

그러므로 클라이언트는 인터페이스의 멤버함수를 호출하고, 다른 쓰레드에서 인자로 넘겨준 메모리의 내용을 참조하는 식의 코딩을 해서는 곤란하다. (위치투명성이 깨진다)

마샬링의 고려

마샬링(marshalling) 은 “정렬”이라는 의미이다. 마샬링은 클라이언트와 서버가 서로 다른 프로세스에 있을 때 클라이언트의 데이타(주로 인자)를 서버로 전달해주는 방법을 지칭한다. 이 때 클라이언트의 데이타가 적절하게 정렬되기 때문에 마샬링이라는 용어가 사용되는 것 같다.

그런데 마샬링은 자동적으로 이루어지는 것은 아니다. 그래서 알고싶지 않아도 마샬링에 대해서는 반드시 알아야 나중에 후회하지 않는다. 마샬링이 문제가 되는 경우는 인터페이스 함수의 인자로 메모리 블럭을 넘기는 경우다. 이 경우 보통 메모리블럭의 포인터와 크기를 인자로 넘긴다. (스트링의 경우 메모리블럭의 끝을 NULL값으로 판단할 수 있으므로 크기를 넘기지는 않는다)

이때 서버의 프록시는 메모리블럭의 크기만큼을 얻어서 마샬링을 한다. 마샬링된 인자정보는 IPC(Inter-Process Communication)나 RPC(Remote Procedure Call)를 통해 서버의 스텁(stub)으로 전달된다. 스텁은 마샬링된 인자정보를 언마샬링(unmarshalling)하여 인자 메모리블럭을 인터페이스 멤버함수의 인자로 넘겨준다.

IDL(Interface Definition Language)에 이러한 마샬링을 위한 문법이 준비되어 있다. 다음에 커스텀 인터페이스가 원격으로 사용될 때를 대비해 어떤 식으로 IDL을 사용해야 하는지 정리했다.

모든 enum상수에 대해서 [v1_enum] 속성을 사용하라.

IDL은 enum상수를 디폴트로 16비트로 처리한다. 그런데 32비트 환경에서는 enum상수를 32비트로 하는 것이 마샬링할 때 더 효율적이다. 따라서 모든 enum상수에 대해서는 [v1_enum]속성을 사용하라.

[length_is]와 [size_is]를 활용할 것.

클라이언트에서 메모리블럭을 인자로 넘길때, COM의 마샬링에 의해서 메모리블럭이 서버측의 스텁으로 복사된다. 이때 고려해야 할 두가지 사항이 있다. 첫째는 스텁쪽에 얼마만큼의 메모리블럭을 할당해야 하는가이고, 둘째는 실제로 IPC나 RPC를 통해 얼마만큼의 데이타가 전송되어야 하는가이다.

비슷한 얘기같지만 엄밀하게 말하면 틀리다. 예를 들어서 클라이언트가 할당한 메모리블럭이 서버측에서 변경되지 않고 읽히기만 한다면 메모리블럭을 그만큼 할당하고, 데이타도 전송되어야 한다. 그러나 클라이언트가 할당한 메모리에 서버측에서 쓰기만 한다면 메모리블럭만 할당하고, 데이타를 전송할 필요는 없다.

메모리블럭의 할당에 관계된 IDL속성은 [size_is]와 [max_is]가 있다. 비슷한 의미이지만 [size_is]는 배열요소의 갯수를 지정하고, [max_is]는 배열의 최대 인덱스를 지정한다. [size_is(n)]과 [max_is(n-1)]은 같은 의미이다.

전송량에 관계된 IDL속성은 [length_is]와 [first_is], [last_is]가 있다. [length_is]는 전송되어야 할 배열요소의 갯수를 지정하고, [first_is]와 [last_is]는 각각 전송되어야 배열요소의 인덱스 범위를 지정한다.

따라서 클라이언트가 할당한 메모리를 서버측에서 쓰기만 한다면, [size_is]만을 기록해서 불필요한 데이타 전송을 막을 수 있다.

그러나 함수의 인자로 배열의 상수크기를 지정한다면 [size_is]나 [length_is]를 쓰는 것이 비효율적이다.

// 배열의 크기가 상수로 지정된 경우
HRESULT Proc([in] short Arr[MAX_SIZE]);

// [size_is]를 사용해도 되지만 비효율적이다.
HRESULT Proc3([in size_is(MAX_SIZE)] short Arr[] );

[ptr] 포인터 사용을 자제할 것.

RPC에서 사용하는 포인터는 성능향상을 위해서 세가지로 나누어진다. 그 세가지는 각각 [ref], [unique], [ptr] 속성에 의해 지정된다.

[ref] 포인터는 다음과 같은 특징을 갖는다.

  • 포인터는 항상 유효한 주소를 가리키고 있다. 즉 NULL같은 것이어서는 안된다. 따라서 항상 참조할 수 있다.
  • 호출과정에서 포인터의 값이 바뀌어서는 안된다. 즉 호출 전후 포인터는 같은 메모리영역을 가리켜야 한다.
  • 얼라이어싱(aliasing)을 허용하지 않는다.

포인터 얼라이어싱이란 포인터 a를 포인터 b에 대입해서 a와 b가 같은 메모리를 가리키도록 하는 것을 말한다. RPC에서의 포인터 얼라이어싱은 실제로 같은 메모리를 가리키는지 검사하는 루틴이 실행되어야 하기 때문에 상당한 부하가 소요된다.

어쨌든 [ref]포인터는 단순히 클라이언트가 할당한 메모리블럭을 서버에 넘기고 서버는 포인터를 손대지 않는 경우(즉 입력인자) 사용할 수 있다. 많은 제한이 있기 때문에 가장 효율적인 포인터이다.

[unique] 포인터는 NULL을 가질 수도 있고, 서버측에서 변경될 수도 있지만 얼라이어싱은 지원하지 않는다. 즉 클라이언트가 포인터의 포인터를 인자로 넘기고 서버가 메모리를 할당하여 포인터를 넘겨주는 경우(즉 출력인자)에서 사용할 수 있다.

[ptr] 포인터는 완전포인터(full pointer) 라고도 불리우듯이, C++의 포인터기능을 모두 가지고 있다. 즉 [unique]포인터의 기능에 얼라이어싱 기능을 포함한 것이다. 그러나 앞에서도 말했듯이 포인터의 얼라이어싱은 성능에 문제가 있기 때문에 되도록이면 [ptr]포인터를 사용하지 않는 것이 좋다.

마치면서

새로운 서비스를 위해서 커스텀 인터페이스를 만드는 경우는 종종 있다. 이때 대부분의 프로그래머들이 편의만을 따져서 쉽게 쉽게 인터페이스를 디자인하는 것을 많이 보아왔다. 그러나 결국에는 인터페이스를 자꾸 손보게 되고, 그러다 보니 클라이언트 코딩도 자꾸 고쳐야 하고, 이러다가 버그에 휘말려 헤어나지 못하는 경우가 허다하다.

되도록이면 표준 인터페이스를 사용하는 것을 권하고 싶다. 그러나 커스텀 인터페이스가 반드시 필요하다면 인터페이스 디자인에 많은 시간을 투자하라고 권하고 싶다.

참고자료

  1. Designing COM Interface -- Charlie Kindel - MSDN Technical Articles
  2. Interface and Component Design with COM - Mary Kirtland - http://www.microsoft.com/com/slides/comdsgn.zip
  3. Inside COM ? Dale Rogerson - Microsoft Press
  4. Inside ATL/COM Programming with Visual C++ -- 전병선 - 삼양출판사

'COM, ATL' 카테고리의 다른 글

ATL and Dual Interfaces  (0) 2008.08.28
ATL_COM Event 사용 하기. COM  (0) 2008.08.20
Property 와 Method 클라이언트 사용 구현부분 참조  (1) 2008.08.19
lcid 로케일  (0) 2008.08.18
IDispatch::Invoke  (1) 2008.08.16
인보크 구현부 참조  (2) 2008.08.16
자동화에서 타입라이브러리 등록과 해제 코드  (0) 2008.08.14