Basic Instincts

리플렉션을 사용한 COM 개체 검사

Lucian Wischik

코드는 MSDN 코드 갤러리에서 다운로드할 수 있습니다.
온라인으로 코드 찾아보기

목차

형식 라이브러리와 런타임 호출 가능 래퍼
형식에 RCW가 없는 경우
ITypeInfo 사용
형식 참조 추적
멤버 가져오기
기본 형식과 종합 형식
값의 COM 표현
COM 개체 속성 덤프
IDispatch.Invoke 사용
토론

여러분들도 COM을 제대로 동작시키기 위해 고생한 경험이 있을 것입니다. 물론 그렇게 해서 성공했을 때 즐거워했던 경험도 있겠지요. 개체의 동작 방식을 파악하기 위한 일반적인 방법은 Microsoft .NET Framework의 리플렉션을 사용하여 개체를 검사하는 것입니다. 경우에 따라서는 COM 개체에도 .NET 리플렉션을 사용할 수 있습니다. 이 말의 의미는 다음 코드를 통해 알 수 있습니다. 이 코드는 .NET 리플렉션을 사용하여 개체 멤버 목록을 구하고 표시합니다.

Dim b As New SpeechLib.SpVoice
Console.WriteLine("GETTYPE {0}", b.GetType())
For Each member In b.GetType().GetMembers()
    Console.WriteLine(member)
Next

그리고 콘솔에 다음과 같은 출력을 생성합니다.

GETTYPE SpeechLib.SpVoiceClass
Void Speak(System.String, UInt32, UInt32 ByRef)
Void SetVoice(SpeechLib.ISpObjectToken)
Void GetVoice(SpeechLib.ISpObjectToken ByRef)
Int32 Volume
...

그러나 이 코드는 일부 COM 개체에 대해서는 동작하지 않습니다. 이 경우 COM 리플렉션을 사용해야 합니다. 이 칼럼에서는 그 이유와 방법을 설명합니다.

개체에 리플렉션을 사용하는 이유는 무엇일까요? 필자의 경험에 따르면 리플렉션은 디버깅과 로깅에 유용합니다. 리플렉션을 사용하면 개체에 대해 가능한 모든 내용을 출력하는 범용 "덤프" 루틴을 작성할 수 있습니다. 이 칼럼의 코드를 통해 각자 고유의 "덤프" 루틴을 충분히 작성할 수 있을 것입니다. 이 루틴을 작성하고 나면 디버깅 중에 직접 실행 창에서 이를 호출할 수도 있습니다. Visual Studio 디버거는 COM 개체에 대해 충분한 정보를 제공하지 않는 경우도 있기 때문에 이 기능은 특히 유용합니다.

프로덕션에서는 작성한 응용 프로그램이 플러그 인 구성 요소를 받고(사용자가 디렉터리에 구성 요소를 넣거나 레지스트리에 나열함), 이러한 구성 요소를 검사하고 이들이 제공하는 클래스와 메서드를 찾아야 하는 경우에 리플렉션이 유용합니다. 예를 들어 Visual Studio는 이러한 방식으로 리플렉션을 사용하여 IntelliSense를 채웁니다.

형식 라이브러리와 런타임 호출 가능 래퍼

설명을 위한 프로젝트를 빌드해 보겠습니다. 먼저 프로젝트를 만들고 Project > AddReference를 통해 COM 참조를 추가합니다. 이 칼럼에서는 "Microsoft Speech Object Library" SpeechLib을 사용하겠습니다. 그림 1은 앞서 본 리플렉션 코드를 실행할 때 검사되는 관련 엔터티와 파일을 보여 줍니다.

fig01.gif

그림 1 SpeechLib의 리플렉션

Sapi.dll은 SpeechLib이 포함된 DLL입니다. 위치는 %windir%\system32\speech\common\sapi.dll입니다. 이 DLL에는 SpVoice COM 클래스 구현과 이에 대한 모든 리플렉션 정보를 포함하는 TypeLibrary가 모두 포함되어 있습니다. TypeLibrary는 옵션이지만 여러분 시스템의 거의 모든 COM 구성 요소에 있습니다.

Interop.SpeechLib.dll은 Visual Studio에서 Project > AddReference를 통해 자동으로 생성되었습니다. 생성기는 TypeLibrary를 리플렉션하고 SpVoice에 대한 Interop 형식을 생성합니다. 이 형식은 관리되는 클래스로, TypeLibrary의 모든 네이티브 COM 메서드에 대한 관리되는 메서드를 포함합니다. Windows SDK의 tlbimp.exe 명령줄 도구를 사용하여 직접 Interop 어셈블리를 생성할 수도 있습니다. Interop 형식의 인스턴스는 RCW(런타임 호출 가능 래퍼)라고 하며, COM 클래스 인스턴스에 대한 포인터를 래핑합니다.

