Windows COM 문자열과 메모리

REIN·2025년 12월 22일

게임 개발 초급 CS

목록 보기
2/18

Windows COM(Component Object Model)은 1990년대 Microsoft가 설계한 바이너리 인터페이스 표준이다. OLE, ActiveX, DirectX, Shell Extension 등 Windows의 핵심 기술이 COM 위에 구축되어 있다. 그런데 왜 COM은 일반 wchar_t* 대신 BSTR이라는 별도의 문자열 타입을 만들었을까? 이 글에서는 COM의 메모리 관리 철학과 BSTR, VARIANT의 내부 구조를 분석한다.

1. BSTR: Length-Prefixed Wide String

왜 BSTR인가?

C 스타일 문자열의 근본적인 문제:

문제C 문자열BSTR
길이 확인O(n) - null 탐색O(1) - 앞 4바이트
null 포함불가능가능 (바이너리 안전)
크기 정보없음앞에 저장됨
언어 중립C/C++ 전용VB, JS, Python 등 연동

COM은 언어 중립적 바이너리 인터페이스를 목표로 했다. Visual Basic, JavaScript, Python 등 다양한 언어가 COM 객체를 사용하는데, 각 언어의 문자열 표현이 다르다. BSTR은 이 문제를 해결한다.

메모리 레이아웃

BSTR bstr = SysAllocString(L"Hello");
오프셋크기내용값 (예시)
-44 bytes바이트 길이 (null 제외)10 (5문자 × 2바이트)
0가변문자열 데이터 (UTF-16LE)H e l l o
+102 bytesnull terminator\0\0

핵심: BSTR 포인터는 문자열 시작을 가리킨다. 길이는 포인터 - 4 위치에 있다.

BSTR bstr = SysAllocString(L"Hello");

// 길이 접근 (내부 동작)
UINT len = *((UINT*)bstr - 1);  // 10 (바이트 단위)

// 안전한 방법
UINT len = SysStringLen(bstr);   // 5 (문자 단위)
UINT bytes = SysStringByteLen(bstr);  // 10 (바이트 단위)

BSTR vs LPWSTR

둘 다 wchar_t*로 정의되지만, 의미가 다르다:

typedef WCHAR* BSTR;    // 사실상 wchar_t*
typedef WCHAR* LPWSTR;  // 역시 wchar_t*
특성BSTRLPWSTR
길이 정보포인터 앞 4바이트에 저장없음 (null로 판단)
할당SysAllocStringmalloc, new, 스택 등
해제SysFreeString할당 방식에 따름
null 허용문자열 중간에 null 가능null이 끝을 의미
COM 마샬링자동 지원수동 처리 필요

위험한 실수:

// 잘못된 코드
BSTR bstr = L"Hello";  // 컴파일은 되지만 BSTR이 아님!
SysFreeString(bstr);   // 크래시! 힙 손상!

// 올바른 코드
BSTR bstr = SysAllocString(L"Hello");
SysFreeString(bstr);

2. BSTR 할당과 해제

SysAllocString 계열 함수

함수용도반환값
SysAllocString(LPCOLESTR)null-terminated 문자열로부터 생성BSTR
SysAllocStringLen(LPCOLESTR, UINT)길이 지정, null 포함 가능BSTR
SysAllocStringByteLen(LPCSTR, UINT)바이트 배열로부터 생성BSTR
SysReAllocString(BSTR*, LPCOLESTR)재할당BOOL
SysFreeString(BSTR)해제void
SysStringLen(BSTR)문자 수 반환UINT
SysStringByteLen(BSTR)바이트 수 반환UINT

내부 구현 추정

BSTR SysAllocStringLen(const OLECHAR* psz, UINT len) {
    // 할당: 길이 필드(4) + 문자열(len*2) + null(2)
    UINT bytes = len * sizeof(OLECHAR);
    BYTE* raw = (BYTE*)CoTaskMemAlloc(4 + bytes + 2);
    if (!raw) return NULL;

    // 길이 저장
    *((UINT*)raw) = bytes;

    // 문자열 복사
    BSTR bstr = (BSTR)(raw + 4);
    if (psz) {
        memcpy(bstr, psz, bytes);
    } else {
        memset(bstr, 0, bytes);
    }

    // null terminator
    bstr[len] = L'\0';

    return bstr;
}

