C++/CLI 강좌: C++/CLI 소개

이 글은 월간 마이크로소프트웨어(일명 마소) 2009년 1월호에 기고한 글입니다. 물론 구성이나 내용 상의 차이가 있을 수 있습니다.

닷넷 프레임워크가 나온 지 6년째다. 버전 1.0에서 시작해 버전 4.0을 바라보는 지금 닷넷 프레임워크는 계속 성장하여 자바 모조품이라는 일부의 평가를 극복한지 오래다. 파이썬이나 루비와 닷넷의 환경을 접목하려는 움직임이 어느 정도 성과를 거뒀으며 웹 프로젝트를 넘어 데스크톱 응용 프로그램도 많이 출시되었다. 이런 성장과 새로운 움직임 가운데 C++/CLI는 매우 독특한 위치를 차지했다. 우리는 앞으로 이 이상한 언어의 가치를 조명하고 그 쓰임새를 탐구해볼 것이다.

최재훈 | SK 아이미디어의 게임 서버 팀에서 일한다. 요즘은 스크립트 엔진을 개발하는 데 전념하며, 새로운 도전을 즐긴다. 직업 외적인 측면에선 배철수의 음악 캠프를 15년째 즐겨 듣고, U2가 최고의 밴드라 생각한다.

1년이 넘는 시간을 Visual C++, C++/CLI, 그리고 C#을 엮는데 써왔다. 게임 서버용 스크립트 엔진을 개발하면서 C++/CLI의 가치를 몸으로 확인했는데, 안타깝게도 그 과정이 순탄치는 않았다. 무엇보다 참고 자료가 부실했다. 이는 가장 심각하면서도 아직까지 해결되지 않은 문제인데, 이 칼럼을 쓰기로 결정하기 전에 인터넷 서점을 검색해보니 국내에 소개된 C++/CLI 관련 서적은 전무했다. 모두 번역되지 않은 원서일 뿐이었다. 현재로썬 한국어로 적은 신뢰할만한 자료는 MSDN 라이브러리가 전부다.

반면 게임 서버 등에 C++/CLI를 적용하려는 움직임은 생각보다 많은 듯 하다. 검색 과정에서 이러한 시도에 대해 언급한 글을 여러 차례 목격했으며 모두 크고 작은 문제를 겪으며 고생하는 것 같았다. 한국에서 가장 뛰어난 C++/CLI 개발자가 쓰는 칼럼은 아닐지라도 이 글을 통해 제각기 떨어져 고군분투하던 사람들이 서로 의견을 주고 받는 기회가 됐으면 좋겠다.

헷갈리기 쉬운 용어들

앞으로 C++/CLI의 기초와 응용에 대해 여러 측면에서 고찰해볼 것이다. 그러나 칼럼은 칼럼일 뿐 책이 아니다. 칼럼과 책은 옴니버스 영화와 보통 영화의 관계와 비슷하다. 칼럼은 옴니버스 영화처럼 매 에피소드마다 짧지만 완결된 이야기를 제시해야 하고, 각 에피소드는 하나의 영화로써의 완결성 또한 보여줘야 한다. 그러니 책과는 달리 칼럼에선 기초적인 이야기를 하나하나 친절하게 설명하며 넘어가기 힘들다. 그렇게 하다 보면 매달 감질 맛 나게 조금씩 이야기를 전달해야 하고, 솔직히 전부 설명하기엔 지면이 부족하다.