다음 Visual Basic 명령을 실행하면 RCW(Interop 형식의 인스턴스)와 SpVoice COM 클래스의 인스턴스가 만들어집니다.

Dim b As New SpeechLib.SpVoice

변수 "b"는 RCW를 참조하므로 코드는 "b"를 리플렉션할 때 사실은 TypeLibrary에서 작성된 관리되는 대응 항목을 리플렉션한 것입니다.

ConsoleApplication1.exe를 배포하는 사용자는 Interop.SpeechLib.dll도 배포해야 합니다. (Visual Studio 2010에서는 ConsoleApplication1.exe 내에 Interop 형식을 직접 복사할 수 있습니다. 이 기능으로 개발 작업은 대폭 간소화될 것입니다. 이 기능은 "No-Primary-Interop-Assembly" 또는 줄여서 "No-PIA"라고 합니다.)

형식에 RCW가 없는 경우

COM 개체에 대한 Interop 어셈블리가 없는 경우에는 어떻게 될까요? 예를 들어 CoCreateInstance를 통해 COM 개체 자체를 만든 경우, 또는 흔히 그렇듯이 COM 개체의 메서드를 호출하면 형식이 미리 알려지지 않은 COM 개체가 반환되는 경우에는 어떻게 될까요? 관리되지 않는 응용 프로그램에 대한 관리되는 플러그 인을 작성했는데, 이 응용 프로그램이 COM 개체를 제공하는 경우에는 어떻게 될까요? 레지스트리를 조사하여 만들 COM 개체를 찾은 경우에는 어떻게 될까요?

이러한 모든 경우 여러분은 RCW에 대한 Object 참조가 아닌 COM 개체에 대한 IntPtr 참조를 받게 됩니다. 이 IntPtr에 대한 RCW를 요청하면 그림 2에 나온 것을 받게 됩니다.

fig02.gif

그림 2 런타임 호출 가능 래퍼를 받음

그림 2에서는 CLR이 기본 RCW(기본 Interop 형식인 "System.__ComObject"의 인스턴스)를 제공했음을 볼 수 있습니다. 다음과 같이 이를 리플렉션하는 경우를 보겠습니다.

Dim b = CoCreateInstance(CLSID_WebBrowser, _
                   Nothing, 1, IID_IUnknown)
Console.WriteLine("DUMP {0}", b.GetType())
For Each member In b.GetType().GetMembers()
    Console.WriteLine(member)
Next

이 경우 여기에는 쓸모 있는 멤버가 아무것도 없음을 알 수 있습니다. 다음과 같은 항목만 있습니다.

DUMP System.__ComObject
System.Object GetLifetimeService()
System.Object InitializeLifetimeService()
System.Runtime.Remoting.ObjRef CreateObjRef(System.Type)
System.String ToString()
Boolean Equals(System.Object)
Int32 GetHashCode()
System.Type GetType()

이러한 COM 개체에 대한 쓸모 있는 리플렉션을 구하려면 직접 개체의 TypeLibrary를 리플렉션해야 합니다. 이를 위해서는 ITypeInfo를 사용하면 됩니다.

그러나 그 전에 간단히 알아둘 점이 있습니다. 메서드가 Object 또는 IDispatch 또는 ITypeInfo 또는 기타 .NET 클래스나 인터페이스를 반환한다면 이 메서드는 RCW에 대한 참조를 제공한 것이며 .NET이 알아서 해제를 처리합니다. 그러나 메서드가 IntPtr을 반환한다면 이는 여러분이 COM 개체 자체에 대한 참조를 가졌음을 의미하며, 이 경우에는 거의 항상 이에 대한 Marshal.Release를 호출해야 합니다(메서드가 IntPtr을 제공한 정확한 의미에 따라 달라짐). 방법은 다음과 같습니다.

   Dim com As IntPtr = ...
   Dim rcw = Marshal.GetObjectForIUnknown(com)
   Marshal.Release(com)

그러나 그림 3의 CoCreateInstance 선언에서와 같이 마샬링으로 함수를 선언하여 마샬러가 GetObjectForIUnknown 및 Release를 자동으로 호출하도록 하는 것이 훨씬 더 일반적입니다.

그림 3 CoCreateInstance

<DllImport("ole32.dll", ExactSpelling:=True, PreserveSig:=False)> _
Function CoCreateInstance( _
    ByRef clsid As Guid, _
    <MarshalAs(UnmanagedType.Interface)> ByVal punkOuter As Object, _
    ByVal context As Integer, _
    ByRef iid As Guid) _
    As <MarshalAs(UnmanagedType.Interface)> Object
