C++/CLI 프로그래밍 가이드

  • Post author:
  • Post category:칼럼
  • Post comments:4 Comments
  • Post last modified:July 7, 2008

사내에서 C++/CLI 프로그래밍을 함께 하게 될 동료 개발자들을 위해 정리해본다. 이 글은 꾸준히 업데이트될 예정이고, 그 중에서도 제목만 달린 항목부터 추가될 것이다. 단, 함께 일하는 사람들을 위한 문서이므로 외부 사람들이 참조했다간 큰 낭패를 볼 수도 있다. 특정 프로젝트에 최적화한 지침서이기 때문에 어설프게 따라했다가 피 본다 해도 책임지지 않는다.

혼합 네이티브 클래스

클래스의 종류

C++/CLI에는 크게 세 가지 클래스가 있다. 순수 네이티브 클래스, 관리되는 클래스, 그리고 네이티브지만 관리되는 기능을 쓰는 클래스.

// 사소한 문법적 오류는 넘어가주길. 무슨 의도인지만 알면 되지 않겠니?

// 순수 네이티브 클래스의 예.
class PureNativeClass
{
public:
	std::string getName() const
	{
		return std::string("야후~~~~"");
	}
};

// 순수 관리되는 클래스의 예
ref class ManagedClass
{
public:
	property System::String^ Name
	{
		System::String^ get()
		{
			return "어이쿠";
		}
	}
};

// 혼합 네이티브 클래스의 예.
class MixedNativeClass
{
public:
	std::string getName() const
	{
		String^ msg = "문서 쓰기 귀찮다.";
		return marshal_as<std::string^>( message ); // 관리되는 문자열을 표준 라이브러리 문자열로 변환한다.
	}
};

보다시피 혼합 네이티브 클래스에선 관리되는 타입과 네이티브 타입이 혼재한다.

헤더와 소스 코드의 분리

혼합 네이티브 클래스의 경우를 생각해보자. 위의 MixedNativeClass 예제를 MixedNativeClass.h 파일에 정의했다면, 과연 어떻게 될까? 만약 다음과 같은 코드가 어딘가 존재한다면 컴파일 오류가 날 것이다.

// 또 다른 네이티브 소스 코드
#include "MixedNativeClass.h"

class MyClass
{
	MixedNativeClass m_Class;
};

혼합 네이티브 클래스의 인터페이스를 설계할 땐 관리되는 타입을 밖으로 드러내선 안 된다. 그렇기 때문에 인터페이스를 드러내는 헤더 파일과 실제로 클래스를 구현하는 소스 파일을 반드시 분리시켜야 한다.

도대체 혼합 네이티브 클래스가 왜 필요한 거냐?

앞서 혼합 네이티브 클래스를 프로그래밍할 때의 원칙을 설명했는데, 정작 혼합 네이티브 클래스가 왜 필요한지는 이야기 하지 않았다. C++/CLI 프로젝트는 크게 두 가지가 있다. 실제론 더 잘게 나눌 수 있지만, 널리 쓰이는 용도로 나누면 그렇다. 하나는 아예 처음부터 닷넷 애플리케이션을 작성하기로 한 경우다. C#으로 프로그래밍하는 것과 똑같은 결과물을 얻고 싶을 때가 있다. C++ 문법에 더 익숙해서(아마도 C++ 개발자라서) 굳이 C++/CLI를 고른 경우가 대부분일 것이다. 이 경우엔 혼합 네이티브 클래스는 쓰지 않는다. 아니, 쓰지 못한다.

혼합 네이티브 클래스는 보통 기존 네이티브 C++ 소스 코드에 관리되는 기능을 추가할 때 쓴다. 우리는 당연히 이 경우에 해당한다. 기존 라이브러리를 C#에서 쓰고 싶다던가 할 때가 있기 마련이다. 그럴 때 혼합 네이티브 클래스가 기존 네이티브 코드와 새로 추가되는 관리되는 클래스를 연결하는 다리 역할을 하게 된다.

템플릿 사용에 대해

관리되는 클래스를 정의할 때 템플릿을 써도 된다. 템플릿은 네이티브 객체를 감싸는(Wrapping) 관리되는 클래스를 짤 때 특히 유용하다. 그러나 템플릿을 쓸 거라면 정말 주의해야 한다. 우선 C#에선 템플릿화된 클래스를 참조하지 못한다. generic으로 구현한 관리되는 클래스는 C#에서 참조할 수 있지만, template으로 구현한 경우는 그렇지 않다. 단, 다음과 같은 경우는 C#에서 참조해 쓸 수 있다. 요컨대 최종적으로 공개하는 인터페이스에만 템플릿이 없으면 된다.

public ref class CDBObjectManaged abstract : public CSharedObjectManaged<CDBObject>
{
}

헤더와 소스 코드를 꼭 분리해야 하나?

앞서 혼합 네이티브 클래스의 경우를 예로 들었다. 혼합 네이티브 클래스는 반드시 헤더와 소스 코드를 분리해야 한다. 하지만 다른 클래스(네이티브 클래스와 관리되는 클래스)도 그래야 할까? 물론이다. 이제부터 왜 그런지 알아보자.

