C++/CLI 강좌: 다시 한번 기초 다지기

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

C++/CLI는 기존 Visual C++에 닷넷 프레임워크, CLR의 기능을 더한 프로그래밍 언어다. 이 새로운 언어는 고성능 및 플랫폼 최적화라는 네이티브 C++의 장점과 고성능?생산성 향상이라는 C#의 장점을 모두 활용하기 위한 출발점이다. 비록 C++/CLI 자체는 무척 까다로운 언어지만 네이티브 C++ 코드와 C#을 엮을 때 더 나은 선택지는 존재하지 않는다.

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

2월 칼럼에선 C++/CLI 코드를 컴파일하고 링크하는 법을 알아봤다. C#을 주로 다뤘던 개발자라면 그 복잡한 컴파일 옵션과 링크 옵션을 보고 크게 당황했으리라. C++ 예외 처리 가능, 최소 다시 빌드 가능, 기본 런타임 검사 등 10여 개나 되는 옵션을 하나하나 세심하게 골라 올바른 구성을 갖추기가 쉽지 않음을 우리는 지난 시간에 알게 됐다. 이런 옵션에 익숙한 C++ 개발자에게도 네이티브 코드와 관리되는 코드마다 다른 옵션은 충분히 두통 유발의 요인이 될 만 하다.

오늘은 지난 두 칼럼에서 은근슬쩍 넘어갔던 내용을 다루려 한다. 지면이 모자라서, 지금 설명하면 되려 머리만 꼬이고 이해하기 힘들 것 같은 내용은 두리뭉실 넘겼다. 독자의 양해를 사전에 구한 경우도 있지만 그렇지 않은 경우도 있는데 기초를 다지는 의미에서 코드 짜기보단 재미가 덜하겠지만 이런 부분을 보충 설명할 생각이다. 그러나 본론으로 넘어가기 전에 두 가지 소식을 먼저 전한다.

공식 사이트 개설

C++/CLI에 대한 논의를 더 깊게 다루기 전에 우선 한 가지 소식을 알리고자 한다. 거의 3년 가깝게 마이크로소프트웨어를 통해 독자들과 만나왔다. 운이 좋게도 흔치 않은 기회를 잡아 지식과 경험을 서로 공유하는 뜻 깊은 자리에 함께 했음을 항상 감사히 여긴다. 그런데 스무 개가 넘는 칼럼을 쓰는 동안 자료를 중구난방 식으로 공유했던 것 같다. 어떤 자료는 블로그에 올리고 또 다른 자료는 파일 공유 사이트에 업로드했고 독자들이 하나의 창구를 통해 질문하고 의견을 피력하기 어려운 상황이었다. 블로그에 댓글을 다는 사람, 이메일로 질문하는 사람, 마이크로소프트웨어에 문의하는 사람, 각양각색이었다. 문득 이런 사실을 깨닫고 몇 주전에 사이트를 하나 개설했다.

http://github.com/andromedarabbit/imaso/을 방문하면 칼럼과 관련된 자료를 서브버전이나 압축된 파일로 다운로드 받을 수 있다. 급조한 탓에 영어로 대충 메뉴를 꾸몄는데 시간을 두고 하나씩 개선할 생각이다. 독자들은 영어든 한국어든 편한 언어를 써서 버그 보고나 질문을 올리면 된다. 최대한 성심성의껏 신속히 답변할 생각이다.

1월호 예제의 버그

마이크로소프트웨어 웹 사이트를 통해 1월호 예제의 버그를 알려준 독자가 있었다. 이 자리를 빌어 감사하다는 말씀을 전한다.

해당 문제는 http://github.com/andromedarabbit/imaso/issues/detail?id=1&can=1 에 이슈로 등록해서 처리했으며 공식 사이트에서 제공하는 소스 코드나 압축 파일에 반영이 됐다. 문제의 소스 코드는 TotalSeconds 메서드인데 목록 1을 참고하자. 부끄럽게도 복사해 붙여넣기 신공에 정신을 놓아 발생한 버그다.

[목록 1] 1월호 예제의 버그
// 예전 코드
inline double TotalSeconds() const
{
    return (m_Time * 0.0001);
}

// 고친 코드
inline double TotalSeconds() const
{
	return (m_Time * 1E-07);
}
			

/clr 옵션이 있고 없고

어느 게 clr 코드야?

이제 소식을 다 전했으니 C++/CLI에 대해 논의해보자.