End Function

Dim IID_NULL As Guid = New Guid("00000000-0000-0000-C000-000000000000")
Dim IID_IUnknown As Guid = New _
    Guid("00000000-0000-0000-C000-000000000046")
Dim CLSID_SpVoice As Guid = New _
    Guid("96749377-3391-11D2-9EE3-00C04F797396")

Dim b As Object = CoCreateInstance(CLSID_SpVoice, Nothing, 1, _ 
    IID_IUnknown)

ITypeInfo 사용

ITypeInfo는 COM 클래스 및 인터페이스에 대한 System.Type에 해당하는 항목입니다. 이를 통해 클래스 또는 인터페이스의 멤버를 열거할 수 있습니다. 이 예에서는 멤버를 출력할 것입니다. 그러나 ITypeInfo를 사용하여 런타임에 멤버를 조회한 후 IDispatch를 통해 속성 값을 가져오거나 호출할 수도 있습니다. 그림 4는 사용해야 할 다른 모든 구조체와 함께 ITypeInfo가 어떻게 조화를 이루는지 보여 줍니다.

그림 4 ITypeInfo 및 형식 정보

첫 번째 단계는 지정된 COM 개체에 대한 ITypeInfo를 가져오는 것입니다. rcw.GetType()을 사용할 수 있다면 좋겠지만 아쉽게도 이는 RCW 자체에 대한 System.Type 정보를 반환합니다. 또한 기본 제공 함수인 Marshal.GetITypeInfoForType(rcw)을 사용할 수 있으면 좋겠지만 이 함수는 Interop 어셈블리의 RCW에 대해서만 동작합니다. 따라서 ITypeInfo는 수동으로 가져와야 합니다.

다음 코드는 RCW의 출처가 mscorlib의 스텁이든 적절한 Interop 어셈블리든 관계없이 이 두 가지 경우 모두에 사용할 수 있습니다.

Dim idisp = CType(rcw, IDispatch)
Dim count As UInteger = 0
idisp.GetTypeInfoCount(count)
If count < 1 Then Throw New Exception("No type info")
Dim _typeinfo As IntPtr
idisp.GetTypeInfo(0, 0, _typeinfo)
If _typeinfo = IntPtr.Zero Then Throw New Exception("No ITypeInfo")
Dim typeInfo = CType(Marshal.GetTypedObjectForIUnknown(_typeinfo, _
                     GetType(ComTypes.ITypeInfo)), ComTypes.ITypeInfo)
Marshal.Release(_typeinfo)

이 코드는 IDispatch 인터페이스를 사용합니다. 이 인터페이스는 .NET Framework에 정의되어 있지 않으므로 그림 5에서와 같이 직접 정의해야 합니다. 함수 GetIDsOfNames가 비어 있는 이유는 현재로서는 이 함수가 필요 없기 때문입니다. 그러나 인터페이스에서 정확한 순서로 정확한 수의 메서드를 나열해야 하므로 이에 대한 항목은 포함해야 합니다.

그림 5 IDispatch 인터페이스 정의