  • 순수 네이티브 클래스

    헤더와 소스를 분리하길 권한다. 다만, 템플릿 클래스의 경우엔 헤더만 놓는 경우가 많다. Visual C++에선 템플릿 클래스를 두 파일로 나누어 놓으려면 How To Organize Template Source Code를 참고하면 된다.

  • 혼합 네이티브 클래스

    여러 번 설명하지 않겠다.

  • 관리되는 클래스

    관리되는 클래스는 헤더와 소스를 분리해도 되고 안 해도 된다. 만약 순수한 관리되는 어셈블리를 만들 생각이라면 .cpp 파일에 모든 소스 코드를 적어 넣고 헤더 파일을 없애도 된다. 하지만 우리는 혼합 코드를 작성하므로 헤더와 소스를 반드시 분리하자. 그리고 반드시 클래스 또는 메서드의 구현은 .cpp 파일에 하자. 이 부분을 강조하는 이유가 있다. 공유 라이브러리(lib이든 dll이든)에 다음과 같은 코드가 있다고 해보자.

    // myclass.h
    #pragma once
    
    public ref class MyClass
    {
    	property QWORD DBID
    	{
    		QWORD get() { return NativeDBObject->DBID; }
    		void set(QWORD newValue) { NativeDBObject->DBID = newValue; }
    	}
    };
    
    // myclass.cpp
    #include "stdafx.h"
    #include "myclass.h"
    		

    이 코드에서 눈여겨 볼 것은 DBID 프로퍼티가 inline이 아니라는 점이다. 사실 관리되는 코드는 inline으로 선언하는 게 허용 안 된다. 어쨌거나 공유 라이브러리가 빌드될 때 이 프로퍼티도 오브젝트 파일에 들어간다. 그리고 이 라이브러리를 참조하는 코드에서는 이미 정의된 심볼이라며 빌드 오류를 내뱉는다. 그 원인을 장황하게 설명하기보단 해결책만 간단히 제시해보자면, 이 프로퍼티의 구현을 myclass.cpp 파일에 넘기면 끝이다. 간단하다. 조금 귀찮더라도 헤더와 소스를 반드시 분리하여 이런 고난을 겪지 말자.

  • 네이티브 타입과 관리되는 타입을 변환하는 함수

    어렵게 써놨는데, 가장 비근한 예가 marshal_as이다. 관리되는 문자열을 네이티브 문자열로 바꾸거나 그 반대의 일을 한다. 이런 기능을 제공하는 함수는 반드시 헤더에 놓여야 하고, inline으로 선언되어야 하며, 소스 파일이 존재해선 안 된다. 왜 그럴까?

    #include <msclr/marshal_cppstd.h>
    
    namespace CoreManaged
    {
    	namespace interop
    	{
    		inline  MTimeSpan^ marshal_as(const FTimeSpan& _from_obj)
    		{
    			return MTimeSpan::FromTicks(_from_obj.Ticks());
    		}
    
    		inline  FTimeSpan marshal_as(MTimeSpan^ _from_obj)
    		{
    			return FTimeSpan(_from_obj->Ticks);
    		}
    
    		inline  MDateTime^ marshal_as(const FDateTime& _from_obj)
    		{
    			return MDateTime::FromFileTimeUtc(_from_obj.ToFileTime());
    		}
    
    		inline FDateTime marshal_as(MDateTime^ _from_obj)
    		{
    			return FDateTime(_from_obj->ToFileTime());
    		}
    	}
    }
    		

    그 이유를 알려면 관리되는 타입의 특성부터 인지해야 한다. 앞서 #include 키워드를 사용해 관리되는 클래스를 참조한다고 했다. 하지만 이것은 같은 프로젝트 안에서 소스 파일을 참조해야 할 때만 그렇다. 만약 다른 프로젝트에서 #include 키워드로 ManagedClass를 참조하려 하면 오류가 날 가능성이 높다. ManagedClass의 메타데이터가 이미 참조 대상인 어셈블리 파일(.dll)에 기록됐기 때문이다. 네이티브 C++에선 어떤 타입의 인터페이스를 알려면 반드시 헤더 파일을 참조해야 한다. 그러나 관리되는 타입의 정보는 어셈블리 파일에 직접 기록된다. 그러므로 소스 파일을 직접 참조할 이유가 없다.