지난 칼럼에서 /clr 옵션을 주고 소스 코드를 컴파일했을 때와 /clr 옵션 없이 소스 코드를 컴파일했을 때 그 결과물에 차이가 있을 수 있다고 경고했다. 특히 전역 변수나 정적 변수의 초기화 순서가 달라지는 문제를 언급했는데 이게 전부는 아니다. /clr 컴파일은 네이티브 컴파일에 대한 하위 호환성을 제공하지만 예상치 않은 곳에서 문제를 일으키곤 한다. 지금부터 그런 사례를 알아볼 것이다.

[목록 2] 정수의 초기화
// main.cpp
int main()
{
    int i;

    for(; i<100; i++)
    {
        // 중략
    }
    ......
}
			

목록 2는 아주 단순한 코드다. 정수 변수 i 를 이용해 for 블록 안의 코드를 100회 실행한다. 이토록 단순한 코드지만 /clr 옵션을 주느냐 그렇지 않느냐에 따라 결과물에는 차이가 있을지 모른다. for 문이 있는 줄에 중단점을 선언해놓고 디버거를 돌려보자. 중단점에 걸렸을 때 변수 i의 값을 확인해보면 경악할만한 사실을 알게 된다.

[그림 1]과 [그림 2]를 보면 과연 어떤 결과가 나오는지 눈으로 확인 가능하다. 네이티브 C++은 코드 상에서 명시적으로 초기화하지 않은 지역 변수는 그냥 놔둔다. 그냥 놔둔다는 말은 해당 지역 변수에 소위 말하는 쓰레기 값이 들어간다는 말이다. 실제로 [그림 1]에선 -858993460이란 값이 들어갔다. 덕분에 for 블록 안의 코드는 무려 858993560 번이나 실행된다. 이는 물론 프로그래머의 의도와는 다른 결과일 것이다. 물론 이 값은 응용프로그램을 실행할 때마다 달라질 수 있다. 아무도 변수 i가 어떤 값으로 초기화될지 장담하지 못한다.

반면 똑같은 코드를 /clr 컴파일하면 전혀 다른 결과가 나온다. CLR 컴파일한 코드는 지역 변수를 암시적으로 초기화한다. 일반적으로 정수 값은 0으로 초기화하는데 [그림 2]는 그러한 결과를 보여준다. 따라서 for 안의 코드는 아마도 main 함수를 작성한 사람의 의도였을, 100회 실행되고 끝난다.

[그림 1] /clr 옵션을 안 줬을 때

[그림 1] clr 옵션을 안 줬을 때

[그림 2] /clr 옵션을 줬을 때

[그림 2] clr 옵션을 줬을 때

Thread Local Storage 사용 문제

/clr 옵션이 있느냐 없느냐는 지역 변수 초기화뿐만 아니라 다른 문제도 일으키는 골칫거리다. /clr 컴파일이 네이티브 컴파일에 하위 호환성이 있다고 하지만 대부분의 경우에 그렇다는 이야기지 100% 호환되는 코드를 뱉어내진 않는다. 그런 사례 중 하나가 Thread Local Storage (TLS) 문제다.

Visual C++에서 TLS 기능을 쓸 때는 보통 [목록 3]과 같이 코드를 짠다.

[목록 3] __declspec(thread) 키워드를 이용한 TLS 사용
__declspec( thread ) int tls_i = 1;
			

그러고 보니 TLS가 뭔지 설명하지 않았는데 세부사항은 MSDN 라이브러리나 Windows via C/C++ 같은 명저를 통해 확인하기로 하고 여기선 간단히 설명한다. [목록 3]처럼 코드를 짜면 변수 tls_i는 스레드마다 따로 선언된다. 예를 들어 1번 스레드에서 tls_i 값을 2로 바꿔도 2번 스레드의 tls_i의 값은 1로 남는다.

Windows C/C++을 보면 TLS 기능이 어디에 유용하게 쓰였는지 사례가 나온다. 멀티스레드 개념이 없을 때 C 런타임 라이브러리에는 가장 최근에 발생한 오류를 나타내는 errno 변수가 도입됐다. 그러던 것이 멀티스레드 환경으로 넘어오면서 문제가 생겼다. 멀티스레딩 환경에선 errno 전역 변수의 값이 언제 바뀔지 알 수 없다.

[목록 4] errno 변수
// crt_get_errno.c
#include <stdio.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <share.h>
#include <errno.h>