''' <summary>
''' IDispatch: this is a managed version of the IDispatch interface
''' </summary>
''' <remarks>We don't use GetIDsOfNames or Invoke, and so haven't 
''' bothered with correct signatures for them.</remarks>
<ComImport(), Guid("00020400-0000-0000-c000-000000000046"), _
 InterfaceType(ComInterfaceType.InterfaceIsIUnknown)> _
 Interface IDispatch
    Sub GetTypeInfoCount(ByRef pctinfo As UInteger)
    Sub GetTypeInfo(ByVal itinfo As UInteger, ByVal lcid _
      As UInteger, ByRef pptinfo As IntPtr)
    Sub stub_GetIDsOfNames()
    Sub Invoke(ByVal dispIdMember As Integer, ByRef riid As Guid, _
               ByVal lcid As UInteger, ByVal dwFlags As UShort, _
               ByRef pDispParams As ComTypes.DISPPARAMS, _
               ByRef pVarResult As [VARIANT], ByRef pExcepInfo As IntPtr, _
               ByRef pArgErr As UInteger)
End Interface

IDispatch의 InterfaceType 특성이 ComInterfaceType.InterfaceIsIDisapatch가 아닌 ComInterfaceType.InterfaceIsUnknown으로 설정되어 있는 이유가 궁금할 것입니다. 이는 InterfaceType 특성이 인터페이스가 아닌 인터페이스가 상속을 받는 대상을 나타내기 때문입니다.

ITypeInfo를 가져왔습니다. 이제 ITypeInfo를 읽을 차례입니다. 그림 6을 보십시오. 이 그림에는 형식 정보를 덤프하기 위해 구현할 함수가 나와 있습니다. GetDocumentation의 경우 첫 번째 매개 변수는 MEMBERID입니다. 즉, GetDocumentation은 형식의 각 멤버에 대한 정보를 반환하기 위한 것입니다. 그러나 -1 값을 가진 MEMBERID_NIL을 전달하여 형식 자체에 대한 정보를 가져올 수도 있습니다.

그림 6 DumpTypeInfo

''' <summary>
''' DumpType: prints information about an ITypeInfo type to the console
''' </summary>
''' <param name="typeInfo">the type to dump</param>
Sub DumpTypeInfo(ByVal typeInfo As ComTypes.ITypeInfo)

    ' Name:
    Dim typeName = "" : typeInfo.GetDocumentation(-1, typeName, "", 0, "")
    Console.WriteLine("TYPE {0}", typeName)

    ' TypeAttr: contains general information about the type
    Dim pTypeAttr As IntPtr : typeInfo.GetTypeAttr(pTypeAttr)
    Dim typeAttr = CType(Marshal.PtrToStructure(pTypeAttr, _
                         GetType(ComTypes.TYPEATTR)), ComTypes.TYPEATTR)
    typeInfo.ReleaseTypeAttr(pTypeAttr)
    ...

End Sub

마샬링이 어떻게 동작하는지 살펴보십시오. typeInfo.GetTypeAttr을 호출하면 이는 관리되지 않는 메모리 블록을 할당하고 포인터 pTypeAttr을 반환합니다. 그러면 Marshal.PtrToStructure가 관리되지 않는 이 블록에서 관리되는 블록으로 복사됩니다(이후 가비지 수집됨). 따라서 typeInfo.ReleaseTypeAttr을 즉시 호출해도 됩니다.

앞서 살펴봤듯이 멤버와 구현된 인터페이스의 수를 알기 위해서는 typeAttr이 필요합니다(typeAttr.cFuncs, typeAttr.cVars 및 typeAttr.cImplTypes).

형식 참조 추적

가장 먼저 완료해야 할 작업은 구현 및 상속된 인터페이스 목록을 구하는 것입니다. (COM에서 클래스는 다른 클래스에서 상속되지 않습니다.) 코드는 다음과 같습니다.

' Inheritance:
For iImplType = 0 To typeAttr.cImplTypes - 1
    Dim href As Integer
    typeInfo.GetRefTypeOfImplType(iImplType, href)
    ' "href" is an index into the list of type descriptions within the
    ' type library.
    Dim implTypeInfo As ComTypes.ITypeInfo
    typeInfo.GetRefTypeInfo(href, implTypeInfo)
    ' And GetRefTypeInfo looks up the index to get an ITypeInfo for it.
    Dim implTypeName = ""
    implTypeInfo.GetDocumentation(-1, implTypeName, "", 0, "")
    Console.WriteLine("  Implements {0}", implTypeName)
Next

여기에는 간접 계층이 있습니다. GetRefTypeOfImplType은 구현된 형식의 ITypeInfo를 직접 제공하지 않고 ITypeInfo에 대한 핸들을 제공합니다. GetRefTypeInfo 함수는 이 핸들을 조회하는 함수입니다. 그러면 익숙한 GetDocumentation(-1)을 사용하여 구현된 형식의 이름을 가져올 수 있습니다. ITypeInfo에 대한 핸들에 대해서는 나중에 다시 설명하겠습니다.

멤버 가져오기

필드 멤버 리플렉션의 경우 각 필드에는 설명을 위한 VARDESC가 있습니다. 이번에도 typeInfo 개체는 관리되지 않는 메모리 블록 pVarDesc를 할당하며 여러분은 이를 관리되는 블록 varDesc로 마샬링하고 관리되지 않는 블록을 해제해야 합니다.

' Field members:
For iVar = 0 To typeAttr.cVars - 1
    Dim pVarDesc As IntPtr : typeInfo.GetVarDesc(iVar, pVarDesc)
    Dim varDesc = CType(Marshal.PtrToStructure(pVarDesc, _
                        GetType(ComTypes.VARDESC)), ComTypes.VARDESC)
    typeInfo.ReleaseVarDesc(pVarDesc)
    Dim names As String() = {""}
    typeInfo.GetNames(varDesc.memid, names, 1, 0)
    Dim varName = names(0)
    Console.WriteLine("  Dim {0} As {1}", varName, _
                      DumpTypeDesc(varDesc.elemdescVar.tdesc, typeInfo))
Next

"GetNames" 함수가 관심을 끕니다. 각 멤버는 여러 이름을 가질 수 있지만 첫 번째 이름만 가져오는 것으로 충분합니다.

함수 멤버를 리플렉션하기 위한 코드는 일반적으로 비슷합니다(그림 7 참조). 반환 형식은 funcDesc.elemdescFunc.tdesc입니다. 형식 매개 변수의 수는 funcDesc.cParams에 의해 제공되며, 형식 매개 변수는 funcDesc.lprgelemdescParam 배열에 저장됩니다. (관리되는 코드에서 이와 같은 관리되지 않는 배열에 액세스하는 작업은 즐거운 일은 아닙니다. 포인터 연산이 필요하기 때문입니다.)

그림 7 함수 멤버 리플렉션

For iFunc = 0 To typeAttr.cFuncs - 1

   ' retrieve FUNCDESC:
   Dim pFuncDesc As IntPtr : typeInfo.GetFuncDesc(iFunc, pFuncDesc)
   Dim funcDesc = CType(Marshal.PtrToStructure(pFuncDesc, _
                         GetType(ComTypes.FUNCDESC)), ComTypes.FUNCDESC)
   Dim names As String() = {""}
   typeInfo.GetNames(funcDesc.memid, names, 1, 0)
   Dim funcName = names(0)

   ' Function formal parameters:
   Dim cParams = funcDesc.cParams
   Dim s = ""
   For iParam = 0 To cParams - 1
        Dim elemDesc = CType(Marshal.PtrToStructure( _
                  New IntPtr(funcDesc.lprgelemdescParam.ToInt64 + _
                  Marshal.SizeOf(GetType(ComTypes.ELEMDESC)) * iParam), _
                  GetType(ComTypes.ELEMDESC)), ComTypes.ELEMDESC)
        If s.Length > 0 Then s &= ", "
        If (elemDesc.desc.paramdesc.wParamFlags And _
            Runtime.InteropServices.ComTypes.PARAMFLAG.PARAMFLAG_FOUT) _
             <> 0 Then s &= "out "
        s &= DumpTypeDesc(elemDesc.tdesc, typeInfo)
   Next

   ' And print out the rest of the function's information:
   Dim props = ""
   If (funcDesc.invkind And ComTypes.INVOKEKIND.INVOKE_PROPERTYGET) _
      <> 0 Then props &= "Get "
   If (funcDesc.invkind And ComTypes.INVOKEKIND.INVOKE_PROPERTYPUT) _
      <> 0 Then props &= "Set "
   If (funcDesc.invkind And ComTypes.INVOKEKIND.INVOKE_PROPERTYPUTREF) _
     <> 0 Then props &= "Set "
   Dim isSub = (FUNCDESC.elemdescFunc.tdesc.vt = VarEnum.VT_VOID)
   s = props & If(isSub, "Sub ", "Function ") & funcName & "(" & s & ")"
   s &= If(isSub, "", " as " & _
     DumpTypeDesc(funcDesc.elemdescFunc.tdesc, typeInfo))
   Console.WriteLine("  " & s)
   typeInfo.ReleaseFuncDesc(pFuncDesc)
Next

PARAMFLAG_FOUT뿐만 아리 다른 플래그도 있습니다(in, retval, optional 등을 위한 플래그). 필드와 멤버에 대한 형식 정보는 TYPEDESC 구조체에 저장되었으며 이를 출력하기 위해 DumpTypeDesc 함수를 호출했습니다. ITypeInfo 대신 TYPEDESC가 사용되었다는 점이 의외로 생각될 수 있습니다. 이에 대해 자세히 살펴보겠습니다.

기본 형식과 종합 형식

COM은 일부 형식은 TYPEDESC를 사용하여, 일부 형식은 ITypeInfo를 사용하여 설명합니다. 차이점은 무엇일까요? COM은 형식 라이브러리에 정의된 클래스와 인터페이스에 대해서만 ITypeInfo를 사용합니다. 그리고 Integer 또는 String과 같은 기본 형식, SpVoice의 Array나 IUnknown Reference와 같은 복합 형식에 대해 TYPEDESC를 사용합니다.

이는 .NET과 다릅니다. 첫째, .NET에서는 Integer 및 String과 같은 기본 형식도 System.Type을 통해 클래스 또는 구조체로 표현되며 둘째, .NET에서는 Integer의 Array와 같은 복합 형식은 System.Type을 통해 표현됩니다.

TYPEDESC를 살펴보기 위해 필요한 코드는 상당히 단순합니다(그림 8 참조). Case VT_USERDEFINED는 이번에도 참조에 대한 핸들을 사용하며, 이 참조는 GetRefTypeInfo를 통해 조회해야 합니다.

그림 8 TYPEDESC 보기

Function DumpTypeDesc(ByVal tdesc As ComTypes.TYPEDESC, _
  ByVal context As ComTypes.ITypeInfo) As String
    Dim vt = CType(tdesc.vt, VarEnum)
    Select Case vt

        Case VarEnum.VT_PTR
            Dim tdesc2 = CType(Marshal.PtrToStructure(tdesc.lpValue, _
                          GetType(ComTypes.TYPEDESC)), ComTypes.TYPEDESC)
            Return "Ref " & DumpTypeDesc(tdesc2, context)

        Case VarEnum.VT_USERDEFINED
            Dim href = CType(tdesc.lpValue.ToInt64 And Integer.MaxValue, Integer)
            Dim refTypeInfo As ComTypes.ITypeInfo = Nothing
            context.GetRefTypeInfo(href, refTypeInfo)
            Dim refTypeName = ""
            refTypeInfo.GetDocumentation(-1, refTypeName, "", 0, "")
            Return refTypeName

        Case VarEnum.VT_CARRAY
            Dim tdesc2 = CType(Marshal.PtrToStructure(tdesc.lpValue, _
                          GetType(ComTypes.TYPEDESC)), ComTypes.TYPEDESC)
            Return "Array of " & DumpTypeDesc(tdesc2, context)
            ' lpValue is actually an ARRAYDESC structure, which also has
            ' information on the array dimensions, but alas .NET doesn't 
            ' predefine ARRAYDESC.

        Case Else
            ' There are many other VT_s that I haven't special-cased, 
            ' e.g. VT_INTEGER.
            Return vt.ToString()
    End Select
End Function

값의 COM 표현

다음 단계는 COM 개체를 실제로 덤프하는 것입니다. 즉, COM 개체의 속성 값을 출력합니다. 속성의 이름을 안다면 Visual Basic에서 런타임에 바인딩된 호출을 사용할 수 있으므로 이 작업을 쉽게 처리할 수 있습니다.

Dim com as Object : Dim val = com.SomePropName

컴파일러는 이를 IDispatch::Invoke 런타임 호출로 변환하여 속성의 값을 가져옵니다. 그러나 리플렉션의 경우 속성 이름을 모를 수 있습니다. 아는 것이 MEMBERID뿐이라면 IDispatch::Invoke를 직접 호출해야 합니다. 별로 재미있는 일은 아닙니다.

첫 번째 고민은 COM과 .NET의 값 표현 방식이 전혀 다르다는 데 있습니다. .NET에서는 Object를 사용하여 임의의 값을 표현합니다. COM에서는 그림 9와 같이 VARIANT 구조체를 사용합니다.

그림 9 VARIANT 사용

''' <summary>
''' VARIANT: this is called "Object" in Visual Basic. It's the universal ''' variable type for COM.
''' </summary>
''' <remarks>The "vt" flag determines which of the other fields have
''' meaning. vt is a VarEnum.</remarks>
<System.Runtime.InteropServices.StructLayoutAttribute( _
           System.Runtime.InteropServices.LayoutKind.Explicit, Size:=16)> _
Public Structure [VARIANT]
    <System.Runtime.InteropServices.FieldOffsetAttribute(0)> Public vt As UShort
    <System.Runtime.InteropServices.FieldOffsetAttribute(2)> _
      Public wReserved1 As UShort
    <System.Runtime.InteropServices.FieldOffsetAttribute(4)> _
      Public wReserved2 As UShort
    <System.Runtime.InteropServices.FieldOffsetAttribute(6)> _
      Public wReserved3 As UShort
    '
    <System.Runtime.InteropServices.FieldOffsetAttribute(8)> Public llVal As Long
    <System.Runtime.InteropServices.FieldOffsetAttribute(8)> Public lVal As Integer
    <System.Runtime.InteropServices.FieldOffsetAttribute(8)> Public bVal As Byte
    ' and similarly for many other accessors
    <System.Runtime.InteropServices.FieldOffsetAttribute(8)> _
      Public ptr As System.IntPtr

    ''' <summary>
    ''' GetObject: returns a .NET Object equivalent for this Variant.
    ''' </summary>
    Function GetObject() As Object
        ' We want to use the handy Marshal.GetObjectForNativeVariant.
        ' But this only operates upon an IntPtr to a block of memory.
        ' So we first flatten ourselves into that block of memory. (size 16)
        Dim ptr = Marshal.AllocCoTaskMem(16)
        Marshal.StructureToPtr(Me, ptr, False)
        Try : Return Marshal.GetObjectForNativeVariant(ptr)
        Finally : Marshal.FreeCoTaskMem(ptr) : End Try
    End Function