그러나 몇몇 기초는 빼먹고 넘어가기엔 너무 중요하다. 특히 C++/CLI는 지구에서 가장 어려운 고급 언어(High-level)에 속하며(적어도 내 생각엔), 기존 네이티브 C++(닷넷 프레임워크의 기능이 빠진 보통의 C++ 코드를 흔히 네이티브 C++이라 부른다)과 관리되는 코드(C#이나 닷넷 기능을 쓰는 C++/CLI 코드 등을 관리되는 코드라 부른다)를 엮는 역할을 하므로 개발자는 두 개의 서로 다른 세계(네이티브와 관리되는 환경)를 잘 알아야 한다. 그런 만큼 일반 프로그래머는 몰라도 되는 명세에 관한 이야기조차 빼놓고 넘어가기 힘들다. 가끔씩 튀어나와 우리를 당혹스럽게 하고 도망가는 몇몇 용어부터 숙지하고 다른 이야기를 해보자.

CLI (Common Language Infrastructure)

공통 언어 기반은 ECMA(정보와 통신 시스템을 위한 국제적이면서도 회원국 기반의 비영리 표준화 기구이다 – 출처: 위키백과)의 공인을 받은 공개된 명세이다. 여기서 구현이 아닌 명세라는 말을 썼음에 주의해야 한다. CLI란 “공통 언어라 불리려면 이것 저것 등등을 구현해야 한다”고 적어놓은 문서에 불과할 뿐이다. 마치 C++ 표준위원회가 명세를 정의하고 이에 맞춰 각 벤더가 C++ 컴파일러나 관련 도구를 개발하는 것과 비슷하다. CLI는 표준 명세이고 이에 맞춰 구현하는 일은 각 벤더나 오픈 소스 프로젝트의 몫이다.

그렇다면 공통 언어 기반은 무엇을 정의하는가? 공통 언어 기반은 말 그대로 공통 언어에 대한 이야기이다. 하드웨어 플랫폼과 독립적인 공통 중간 언어(Common Intermediate Language)를 둔다. C#이나 Visual Basic .NET 같은 언어의 컴파일러는 C# 코드 등을 공통 언어로 번역하고, 공통 언어 엔진은 이 언어를 기계어로 번역하는 식이다. 자세한 이야기는 기회가 닿으면 다루기로 한다.

CLI 명세에 따른 구현물은 마이크로소프트 사의 닷넷 프레임워크 외에도 몇 개가 더 있다. 모노DotGNU 같은 유명한 오픈 소스 프로젝트가 있다. 일부 소프트웨어는 모노와 닷넷 프레임워크 양쪽을 모두 지원하는데 닷넷 3.5에서 지원하는 LINQ 같은 최신 기능을 쓰지 못하기 때문에 쉽지 않은 일이다.

.NET Framework

닷넷 프레임워크는 공통 언어 기반(Common Language Infrastructure) 명세에 따라 마이크로소프트 사가 개발한 구현물이다. 닷넷 프레임워크에는 공통 언어 런타임(Common Language Runtime)과 BCL(Base Class Library)가 포함된다.

CLR (Common Language Runtime)

CLR은 닷넷 프레임워크의 핵심이다. 가비지 수집기나 Just-in-Time 컴파일러가 이에 속한다. CLI 명세가 명세대로 움직이게 해주는 핵심인데, 상황에 따라선 CLR 자체를 사용자 요구에 맞춰 제어할 수 있다. Microsoft SQL Server 2005 등이 대표적인 사례인데 안타깝게도 출판된 참고 문헌은 한두 개에 불과하다.

BCL (Base Class Library)

말 그대로 기본 클래스 라이브러리인 BCL은 마이크로소프트 사가 제공한다. CLI에 명세로 기록된 기본 타입을 제외한 닷넷 프레임워크의 나머지는 BCL에 속한다고 보면 된다. 웹 개발과 관련된 기능을 제공하는 System.Web 네임스페이스나 데이터베이스 관련 기능을 제공하는 System.Data 네임스페이스 등은 모두 BCL의 일부라 보면 된다. 사실상 mscorlib만 CLI 명세에 따른 구현물일 뿐 나머지는 BCL에 속한다.

그림 1. Reflector로 본 BCL 어셈블리

그림 1. Reflector로 본 BCL 어셈블리

CTS (Common Type System)

CTS란 런타임에서 형식을 선언하고 사용 및 관리하는 방법을 정의할 뿐 아니라 언어 간 통합에 대한 런타임 지원의 중요한 부분을 차지합니다.

출처: MSDN 라이브러리

C++/CLI를 익히려면 무엇보다 기본 타입(형식)을 숙지해야 하므로 이에 대해선 앞으로 심도 깊게 다룰 생각이다. 일단 CTS, CLI, CLR 같이 비슷비슷한 약어들 간의 차이점을 잘 알아두자. 초반엔 이런 용어가 자주 튀어나올 가능성이 높으니 말이다.

CLS (Common Language Specification)

개체를 구현한 언어에 상관 없이 개체가 다른 개체와 완전하게 상호 작용할 수 있으려면, 개체는 함께 상호 운용되어야 할 모든 언어에 공통적인 기능만을 호출자에게 노출해야 합니다. 따라서 많은 응용 프로그램에 필요한 기본 언어 기능 집합인 CLS(공용 언어 사양)가 정의되어 있습니다. CLS 규칙은 공용 형식 시스템의 하위 집합을 정의합니다. 따라서 공용 형식 시스템에 적용되는 모든 규칙은 CLS에도 적용되며 둘 중에서 CLS 규칙이 더 엄격합니다.

출처: MSDN 라이브러리

CLS와 CTS는 정말 헷갈리기 쉬운데 고백하건대 아직까지 이게 저건가? 저게 이건가? 싶을 때가 많다. CLS에 대해선 딱 하나만 기억하면 된다. CLS-Compliant라는 말을 가끔 듣게 될 텐데 이렇게 생각하면 이해하기 쉽다. “CLS-Compliant인 코드라면 C#, F#, IronPython 등 언어를 가리지 않고 잘 돌아간다”는 뜻이다. CLS는 언어 간 호환성을 위한 최소 조건이다.

왜 C++/CLI인가?

C#의 대체물로써의 C++/CLI

마이크로소프트 사가 C++/CLI를 처음 시장에 내놓았을 때 이런 이야기가 돌았다. 지금 와선 마이크로소프트 사가 퍼뜨린 이야기인지 누리꾼들의 추측이었는지 확실치 않지만 요지는 이랬다. C++ 개발자들을 닷넷 프레임워크로 끌어들이려고 MS사가 C++의 문법으로 닷넷의 기능을 쓸 수 있는 새로운 언어를 개발했다. 어쩌면 마이크로소프트 사의 마케팅 팀의 전략이었을지도 모르지만 결과적으로 이런 의도는 100% 실패했다.

불행하게도 C++/CLI는 매우 어려운 문법 구조를 갖추고 있으며 덕분에 C++ 개발자가 C++/CLI를 익히는 것보단 차라리 C#을 새로 익히는 편이 빠르다. 개인적인 경험이라 예외적인 경우가 있긴 하겠지만, 내가 만나본 개발자들은 이런 의견에 대부분 동조했다.

C++/CLI의 진정한 가치

너무 괴상한 문법이라 문서를 읽다가 때려 쳤다는 사람도 있고 내 자신은 C++과 C#의 난이도를 곱해놓은 것 같다고 흔히 말한다. 그렇다면 좀더 쉬운 C#이나 Visual Basic .NET을 두고 굳이 C++/CLI를 쓸 이유가 있을까?

칼럼 주제가 C++/CLI인만큼 당연한 이야기겠지만 정답은 ‘그렇다’이다. C++/CLI은 다른 CLS 언어가 하지 못하는 일을 해낸다. 기존에 작성한 네이티브 C++ 라이브러리와 닷넷 프레임워크를 연결하는 역할은 C++/CLI밖에 하지 못하는 것이다.

예를 들어, 네이티브 코드로 작성한 게임 서버에 스크립트 엔진을 도입할 생각이라 가정해보자. 이런 경우에 C++/CLI가 그 역할을 맡을 수 있다. 관리되는 코드를 이용해 기존의 네이티브 C++ 라이브러리를 C# 등에서 참조할 수 있게 API를 만든다. 그런 후 VB .NET이나 IronPython(Visual C++의 관리되는 언어 버전이 C++/CLI라면 파이썬의 관리되는 언어 버전은 IronPython이다)으로 스크립트를 짜면 된다.

Microsoft SQL Server 2005 이후로 마이크로소프트 사의 상당수 제품이 이러한 접근법을 취한다. 순수하게 닷넷으로 작성한 Windows Live Writer 같은 응용 프로그램도 있지만 고도의 성능이 필요한 시스템에선 핵심은 네이티브 코드로 짜고 그 위에 안전성이나 빠른 개발속도가 요구되는 부분은 관리되는 코드로 메우는데 그 사이를 C++/CLI로 묶는 것이다. 어쩌면 여러분의 시스템도 이런 과정을 통해 좀더 업그레이드된 모습으로 탈바꿈할 수 있을지도 모른다.

CLI의 기본 타입

앞서 CTS, 그러니까 공용 형식 시스템에 대해 이야기했다. C++/CLI 역시 CLI의 한 구현물이다 보니 CTS에서 말하는 기본 타입을 모두 지원한다. 다음 표에 13개의 기본 타입을 요약해놨는데, 다행히 이런 기본 타입은 네이티브 쪽이나 관리되는 코드 쪽이나 같은 키워드를 사용하므로 금방 익숙해진다.

CLI 타입

C++/CLI 키워드

예제

설명

System::Boolean

bool

bool success = true;

true/false

System::Byte

unsinged char

unsigned char c = ‘c’;

부호 없는 8비트 정수

System::Char

wchar_t

wchar_t wc = L’w’;

유니코드 문자

System::Double

double

double d = 1.1E-11;

8바이트짜리 배정밀도 부동 소수점

System::Int16

short

short s = 12;

16비트 정수

System::Int32

int, long

int i = 100;

32비트 정수

System::Int64

__int64, long long

__int64 i = 100;

64비트 정수

System::SByte

char

char c = ‘T’;

8비트 정수

System::Single

float

float i = 1.01f

4바이트짜리 단정밀도 부동 소수점

System::Uint16

unsigned short

unsigned short i = 100;

부호 없는 16비트 정수

System::UInt32

unsigned int, unsigned long

unsigned int i = 100;

부호 없는 32비트 정수

System::UInt64

unsigned __int64, unsigned long long

unsigned __int64 i = 100;

부호 없는 64비트 정수

Void

void

 

타입 없는 데이터나 데이터 없음

시각과 시간

위에서 언급한 기본 타입 외에 자주 쓰는 타입이 더 있다. 그 중 대표적인 것이 DateTime과 TimeSpan이다. 각각 시각과 시간을 대표하는 클래스인데, 시각과 시간의 차이점에 대해 초등학교 다닐 적 수없이 반복해 들었던 기억이 아직도 생생하다. 시각은 특정 시점, 그러니까 12시 30분, 1시 1분 등을 나타내며, 시간은 두 시각의 차이, 이를테면 10분, 1시간 30분 등을 나타낸다.

C++/CLI에서 DateTime과 TimeSpan은 다음과 같이 사용된다. 첫 줄은 현재 시각을 구하는 코드이고 두 번째 줄은 이틀을 나타내는 TimeSpan 값을 구하는 코드다.

[목록 1] DateTime과 TimeSpan 예제
System::DateTime managedNow = System::DateTime::Now;
System::TimeSpan interval = System::TimeSpan::FromDays(2);
			

시각과 시간을 표현하는 기능을 기본으로 제공하지 않는 언어, 예를 들어 C++만 다뤄본 프로그래머 중에는 이러한 구분이 상당한 이점을 안겨준다는 사실을 미처 깨닫지 못하기도 한다. Tick 값이 코드 여기저기서 쓰이는 경우가 흔한데, 하나의 정수 값에 불과한 Tick은 그 자체로는 시각을 나타내는지 시간을 나타내는지 스스로 밝히지 않는다. 이 때문에 가독성이 떨어지고 시각과 시각을 더한다던가(1999년 1월 1일과 2000년 1월 1일을 합치면 어떤 결과가 나와야 할까?) 하는 이해하기 힘든 상황이 벌어지고 때로는 이런 문제가 버그로 이어지기까지 한다.

왜 이런 이야기를 할까? 우리는 앞으로 네이티브 C++과 관리되는 C++을 모두 활용할 텐데 문제는 네이티브 C++이다. 관리되는 C++에서야 DateTime이나 TimeSpan을 쓰면 되니 문제가 안 된다. 그러나 닷넷 프레임워크에 포함된 DateTime이나 TimeSpan을 쓰지 못하는 네이티브 환경이라면 별도의 DateTime, TimeSpan 클래스를 만들어 써야 버그를 줄일 수 있다. 또한 네이티브와 관리되는 코드 양쪽에서 동일한 방식으로 시간과 시각을 다룰 수 있다면 개발자가 외워야 할 것이 줄어든다는 장점도 있다.

설계 따라하기

칼럼에서 여러 번 언급했는데 닷넷 개발자라면 알아둬야 할 필수 유틸리티 중에 .NET Reflector라는 게 있다. Reflector는 어셈블리의 코드를 디어셈블리하여 C# 등의 언어로 다시 보여준다. 그림 2는 Reflector로 TimeSpan의 구현 코드를 확인하는 장면이다.

그림 2. Reflector로 TimeSpan 들여다보기

그림 2. Reflector로 TimeSpan 들여다보기

그림 2. Reflector로 TimeSpan 들여다보기 #2

네이티브용 TimeSpan 또는 DateTime을 구현할 때 닷넷 프레임워크 쪽의 구현을 보고 따라하면 좋다. 무엇보다 시간을 아낄 수 있고 상당히 검증된 코드이기 때문에 믿어도 좋다. 굳이 스스로 시각/시간 클래스를 개발하겠다면 그에 합당한 이유가 있어야 할 것이다. 직업 프로그래머로써 우리는 일하는 대가를 지급 받으며, 그러므로 시간과 노력을 아껴 좀더 많은 일을 해야 할 직업 윤리 또는 의무가 있기 때문이다.

[목록 2] 네이티브 C++용 TimeSpan
#pragma once

typedef signed __int64 TIMESPANTICKS;


class NTimeSpan
{
	friend class NDateTime;
public:
	static const TIMESPANTICKS TicksPerDay =  864000000000; // 24 * 60 * 60 * 1000 * 1000 * 10
	static const TIMESPANTICKS TicksPerHour = 36000000000; // 60 * 60 * 1000 * 1000 * 10
	static const TIMESPANTICKS TicksPerMinute = 600000000;
	static const TIMESPANTICKS TicksPerSecond = 10000000;
	static const TIMESPANTICKS TicksPerMillisecond = 10000;


	NTimeSpan()
		: m_Time(0)
	{
	}

	NTimeSpan(const TIMESPANTICKS ticks)
		: m_Time(ticks)
	{
	}

	NTimeSpan(const NTimeSpan& other)
		: m_Time(other.m_Time)
	{
	}

	inline TIMESPANTICKS Ticks() const
	{
		return m_Time;
	}

	inline int Days() const
	{
		return static_cast<int> (m_Time / TicksPerDay);
	}

	inline int Hours() const
	{
		return static_cast<int> ((m_Time / TicksPerHour) % 24L);
	}

	inline int Minutes() const
	{
		return static_cast<int> ((m_Time / TicksPerMinute) % 60L);
	}

	inline int Seconds() const
	{
		return static_cast<int> ((m_Time / TicksPerSecond) % 60L);
	}

	inline int Milliseconds() const
	{
		return static_cast<int> ((m_Time / TicksPerMillisecond) % 1000L);;
	}

	inline double TotalDays() const
	{
		return (m_Time * 1.15740740740741E-12);
	}

	inline double TotalHours() const
	{
		return (m_Time * 2.77777777777778E-11);
	}

	inline double TotalMinutes() const
	{
		return (m_Time * 1.66666666666667E-09);
	}

	inline double TotalSeconds() const
	{
		return (m_Time * 0.0001);
	}

	inline double TotalMilliseconds() const
	{
		double num = m_Time * 0.0001;
		if (num > 922337203685477)
		{
			return 922337203685477;
		}
		if (num < -922337203685477)
		{
			return -922337203685477;
		}
		return num;
	}

	NTimeSpan operator+(const NTimeSpan& other)
	{
		return NTimeSpan(this->m_Time + other.m_Time);
	}

	NTimeSpan operator+(const NTimeSpan& other) const
	{
		return NTimeSpan(this->m_Time + other.m_Time);
	}

	NTimeSpan operator-(const NTimeSpan& other)
	{
		return NTimeSpan(this->m_Time - other.m_Time);
	}

	NTimeSpan operator-(const NTimeSpan& other) const
	{
		return NTimeSpan(this->m_Time - other.m_Time);
	}

	bool operator==(const NTimeSpan& other) const
	{
		return this->m_Time == other.m_Time;
	}

	bool operator==(const TIMESPANTICKS otherTick) const
	{
		return this->m_Time == otherTick;
	}

	bool operator!=(const NTimeSpan& other) const
	{
		return this->m_Time != other.m_Time;
	}

	bool operator!=(const TIMESPANTICKS otherTick) const
	{
		return this->m_Time != otherTick;
	}

	bool operator<=(const NTimeSpan& other) const
	{
		return this->m_Time <= other.m_Time;
	}

	bool operator<=(const TIMESPANTICKS otherTick) const
	{
		return this->m_Time <= otherTick;
	}

	bool operator>=(const NTimeSpan& other) const
	{
		return this->m_Time >= other.m_Time;
	}

	bool operator<(const NTimeSpan& other) const
	{
		return this->m_Time < other.m_Time;
	}

	bool operator>(const NTimeSpan& other) const
	{
		return this->m_Time > other.m_Time;
	}

	bool operator<(const TIMESPANTICKS otherTick) const
	{
		return this->m_Time < otherTick;
	}

	bool operator>(const TIMESPANTICKS otherTick) const
	{
		return this->m_Time > otherTick;
	}

	operator TIMESPANTICKS() const
	{
		return m_Time;
	}

private:
	TIMESPANTICKS m_Time;
};
			
[목록 2]는 닷넷의 TimeSpan을 네이티브 코드로 포팅한 예제다. 이 코드는 사실상 Reflector의 결과물을 베낀 것이며 주요 차이점이라곤 C++ 특유의 연산자 오버로딩 정도이다. Add 같은 메서드 대신 + 연산자를 정의하는 식인데 기능상의 차이는 전무하다고 봐도 좋다. DateTime::FromDays 같은 메서드는 포팅하지 않았는데 시간과 지면상의 이유일 뿐 다른 문제는 아니다.

[목록 3] NTimeSpan의 용례
NTimeSpan twoDays(NTimeSpan::TicksPerDay * 2);
NTimeSpan threeDays(NTimeSpan::TicksPerDay * 3);

NTimeSpan oneDay = threeDays - twoDays;
CHECK(oneDay.Ticks() == NTimeSpan::TicksPerDay);
			

DateTime에 대해

DateTime을 포팅한 코드는 다음 칼럼으로 미루려 한다. 사실 닷넷의 DateTime을 그대로 포팅한다면야 문제랄 것도 없다. Reflector가 보여주는 코드를 그대로 베끼면 끝일뿐. 하지만 안타깝게도 문제가 그리 단순하진 않다. 디버깅 없이, 버그 없이 한번에 완벽하게 돌아가는 코드는 없듯이 DateTime도 포팅만 한다고 모든 이의 귀여움을 받는 그런 코드가 되진 못한다. 이 문제는 TimeSpan보다 복잡하고 그만큼 지면을 더 차지할 것이니 다음 시간에 다루기로 하겠다.

끝마치는 말

1년이 넘는 시간을 Visual C++, C++/CLI, 그리고 C#을 엮는데 써왔다. 게임 서버용 스크립트 엔진을 개발하면서 C++/CLI의 가치를 몸으로 확인했는데, 안타깝게도 그 과정이 순탄치는 않았다. 무엇보다 참고 자료가 부실했다. 이는 가장 심각하면서도 아직까지 해결되지 않은 문제인데, 이 칼럼을 쓰기로 결정하기 전에 인터넷 서점을 검색해보니 국내에 소개된 C++/CLI 관련 서적은 전무했다. 모두 번역되지 않은 원서일 뿐이었다. 현재로썬 한국어로 적은 신뢰할만한 자료는 MSDN 라이브러리가 전부다.

최 재훈

블로그, 페이스북, 트위터 고성능 서버 엔진, 데이터베이스, 지속적인 통합 등 다양한 주제에 관심이 많다.
Close Menu