int main()
{
   errno_t err;
   int pfh;
   _sopen_s( &pfh, "nonexistent.file", _O_WRONLY, _SH_DENYNO, _S_IWRITE );
   _get_errno( &err );
   printf( "errno = %d\n", err );
   printf( "fyi, ENOENT = %d\n", ENOENT );
}
			

[목록 4]에서 _sopen_s 함수를 호출한 후 함수 호출이 성공했는지 실패했는지 알아보려고 _get_errno 함수를 부른다. 만약 이 코드가 멀티스레딩 환경에서 동시에 여러 스레드에서 실행된다고 해보자. 스레드 1에서 _sopen_s가 성공한다. 스레드 1에서 스레드 2로 문맥이 전환되고 _sopen_s가 불린다. 이번엔 _sopen_s가 실패한다. 다시 문맥이 전환되어 스레드 1으로 넘어간다. 이런 상황에서 스레드 1이 errno의 값을 확인하면 오류가 발생했다고 판단하게 된다. 물론 이는 우리가 원하는 바가 아니다. 그래서 멀티스레드용 C 런타임 라이브러리는 errno 변수를 TLS로 선언하여 이런 문제가 발생하지 않도록 한다.

자, TLS에 대한 설명은 여기까지! 이제 다시 C++/CLI 이야기로 넘어가자.

안타까운 이야기지만 /clr 컴파일하는 소스 코드에는 __declspec(thread) 키워드를 쓰면 안 된다. __declspec(thread) 키워드를 쓰더라도 컴파일 오류는 나지 않지만 응용프로그램을 실행했을 때 런타임 오류가 난다. 기존 네이티브 C++ 코드가 /clr로 컴파일이 잘 돼서 잔뜩 흥분했을 때 뚝! 하고 떨어지는 런타임 오류창!

Jeremy Kuhne는 CLR이 __declspec(thread)을 지원하지 않는다고 밝혔다. 그래서 그는 별도의 자원 관리 클래스를 만들어서, WIN32 API를 호출하게 했다. 사실 __declspec(thread)로 선언한 변수는 컴파일러가 나중에 WIN32 API를 호출하는 코드로 바꿔준다. 그러니까 별도의 자원 관리 클래스를 작성한다는 말은 컴파일러가 대신 해줄 일을 사람이 직접해야 하는 상황이라는 것이다. 물론 클래스만 제대로 설계하면 기존 코드를 손 댈 일이 거의 없긴 하다. Jeremy Kuhne가 소개한 클래스는 [목록 5]에 실었는데 기존 코드에 버그가 하나 있었고 이것은 버그를 수정한 버전이다.

[목록 5] TLS 래퍼 클래스
#pragma unmanaged
template <typename T>
class ThreadLocal
{
private:
    DWORD threadLocalIndex;
    ThreadLocal(ThreadLocal const&);

    T *GetPointer(void)
    {
        return static_cast<T*>(::TlsGetValue(this->threadLocalIndex));
    }

    void SetPointer(T *value)
    {
        ::TlsSetValue(this->threadLocalIndex, static_cast<void*>(value));
    }

    T t;
public:
    void SetValue(const T &value)
    {
        T* currentPointer = this->GetPointer();

        if (currentPointer == NULL)
        {
            this->SetPointer(new T(value));
        }
        else
        {
            *currentPointer = value;
        }
    }

    T &GetValue(void)
    {
        T* currentPointer = this->GetPointer();

        if (currentPointer == NULL)
        {
            // modified
            this->SetPointer(new T(t));
        }

        return *this->GetPointer();
    }

    void DeleteValue()
    {
        T* currentPointer = this->GetPointer();

        if (currentPointer != NULL)
        {
            delete currentPointer;
            this->SetPointer(NULL);
        }
    }

    ThreadLocal(const T& value)
    {
        this->threadLocalIndex = ::TlsAlloc();
        // modified
        this->t = value;
    }

    ThreadLocal()
    {
        this->threadLocalIndex = ::TlsAlloc();
    }

    virtual ~ThreadLocal()
    {
        this->DeleteValue();
        ::TlsFree(this->threadLocalIndex);
    }
};
			

결론

이제까지 /clr 옵션이 있고 없고에 따라 어떻게 결과가 달라질 수 있는지 두 가지 실제 사례를 살펴봤다. 아예 컴파일 오류가 나는 경우라면 MSDN 라이브러리와 구글 검색신에게 물어보며 해결하면 된다. 그러나 위의 사례처럼 컴파일은 되지만 런타임의 결과가 다른 경우라면 속된 말로 삽질을 거듭하게 된다.