End Structure

COM 값은 vt 필드를 사용하여 형식을 나타냅니다. VarEnum.VT_INT, VarEnum.VT_PTR 또는 30여 가지의 VarEnum 형식 중 하나일 수 있습니다. 형식을 알면 Select Case 문에서 조회할 다른 필드를 파악할 수 있습니다. 다행히 Select Case 문은 이미 Marshal.GetObjectForNativeVariant 함수에 구현되었습니다.

COM 개체 속성 덤프

COM 개체의 속성은 다음과 같이 Visual Studio의 "간략한 조사식" 창과 비슷하게 덤프하면 좋을 것입니다.

DUMP OF COM OBJECT #28114988
ISpeechVoice.Status = System.__ComObject   As Ref ISpeechVoiceStatus
ISpeechVoice.Rate = 0   As Integer
ISpeechVoice.Volume = 100   As Integer
ISpeechVoice.AllowAudioOutputFormatChangesOnNextSet = True   As Bool
ISpeechVoice.EventInterests = 0   As SpeechVoiceEvents
ISpeechVoice.Priority = 0   As SpeechVoicePriority
ISpeechVoice.AlertBoundary = 32   As SpeechVoiceEvents
ISpeechVoice.SynchronousSpeakTimeout = 10000   As Integer

문제는 COM에는 다양한 형식이 있다는 것입니다. 하나하나 모든 경우를 올바르게 처리하도록 코드를 작성하기란 힘든 일이고, 모두 테스트할 수 있을 정도로 충분한 수의 사례를 조합하기도 어렵습니다. 여기에서는 정확하게 처리할 수 있는 소수의 형식을 덤프하는 것으로 만족합니다.