void SysFreeString(BSTR bstr) {
    if (bstr) {
        BYTE* raw = (BYTE*)bstr - 4;
        CoTaskMemFree(raw);
    }
}

UINT SysStringLen(BSTR bstr) {
    if (!bstr) return 0;
    return *((UINT*)bstr - 1) / sizeof(OLECHAR);
}

NULL BSTR의 의미

의미SysStringLen 반환
NULL빈 문자열 (유효)0
SysAllocString(L"")빈 문자열 (할당됨)0
SysAllocString(L"Hello")"Hello"5

중요: COM에서 NULL BSTR은 빈 문자열로 취급된다. 에러가 아니다.

void ProcessString(BSTR bstr) {
    // NULL 체크 후 사용
    if (bstr && SysStringLen(bstr) > 0) {
        // 비어있지 않은 문자열
    }
    // 또는 안전하게
    UINT len = bstr ? SysStringLen(bstr) : 0;
}

3. COM 메모리 할당자

왜 별도의 할당자인가?

COM 객체는 프로세스 경계를 넘어 통신한다. DLL A에서 할당한 메모리를 DLL B에서 해제해야 하는 경우가 발생한다:

상황문제해결책
DLL A: malloc → DLL B: free힙 손상 (각 DLL이 다른 CRT 힙 사용 가능)공유 할당자 사용
EXE: new → COM 객체: delete같은 문제CoTaskMemAlloc

CoTaskMemAlloc vs malloc

함수할당자용도
malloc / freeCRT 힙모듈 내부 전용
new / deleteCRT 힙모듈 내부 전용
HeapAlloc / HeapFree프로세스 힙저수준 Windows API
CoTaskMemAlloc / CoTaskMemFreeCOM 힙COM 인터페이스 경계
SysAllocString / SysFreeStringCOM 힙 (BSTR 전용)BSTR만
// COM 인터페이스에서 문자열 반환
HRESULT GetName(BSTR* pbstrName) {
    // 호출자가 SysFreeString으로 해제
    *pbstrName = SysAllocString(L"MyObject");
    return S_OK;
}

// COM 인터페이스에서 배열 반환
HRESULT GetData(BYTE** ppData, ULONG* pcb) {
    *pcb = 100;
    // 호출자가 CoTaskMemFree로 해제
    *ppData = (BYTE*)CoTaskMemAlloc(*pcb);
    return S_OK;
}

IMalloc 인터페이스

COM 할당자의 근본:

// COM 할당자 얻기
IMalloc* pMalloc = NULL;
CoGetMalloc(1, &pMalloc);

void* p = pMalloc->Alloc(100);
pMalloc->Free(p);

pMalloc->Release();

// 또는 간단히
void* p = CoTaskMemAlloc(100);
CoTaskMemFree(p);

4. IUnknown: COM의 근본

세 가지 메서드

모든 COM 인터페이스는 IUnknown을 상속한다:

interface IUnknown {
    virtual HRESULT QueryInterface(REFIID riid, void** ppv) = 0;
    virtual ULONG AddRef() = 0;
    virtual ULONG Release() = 0;
};
메서드역할
QueryInterface다른 인터페이스 요청
AddRef참조 카운트 증가
Release참조 카운트 감소, 0이면 삭제

AddRef/Release 구현

class CMyObject : public IUnknown {
    LONG m_cRef;  // 참조 카운트

public:
    CMyObject() : m_cRef(1) {}

    ULONG AddRef() override {
        return InterlockedIncrement(&m_cRef);
    }

    ULONG Release() override {
        ULONG cRef = InterlockedDecrement(&m_cRef);
        if (cRef == 0) {
            delete this;
        }
        return cRef;
    }