그러니 기존 네이티브 코드에 CLR의 기능을 덧붙이고 싶다면 이런 원칙을 따르자. 첫째, 프로젝트 전체를 /clr 컴파일하지 말고 네이티브 코드와 관리되는 코드를 나누자. 2월 칼럼에서 네이티브 코드용 미리 컴파일된 헤더와 관리되는 코드용 미리 컴파일된 헤더를 만들고 소스 파일에 따라 컴파일 옵션을 조정하는 방법을 보인 바 있다. 지난 칼럼에서 설명한 방법을 충실히 따른다면 조금 더 쉽게 마이그레이션을 할 수 있으리라 생각한다.

둘째, 단위 테스트를 하자. 런타임 오류로 인한 버그를 줄이려면 역시 단위 테스트가 필수적이다. 사실 레가시 코드에 단위 테스트를 하나하나 추가하며 마이그레이션하기가 쉽진 않다. 특히 C++이라면 자바나 C#에 비해 월등히 힘들다. 단위 테스트 도구를 설정하고 코드를 작성하기가 쉽진 않다. 그러나 모든 걸 처음부터 완벽하게 갖추려고 하지 않는다면 의외로 보다 안전하게 마이그레이션할 수 있을지 모른다. 마이그레이션 과정에서 발견되는 버그만 차례대로 하나씩 단위 테스트를 추가하고 검증한다면 한번에 들이는 노력이 그리 크진 않을 테고 자연히 인내심을 갖고 차분히 대응해나갈 수 있을 것이다. 단위 테스트에 대해선 잘 쓴 웹 칼럼이나 책이 많으니 이쯤에서 잔소리는 마치도록 한다.

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

소스 코드에 종류에 따라 /clr 옵션을 켜거나 끈다는 건 여러 차례 강조했다. 문제는 프로젝트의 기본 옵션이 아닌 소스 파일은 일일이 고쳐줘야 한다는 사실이다. 프로젝트의 기본 구성이 /clr 이면 네이티브로 컴파일해야 하는 소스 코드는 일일이 해당 소스 파일의 속성 페이지를 열어서 컴파일 옵션을 바꿔줘야 한다. 기본 구성이 “공용 언어 런타임 지원 안 함”이라면 반대로 관리되는 소스 파일의 속성 값을 바꿔줘야 한다. 프로젝트 기본값을 쓰지 않는 소스 파일이 많을수록 소위 말해 노가다가 많아지는 셈이다. 그런데 더욱 큰 문제는 2월 칼럼에서 봤듯이 DEBUG 빌드냐 RELEASE 빌드냐, 32비트냐 64비트냐 등에 따라 옵션 값이 다르고 그 외에도 고쳐 써야 할 옵션이 많다는 점이다. 보통 소스 파일 하나당 십여 개가 넘는 옵션을 켜거나 꺼야 한다. 설사 철인적인 의지와 노력으로 이러한 위업을 달성하더라도 사람은 실수하기 마련이다. 단 하나의 소스 파일만 옵션 하나가 틀리는 경우가 얼마나 많은지 겪어본 사람은 안다. 그 눈물의 고통이란. 그나마 빌드 자동화를 해놨으면 빌드 서버가 잘못한 설정을 잡아내겠지만 그렇지 않은 경우는 통합 이슈로 더 큰 고난을 겪게 되리라.

그러나 다행히 손쉬운 해결책이 있으니 고민 끝! 프로젝트 파일(.vcproj)을 직접 열어서 Copy & Paste, 복사해 붙여넣기 신공을 펼치면 된다.

프로젝트의 기본 구성값이 /clr 이고 네이티브 소스 파일의 컴파일 옵션을 바꿔야 한다고 해보자. 그런 경우 일반적인 /clr 소스 파일은 [목록 6]과 같이 구성된다.

[목록 6] 기본 구성대로 컴파일할 소스 파일
<File
	RelativePath=".\src\AppConfiguration.cpp"
	>
</File>
			

반면 네이티브 소스 파일은 [목록 7]처럼 구성된다.

[목록 7] 기본 구성과 달리 컴파일할 소스 파일
<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 코드를 복사해 붙여넣고 파일 이름만 고치면 된다. 너무나 쉽지만 강력한 해결법이다.

값 타입과 참조