그 이외에 덤프하기에 유용한 것은 무엇일까요? 속성 외에 IsTall()와 같이 순수한(부수적 효과가 없는) 함수를 통해 노출되는 것이라면 무엇이든 유용할 것입니다. 그러나 AddRef()와 같은 함수는 호출을 피해야 합니다. 이 둘을 구분하기 위해 "Is*"와 같은 함수 이름이라면 모두 덤프에 적합한 것으로 간주합니다(그림 10 참조). COM 프로그래머가 Is* 함수를 사용하는 빈도는 프로그래머가 .NET을 사용하는 빈도보다 훨씬 낮은 것으로 보입니다!

그림 10 Get* 및 Is* 메서드 보기

' We'll only try to retrieve things that are likely to be side-effect-
' free properties:

If (funcDesc.invkind And ComTypes.INVOKEKIND.INVOKE_PROPERTYGET) = 0 _
   AndAlso Not funcName Like "[Gg]et*" _
   AndAlso Not funcName Like "[Ii]s*" _
   Then Continue For
If funcDesc.cParams > 0 Then Continue For
Dim returnType = CType(funcDesc.elemdescFunc.tdesc.vt, VarEnum)
If returnType = VarEnum.VT_VOID Then Continue For
Dim returnTypeName = DumpTypeDesc(funcDesc.elemdescFunc.tdesc, typeInfo)