    marshal_as는 위의 세 가지 클래스와는 차이가 있다. 네이티브 타입과 관리되는 타입을 변환한다는 점에서 혼합 네이티브 클래스의 성격을 띈다. 그러나 혼합 네이티브 클래스는 공개된 인터페이스에선 관리되는 타입을 쓰지 않는다. 반면, marshal_as의 인터페이스엔 관리되는 타입과 네이티브 타입이 공존한다. 이렇게 혼합된 인터페이스의 정의는 반드시 헤더 파일에 두어야 한다. 만약 이런 인터페이스를 소스 파일에 두고 /clr 컴파일을 하게 되면, 다른 라이브러리에선 이 기능을 쓰지 못한다. 메타데이터엔 네이티브 타입의 정보는 기술되지 않기 때문이다. 하지만 marshal_as 같은 기능은 두고두고 써먹으려고 만드는 것이니 이래선 곤란하다.

    그렇다면 어떻게 해야 하느냐? marshal_as에서 쓰는 네이티브 타입을 드러내려면, 헤더 파일에 정의를 두는 수밖에 없다. 그래서 이런 기능은 반드시 헤더 파일에 놓여야 한다는 것이다. 그런데 왜 소스 파일이 존재해선 안 된다고 했을까? 공개된 인터페이스는 헤더 파일에 두고 구현은 소스 파일에 두면 되지 않는가? 안타깝게도 그렇게 했다간, 나중에 이미 정의되어 있는 정의라는 링크 오류를 보게 될 것이다. 바로 이 때문에 marshal_as 류의 기능은 헤더 파일에 두고 inline으로 선언해야 한다.

C++/CLI 프로젝트에 소스 코드를 추가할 때

프로젝트 속성을 보자. 프로젝트 기본값으로 공용 언어 런타임 지원(/clr) 옵션을 켜놓은 게 보인다. 소스 파일(.cpp)을 프로젝트에 추가하면 이 기본값이 적용된다. 네이티브 타입만 쓰는 소스 파일일지라도 /clr 컴파일을 하게 된다. 이는 컴파일 시간을 늘리고, 런타임시 애플리케이션의 성능에도 영향을 미치게 된다(네이티브와 CLR 간의 전환 때문에). 그러므로 새 소스 코드를 추가할 때는 해당 파일의 용도부터 차분히 생각해보고, 만약 네이티브 타입만 필요한 경우라면 해당 소스 파일의 속성값을 고쳐주어야 한다. 그런데 /clr 옵션만 끈다고 문제가 해결되는 게 아니라서 골치 아프다. 미리 컴파일된 헤더 파일의 이름이나 기본 런타임 검사 등의 값을 고쳐 써야 하는데, 매우 귀찮은 일이기도 하고 실수하기 쉽기도 하다. 그러나 손쉬운 해결책이 있으니 고민할 필요 없다. 프로젝트 파일(.vcproj)을 직접 열어서 Copy&Paste 신공을 펼치면 된다.

우선 일반적인 /clr 소스 파일은 다음과 같이 구성된다.

<File
	RelativePath=".\src\AppConfiguration.cpp"
	>
</File>

반면 네이티브 소스 파일은 이렇게 구성된다.

<File
	RelativePath=".\src\UserStatSIS.cpp"
	>
	<FileConfiguration
		Name="Debug|Win32"
		>
		<Tool
			Name="VCCLCompilerTool"
			MinimalRebuild="true"
			ExceptionHandling="1"
			BasicRuntimeChecks="3"
			SmallerTypeCheck="true"
			PrecompiledHeaderThrough="StdAfx.h"
			PrecompiledHeaderFile="$(IntDir)$(TargetName).pch"
			CompileAsManaged="0"
		/>
	</FileConfiguration>