지난 칼럼에서 NTimeSpan과 System.TimeSpan을 서로 변환하는 marshal_as 코드를 짜고 단위 테스트까지 해봤다. 그때 스쳐 지나가는 말로 System.TimeSpan은 값 타입이고 값 타입을 매개변수로 넘길 땐 참조(^)로 넘기지 말라고 했다. 왜 그럴까? 사실 참조로 넘겨도 되지만 실제 컴파일된 결과물은 참조로 처리하질 않는다. 다시 말해 코드 상으론 참조처럼 보여도 실제로는 값 복사로 처리된다.

예를 들어, [목록 8]의 코드는 어떤 결과가 나올까?

[목록 8] 값 타입을 매개변수로 넘기기
using namespace System;

value struct MyValueType
{
	int i;
};

void f1(MyValueType obj)
{
	obj.i = obj.i + 1;
}

void f2(MyValueType^ obj)
{
	obj->i = obj->i + 1;
}

int main()
{
	MyValueType obj1;
	obj1.i = 100;

	Console::WriteLine(obj1.i.ToString());

	f1(obj1);
	Console::WriteLine(obj1.i.ToString());

	f2(%obj1);
	Console::WriteLine(obj1.i.ToString());
}
			

우선 MyValueType을 value struct로 선언했다는 걸 눈 여겨 보자. value struct는 C#의 struct 키워드와 동일한데 값 타입을 선언할 때 쓰는 키워드다. 값 타입은 말 그대로 값에 의한 참조로만 전달되는데 이는 매개변수 등으로 MyValueType 인스턴스를 넘길 때 원래 인스턴스의 복사본을 넘긴다는 말이다.

첫 콘솔 출력은 예상했던 대로 100이다. 두 번째 콘솔 출력은 어떨까? 함수 f1은 클래스 MyValueType의 인스턴스를 값으로 넘겨 받는다. 다시 말해 obj1 인스턴스의 복사본을 f1로 넘기므로 이 안에서 아무리 멤버 변수의 값를 바꾼다 해도 원래 obj1의 멤버는 그대로일 것이다. 그러므로 두 번째 콘솔 출력도 100이다.

문제는 마지막 f2 함수다. 이 함수는 시그너처를 보면 MyValueType을 참조로 넘기는 것 같아 보인다. 만약 참조의 의한 호출이라면 obj1 인스턴스의 복사본이 아닌 obj1 인스턴스 자체가 함수 f2로 넘어가는 걸 테니 세 번째 콘솔 출력의 값은 101이 되어야 한다. 그러나 실제로 [목록 8]의 코드를 돌려보면 100이 출력된다. 즉, 컴파일러가 내뱉은 중간 언어는 f1이나 f2나 같다는 말이다.

사실상 똑같은 결과를 내놓기 때문에 f1식으로 구현하든 f2로 구현하든 문제될 건 없다. 그러나 f2 방식은 MyValueType이 값 타입이라 걸 모르거나 깜박했을 때 그 결과를 잘못 예측하게 할 여지가 크다. 그렇기 때문에 값 타입을 참조로 표시하지 말라고 당부했던 것이다. 개인적으론 C++/CLI 차기 컴파일러는 이런 경우에 아예 컴파일 오류를 내뱉어야 한다고 생각한다.

마지막으로 한 가지만 더 이야기하자. MyValueType 같은 값 타입도 참조에 의한 호출이 가능하긴 하다. 다만 이때 ^가 아닌 %를 써야 한다. [목록 9]처럼 코드를 짜면 참조에 의한 호출이 이뤄지고 콘솔에는 101이 찍힌다.

[목록 9] 참조에 의한 호출에 값 타입 사용하기
void f3(MyValueType% obj)
{
	obj.i = obj.i + 1;
}

int main()
{
	ValueType obj1;
	obj1.i = 100;

	f3(obj1);
	Console::WriteLine(obj1.i.ToString());
}
			

끝마치는 말

오늘은 이걸로 끝이다. 값 타입과 참조 타입의 차이에 대해 조금 더 자세히 기술했으면 좋겠지만 정말 필요할 때 한번 더 기회를 내기로 한다. 마음이 급한 사람은 웹 검색을 활용하면 되겠다. C#쪽 자료 중에 값 타입과 참조 타입, 그리고 그와 관련된 박싱(boxing)과 언박싱(unboxing)을 쉽고 자세하게 설명한 훌륭한 글이 많다. 그럼 다음을 기대하시라.

최 재훈

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