    HRESULT QueryInterface(REFIID riid, void** ppv) override {
        if (riid == IID_IUnknown) {
            *ppv = static_cast<IUnknown*>(this);
            AddRef();
            return S_OK;
        }
        *ppv = NULL;
        return E_NOINTERFACE;
    }
};

참조 카운트 규칙

규칙설명
생성 시 1객체 생성 시 카운트 = 1
복사 시 AddRef포인터 복사하면 AddRef 호출
소멸 시 Release포인터 버리면 Release 호출
out 파라미터호출자가 Release 책임
in 파라미터호출 중에만 유효, AddRef 불필요
void UseObject(IUnknown* pUnk) {
    // in 파라미터: AddRef 불필요
    pUnk->DoSomething();
}  // Release 안 함

void StoreObject(IUnknown* pUnk) {
    // 저장하려면 AddRef
    pUnk->AddRef();
    m_pStored = pUnk;
}

void ClearStored() {
    if (m_pStored) {
        m_pStored->Release();
        m_pStored = NULL;
    }
}

5. VARIANT: 타입 태그 + Union

왜 VARIANT인가?

COM은 언어 중립적이다. C++의 int와 VBScript의 Integer, JavaScript의 Number를 어떻게 통일할까?

struct VARIANT {
    VARTYPE vt;           // 타입 태그
    WORD    wReserved1;
    WORD    wReserved2;
    WORD    wReserved3;
    union {
        LONGLONG    llVal;      // VT_I8
        LONG        lVal;       // VT_I4
        BYTE        bVal;       // VT_UI1
        SHORT       iVal;       // VT_I2
        FLOAT       fltVal;     // VT_R4
        DOUBLE      dblVal;     // VT_R8
        VARIANT_BOOL boolVal;   // VT_BOOL
        BSTR        bstrVal;    // VT_BSTR
        IUnknown*   punkVal;    // VT_UNKNOWN
        IDispatch*  pdispVal;   // VT_DISPATCH
        SAFEARRAY*  parray;     // VT_ARRAY | VT_*
        // ... 더 많은 타입
    };
};

주요 VARTYPE 값

VARTYPEC++ 타입설명
VT_EMPTY0-초기화 안 됨
VT_NULL1-SQL NULL
VT_I22short16비트 정수
VT_I43long32비트 정수
VT_R44float32비트 실수
VT_R85double64비트 실수
VT_BSTR8BSTR문자열
VT_BOOL11VARIANT_BOOL불리언 (-1=TRUE, 0=FALSE)
VT_UNKNOWN13IUnknown*COM 인터페이스
VT_DISPATCH9IDispatch*Automation 인터페이스
VT_ARRAY0x2000SAFEARRAY*배열 (OR로 조합)
VT_BYREF0x4000-참조 (OR로 조합)

VARIANT 조작 함수

함수용도
VariantInit(VARIANT*)VT_EMPTY로 초기화
VariantClear(VARIANT*)내용 해제 후 VT_EMPTY
VariantCopy(VARIANT*, VARIANT*)깊은 복사
VariantChangeType(VARIANT*, VARIANT*, USHORT, VARTYPE)타입 변환
// VARIANT 사용 예시
VARIANT var;
VariantInit(&var);  // 필수!

// 정수 저장
var.vt = VT_I4;
var.lVal = 42;

// 문자열 저장
VariantClear(&var);  // 이전 값 정리
var.vt = VT_BSTR;
var.bstrVal = SysAllocString(L"Hello");

// 사용 후 정리
VariantClear(&var);  // BSTR도 자동 해제

VARIANT_BOOL 주의사항

의미주의
-1 (0xFFFF)VARIANT_TRUEtrue가 아님!
0VARIANT_FALSEfalse와 같음
// 잘못된 코드
var.boolVal = true;  // 1이 됨, VARIANT_TRUE(-1)가 아님!

// 올바른 코드
var.boolVal = VARIANT_TRUE;  // -1
var.boolVal = VARIANT_FALSE; // 0