	<!-- 중략 -->
</File>

다른 생각할 것 없이 XML 코드를 복사해 붙여넣고 파일 이름만 고치면 된다.

포인터 사용시 주의할 점

System::IntPtr과 네이티브 포인터

ref class MyClass
{
public:
	explicit MyClass(const NativeClass* ptr)
	{
		Init(ptr);
	}

	explicit MyClass(System::IntPtr ptr)
	{
		Init(ptr.ToPointer());
	}

private:
	void Init(const NativeClass* ptr);
};

네이티브 클래스에 대한 래퍼 클래스를 구현할 때는 위와 같은 패턴을 적용하길 권한다. System::IntPtr은 네이티브 포인터를 관리되는 환경에 전달할 때 쓰는 타입이다. 순수한 관리되는 환경(예, C#)에선 네이티브 타입을 알 리 없으므로 이러한 특수 타입이 필요하다. 하지만 System::IntPtr::ToPointer()void*를 반환하므로, 엉뚱한 타입의 포인터를 넘겨도 알 도리가 없다.

그러므로 같은 C++/CLI 어셈블리(같은 프로젝트) 내부의 클래스만이라도 정확한 타입의 포인터를 넘겨 받도록 첫번째 생성자를 구현한다. 안타깝게도 C#쪽에선 첫번째 생성자의 존재를 모르므로(알 수 없으므로), 항상 이렇게 짝을 지어 구현해야 한다. 이는 비단 C#에만 적용되는 이야기가 아니다. 이 클래스가 든 어셈블리를 사용하려는 어셈블리가 C++/CLI로 구현되었더라도 마찬가지다. 관리되는 타입의 정보는 헤더 파일이 아닌 메타데이터를 통해 전달되는데, 첫번째 클래스에 대한 메타데이터는 만들어지지 않는다. 그러므로 다른 어셈블리(다른 프로젝트)에서 MyClass의 인스턴스를 만들려면 두번째 생성자가 반드시 있어야 한다.

스마트 포인터

네이티브 클래스에 대한 래퍼 클래스를 구현하는 경우를 생각해보자. 보통 래퍼 클래스(관리되는 클래스)의 멤버 변수로써, 네이티브 클래스에 대한 포인터를 들고 있기 마련이다. 여기까진 좋다. 위의 예제처럼 생성자에서 원본 네이티브 인스턴스의 포인터를 받아서 멤버 변수에 저장하면 된다.

문제는 네이티브 클래스 쪽에서 스마트 포인터를 쓸 때 발생한다. 스마트 포인터를 가비지 콜렉터가 없는 네이티브 환경에서 자원 관리를 조금이라도 편하게 만들고자 도입한 수단이다. 그런데 이 스마트 포인터는 실은 포인터 흉내를 내는 클래스다. 그런데 관리되는 클래스는 진짜 포인터만을 멤버로 가질 수 있다. 즉, 스마트 포인터를 멤버로 가지려면, 스마트 포인터에 대한 진짜 포인터를 멤버로 가져야 한다는 말이다. 말부터 이렇게 꼬이는데 실제로 스마트 포인터에 대한 포인터를 멤버로 갖는 클래스를 작성하려면 얼마나 힘들겠는가!

안타깝게도 뾰족한 해결책은 없다. 스마트 포인터에 대한 포인터를 멤버로 가지는 대신, 어떻게든 쓰기 편하게 잘 구현해보는 것도 좋은 방법이리라 생각한다. 하지만 우리는 스마트 포인터 대신 소유권의 개념을 적용했다. 이에 대해선 자세히 언급하진 않겠다. 다만 오직 네이티브 클래스에 대한 포인터만이 관리되는 클래스의 멤버가 될 수 있다는 사실을 잊지 말자.

연산자 오버로딩 패턴

이 문제에 대해선 C++/CLI에서의 연산자 오버로딩 패턴을 참고하기 바란다.

Author Details
Kubernetes, DevSecOps, AWS, 클라우드 보안, 클라우드 비용관리, SaaS 의 활용과 내재화 등 소프트웨어 개발 전반에 도움이 필요하다면 도움을 요청하세요. 지인이라면 가볍게 도와드리겠습니다. 전문적인 도움이 필요하다면 저의 현업에 방해가 되지 않는 선에서 협의가능합니다.
0 0 votes
Article Rating
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

4 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
daybreaker
15 years ago

음, RSS에서 이 글을 보니 서식이고 뭐고 다 한 줄로(…) 붙어나오네요;; 블로그 뭔가 수정을 하고 계시는 듯한데 손좀 봐주셔야 할 것 같습니다;

최재훈
15 years ago

원복이 제대로 안 됐네요. 구글 리더 쪽에서 테스트해보니 이 블로그의 상당수 피드가 제대로 업데이트가 안 되는 듯 하여 손을 좀 보던 중이예요. 혹 구글 리더 쓰면 제대로 새 글이 업데이트되는지 알려줄래요? 적어도 내 계정에선 안 되던데 말이죠.

테스트용 rss 페이지를 만들었어야 했는데, 덥고 짜증나다 보니 대충하다 일 냈네요. 쩝.

daybreaker
15 years ago

저는 HanRSS를 쓰고 있습니다만 지금 글을 쓰는 현재 제대로 잘 나오고 있습니다. smile

최재훈
15 years ago

HanRSS는 문제 없는 걸 확인했는데, 구글 리더가 파싱을 제대로 못 하는 듯 해서. 계정 하나 더 만들어서 테스트해봐야 하나.