' And we'll only try to evaluate the easily-evaluatable properties:
Dim dumpableTypes = New VarEnum() {VarEnum.VT_BOOL, VarEnum.VT_BSTR, _
           VarEnum.VT_CLSID, _ 
           VarEnum.VT_DECIMAL, VarEnum.VT_FILETIME, VarEnum.VT_HRESULT, _
           VarEnum.VT_I1, VarEnum.VT_I2, VarEnum.VT_I4, VarEnum.VT_I8, _
           VarEnum.VT_INT, VarEnum.VT_LPSTR, VarEnum.VT_LPWSTR, _
           VarEnum.VT_R4, VarEnum.VT_R8, _
           VarEnum.VT_UI1, VarEnum.VT_UI2, VarEnum.VT_UI4, VarEnum.VT_UI8, _
           VarEnum.VT_UINT, VarEnum.VT_DATE, _
           VarEnum.VT_USERDEFINED}
Dim typeIsDumpable = dumpableTypes.Contains(returnType)
If returnType = VarEnum.VT_PTR Then
    Dim ptrType = CType(Marshal.PtrToStructure( _
      funcDesc.elemdescFunc.tdesc.lpValue, _
                        GetType(ComTypes.TYPEDESC)), ComTypes.TYPEDESC)
    If ptrType.vt = VarEnum.VT_USERDEFINED Then typeIsDumpable = True
End If

이 코드에서 최종적으로 덤프 가능한 것으로 간주되는 형식은 VT_PTR에서 VT_USERDEFINED까지의 형식입니다. 이는 다른 개체에 대한 참조를 반환하는 속성의 일반적인 사례를 포괄하는 범위입니다.