// 또는
var.boolVal = condition ? VARIANT_TRUE : VARIANT_FALSE;

6. SAFEARRAY: 자기 기술적 배열

구조

struct SAFEARRAY {
    USHORT cDims;        // 차원 수
    USHORT fFeatures;    // 플래그
    ULONG  cbElements;   // 원소 크기
    ULONG  cLocks;       // 잠금 카운트
    PVOID  pvData;       // 데이터 포인터
    SAFEARRAYBOUND rgsabound[1];  // 각 차원의 경계
};

struct SAFEARRAYBOUND {
    ULONG cElements;     // 원소 수
    LONG  lLbound;       // 하한 (VB는 1부터 시작 가능)
};

사용 예시

// 1차원 LONG 배열 생성
SAFEARRAYBOUND sab = { 10, 0 };  // 10개, 인덱스 0부터
SAFEARRAY* psa = SafeArrayCreate(VT_I4, 1, &sab);

// 데이터 접근
LONG* pData;
SafeArrayAccessData(psa, (void**)&pData);
for (int i = 0; i < 10; i++) {
    pData[i] = i * 100;
}
SafeArrayUnaccessData(psa);

// VARIANT에 담기
VARIANT var;
VariantInit(&var);
var.vt = VT_ARRAY | VT_I4;
var.parray = psa;

// 정리
VariantClear(&var);  // SafeArrayDestroy 자동 호출

7. C++ 래퍼 클래스

_bstr_t (comutil.h)

#include <comutil.h>

_bstr_t bstr1(L"Hello");              // 복사 생성
_bstr_t bstr2 = L"World";             // 암시적 변환
_bstr_t bstr3 = bstr1 + L" " + bstr2; // 연결

// BSTR로 변환 (소유권 유지)
BSTR raw = bstr3;  // 또는 bstr3.GetBSTR()

// BSTR로 변환 (소유권 이전)
BSTR detached = bstr3.Detach();  // bstr3는 이제 비어있음
SysFreeString(detached);         // 수동 해제 필요
메서드동작
GetBSTR()내부 BSTR 반환 (소유권 유지)
GetAddress()BSTR* 반환 (out 파라미터용)
Detach()소유권 이전
Attach(BSTR)기존 BSTR 소유권 획득
copy()복사본 생성 (호출자 해제 책임)

CComBSTR (atlbase.h)

#include <atlbase.h>

CComBSTR bstr1(L"Hello");
CComBSTR bstr2(10);  // 10문자 공간 할당

bstr1.Append(L" World");
bstr1.AppendBSTR(bstr2);

// 대소문자 무시 비교
if (bstr1.CompareNoCase(L"HELLO WORLD") == 0) {
    // 같음
}

// out 파라미터로 전달
HRESULT hr = pObj->GetName(&bstr1);  // 기존 값 자동 해제 후 받음

CComVariant (atlbase.h)

CComVariant var1(42);         // VT_I4
CComVariant var2(L"Hello");   // VT_BSTR
CComVariant var3(pUnknown);   // VT_UNKNOWN (AddRef 호출)

// 타입 변환
var1.ChangeType(VT_BSTR);     // "42"로 변환

// 비교
if (var1 == var2) { }

// 복사
CComVariant var4;
var4.Copy(&var1);

ComPtr (wrl/client.h) - Modern C++

#include <wrl/client.h>
using Microsoft::WRL::ComPtr;

ComPtr<IUnknown> pUnk;
HRESULT hr = CoCreateInstance(CLSID_Something, nullptr,
    CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pUnk));

// 자동 Release
// 범위 벗어나면 Release 호출

// QueryInterface
ComPtr<IDispatch> pDisp;
hr = pUnk.As(&pDisp);