IDispatch.Invoke 사용

마지막 단계는 MEMBERID로 식별한 속성을 읽거나 함수를 호출하는 것입니다. 이를 수행하는 코드는 그림 11에 나와 있습니다. 여기에서 핵심 메서드는 IDispatch.Invoke입니다. 이 메서드의 첫 번째 인수는 호출 중인 함수 또는 속성의 멤버 ID입니다. 변수 dispatchType은 2(속성 가져오기) 또는 1(함수 호출)입니다. 인수를 취하는 함수를 호출 중이었다면 dispParams 구조체도 설정했을 것입니다. 마침내 결과가 varResult로 돌아옵니다. 전과 마찬가지로 이에 대해 GetObject를 호출하여 VARIANT를 .NET 개체로 변환할 수 있습니다.

그림 11 속성을 읽거나 함수를 호출

' Here's how we fetch an arbitrary property from a COM object, 
' identified by its MEMBID.
Dim val As Object
Dim varResult As New [VARIANT]
Dim dispParams As New ComTypes.DISPPARAMS With {.cArgs = 0, .cNamedArgs = 0}
Dim dispatchType = If((funcDesc.invkind And _
   ComTypes.INVOKEKIND.INVOKE_PROPERTYGET)<>0, 2US, 1US)
idisp.Invoke(funcDesc.memid, IID_NULL, 0, dispatchType, dispParams, _
   varResult, IntPtr.Zero, 0)
val = varResult.GetObject()
If varResult.vt = VarEnum.VT_PTR AndAlso varResult.ptr <> IntPtr.Zero _ 
   Then
   Marshal.Release(varResult.ptr)
End If

Marshal.Release 호출을 주목하십시오. COM에서는 일반적으로 함수가 포인터를 전달할 경우 이 함수는 먼저 AddRef를 호출하며, 이에 대해 Release를 호출하는 것은 호출자의 책임입니다. .NET에 가비지 수집이 있다는 것이 정말 다행이란 생각이 듭니다.

의도하지 않게 IDispatch.Invoke가 아닌 ITypeInfo.Invoke를 사용했을 수 있습니다. 그러나 여기에는 약간 혼란스러운 부분이 있습니다. COM 개체의 IUnknown 인터페이스를 가리키는 "com"이라는 변수가 있다고 가정해 보십시오. 그리고 이 com의 ITypeInfo는 SpeechLib.SpVoice이며, 여기에 멤버 ID가 12인 속성이 있다고 가정해 보십시오. 이 경우 ITypeInfo.Invoke(com,12)를 직접 호출할 수는 없습니다. 먼저 QueryInterface를 호출하여 com의 SpVoice 인터페이스를 가져온 다음 이에 대해 ITypeInfo.Invoke를 호출해야 합니다. 결국 IDispatch.Invoke를 사용하기가 더 쉽습니다.

지금까지 ITypeInfo를 통해 COM 개체를 리플렉션하는 방법을 살펴봤습니다. 이 방법은 Interop 형식이 없는 COM 클래스에 유용합니다. 그리고 IDispatch.Invoke를 사용하여 VARIANT 구조체에 저장된 COM에서 값을 가져오는 방법도 확인했습니다.

필자는 사실 System.Type에서 상속되는 TYPEDESC 및 ITypeInfo에 대한 완전한 래퍼를 만드는 것이 탐탁지 않았습니다. 이 래퍼로 사용자는 COM 형식 리플렉션에 대해 .NET 형식과 동일한 코드를 사용할 수 있지만 적어도 필자의 프로젝트에서는 이러한 유형의 래퍼는 얻는 부분에 비해 너무 많은 작업이 필요했습니다.

리플렉션의 기능에 대한 자세한 내용은 "빠른 응용 프로그램을 개발하기 위한 일반적인 성능 문제 방지" 및 "CLR Inside Out: 리플렉션에 대한 고찰"을 참조하십시오.

칼럼을 작성하는 데 도움을 준 Eric Lippert, Sonja Keserovic, Calvin Hsia에게 감사 인사를 전합니다.

질문이나 의견이 있으면 instinct@microsoft.com으로 보내시기 바랍니다.

Lucian Wischik은 Visual Basic 사양 책임자로, Visual Basic 컴파일러 팀에 합류한 이후 형식 유추, 람다 및 제네릭 공변성(covariance)과 관련된 새로운 기능을 담당하고 있습니다. 또한 Robotics SDK와 동시성도 연구했으며 이에 대한 몇몇 학술 논문도 발표했습니다. Lucian은 캠브리지 대학에서 동시성 이론으로 박사 학위를 받았습니다.