// out 파라미터
ComPtr<IStream> pStream;
hr = SHCreateStreamOnFile(L"test.txt", STGM_READ, &pStream);
래퍼헤더용도
_bstr_tcomutil.hBSTR 관리 (가벼움)
CComBSTRatlbase.hBSTR 관리 (ATL)
CComVariantatlbase.hVARIANT 관리
CComPtr<T>atlbase.hCOM 포인터 관리 (ATL)
ComPtr<T>wrl/client.hCOM 포인터 관리 (Modern)
_variant_tcomutil.hVARIANT 관리 (가벼움)

8. 흔한 실수와 디버깅

메모리 누수 패턴

실수원인해결
BSTR 누수SysFreeString 누락래퍼 클래스 사용
VARIANT 누수VariantClear 누락CComVariant 사용
COM 객체 누수Release 누락ComPtr 사용
이중 해제소유권 혼란규칙 명확화

BSTR 디버깅

// 디버거에서 BSTR 길이 확인
BSTR bstr = SysAllocString(L"Test");
UINT* pLen = (UINT*)bstr - 1;  // Watch: *pLen = 8

// 메모리 덤프
// bstr-4 위치에서 시작하면 길이 필드 포함

힙 손상 원인

원인코드 예시
잘못된 할당자BSTR b = (BSTR)malloc(10); SysFreeString(b);
리터럴 해제BSTR b = L"Hello"; SysFreeString(b);
이중 해제SysFreeString(b); SysFreeString(b);
버퍼 오버런길이 필드 덮어쓰기

Application Verifier

Windows SDK의 Application Verifier로 COM 메모리 문제 감지:

1. Application Verifier 실행
2. 대상 EXE 추가
3. "Basics > Heaps" 활성화
4. "COM" 테스트 활성화
5. 프로그램 실행 → 문제 발생 시 브레이크

9. 프로세스 간 마샬링

왜 마샬링이 필요한가?

COM은 프로세스 경계를 넘어 호출할 수 있다:

호출 유형설명마샬링
In-process같은 프로세스 내 DLL불필요
Local같은 머신, 다른 프로세스필요
Remote다른 머신 (DCOM)필요

BSTR 마샬링

BSTR은 자동으로 마샬링된다:

단계동작
1. 프록시BSTR 길이 읽기 → 버퍼+길이 패킹
2. RPC바이트 스트림 전송
3. 스텁SysAllocStringLen으로 재생성
4. 호출새 BSTR을 파라미터로 전달

length-prefix 덕분에 정확한 크기를 알 수 있어 효율적이다.

커스텀 마샬링

IMarshal 인터페이스로 직접 제어 가능:

interface IMarshal {
    GetUnmarshalClass(...);
    GetMarshalSizeMax(...);
    MarshalInterface(...);
    UnmarshalInterface(...);
    ReleaseMarshalData(...);
    DisconnectObject(...);
};

마치며

COM의 문자열과 메모리 관리는 언어 중립성프로세스 경계 투명성을 위해 설계되었다:

설계 결정이유
BSTR (length-prefix)바이너리 안전, 마샬링 효율
CoTaskMemAlloc모듈 간 메모리 공유
IUnknown::AddRef/Release언어 중립 참조 카운팅
VARIANT동적 타입, 스크립트 언어 지원
SAFEARRAY자기 기술적 배열, 경계 검사

현대 C++에서는 래퍼 클래스를 사용하여 이 복잡성을 숨긴다:

용도권장 래퍼
BSTR_bstr_t 또는 CComBSTR
VARIANTCComVariant 또는 _variant_t
COM 포인터ComPtr<T> (WRL) 또는 CComPtr<T>

COM이 1993년에 설계되었음에도 여전히 Windows의 핵심으로 남아있는 이유는, 이러한 견고한 메모리 모델 덕분이다.


더 읽을거리

공식 문서

서적

  • Box, D. (1998). Essential COM - COM의 바이블
  • Tavares, C. & Ferracchiati, F. (2006). .NET and COM: The Complete Interoperability Guide
  • Rector, B. & Sells, C. (1999). ATL Internals

소스 코드

profile
RL Researcher, Video Game Developer

0개의 댓글